pico_stdlib — Calling SDK Functions from RISC-V Assembly
This document shows how to call Pico SDK functions from RISC-V Assembly code,
following the RISC-V ABI (Application Binary Interface). Each function includes
its C signature, the argument-to-register mapping, and an annotated assembly example.
RISC-V ABI Conventions — Summary
| Registers |
ABI Name |
Role in function calls |
Preserved? |
| x10–x17 |
a0–a7 |
Function arguments; a0 (and a1) also hold the return value |
No (caller-saved) |
| x1 |
ra |
Return address — overwritten by call; must be saved to the stack if the function makes further calls |
No (caller-saved) |
| x5–x7, x28–x31 |
t0–t6 |
Temporaries — the called function may freely overwrite them |
No (caller-saved) |
| x8–x9, x18–x27 |
s0–s11 |
Saved registers — the called function must restore them before returning |
Yes (callee-saved) |
| x2 |
sp |
Stack pointer — must be 16-byte aligned at the point of call |
Yes (callee-saved) |
Golden rule: before calling any function with call,
place the arguments in a0, a1, a2… (in that order).
After the function returns, the result is in a0 (and a1 for 64-bit values).
Any value held in t0–t6 or a0–a7 before the call
may have been destroyed — save them to the stack if you need them afterwards.
Function Prologue and Epilogue (Stack Frame)
Whenever an assembly function calls other functions, it must save ra (and any s registers it uses) onto the stack.
my_function:
# ── Prologue ─────────────────────────────────────────
addi sp, sp, -16 # allocate 16 bytes on the stack (multiple of 16 = ABI compliant)
sw ra, 12(sp) # save return address
sw s0, 8(sp) # save s0 (frame pointer, if used)
sw s1, 4(sp) # save s1 (if used by this function)
# ── Function body ────────────────────────────────────
# ... your instructions here ...
# ── Epilogue ─────────────────────────────────────────
lw s1, 4(sp) # restore s1
lw s0, 8(sp) # restore s0
lw ra, 12(sp) # restore return address
addi sp, sp, 16 # deallocate stack frame
ret # return (equivalent to: jalr zero, ra, 0)
Why use s registers instead of t for loop variables?
t registers are caller-saved: any call may destroy them.
s registers are callee-saved: the called function guarantees it restores them.
Therefore, values that must survive a call (such as the SIO base address or a bitmask
used in a loop) should be kept in s0–s11.
hardware_gpio
🔗 docs
gpio_init
| C Signature |
void gpio_init(uint gpio) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number (0–29) |
| Return | — | void | No return value |
li a0, 15 # a0 = GPIO 15 (1st argument)
call gpio_init # calls gpio_init(15)
# void return — a0 has no useful value after
gpio_set_dir
| C Signature |
void gpio_set_dir(uint gpio, bool out) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number |
| out | a1 | bool | 1 = output, 0 = input |
| Return | — | void | No return value |
li a0, 15 # a0 = GPIO 15
li a1, 1 # a1 = 1 (output)
call gpio_set_dir # calls gpio_set_dir(15, true)
gpio_put
| C Signature |
void gpio_put(uint gpio, bool value) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number |
| value | a1 | bool | 1 = high (LED on), 0 = low (LED off) |
| Return | — | void | No return value |
# Turn LED on
li a0, 15 # a0 = GPIO 15
li a1, 1 # a1 = 1 (high)
call gpio_put # calls gpio_put(15, 1)
# Turn LED off
li a0, 15 # a0 = GPIO 15
li a1, 0 # a1 = 0 (low)
call gpio_put # calls gpio_put(15, 0)
gpio_get
| C Signature |
bool gpio_get(uint gpio) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number |
| Return | a0 | bool | 1 if pin is high, 0 if pin is low |
li a0, 15 # a0 = GPIO 15
call gpio_get # calls gpio_get(15)
# after return: a0 = 0 or 1
bnez a0, pin_high # branch if a0 != 0 (pin is high)
gpio_pull_up / gpio_pull_down
| C Signature |
void gpio_pull_up(uint gpio) / void gpio_pull_down(uint gpio) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number |
| Return | — | void | No return value |
li a0, 14 # a0 = GPIO 14 (e.g. a button)
call gpio_pull_up # enables internal pull-up resistor on GPIO 14
gpio_set_function
| C Signature |
void gpio_set_function(uint gpio, gpio_function_t fn) |
| Argument | Register | Type | Description |
| gpio | a0 | uint | GPIO pin number |
| fn | a1 | enum | Function: GPIO_FUNC_SIO=5, GPIO_FUNC_PWM=4, GPIO_FUNC_I2C=3, GPIO_FUNC_SPI=1, GPIO_FUNC_UART=2 |
| Return | — | void | No return value |
li a0, 0 # a0 = GPIO 0 (UART0 TX)
li a1, 2 # a1 = GPIO_FUNC_UART (value 2)
call gpio_set_function # configures GPIO 0 for UART function
sleep_ms
| C Signature |
void sleep_ms(uint32_t ms) |
| Argument | Register | Type | Description |
| ms | a0 | uint32_t | Delay time in milliseconds |
| Return | — | void | No return value |
li a0, 250 # a0 = 250 ms
call sleep_ms # waits 250 milliseconds
sleep_us ⚠ 64-bit argument — special attention required!
| C Signature |
void sleep_us(uint64_t us) |
| Argument | Register | Type | Description |
| us (bits 31:0) | a0 | uint32_t | Lower 32 bits of the microsecond value |
| us (bits 63:32) | a1 | uint32_t | Upper 32 bits (typically 0 for small values) |
| Return | — | void | No return value |
RISC-V 32-bit ABI: 64-bit values are passed in two consecutive registers
(a0 = lower half, a1 = upper half). For values up to ~4 billion µs,
the upper half is zero.
li a0, 500 # a0 = lower half: 500 µs
li a1, 0 # a1 = upper half: 0 (value < 2^32)
call sleep_us # waits 500 microseconds
hardware_uart
🔗 docs
uart_init
| C Signature |
uint uart_init(uart_inst_t *uart, uint baudrate) |
| Argument | Register | Type | Description |
| uart | a0 | pointer | Instance: uart0 = address 0x40034000, uart1 = 0x40038000 |
| baudrate | a1 | uint | Baud rate (e.g. 115200) |
| Return | a0 | uint | Actual baud rate configured by the hardware (may differ slightly) |
li a0, 0x40034000 # a0 = uart0 (peripheral base address)
li a1, 115200 # a1 = baud rate
call uart_init # calls uart_init(uart0, 115200)
# a0 = actual baud rate set by hardware
uart_putc
| C Signature |
void uart_putc(uart_inst_t *uart, char c) |
| Argument | Register | Type | Description |
| uart | a0 | pointer | UART instance |
| c | a1 | char | Character to send (ASCII code) |
| Return | — | void | No return value |
li a0, 0x40034000 # a0 = uart0
li a1, 'A' # a1 = ASCII code of 'A' (65)
call uart_putc # sends character 'A' over UART0
uart_getc
| C Signature |
char uart_getc(uart_inst_t *uart) |
| Argument | Register | Type | Description |
| uart | a0 | pointer | UART instance (blocks until a byte is received) |
| Return | a0 | char | Received character (ASCII code in a0) |
li a0, 0x40034000 # a0 = uart0
call uart_getc # waits for and reads one byte (blocking)
# after return: a0 = ASCII code of received byte
mv s0, a0 # save in s0 (callee-saved) to survive subsequent calls
Complete Example — Blink using gpio_put and sleep_ms
This example shows how to combine multiple SDK calls in a loop while correctly preserving s registers across calls.
.equ LED_PIN, 15
.equ DELAY_MS, 250
main:
# ── Prologue ─────────────────────────────────────────────
addi sp, sp, -16
sw ra, 12(sp) # save ra (will be overwritten by calls)
sw s0, 8(sp) # save s0 (we will use it to hold LED_PIN)
# ── Initialization ───────────────────────────────────────
li a0, LED_PIN
call gpio_init # gpio_init(LED_PIN)
li a0, LED_PIN
li a1, 1 # 1 = output
call gpio_set_dir # gpio_set_dir(LED_PIN, true)
li s0, LED_PIN # s0 = LED_PIN — callee-saved: survives calls inside the loop
loop:
# Turn LED on
mv a0, s0 # a0 = LED_PIN (read from s0, not li — s0 survived)
li a1, 1
call gpio_put # gpio_put(LED_PIN, 1)
li a0, DELAY_MS
call sleep_ms # sleep_ms(250) — a0 destroyed, but s0 is intact
# Turn LED off
mv a0, s0 # a0 = LED_PIN (s0 still valid)
li a1, 0
call gpio_put # gpio_put(LED_PIN, 0)
li a0, DELAY_MS
call sleep_ms # sleep_ms(250)
j loop # repeat forever
# (epilogue never reached in this example, but good practice to include)
lw s0, 8(sp)
lw ra, 12(sp)
addi sp, sp, 16
ret
Why mv a0, s0 instead of li a0, LED_PIN inside the loop?
Both work in this case. However, using s0 demonstrates the correct pattern:
across a loop with multiple call instructions, values in a and t registers
are destroyed. Storing values in s registers is the ABI-correct way to keep them
alive across calls.
Official documentation:
- Hardware APIs — GPIO, UART, SPI, I2C, PWM, ADC, DMA, PIO
- High-Level APIs — Time, multicore, sync
- Runtime APIs — stdio, boot