Microcontroller projects

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

last updated: 2025-10-24

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:

toggle_output 1
Click for a sharper view!
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):
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)
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):
Executable instructions in assembler. + Comments (4 column):
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:
In this section in memory we find our program (assembler opcodes). +.text:
In this section in memory we find our program (assembler opcodes). +.text:
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.

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

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/