Low level Raspberry Pi Pico 2 with Rust
This a set of tutorials-like for playing with registers and understanding the Raspberry Pi Pico 2 as I went though my adventures.
Many parts are not written in a very clear manner and require you to dig into the datasheet to learn it yourself.
Aims and scope
The objective here is to understand how a Raspberry Pi Pico 2 can be programmed using mostly registers relying as little as possible/reasonable on HALs. It’s not code meant for production and there can be soundness issues.
Disclaimer
I’m not an expert, this can contain errors, it’s written by a human. It was written in hope that will be useful but without any guarantee, use at your own risk.
License
Copyright Marco Inacio, 2026.
The text is licensed under CC-BY 4.0.
Source code is dual licensed under Apache License, Version 2.0 or MIT License.
Source code
Software and knowledge requirements
To setup your development environments and understand some of the basics, I recommend reading sections 1-5 of the micro::bit v2 Embedded Discovery Book.
Hardware requirements
- Raspberry Pi Pico 2 or Raspberry Pi Pico 2W.
- A debug probe.
- USB micro USB cables.
- Desirable: some jumper wires, 220 ohms resistor, a button and led.
A first glimpse at the code
First, let’s consider the Cargo.toml:
[package]
name = "pico-simple"
version = "0.1.0"
edition = "2024"
[dependencies]
cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7.0" }
cortex-m-rt-macros = "0.7.5"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
And src/main.rs:
#![no_std]
#![no_main]
use cortex_m::asm;
pub use cortex_m_rt::entry;
use defmt::info;
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21, 0x10, 0xff, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2, 0xeb, 0x88, 0x71,
0x6c, 0x93, 0x02, 0x10, 0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02, 0x10,
0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01, 0x02, 0x03,
];
#[entry]
fn main() -> ! {
for _ in 1..100000 {
asm::nop();
}
info!("Hello world!");
loop {
asm::wfi();
}
}
Hopefully this structure will get clearer as we proceed.
Flash it
cargo run to flash your code.
You may get a message that the firmware of the pico probe is outdated. In that case, follow these instructions.
What will be sent to the flash memory
rustc compiles a binary in the ELF format, but probe-rs will convert this to flat binary before flashing it to the Pico. We can also do this conversion ourselves to learn what will be flashed to the Pico using the LLVM tools objcopy:
cargo objcopy --bin pico-simple -- --strip-all -O binary flat.bin
You can then open this with an hex editor. If you have installed the hex editor extension on VSCode, you can use it:
code flat.bin
Also, if you have picotool installed, you can boot the Pico in BOOTSEL mode (hold the button and power on/hard reset the Pico) and run picotool save -a ./pico_dump.bin to obtain what is currently stored in the flash and open and compare with your local file. Since the debug probe is also a Pico, you can also do that with it to see what’s stored in the debug probe flash.
Linker script
Take a look at the memory.x file that is a linker script used to tell the linker how to lay out the memory of our binary, it is extending the configs at cortex-m-rt repo.
I won’t go into the detail of how this works (for that, see this), but encourage you to play with it, changing values and seeing how it affects the binary in the hex editor and also seeing how this is called in the build.rs file.
Startup code
An ordinary ARM flat binary will not work with RPi Pico 2, even though it has the vector table at the beginning and a proper entrypoint and stack start point. The RPi Pico 2 has a lot of possibilities to configure the start code (partitions and images, we won’t go into details here), so it looks for those configurations, instead of going straight to the vector table entrypoint. So in order to drop a minimal startup config that just tells to go the vector table at the begging of the flash memory, we have:
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21, 0x10, 0xff, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2, 0xeb, 0x88, 0x71,
0x6c, 0x93, 0x02, 0x10, 0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02, 0x10,
0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01, 0x02, 0x03,
];
Forcing it to be in the .start_block section, makes sure that it’s the first 4kB of the flash where the Pico expects that configuration be.
Registers
In this section will see how to read and write to special function registers to controls the GPIO and other peripherals.
Control GPIO
Using registers, we can control the GPIO 16 to be in output mode and with output high or low, or in input mode with the possibility to read those values.
Search for those registers on the datasheet and read their description to learn about how it works.
#![no_std]
#![no_main]
use cortex_m::asm;
pub use cortex_m_rt::entry;
use defmt::info;
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21, 0x10, 0xff, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2, 0xeb, 0x88, 0x71,
0x6c, 0x93, 0x02, 0x10, 0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02, 0x10,
0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01, 0x02, 0x03,
];
#[entry]
fn main() -> ! {
unsafe {
// IO_BANK0: GPIO16_CTRL Register
// Set to use SIO
core::ptr::write_volatile(0x40028084 as *mut u32, 0x5);
// Now put in Output mode!
// PADS_BANK0: GPIO16 Register
// 0x40: set bit 6: IE: Input enable
core::ptr::write_volatile(0x40038044 as *mut u32, 0x40);
// GPIO_OE_SET
// 0x10000: enable output for bit 16 (pin 16)
core::ptr::write_volatile(0xd0000038 as *mut u32, 1 << 16);
// 0x018 GPIO_OUT_SET
// High
core::ptr::write_volatile(0xd0000018 as *mut u32, 1 << 16);
status();
// 0x020 GPIO_OUT_CLEAR
// Low
core::ptr::write_volatile(0xd0000020 as *mut u32, 1 << 16);
status();
// Now put in Input mode!
// GPIO_OE_CLEAR
// 0x10000: disable output for bit 16 (pin 16)
core::ptr::write_volatile(0xd0000040 as *mut u32, 1 << 16);
// PADS_BANK0: GPIO16 Register
// 0x48: set bit 6 (IE: Input enable() and bit 3 (PUE: Pull up enable)
core::ptr::write_volatile(0x40038044 as *mut u32, 1 << 6 | 1 << 3);
};
status();
loop {
unsafe {
info!(
"{:#x} GPIO_IN",
core::ptr::read_volatile(0xd0000004 as *const u32)
);
}
for _ in 1..100000 {
asm::nop();
}
}
}
fn status() {
unsafe {
info!(
"{:#x} IO_BANK0: GPIO16_STATUS Register",
core::ptr::read_volatile(0x40028080 as *const u32)
);
info!(
"{:#x} IO_BANK0: GPIO16_CTRL Register",
core::ptr::read_volatile(0x40028084 as *const u32)
);
info!(
"{:#x} GPIO_OUT",
core::ptr::read_volatile(0xd0000010 as *const u32)
);
info!(
"{:#x} GPIO_OE",
core::ptr::read_volatile(0xd0000030 as *const u32)
);
info!(
"{:#x} PADS_BANK0: GPIO16 Register",
core::ptr::read_volatile(0x40038044 as *const u32)
);
}
}
Registers with gdb
You can start gdb with:
probe-rs gdb --chip RP235x --gdb gdb-multiarch ./target/thumbv8m.main-none-eabihf/debug/pico-simple -- -q -ex "set mem inaccessible-by-default off" -ex "break src/main.rs:21
And replace 21 above with any line you want the debugger to break at. Once you start the debugger, it halts the core execution.
Note: the set mem inaccessible-by-default off is important to be able to access those special registers.
Here’s a few interesting commands for gdb (see x command reference):
x/bx 0x40028004: print the byte at this address in hexadecimal format.x/bt 0x40028004: print the byte at this address in binary format.x/wx 0x40028004: print the word (4 bytes) at this address in hexadecimal format.x/20wx 0x40028004: print the 20 first words (4 bytes each) at starting this address in hexadecimal format.x/10i 0x1002013D: print the 10 first instructions starting at this address.set *(0x2000000c as *mut u8) = 0x04: set the byte address to be 0x04.set *(0x2000000c as *mut u32) = 0xff030104: set the word address to be 0xff030104.info registers: print the value of all processor registers (not be confused with special function registers).p $pc: print the value of register pc.info local: print value of all local variables.p led: print the value of the variable named led.
Restart before halting
One caveat in all this, when the debug command is run, it will halt execution of the program running in the RPi Pico 2 for you, but it might have run past the your desired break point already, so you can pass the flag --reset-halt to the command above the restart before halting… except that it doesn’t work… or at least I haven’t found a way to do that yet (it might work with some other tool).
Ideally, there should be an easy way to do that, but in the absence of something better, you can just put asm::wfi(); twice in the very beginning of your main function and once the debugger starts, send a continue, and it will work.
Start another CPU
When the Pico 2 boots, only CPU 0 run our code, with the CPU 1 entering a wait state awaiting instructions from the CPU 0 to go to an specified entry point and start executing code.
The datasheet describes what the CPU 0 must send though the inter CPU FIFO communication pipeline in other to have CPU 1 start executing code from an specific entrypoint.
First attempt
Let’s start with our initial attempt following the datasheet:
#![no_std]
#![no_main]
use cortex_m::asm;
use cortex_m_rt::entry;
use defmt::{assert_eq, error, info};
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21, 0x10, 0xff, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2, 0xeb, 0x88, 0x71,
0x6c, 0x93, 0x02, 0x10, 0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02, 0x10,
0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01, 0x02, 0x03,
];
fn second_cpu() {
for _ in 1..10000 {
asm::nop();
}
loop {
info!(">>>>>>>> cpu 2 is alive");
for _ in 1..100 {
asm::nop();
}
}
}
#[entry]
fn main() -> ! {
unsafe {
let vector_table_address =
core::ptr::read_volatile(0xE000ED08 as *const usize);
info!("vector table address: {:#x}", vector_table_address);
let cmd_sequence: [usize; 6] = [
0,
0,
1,
vector_table_address,
0x20040000,
second_cpu as *const usize as usize,
];
// algorithm in page 377
'outer: loop {
for _ in 1..10000 {
asm::nop();
}
// discard data from read FIFO until empty
// SIO: FIFO_RD Register
while core::ptr::read_volatile(0xd0000050 as *const usize) & 1 == 1
{
let answer =
core::ptr::read_volatile(0xd0000058 as *const usize);
info!("cleaning with answer: {}", answer);
}
for cmd in cmd_sequence {
// validate no FIFO error
// SIO: FIFO_ST Register
assert_eq!(
core::ptr::read_volatile(0xd0000050 as *const usize)
& 0b1110,
2
);
// SIO: FIFO_WR Register
core::ptr::write_volatile(0xd0000054 as *mut usize, cmd);
asm::sev();
info!("waiting for data");
while core::ptr::read_volatile(0xd0000050 as *const usize) & 1
== 0
{
asm::nop();
}
info!("got data");
let answer =
core::ptr::read_volatile(0xd0000058 as *const usize);
info!("cmd: {} with answer: {}", cmd, answer);
if answer != cmd {
error!("bad answer, will restart");
continue 'outer;
}
}
break;
}
for _ in 1..10000 {
asm::nop();
}
loop {
info!(">>>>>>>> cpu 1 is alive");
for _ in 1..100 {
asm::nop();
}
}
}
}
This will work on waking up CPU 1 but we will quickly encounter a hardware fault.
The reason for the hardware fault is due defmt printing functionally relies on our configured critical section from the cortex-m-rt crate:
[package]
name = "pico-simple"
version = "0.1.0"
edition = "2024"
[dependencies]
cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7.0" }
cortex-m-rt-macros = "0.7.5"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
But as the documentation (and even the feature name!) of this, explicitly states that it is only suitable for single-core machines, which is not the case as we have two CPUs working here. Take a look at the critical-section create to learn why.
Second attempt
To fix this problem, we can rely on the rp235x-hal critical section implementation that handles the multi-core nature of our code:
[package]
name = "pico-simple"
version = "0.1.0"
edition = "2024"
[dependencies]
cortex-m-rt = "0.7.0"
cortex-m-rt-macros = "0.7.5"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
rp235x-hal = { version = "0.4.0", features = ["critical-section-impl"] }
cortex-m = { version = "0.7.6" }
We have to use the crate with use rp235x_hal as _;, otherwise the linker is free to not include it our final binary (try and see what happens).
#![no_std]
#![no_main]
use cortex_m::asm;
use cortex_m_rt::entry;
use defmt::{assert_eq, error, info};
use rp235x_hal as _;
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21, 0x10, 0xff, 0x01, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2, 0xeb, 0x88, 0x71,
0x6c, 0x93, 0x02, 0x10, 0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02, 0x10,
0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01, 0x02, 0x03,
];
fn second_cpu() {
for _ in 1..10000 {
asm::nop();
}
loop {
info!(">>>>>>>> cpu 2 is alive");
for _ in 1..100 {
asm::nop();
}
}
}
#[entry]
fn main() -> ! {
unsafe {
let vector_table_address =
core::ptr::read_volatile(0xE000ED08 as *const usize);
info!("vector table address: {:#x}", vector_table_address);
let cmd_sequence: [usize; 6] = [
0,
0,
1,
vector_table_address,
0x20040000,
second_cpu as *const usize as usize,
];
// algorithm in page 377
'outer: loop {
for _ in 1..10000 {
asm::nop();
}
// discard data from read FIFO until empty
// SIO: FIFO_RD Register
while core::ptr::read_volatile(0xd0000050 as *const usize) & 1 == 1
{
let answer =
core::ptr::read_volatile(0xd0000058 as *const usize);
info!("cleaning with answer: {}", answer);
}
for cmd in cmd_sequence {
// validate no FIFO error
// SIO: FIFO_ST Register
assert_eq!(
core::ptr::read_volatile(0xd0000050 as *const usize)
& 0b1110,
2
);
// SIO: FIFO_WR Register
core::ptr::write_volatile(0xd0000054 as *mut usize, cmd);
asm::sev();
info!("waiting for data");
while core::ptr::read_volatile(0xd0000050 as *const usize) & 1
== 0
{
asm::nop();
}
info!("got data");
let answer =
core::ptr::read_volatile(0xd0000058 as *const usize);
info!("cmd: {} with answer: {}", cmd, answer);
if answer != cmd {
error!("bad answer, will restart");
continue 'outer;
}
}
break;
}
for _ in 1..10000 {
asm::nop();
}
loop {
info!(">>>>>>>> cpu 1 is alive");
for _ in 1..100 {
asm::nop();
}
}
}
}
Interrupts on GPIO events
We now will explore causing an interrupt to trigger when the GPIO pin 16 goes from high to low.
Interrupts on single-core
Let’s try with a single-core example. See what happens when you make GPIO go from high to low (e.g. use a button) or alternatively change the code to put the GPIO in output mode and make it go from high to low yourself.
#![no_std]
#![no_main]
use cortex_m::{
asm, interrupt::InterruptNumber,
peripheral::NVIC,
};
pub use cortex_m_rt::entry;
use defmt::info;
use rp235x_hal as _;
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21,
0x10, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2,
0xeb, 0x88, 0x71, 0x6c, 0x93, 0x02, 0x10,
0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02,
0x10, 0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01,
0x02, 0x03,
];
mod interrupt {
pub const IO_IRQ_BANK0: isize = 21;
}
#[derive(Clone, Copy)]
struct Interrupt;
unsafe impl InterruptNumber for Interrupt {
fn number(self) -> u16 {
21
}
}
#[cortex_m_rt_macros::interrupt]
fn IO_IRQ_BANK0() {
info!("Hello from interrupt!");
unsafe {
// IO_BANK0: INTR2 Register
// 3 GPIO16_EDGE_HIGH WC 0x0
// 2 GPIO16_EDGE_LOW WC 0x0
// 1 GPIO16_LEVEL_HIGH RO 0x0
// 0 GPIO16_LEVEL_LOW RO 0x0
core::ptr::write_volatile(
0x40028238 as *mut u32,
1 << 2 | 1 << 3,
);
}
for _ in 1..10000 {
asm::nop();
}
}
#[entry]
fn main() -> ! {
unsafe {
for _ in 1..10000 {
asm::nop();
}
// this would call the interrupt handler
NVIC::pend(Interrupt);
// if comment out this line, the interrupt handler will be
// called immediately after unmasking
NVIC::unpend(Interrupt);
info!("unmasking");
NVIC::unmask(Interrupt);
// now this will call the interrupt once
info!("pending");
NVIC::pend(Interrupt);
// IO_BANK0: GPIO16_CTRL Register
// Set to use SIO
core::ptr::write_volatile(
0x40028084 as *mut u32,
0x5,
);
// GPIO_OE_CLEAR
// 0x10000: enable output for bit 16 (pin 16)
core::ptr::write_volatile(
0xd0000040 as *mut u32,
1 << 16,
);
// PADS_BANK0: GPIO16 Register
// 0x48: set bit 6 (IE: Input enable) and bit 3 (PUE: Pull up enable)
core::ptr::write_volatile(
0x40038044 as *mut u32,
1 << 6 | 1 << 3,
);
info!("in input mode");
// IO_BANK0: PROC0_INTE2 Register
// Interrupt Enable for processor 0
// Bit 3: GPIO16_EDGE_HIGH
// Bit 2: GPIO16_EDGE_LOW
// Bit 1: GPIO16_LEVEL_HIGH
// Bit 0: GPIO16_LEVEL_LOW
info!("will enable interrupt now");
core::ptr::write_volatile(
0x40028250 as *mut u32,
1 << 2,
);
};
status();
loop {
unsafe {
info!(
"{:#x} GPIO_IN",
core::ptr::read_volatile(
0xd0000004 as *const u32
)
);
}
for _ in 1..100000 {
asm::nop();
}
}
}
fn status() {
unsafe {
info!(
"{:#x} IO_BANK0: GPIO16_STATUS Register",
core::ptr::read_volatile(
0x40028080 as *const u32
)
);
info!(
"{:#x} IO_BANK0: GPIO16_CTRL Register",
core::ptr::read_volatile(
0x40028084 as *const u32
)
);
info!(
"{:#x} GPIO_OUT",
core::ptr::read_volatile(
0xd0000010 as *const u32
)
);
info!(
"{:#x} GPIO_OE",
core::ptr::read_volatile(
0xd0000030 as *const u32
)
);
info!(
"{:#x} PADS_BANK0: GPIO16 Register",
core::ptr::read_volatile(
0x40038044 as *const u32
)
);
}
}
Cargo.toml changes
To make this work, we had again to make some changes in our Cargo.toml:
[package]
name = "pico-simple"
version = "0.1.0"
edition = "2024"
[dependencies]
cortex-m = { version = "0.7.6", features = ["critical-section-single-core"] }
cortex-m-rt = { version = "0.7.0", features = ["device"] }
cortex-m-rt-macros = "0.7.5"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
rp235x-hal = "0.4.0"
We don’t need the multi-core critical section anymore as again running on single-core, but on the other hand we had to enable the device feature of cortex-m-rt and again needed to use the rp235x-hal crate. The reason for that is that the pointer to the function that is our interrupt handle must be stored at cortex-m-rt
If you try to use the Cargo.toml like it was in the previous sections, then the code won’t go our desired interrupt handler, but rather to a default one, that doesn’t clear the GPIO16_EDGE_LOW, so it will be stuck calling the interrupt handler again and again.
Interrupts on multi-core
Now let’s combine the single-core interrupt code with our previous wake the second processor code.
This time however, we will have the GPIO in output mode and make it go from high to low ourselves, this so that we generate exactly one interrupt as the physical button can bounce and cause multiple interrupts to trigger, but here I wanted to make it clear that we get exactly one interrupt triggered for each CPU.
#![no_std]
#![no_main]
use aux::start_second_cpu;
use cortex_m::{
asm, interrupt::InterruptNumber,
peripheral::NVIC,
};
pub use cortex_m_rt::entry;
use defmt::info;
mod aux;
mod interrupt {
pub const IO_IRQ_BANK0: isize = 21;
}
#[derive(Clone, Copy)]
struct Interrupt;
unsafe impl InterruptNumber for Interrupt {
fn number(self) -> u16 {
21
}
}
#[cortex_m_rt_macros::interrupt]
fn IO_IRQ_BANK0() {
unsafe {
info!(
"Hello from interrupt at CPU {}!",
// SIO: CPUID Register
core::ptr::read_volatile(
0xd0000000 as *const u32
)
);
// IO_BANK0: INTR2 Register
// 3 GPIO16_EDGE_HIGH WC 0x0
// 2 GPIO16_EDGE_LOW WC 0x0
// 1 GPIO16_LEVEL_HIGH RO 0x0
// 0 GPIO16_LEVEL_LOW RO 0x0
core::ptr::write_volatile(
0x40028238 as *mut u32,
1 << 2 | 1 << 3,
);
}
}
fn second_cpu_entry_point() -> ! {
unsafe {
NVIC::unmask(Interrupt);
}
loop {
asm::wfi();
}
}
#[entry]
fn main() -> ! {
unsafe {
start_second_cpu(second_cpu_entry_point);
NVIC::unmask(Interrupt);
// IO_BANK0: GPIO16_CTRL Register
// Set to use SIO
core::ptr::write_volatile(
0x40028084 as *mut u32,
0x5,
);
// PADS_BANK0: GPIO16 Register
// 0x40: set bit 6: IE: Input enable
core::ptr::write_volatile(
0x40038044 as *mut u32,
0x40,
);
// GPIO_OE_SET
// 0x10000: enable output for bit 16 (pin 16)
core::ptr::write_volatile(
0xd0000038 as *mut u32,
1 << 16,
);
// IO_BANK0: PROC0_INTE2 Register
// Interrupt Enable for processor 0
// Bit 3: GPIO16_EDGE_HIGH
// Bit 2: GPIO16_EDGE_LOW
// Bit 1: GPIO16_LEVEL_HIGH
// Bit 0: GPIO16_LEVEL_LOW
core::ptr::write_volatile(
0x40028250 as *mut u32,
1 << 2,
);
// IO_BANK0: PROC0_INTE2 Register
// Interrupt Enable for processor 1
// Bit 3: GPIO16_EDGE_HIGH
// Bit 2: GPIO16_EDGE_LOW
// Bit 1: GPIO16_LEVEL_HIGH
// Bit 0: GPIO16_LEVEL_LOW
core::ptr::write_volatile(
0x40028298 as *mut u32,
1 << 2,
);
// 0x018 GPIO_OUT_SET
// High
core::ptr::write_volatile(
0xd0000018 as *mut u32,
1 << 16,
);
// 0x020 GPIO_OUT_CLEAR
// Low
core::ptr::write_volatile(
0xd0000020 as *mut u32,
1 << 16,
);
};
loop {
asm::wfi();
}
}
This should give us:
[INFO ] Hello from interrupt at CPU 1! (pico_simple multi-core/src/main.rs:38)
[INFO ] Hello from interrupt at CPU 0! (pico_simple multi-core/src/main.rs:38)
Note that for interrupt to trigger on both CPUs, we need to unmask the interrupt individually for each CPU and also need to set the PROCx_INTE2 registers for each CPU (not that, in practice, you want that the same event to trigger an interrupt on both CPUs, but it’s the case here for didactic purposes).
Cargo.toml changes
To make this work, we had again to make some changes in our Cargo.toml to incorporate again the critical section for multi-core:
[package]
name = "pico-simple"
version = "0.1.0"
edition = "2024"
[dependencies]
cortex-m = "0.7.6"
cortex-m-rt = { version = "0.7.0", features = ["device"] }
cortex-m-rt-macros = "0.7.5"
panic-probe = { version = "1.0.0", features = ["print-defmt"] }
defmt = "1.0.1"
defmt-rtt = "1.0.0"
rp235x-hal = { version = "0.4.0", features = ["critical-section-impl"] }
Using the XOSC
When the Pico starts, it uses the ROSC (variable frequency, around 11 MHz) as both the reference clock (used by multiple components) and the system clock (i.e.: the CPU clock).
So let’s see how to use the crystal oscillator, the XOSC (stable frequency, default to 12 MHz on the board reference design) for the reference clock and the PLL (a clock multiplier that takes the XOSC as input) for the system clock:
#![no_std]
#![no_main]
use cortex_m::asm;
pub use cortex_m_rt::entry;
use defmt::info;
use rp235x_hal as _;
use {defmt_rtt as _, panic_probe as _};
#[unsafe(link_section = ".start_block")]
#[unsafe(no_mangle)]
static BOOT_ROM_INFO: [u8; 44] = [
0xd3, 0xde, 0xff, 0xff, 0x42, 0x01, 0x21,
0x10, 0xff, 0x01, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x79, 0x35, 0x12, 0xab, 0xf2,
0xeb, 0x88, 0x71, 0x6c, 0x93, 0x02, 0x10,
0x7c, 0x93, 0x02, 0x10, 0x70, 0xf6, 0x02,
0x10, 0x90, 0xa3, 0x1a, 0xe7, 0x1f, 0x01,
0x02, 0x03,
];
#[entry]
fn main() -> ! {
unsafe {
blink_led();
// XOSC: CTRL Register
core::ptr::write_volatile(
0x40048000 as *mut u32,
0xfabaa0,
);
// CLK_REF_CTRL
// Set to use XOSC
core::ptr::write_volatile(
0x40010030 as *mut u32,
2,
);
// deassert PLL reset
// RESETS: RESET Register
core::ptr::write_volatile(
(0x40020000 + 0x3000) as *mut u32,
1 << 14,
);
// Wait until PLL is out of reset
// RESETS: RESET_DONE Register
while core::ptr::read_volatile(
0x40020008 as *const u32,
) >> 14
& 1
!= 1
{
asm::nop();
}
// PLL final outpu frequency FOUTPOSTDIV ==
// (FREF / REFDIV) × FBDIV / (POSTDIV1 * POSTDIV2)
// Therefore, with a 12Mhz XOSC
// 150 == (12 / 1) * 125 / (5 * 2)
// PLL: FBDIV_INT Register
// FBDIV = 125
core::ptr::write_volatile(
0x40050008 as *mut u32,
125,
);
// Power up the PLL VCO and PLL
// PLL: PWR Register
// The 0x3000 is the atomic clear
core::ptr::write_volatile(
(0x40050004 + 0x3000) as *mut u32,
1 | 1 << 5,
);
// Wait until PLL is unlocked
while core::ptr::read_volatile(
0x40050000 as *const u32,
) >> 31
!= 1
{
asm::nop();
}
// PLL: PRIM Register
// POSTDIV1 = 5
// POSTDIV2 = 2
core::ptr::write_volatile(
0x4005000c as *mut u32,
5 << 16 | 2 << 12,
);
// Power up the PLL POSTDIVPD
// PLL: PWR Register
core::ptr::write_volatile(
(0x40050004 + 0x3000) as *mut u32,
1 << 3,
);
// CLOCKS: CLK_SYS_CTRL Register
// Set to use CLKSRC_PLL_SYS
core::ptr::write_volatile(
0x4001003c as *mut u32,
1,
);
};
loop {
unsafe {
blink_led();
}
for _ in 1..100000 {
asm::nop();
}
}
}
unsafe fn blink_led() {
static mut LED_COUNTER: u64 = 0;
unsafe {
let counter = LED_COUNTER;
info!("led blink {}", counter);
LED_COUNTER += 1;
// Set GPIO 16 in output low mode
core::ptr::write_volatile(
0x40028084 as *mut u32,
0x5,
);
core::ptr::write_volatile(
0x40038044 as *mut u32,
0x40,
);
core::ptr::write_volatile(
0xd0000038 as *mut u32,
1 << 16,
);
core::ptr::write_volatile(
0xd0000020 as *mut u32,
1 << 16,
);
for _ in 1..200_000 {
asm::nop();
}
// disable GPIO output
core::ptr::write_volatile(
0xd0000040 as *mut u32,
1 << 16,
);
}
}
Notice how fast the led blinks (i.e.: how fast the for loop is evaluated) after having the system clock use the PLL: the CPU frequency goes from around 11 MHz to 150 MHz.
Timers
In this, we will have some examples of driving the timer components of the Pico.
Reading a timer
Let’s start with a basic operation: starting and then read a timer.
#![no_std]
#![no_main]
use aux::{blink_led, setup_xosc_on_clks};
use cortex_m::asm;
pub use cortex_m_rt::entry;
use defmt::info;
mod aux;
#[entry]
fn main() -> ! {
unsafe {
// GPIO 16 to visually signal board startup to us
blink_led();
setup_xosc_on_clks();
// TICKS: TIMER0_CTRL Register
// Without this the watchdog timer won't tick
// (i.e.: won't go down)
core::ptr::write_volatile(0x40108018 as *mut u32, 1);
// TICKS: TIMER0_CYCLES Register
// A divider that must result in a 1MHz clock.
// If using the XOSC of reference board which 12MHz
// this needs to be 12.
core::ptr::write_volatile(0x4010801c as *mut u32, 12);
// TIMER0: TIMEHR Register
// Optional: set some initial value on the high counter
// core::ptr::write_volatile(0x400b0000 as *mut u32, 7);
};
loop {
let (timer_hr, timer_lr) = unsafe {
// TIMER0: TIMELR Register
let timer_lr =
core::ptr::read_volatile(0x400b000c as *const u32) as u64;
// TIMER0: TIMEHR Register
let timer_hr =
core::ptr::read_volatile(0x400b0008 as *const u32) as u64;
(timer_hr, timer_lr)
};
info!("TIME0_LR: {} ", timer_lr);
info!("TIME0_HR: {} ", timer_hr);
let timer_combined = timer_hr << 32 | timer_lr;
info!("TIMER0: {} ", timer_combined);
for _ in 1..10_000_000 {
asm::nop();
}
}
}
Interrupts on alarms
Now let’s see an example of using the Pico to interrupt on a timer alarm:
#![no_std]
#![no_main]
mod aux;
use aux::{blink_led, setup_xosc_on_clks};
use cortex_m::{asm, interrupt::InterruptNumber, peripheral::NVIC};
pub use cortex_m_rt::entry;
use defmt::info;
use crate::aux::start_second_cpu;
mod interrupt {
pub const TIMER0_IRQ_0: isize = 0;
pub const TIMER0_IRQ_1: isize = 1;
}
#[derive(Clone, Copy)]
struct Interrupt0;
unsafe impl InterruptNumber for Interrupt0 {
fn number(self) -> u16 {
0
}
}
#[derive(Clone, Copy)]
struct Interrupt1;
unsafe impl InterruptNumber for Interrupt1 {
fn number(self) -> u16 {
1
}
}
#[cortex_m_rt_macros::interrupt]
fn TIMER0_IRQ_0() {
unsafe {
info!(
"Hello from interrupt TIMER0_IRQ_0 at CPU {}!",
// SIO: CPUID Register
core::ptr::read_volatile(0xd0000000 as *const u32)
);
// TIMER0: INTR Register
// Clear interrupt
core::ptr::write_volatile((0x400b003c + 0x3000) as *mut u32, 1);
// Will fire the interrupt for ALARM1
// (which is unmasked on CPU0)
alarmer(3_000_000, 1);
}
}
#[cortex_m_rt_macros::interrupt]
fn TIMER0_IRQ_1() {
unsafe {
info!(
"Hello from interrupt TIMER0_IRQ_1 at CPU {}!",
// SIO: CPUID Register
core::ptr::read_volatile(0xd0000000 as *const u32)
);
// TIMER0: INTR Register
// Clear interrupt
core::ptr::write_volatile((0x400b003c + 0x3000) as *mut u32, 2);
// Will fire the interrupt for ALARM0
// (which is unmasked on CPU1)
alarmer(1_000_000, 0);
}
}
fn second_cpu_entry_point() -> ! {
unsafe {
NVIC::unmask(Interrupt0);
}
loop {
asm::wfi();
}
}
#[entry]
fn main() -> ! {
unsafe {
// GPIO 16 to visually signal board startup to us
blink_led();
setup_xosc_on_clks();
start_second_cpu(second_cpu_entry_point);
NVIC::unmask(Interrupt1);
// TICKS: TIMER0_CTRL Register
core::ptr::write_volatile(0x40108018 as *mut u32, 1);
// TICKS: TIMER0_CYCLES Register
// A divider that must result in a 1MHz clock.
// If using the XOSC of reference board which 12MHz
// this needs to be 12.
core::ptr::write_volatile(0x4010801c as *mut u32, 12);
// TIMER0: INTE Register
// Enable interrupts ALARM0 and ALARM1
// for TIMER0
core::ptr::write_volatile(0x400b0040 as *mut u32, 0b11);
// Will fire the interrupt for ALARM0
// (which is unmasked on CPU1)
alarmer(1_000_000, 0);
};
loop {
asm::wfi();
}
}
unsafe fn alarmer(to_add: u32, alarm_id: u32) {
if alarm_id > 3 {
panic!()
}
unsafe {
// TIMER: PAUSE Register
// Pause timer
core::ptr::write_volatile(0x400b0030 as *mut u32, 1);
let firing_when = core::ptr::read_volatile(0x400b000c as *const u32);
let firing_when = firing_when.wrapping_add(to_add);
// TIMER0: ALARMx Register
core::ptr::write_volatile(
(0x400b0010 + alarm_id * 4) as *mut u32,
firing_when,
);
// TIMER: PAUSE Register
// Unpause timer
core::ptr::write_volatile(0x400b0030 as *mut u32, 0);
}
}
Watchdog timer
Now let’s see an example of using the Pico watchdog timer:
#![no_std]
#![no_main]
use aux::blink_led;
pub use cortex_m_rt::entry;
use defmt::info;
use crate::aux::{setup, sleep};
mod aux;
#[entry]
fn main() -> ! {
unsafe {
setup();
// put GPIO Pin 16 in output low mode
// then disables output mode
// this allows you to briefly blink a LED
// at the beginning to help us see that the pico
// is being reset
blink_led();
// These two tell which components the watchdog will reset
// (without these, the watchdog event wouldn't cause anything
// to reset)
{
// PSM: WDSEL Register bit 24 to reset
core::ptr::write_volatile(0x40018008 as *mut u32, (1 << 25) - 1);
// POWMAN: WDSEL Register
// RESET_POWMAN_ASYNC + password
core::ptr::write_volatile(0x40100030 as *mut u32, 0x5AFE0001);
}
// WATCHDOG: CTRL Register Enable
core::ptr::write_volatile(0x400d8000 as *mut u32, 1 << 30);
// WATCHDOG: CTRL Register Trigger Reset
// This would trigger the watchdog reset immediatelly
// core::ptr::write_volatile(0x400d8000 as *mut u32, 1 << 31);
// TICKS: WATCHDOG_CTRL Register
// Without this the watchdog timer won't tick
// (i.e.: won't go down)
core::ptr::write_volatile(0x40108030 as *mut u32, 1);
// TICKS: WATCHDOG_CYCLES Register
// A divider that must result in a 1MHz clock.
// If using the XOSC of reference board which 12MHz
// this needs to be 12.
core::ptr::write_volatile(0x40108034 as *mut u32, 12);
};
let mut i = 16_000_000;
loop {
unsafe {
info!(
"{} WATCHDOG: CTRL Register TIME",
core::ptr::read_volatile(0x400d8000 as *const u32)
& ((1 << 24) - 1)
);
sleep(500_000);
info!(
"{} WATCHDOG: CTRL Register TIME",
core::ptr::read_volatile(0x400d8000 as *const u32)
& ((1 << 24) - 1)
);
// WATCHDOG: LOAD Register
// Set how long we have until the watchdog timer
// will trigger the reset event
core::ptr::write_volatile(0x400d8004 as *mut u32, i);
sleep(1_000_000);
}
i >>= 1;
}
}