RISC-V assembler on bare metal Pi Pico 2 (RP2350)

last updated: 2025-10-26

Work in progress

Intro

The latest political signals from China (pact with dictators Putin and Kim Jong-un) lead me to believe that it is time to boycott as far as possible Chinese microcontrollers. America is no longer the friend to Europe, with M. Trump as president. American Tech Giants get too greedy.

It is time to change. Linux as OS and RISC-V for our microcontroller. And as far as possible no more chinese or american products.

The new Raspberry Pi Pico 2 processor (RP2350) was designed in Europe and has beneath ARM cores 2 RISC-V (Hazard3, RV32IMAC+) cores. So let’s use them.

pico2 rp2350

And here we will not use the SDK but get back to the real basics! Bare metal as its best and it must hurt (and we want to learn something and have fun ;)!

The code in assembler refers to a special chip. It is not portable like high level languages. So the data sheet of RP2350 is our friend and I will mention the interesting chapters in parentheses.

We use the RISC-V cores with RISC-V assembler! You can not use directly programs written for the RP2350, so even if you find assembler code for the RP2350 it is often code for the ARM cores.

I had problems to find documentation how to do this bare metal in the RP2350 and most of the suggestions of an AI is useless! That’s why I’m documenting this.

I combed the net and found only one valuable source for RISC-V bare metal. A blog called Wolfmans Howlings. And naturally the cool videos from Life with David on bare metal on the Pico with arm: Thanks wolfman and david, your code and infos worked and helped a lot to start.

Now let’s go to work.

Please look here: https://www.weigu.lu/microcontroller/pico_risc-v/index.html to create the udev rules and install openOCD to program the Pico 2 with a Debugprobe.

Installing the RISC-V toolchain

Everything here is done on a Linux machine (Kubuntu, resp. Debian). If you have no Linux machine, install some in a virtual box or use a Raspberry Pi 5 with Pi OS for this.

We need a 32 bit toolchain for the Hazard3 core on the Raspberry Pi Pico 2 (RP2350). We will use the GNU Assembler.

The ISA supported by Hazard3 is rv32imac ( The easiest way is to use a pre-built toolchain from the jenkins server of embecosm.com. We download it in our own directory and extract the file:

    mkdir -p ~/pi_pico2/risc_v_gcc
    cd ~/pi_pico2/risc_v_gcc
    wget https://buildbot.embecosm.com/job/corev-gcc-ubuntu2204/83/artifact/corev-openhw-gcc-ubuntu2204-20250302.tar.gz
    tar -xzf corev-openhw-gcc-ubuntu2204-20250302.tar.gz

Open a new terminal and test the assembler with:

    cd ~/pi_pico2/risc_v_gcc/corev-openhw-gcc-ubuntu2204-20250302/bin/riscv32-corev-elf-gcc --version

Add the following line to your ~/.bashrc file:

    export PATH="$PATH:/home/weigu/pi_pico2/risc_v_gcc/corev-openhw-gcc-ubuntu2204-20250302/bin"

Let’s toggle an GPIO output (RISC-V)

Let’s try with our own minimal code. The code should allow to easily change the pin. And we want to do it as fast as possible :). Explanations will follow after the cool work is done, and naturally iterations to reach our goal:

    ### Toggle an GPIO pin (minimal version)
    ### 01_toggle.S
    ### weigu.lu

    	### RP2350 Image Definition Block
    	# This block is required for the RP2350 to recognize the binary as a valid
    	# RISC-V executable. It must appear within the first 4 KB of the image.
    	# RP2350 data sheet: 5.9.1. Blocks and block loops
    	# 5.9.3. Image definition items, 5.9.3.4. ENTRY_POINT item
    	.p2align 8       # Align to 8-byte boundary (required for RP2350 image header)
    	.word 0xffffded3 # Header magic number (identifies the image)
    	.word 0x11010142 # Item 0 flags: 0b0001 0001 0000 0001 0000 0100 0010
    			 # IMAGE_TYPE_EXE, EXE_SECURITY_UNSPECIFIED,
    			 # EXE_CPU_RISC,  EXE_CHIP_RP2350
    	.word 0x00000344 # Item 1: Block size = 3,
    			 # Block type = PICOBIN_BLOCK_ITEM_1BS_ENTRY_POINT
    	.word _start     # Item 2: Inital PC (runtime) address (aka entry point)
    	.word _stack_top # Item 3: Initial SP address (aka stack pointer)
    	.word 0x000004ff # Item 4: Optional SP limit address (aka stack limit)
    	.word 0x00000000 # Link: Single block loop has 0 items
    	.word 0xab123579 # Footer magic number

    	.section .text 			# Marks the start of the code section in memory
    	.global _start 			# Makes the symbol '_start' visible to the linker

    	### Pin to use
    	.equ PIN_OUT, 15
    	.equ GPIO_MASK,       (1 << PIN_OUT)	# Bitmask for GPIO (e.g. GPIO15 = bit 15)
    	.equ GPIO_OFFSET,     PIN_OUT*8+4	# Offset for GPIO reg. (+0 = Status, +4 = Ctrl)
    	.equ PADS_OFFSET,     PIN_OUT*4+4	# Offset for PAD reg. (GPIO0 starts with +4)
    	.equ DELAY_NUMBER,    1000000		# 1000000 gives us about 2 Hz (12MHz clock)


    	### Memory-Mapped Register Definitions
    	.equ IO_BANK0_BASE,   0x40028000	# IO Bank0 Base Address
    	.equ GPIO_CTRL,       IO_BANK0_BASE + GPIO_OFFSET # GPIO_CTRL register (for Mux)
    	.equ SIO_BASE,        0xD0000000	# SIO (Single-Cycle I/O) Base Address
    	.equ GPIO_OUT_XOR,    SIO_BASE + 0x28	# GPIO output XOR register (atomic)
    	.equ GPIO_DIR,        SIO_BASE + 0x30	# GPIO direction register
    	.equ PADS_BANK0_BASE, 0x40038000	# User Bank Pad Control register base
    	.equ PAD,             PADS_BANK0_BASE + PADS_OFFSET # PADS_BANK0: PIN_OUT register

    ### Entry Point: _start
    _start:		la sp, _stack_top	# Load the stack pointer with the top of the stack
    		call main		# Call the main function
    		wfi			# Wait for interrupt (low-power mode)
    		j _start		# Infinite loop (should never reach here)


    ### Main Function
    	.section .text			# Code section
    	.global main   			# Make 'main' visible to the linker

    main:
    		# SETUP: Tell the multiplexer to set our Pin to GPIO
    		li t0, GPIO_CTRL	# Load the address of the GPIO_CTRL register to t0
    		lw t1, 0(t0)		# Load the current value of the GPIO_CTRL register to t1
    		andi t1, t1, ~0x1F	# Clear the lower 5 bits (function select)
    		ori t1, t1, 5		# Set the function select to 5 (GPIO mode)
    		sw t1, 0(t0)		# Store the updated value back to the GPIO_CTRL reg.

    		### SETUP: Set GPIO15 as an output
    		li t0, GPIO_DIR		# Load the address of the GPIO direction register
    		lw t1, 0(t0)		# Load the current value of the direction reg. to t1
    		li t2, GPIO_MASK	# Load the bitmask for the used GPIO
    		or t1, t1, t2		# Set the bit for used GPIO (output mode)
    		sw t1, 0(t0)		# Store the updated value back to the GPIO dir. reg.

    		### Setup: Clear Pad Isolation for GPIO
    		li t0, PAD		# Load the address of the pad register
    		lw t1, 0(t0)		# Load the current value of the pad register
    		andi t1, t1, ~0x100	# Clear isolation bit (bit nr 8)
    		ori t1, t1, 0x30	# Drive strength 12mA
    		sw t1, 0(t0)		# Store the updated value back to the pad register

    		li t0, GPIO_OUT_XOR	# Load the address of the GPIO_OUT_XOR
    		li t1, GPIO_MASK	# Load the bit for the output GPIO to be toggled

    mainloop:	# Main Loop: Toggle output on GPIOx
    		sw t1, 0(t0)		# Set the bit (atomic bit setting-writing)
    		#li a0, DELAY_NUMBER     # set counter for delay loop (parameter a0)
    		#jal delay		# jump and link delay (command call bor big jumps)
    		j mainloop		# jump mainloop forever

    delay:		# Subroutine delay (parameter a0)
    		addi a0, a0, -1 	# decrement a0
                    bnez a0, delay		# stay in loop until counter = 0
                    ret

Create a directory:

    mkdir -p ~/pi_pico2/pico2_riscv_ass/01_toggle
    cd ~/pi_pico2/pico2_riscv_ass/01_toggle
    nano 01_toggle.S

Copy and paste the code and save the file (Ctrl+o, Ctrl+x).

We use .S extension for assembly files and not .s or .asm. .S files have the preprocessor run on them (.s files do not).

Next we create a linker.ld file:

    OUTPUT_ARCH(riscv)
    ENTRY(_start)
    MEMORY {
        FLASH (rx)  : ORIGIN = 0x10000000, LENGTH = 2M
        RAM   (rwx) : ORIGIN = 0x20000000, LENGTH = 256K
    }
    SECTIONS {
        .text : {
            *(.text*)
        } > FLASH

        .data : {
            *(.data*)
        } > RAM

        .bss : {
            *(.bss*)
        } > RAM

        .stack (NOLOAD) : {
            . = ALIGN(8);
            _stack_top = . + 4K; /* 4KB stack */
        } > RAM
    }
    nano linker.ld

Copy and paste the code and save the file (Ctrl+o, Ctrl+x).

Next we save the following script lines as bash script:

    #!/bin/bash
    if [ "$#" -ne 1 ]; then
        printf 'ERROR! You must provide one and only one argument!\n' >&2
        exit 1
    fi
    riscv32-corev-elf-as -g -march=rv32imac -mabi=ilp32 -o $1.o $1.S
    riscv32-corev-elf-ld -g -m elf32lriscv -T linker.ld -o $1.elf $1.o
    riscv32-corev-elf-objcopy -O binary $1.elf $1.bin
    riscv32-corev-elf-objdump -f $1.elf
    openocd -f interface/cmsis-dap.cfg -f target/rp2350-riscv.cfg -c "adapter speed 5000" \
    -c "program blink.elf verify reset exit"
    nano ass_link_program.sh

Copy and paste the code and save the file (Ctrl+o, Ctrl+x).

Connect the Debugprobe, make the script executable and run the script with:

    chmod u+x ass_link_program.sh
    ./ass_link_program.sh blink

YES! Now let’s look at our output by using an oscilloscope:

<center>[![toggle_output 1](png/toggle_output_1_600.png "toggle_output 1")](png/toggle_output_1.png)</center> <center><small>Click for a sharper view!</small></center> Yes it works but the frequency is weird. Ok we will fix this later.

If you have no oscilloscope, you can uncomment the two lines in mainloop and connect an LED with series resistor to the toggle pin. If you have a Pico2 (no W) you can use pin 25 for the onboard LED.

Let’s now look at the code:

Analysing the source code

The source code is a plain text file with the ending .asm (e.g. for RARS), or .s (no C preprocessor) .S (using C preprocessor). The C preprocessor runs first and performs textual substitutions on the source code before passing it to the assembler. As we use the gcc assembler and I don’t know if I need the preprocessor in later examples, our files will have always big .S as ending.

There are no rules how to format the text file, but I will use 4 different columns and a tabulator with a tab size of 8 character. + Labels (1 column):<br>Mark addresses for jumps, data fields or strings. They should be explicit and end with a colon. Special labels begin with an underscore. + Assembler directives (2 column)<br>Assembler directives start always with a dot (.). They are also called pseudo-opcodes and are instructions for the assembler (no executable instructions) that help with the assembly process. E.g. they define constants (.equ), reserve memory space for variables (.word), or set the program origin (.section .text). + Op-codes (3 column):<br>Executable instructions in assembler. + Comments (4 column):<br>The most important thing ;). They start with the number sign # (this can be confusing in combination with the C preprocessor (e.g. #define)). For comments over mor lines we can also use /* comment */ like in C.

The block

We want to use a RISC-V core. A block of 32 bytes (8 words) tells the chip what core to use. Here is the block with some comments:

	### RP2350 Image Definition Block
	# This block is required for the RP2350 to recognize the binary as a valid
	# RISC-V executable. It must appear within the first 4 KB of the image.
	# RP2350 data sheet: 5.9.1. Blocks and block loops
	# 5.9.3. Image definition items, 5.9.3.4. ENTRY_POINT item
	.p2align 8       # Align to 8-byte boundary (required for RP2350 image header)
	.word 0xffffded3 # Header magic number (identifies the image)
	.word 0x11010142 # Item 0 flags: 0b0001 0001 0000 0001 0000 0100 0010
             			 # IMAGE_TYPE_EXE, EXE_SECURITY_UNSPECIFIED,
			             # EXE_CPU_RISC,  EXE_CHIP_RP2350
	.word 0x00000344 # Item 1: Block size = 3,
			             # Block type = PICOBIN_BLOCK_ITEM_1BS_ENTRY_POINT
	.word _start     # Item 2: Inital PC (runtime) address (aka entry point)
	.word _stack_top # Item 3: Initial SP address (aka stack pointer)
	.word 0x000004ff # Item 4: Optional SP limit address (aka stack limit)
	.word 0x00000000 # Link: Single block loop has 0 items
	.word 0xab123579 # Footer magic number

In further programs we will use this block without comments.

Sections

There are 5 section directives: .text,.data, .rodata, .bss and .noinit. In gcc

+.text:<br>In this section in memory we find our program (assembler opcodes). +.text:<br>In this section in memory we find our program (assembler opcodes). +.text:<br>In this section in memory we find our program (assembler opcodes).

Setting up a GPIO pin

To use a GPIO for digital I/O operations we need to configure some register. This is the price to pay for the flexibility of modern µC pins.

If we look at the RP2350 bus fabric overview (Figure 5) we see that we have a high speed bus AHB5 and a low speed bus called APB. It gives access to system control registers and lower-bandwidth peripherals. But for our GPIO pins we have a separated single-cycle IO subsystem called SIO that is directly connected to the high speed bus. The SIO peripherals are accessed via a dedicated path from each processor.

Multiplexing

Every pin can have different functions like SPIx, UARTx, I2Cx, SIO …​. (1.2.3. GPIO functions (Bank 0), 9.4. Function select). We need here the SIO function (function nr 5) that handles all user GPIOs (3.1.3. GPIO control). GPIO functions are on User Bank 0. The User Bank IO registers start at a base address of 0x40028000 defined as IO_BANK0_BASE (9.11.1. IO - User Bank). To multiplex pin 15 to the GPIO function we need the GPIO15_CTRL register at address 0x4002807C. It is no good idea to use absolute addresses, so we use an offset (0x07C) to the base address.

The different bit of this register is in table 681 of the data sheet.

<!-- # Original code: li t2, ~0x1F and t1, t1, t2

Optimized version:

andi t1, t1, ~0x1F

andi only supports 12-bit immediate values in RISC-V. Since ~0x1F is 0xFFFFFFE0, which is a 32-bit value, the assembler will sign-extend it correctly.

If you were using a larger mask (e.g., ~0x1FFFFF), you would need to use li + and because andi cannot handle 32-bit immediates directly. But for ~0x1F, andi works perfectly.

1.2.3. GPIO functions (Bank 0) Each individual GPIO pin can be connected to an internal peripheral via the GPIO functions defined below. Some internal peripheral connections appear in multiple places to allow some system level flexibility. SIO, PIO0, PIO1 and PIO2 can connect to all GPIO pins and are controlled by software (or software controlled state machines) so can be used to implement many functions.

Every pin can have different functions like SPIx, UARTx, I2Cx, SIO …​. (9.4. Function select). We need here the SIO function (5) that handles all user GPIOs (3.1.3. GPIO control).

We have the following groups of registers:

+ Output registers:<br>GPIO_OUT and GPIO_HI_OUT set the output level of the GPIO. 0 for low output, 1 for high output. + Output enable registers:<br>GPIO_OE and GPIO_HI_OE, are used to enable the output driver. 0 for high-impedance, 1 for drive high or low based on GPIO_OUT and GPIO_HI_OUT. + Input registers:<br>GPIO_IN and GPIO_HI_IN, allow the processor to sample the current state of the GPIOs

Pad isolation

9.11.3. Pad Control - User Bank

9.6. Pads

Each GPIO is connected off-chip via a pad. Pads are the electrical interface between the chip’s internal logic and external circuitry. They translate signal voltage levels, support higher currents and offer some protection against electrostatic discharge (ESD) events. You can adjust pad electrical behaviour to meet the requirements of external circuitry in the following ways: • Output drive strength can be set to 2mA, 4mA, 8mA or 12mA. • Output slew rate can be set to slow or fast. • Input hysteresis (Schmitt trigger mode) can be enabled. • A pull-up or pull-down can be enabled, to set the output signal level when the output driver is disabled. • The input buffer can be disabled, to reduce current consumption when the pad is unused, unconnected or connected to an analogue signal. An e

RP2350 has isolation latches on all GPIO (RP2040 only on some). We need to enable the pad input (GPIO0.IE = 1) and disable pad isolation latches (GPIO0.ISO = 0) before using the pads for digital I/O. If the SDK is used this is done with the function gpio_set_function().

Pad isolation is needed for extended low-power states. More info in chapter 9.7. (Pad isolation latches) in the RP2350 data sheet.

Single-cycle IO subsystem (SIO)

equ PAD_ISO_REG, 0x40038000 + 0x40 # Pad isolation control register for GPIO15

Core-local Peripherals (SIO)

3.1.11. List of registers

The SIO registers start at a base address of 0xd0000000 (defined as SIO_BASE in SDK).

Table 17. List of SIO registers Table 21. GPIO_OUT register Set output level (1/0 → high/low)

9.8. Processor GPIO controls (SIO) (Section 3.1) contains memory-mapped GPIO registers. The processors can use these to perform input/output operations on GPIOs: • The GPIO_OUT and GPIO_HI_OUT registers set the output level: 1 = high, 0 = low • The GPIO_OE and GPIO_HI_OE registers set the output enable: 1 = output, 0 = input • The GPIO_IN and GPIO_HI_IN registers read the GPIO inputs These registers are all 32 bits in size. The low registers (e.g. GPIO_OUT) connect to GPIOs 0 through 31, and the high registers (e.g. GPIO_HI_OUT) connect to GPIOs 32 through 47, the QSPI pads, and the USB DM/DP pads. For the output and output enable registers to take effect, the SIO function must be selected on each GPIO (function 5). However, the GPIO input registers read back the GPIO input values even when the SIO function is not selected, so the processor can always check the input state of any pin. The SIO GPIO registers are shared between the two processors and between the Secure and Non-secure security domains. This avoids programming errors introduced by selecting multiple GPIO functions for access from different contexts. Non-secure code’s view of the SIO registers is restricted by the Non-secure GPIO mask defined in GPIO_NSMASK0 and GPIO_NSMASK1. Non-secure writes to Secure GPIOs are ignored. Non-secure reads of Secure GPIOs return 0. These registers are documented in more detail in the SIO GPIO register section (Section 3.1.3). The DMA cannot access registers in the SIO subsystem. The recommended method to DMA to GPIOs is a PIO program that continuously transfers TX FIFO data to the GPIO outputs, which provides more consistent timing than DMA directly into GPIO registers

 when the ISO bit for each pad
is set (e.g. GPIO0.ISO) or the switched core domain is powered down, the control signals currently presented to that pad
are latched until the isolation is disabled. This includes the output enable state, output high/low level, and pull-up/pull-
down resistor enable. The input signal from the pad back into the switched core domain is not isolated.
Consequently, when switched core logic is powered down, all Bank 0 and Bank 1 pads maintain the output state they
held immediately before the power down, unless overridden by always-on logic in POWMAN. When the switched core
power domain powers back up, all the GPIO ISO bits reset to 1, so the pre-power down state continues to be maintained
until user software starts up and clears the ISO bit to indicate it is ready to use the pad again. Pads whose IO muxing
has not yet been set up can be left isolated indefinitely, and will maintain their pre-power down state.
when software has finished setting up the IO muxing for a given pad, and the peripheral that is to be muxed in, the ISO
bit should be cleared. At this point the isolation latches will become transparent again: output signals passing through
the IO muxing block are now reflected in the pad output state, so peripherals can communicate with the outside world.
This process allows the switched core domain to be power cycled without causing any transitions on the pad outputs
that may interfere with the operation of external hardware connected to the pads.

RARS — RISC-V Assembler and Runtime Simulator

p2align 8: This is a GNU assembler directive that aligns the following code or data to an \(8\)-byte boundary. The "p2" stands for "power of 2" and 8 indicates the alignment is \(2^{8}\), or \(256\) bytes. This ensures that the subsequent code is placed at a specific address that is a multiple of \(256\), which can improve performance on some processors by ensuring that instructions or data are aligned on cache lines or other memory boundaries. # This special signature must appear within the first 4 kb of: This is a comment, ignored by the assembler, that provides important context about the following line. It explains that the signature is crucial for the binary to be recognized. image_def:: This is a label that defines the start of the signature. In the context of RISC-V, this is part of a larger header that the bootloader or operating system uses to identify the image as a valid RISC-V binary. # the memory image to be recognized as a valid RISC-V binary.: This comment further clarifies that the combination of the alignment and image_def: is the "signature" that the RISC-V boot process looks for to confirm the file is a valid executable binary. The requirement to be within the first \(4\) kilobytes is a common practice for embedded systems and bootloaders, as they often scan the beginning of a bootable device for these headers.

Download rars1_6.jar from https://github.com/TheThirdOne/rars/releases/tag/v1.6 java -jar -Dsun.java2d.uiScale=2.0 ./rars1_6.jar

The loop

The GPIO_OUT_SET register is designed for atomic bit setting—writing a 1 to any bit sets that bit in the GPIO output register without read-modify-write overhead.

Single Instruction:

Only 1 sw instruction is needed to set the bit. No lw (load) or RMW (read-modify-write) overhead.

Atomic Operation:

The hardware guarantees atomicity—no risk of race conditions.

No Stalls: The sw instruction completes in 1 cycle (assuming no memory contention).

| code | cycles | comment | |--|--| |loop: || | li t0, GPIO_OUT # Load the address of the GPIO output register|| | lw t1, 0(t0) # Load the current value of the GPIO output register|| | li t2, GPIO_MASK # Load the bitmask for GPIO|| | or t1, t1, t2 # Set the bit for our GPIO (turn OUTPUT on)|| | sw t1, 0(t0) # Store the updated value back to the GPIO output reg.|| | li t0, GPIO_OUT # Load the address of the GPIO output register|| | lw t1, 0(t0) # Load the current value of the GPIO output register|| | li t3, ~GPIO_MASK # Load the bitmask for GPIO|| | and t1, t1, t3 # Clear the bit for GPIO (turn OUTPUT off)|| | sw t1, 0(t0) # Store the updated value back to the GPIO output reg.|| | j loop # Jump back to the start of the loop (infinite loop)||

| code | cycles | |--|--| li t0, GPIO_OUT # Load the address of the GPIO output register li t2, GPIO_MASK # Load the bitmask for GPIO li t3, ~GPIO_MASK # Load the bitmask for GPIO |loop: || lw t1, 0(t0) # Load the current value of the GPIO output register or t1, t1, t2 # Set the bit for our GPIO (turn OUTPUT on) sw t1, 0(t0) # Store the updated value back to the GPIO output reg. lw t1, 0(t0) # Load the current value of the GPIO output register and t1, t1, t3 # Clear the bit for GPIO (turn OUTPUT off) sw t1, 0(t0) # Store the updated value back to the GPIO output reg. j loop # Jump back to the start of the loop (infinite loop)

The tight loop in the code consists of 3 instructions:

toggle_loop: li t0, GPIO_OUT_XOR | 2 | pseudo-instruction, expands to addi | li t1, (1 << 15) | 2 | pseudo-instruction, expands to addi | sw t1, 0(t0) | 1 | Store Word to GPIO_XOR (assuming no stalls) | j toggle_loop | 1 | branch prediction helps here | | | Total per iteration | 6 | |

590ns/6 = 98,33ns = 10,169MHz

From the data sheet (8.3. Ring oscillator (ROSC)): <quote>The Ring Oscillator (ROSC) is an on-chip oscillator built from a ring of inverters. It requires no external components and is started automatically during RP2350 power up. It provides the clock to the cores during boot. During boot, the ROSC runs at a nominal 11MHz and is guaranteed to be in the range 4.6MHz to 19.6MHz without randomisation…​</quote>

Kaz

To set the clock to 200 MHz on the Raspberry Pi Pico 2 (RP2350) in RISC-V assembly, you need to configure the clock control registers in the system control block. Here’s how to do it:

Key Registers for Clock Configuration
SYSCTL_BASE0x40000000System Control Base AddressCLK_EN_REGSYSCTL_BASE + 0x100Clock Enable RegisterCLK_SEL_REGSYSCTL_BASE + 0x104Clock Select RegisterCLK_DIV_REGSYSCTL_BASE + 0x108Clock Divider RegisterPLL_SYS_REGSYSCTL_BASE + 0x140PLL System Control RegisterPLL_PWR_REGSYSCTL_BASE + 0x144PLL Power Register

Steps to Set the Clock to 200 MHz

Enable the PLL (Phase-Locked Loop) for the system clock.
Configure the PLL to generate a 200 MHz clock.
Select the PLL as the clock source for the system.
Wait for the PLL to lock.


RISC-V Assembly Code
# Define system control registers
.equ SYSCTL_BASE,    0x40000000
.equ CLK_EN_REG,     SYSCTL_BASE + 0x100   # Clock Enable Register
.equ CLK_SEL_REG,    SYSCTL_BASE + 0x104   # Clock Select Register
.equ CLK_DIV_REG,    SYSCTL_BASE + 0x108   # Clock Divider Register
.equ PLL_SYS_REG,    SYSCTL_BASE + 0x140   # PLL System Control Register
.equ PLL_PWR_REG,    SYSCTL_BASE + 0x144   # PLL Power Register

# Define PLL configuration values for 200 MHz
# Assuming a 12 MHz input clock (typical for RP2350)
# PLL formula: Fout = Fin * (FBDIV / PDIV1) / PDIV2
# For 200 MHz: FBDIV = 200, PDIV1 = 1, PDIV2 = 6
.equ PLL_FBDIV,      200                   # Feedback divisor
.equ PLL_PDIV1,      1                     # Pre-divider 1
.equ PLL_PDIV2,      6                     # Post-divider 2
.equ PLL_PWR_ON,     0x80000000            # PLL power-on bit
.equ PLL_PWR_READY,  0x80000000            # PLL ready bit

.section .text
.global set_clock_200mhz

set_clock_200mhz:
    # Enable the PLL
    li t0, PLL_PWR_REG
    li t1, PLL_PWR_ON
    sw t1, 0(t0)         # Power on the PLL

    # Wait for the PLL to power up
pll_power_wait:
    lw t1, 0(t0)         # Read PLL power register
    andi t1, t1, PLL_PWR_READY
    beqz t1, pll_power_wait  # Wait until PLL is ready

    # Configure the PLL for 200 MHz
    li t0, PLL_SYS_REG
    li t1, (PLL_FBDIV << 12) | (PLL_PDIV1 << 5) | PLL_PDIV2
    sw t1, 0(t0)         # Set PLL divisors

    # Wait for the PLL to lock
pll_lock_wait:
    lw t1, 0(t0)         # Read PLL status
    andi t1, t1, 0x8000  # Check LOCK bit (bit 15)
    beqz t1, pll_lock_wait  # Wait until PLL is locked

    # Select the PLL as the system clock source
    li t0, CLK_SEL_REG
    li t1, 0x3           # Select PLL as the clock source
    sw t1, 0(t0)         # Set clock source to PLL

    # Enable the clock
    li t0, CLK_EN_REG
    li t1, 0x1           # Enable the clock
    sw t1, 0(t0)         # Enable the clock

    ret                  # Return from subroutine

Explanation


Power on the PLL:

Write PLL_PWR_ON to the PLL_PWR_REG to turn on the PLL.



Wait for PLL to Power Up:

Poll the PLL_PWR_REG until the PLL_PWR_READY bit is set.



Configure the PLL:

Set the feedback divisor (FBDIV), pre-divider (PDIV1), and post-divider (PDIV2) in the PLL_SYS_REG.
For 200 MHz, use FBDIV = 200, PDIV1 = 1, and PDIV2 = 6.



Wait for PLL to Lock:

Poll the PLL_SYS_REG until the LOCK bit (bit 15) is set.



Select the PLL as the Clock Source:

Write 0x3 to the CLK_SEL_REG to select the PLL as the system clock source.




8.6.4. Configuration
The programming sequence for the PLL is as follows:
1. Program the reference clock divider (is a divide by 1 in the RP2350 case).
2. Program the feedback divider.
3. Turn on the main power and VCO.
4. Wait for the VCO to achieve a stable frequency, as indicated by the LOCK status flag.
5. Set up post dividers and turn them on



How to Use


Call set_clock_200mhz from your main code:
call set_clock_200mhz


Ensure your code is running on the RISC-V core (Hazard3).



Notes

This assumes a 12 MHz input clock (typical for the RP2350).
If your input clock is different, adjust the PLL divisors accordingly.
The PLL configuration may need tweaking based on your specific hardware.


GPIO Register: 9.11.1. IO - User Bank (604)


-->
=== How to reset the pico2 if everything goes wrong

If you are no more able to program the pico or if the clock speed is false we need to reset the chip in its original state. To do so need to power it with pressed `bootsel` button. This will cause the Pico to appear as a removable drive, where we erase all files on this drive to reset it completely.

=== Downloads

- Everything on github: [https://github.com/weigu1/rpi_pico_pio](https://github.com/weigu1/rpi_pico_pio)



=== Interesting links


<http://blog.wolfman.com/articles/2025/5/19/bare-metal-gpio-twiddling-for-risc-v-on-rpi-pico2>
<https://github.com/wolfmanjm/RISC-V-RP2350-baremetal>
<https://mcyoung.xyz/2021/11/29/assembly-1/>
<https://smist08.wordpress.com/2024/10/21/risc-v-on-the-raspberry-pi-pico-2/>


+ SDK on github: <https://github.com/raspberrypi/pico-sdk>
+ SDK: <https://datasheets.raspberrypi.com/pico/raspberry-pi-pico-c-sdk.pdf>
+ Datasheet RP2350: <https://datasheets.raspberrypi.com/rp2350/rp2350-datasheet.pdf>
+ Getting started with Pico (pdf): <https://datasheets.raspberrypi.com/pico/getting-started-with-pico.pdf>
+ Picotool: <https://github.com/raspberrypi/picotool>

<br>
<br>