k4s.xyz / Embedded Rust on the RP2350
2026-10-11
This is the first lesson of Embedded Rust on the RP2350, a course in embedded Rust on the Raspberry Pi RP2350, built from the reset vector up. It assumes you know Rust reasonably well and have done a little microcontroller work. Everything runs on Linux; the setup steps are written for Fedora.
This lesson proves your bench end to end: a stock rp-hal example builds, flashes and blinks, and you can read your firmware's bytes back out of the chip. That matters for Lesson 1. When your hand-built image refuses to boot, you'll know the fault is in the image, not the toolchain or the wiring.
You finish with:
thumbv8m.main-none-eabihf).The crate rule. Part 1 uses zero dependencies. After that, a crate appears only once you've built a small version of what it does.
How every lesson is built. Goals, datasheet sections to read, concepts, a step-by-step build with complete code, a comparison with how rp-hal does it, exercises, and self-check questions. Code accumulates in one Cargo workspace, and each lesson builds on the one before it, so run each one on hardware before moving on.
Keep these open: the RP2350 datasheet, the Pico 2 datasheet, rp-hal, the pico-pico book and Shawn Hymel's course repo.
PIO has its own lesson (10), and Lesson 11 uses it to build a logic analyzer, so you don't need to buy one. Lesson 7 teaches Rust's concurrency tools on your laptop first; Lesson 9 then puts the second Cortex-M33 to work, well before Embassy.
Lesson 21 comes right before the capstone so the station can sleep between readings. A bench supply that reports current measures the awake states, and a multimeter measures the deep ones. It leans on Lesson 5's clocks, Lesson 8's interrupts and both cores going idle.
After the capstone, a security track (S1 to S4) covers the RP2350's security features. Short security notes appear earlier, wherever those mechanisms first show up. Signed boot burns a key into OTP and can't be undone, so S3 runs on a second Pico 2 kept for that purpose.
The analyzer samples the chip's own pins while your code drives them, so one board both runs the bus and watches it. You'll open captures in PulseView, which is free. Lesson 18 upgrades it with a ring buffer so it can show what happened before the trigger.
| # | Lesson | What you build |
|---|---|---|
| 0 | Toolchain and baseline | A proven bench: blinky flashed, ELF inspected, bytes read back |
| 1 | Anatomy of an RP2350 image | Linker script and a hand-assembled IMAGE_DEF the boot ROM accepts |
| 2 | Reset and the runtime contract | Reset handler in a naked function; LED on with raw writes |
| 3 | GPIO from the registers up | Your register layer and type-state pins; blinky plus button |
| 4 | PAC and runtime crates | Your own PAC generated with svd2rust, then rp235x-pac and cortex-m-rt; defmt logging |
| 5 | Clocks from scratch | XOSC and PLLs to 150 MHz, checked by the frequency counter |
| 6 | Time, PWM and first trait impls | Mini-HAL v0.1: DelayNs, OutputPin, SetDutyCycle; the watchdog: enable, feed, reset reason, pause while debugging |
| 7 | Rust concurrency, laptop first | Small std programs on move closures, Send and Sync, Rc vs Arc, RefCell vs Mutex, atomics; then what changes without std |
| 8 | Interrupts and shared state | Interrupt-driven blinker, debounced button, your critical-section impl; a HardFault handler that reads the fault registers, saves them to watchdog scratch and reboots to report the crash |
| 9 | The second core (Arm only) | Core 1 booted by hand over the SIO FIFO; spinlocks, ordered atomics, a heap with Arc, your own safe spawn API |
| 10 | PIO from the instruction up | Hand-encoded PIO programs, then pio_asm!; side-set, FIFOs, clock dividers |
| 11 | Build a logic analyzer | PIO capture plus your first DMA transfer; captures opened in PulseView |
| 12 | UART and embedded-io | PL011 driver, interrupt-driven RX ring buffer, command console |
| 13 | I²C from registers | DesignWare I²C driver implementing I2c; bus scanner |
| 14 | Driver: SHT31-D | Standalone driver crate with host-side tests |
| 15 | SPI and bus sharing | PL022 SpiBus and shared-bus devices |
| 16 | Driver: ILI9340C TFT | Display driver, your bitmap font, DrawTarget |
| 17 | Driver: SD card | SPI-mode block driver, FAT32 walked by hand, embedded-sdmmc |
| 18 | DMA in depth | Chaining, ring buffers, safe buffer ownership; BMP viewer; analyzer pre-trigger |
| 19 | rp235x-hal under the hood | HAL swap with your drivers unchanged |
| 20 | Async to Embassy | Toy executor, then embassy-rp, async drivers, an executor per core |
| 21 | Power: sleep, dormant, power-down | Clock gating while asleep, DORMANT woken by a GPIO edge or the always-on timer, switched-core power-down that keeps SRAM; current logged from a bench supply by a small Rust host tool, with a multimeter for the microamp states |
| 22 | Capstone: environment station | SHT31 dashboard on the TFT, CSV logging to SD, work split across both cores, sleeping between samples, a watchdog fed only while both cores are alive |
| E1 | TMC2209 stepper (after 12) | STEP/DIR ramps, single-wire UART driver, PIO step generator |
| E2 | OLED FeatherWing (after 16) | Framebuffer driver plus the wing's buttons |
| S1 | TrustZone-M | SAU setup, a Secure and Non-secure split, calls through .gnu.sgstubs veneers |
| S2 | OTP | Reading the OTP rows, what each controls, and which writes are permanent |
| S3 | Hashed and signed images | Hash and signature items in the block loop, signing with picotool, secure boot on the spare board |
| S4 | Hardening | Glitch detector, debug lockdown, and the published attacks on the A2 stepping |
Eight milestones end in a standalone repo you can publish. Between them, the course workspace stays your lab notebook (a private repo works well) and keeps building on its own copy.
| After | Repo | What it shows |
|---|---|---|
| 3 | rp2350-from-reset | Boots with zero dependencies: your linker script, IMAGE_DEF, reset handler, GPIO from raw registers |
| 8 | rp2350-minihal | Your HAL implementing embedded-hal 1.0: clocks, timers, PWM, interrupts. New releases at 13, 15 and 18 |
| 9 | rp2350-dual-core | Core 1 booted by hand, a safe spawn API, cross-core messaging, a measured speedup |
| 11 | pio-logic-analyzer | A working tool: PIO and DMA capture, opened in PulseView. Version 2 adds pre-trigger at 18 |
| 14 | sht31 | A HAL-agnostic driver crate with host-side and mock-bus tests |
| 17 | tft-breakout | Display and SD card drivers for the Adafruit 2.2" breakout. A DMA image viewer joins at 18 |
| 22 | environment-station | The capstone: both cores, async tasks, live dashboard, SD logging |
| E1 | tmc2209 | Stepper driver crate: single-wire UART protocol plus PIO step generation |
Repo names are suggestions. Driver repos stay HAL-agnostic: their examples run on your mini-HAL, pinned by git tag, and gain rp235x-hal examples after Lesson 19 so anyone can run them.
Exporting a milestone
*.workspace = true field with a real
value, and bring .cargo/config.toml, rust-toolchain.toml, memory.x and
build.rs along. Path dependencies on your mini-HAL become git dependencies
pinned to a tag.MIT OR Apache-2.0 is the Rust
norm.cargo fmt --check, cargo clippy, any host tests,
and cargo build --release.v0.1.0, and push to your git host.Lesson 3 walks through the first export end to end. A repo that later lessons
improve gets a new tagged release from a fresh copy, so every published version
stays a coherent snapshot. If you want a crate's commit history in its repo,
git subtree split --prefix=lessons/<crate> extracts it; a plain copy starts
clean.
The two essentials are a Pico 2 and a Raspberry Pi Debug Probe. Every lesson flashes and debugs through the probe.
| Item | When | Why |
|---|---|---|
| Raspberry Pi Pico 2 | Now | The course uses its pinout: 12 MHz crystal, LED on GPIO25 |
| Raspberry Pi Debug Probe | Now | Flashes over SWD, halts the core, reads any address; its UART port serves Lesson 12 |
| USB cables that carry data, one per board | Now | A charge-only cable fails silently |
| 3-pin header for the Pico 2's debug pads | Now | Connects the probe's SWD cable |
| Breadboard, jumpers, LED with 330 Ω resistor, push button | Lesson 3 | GPIO output and input |
| Logic analyzer | Never | You build one in Lesson 11 |
| Bench supply that reports current (optional) | Lesson 21 | Powers the Pico 2 through VSYS and logs its current while running and sleeping |
| Multimeter with a µA range | Lesson 21 | Dormant and power-down draw tens to hundreds of µA, below what most bench supplies resolve |
| A second Pico 2 | Before S3 | Secure boot burns OTP permanently; keep your main board unlocked |
Why the probe matters. cargo run flashes over SWD with no button presses.
You can halt the CPU, read memory, and stream logs. In Lesson 2, a broken reset
handler leaves the board silent, and the probe is your only window into it.
Software, installed next: Rust stable with the thumbv8m.main-none-eabihf
target, cargo-binutils, probe-rs, picotool, git, and an editor with
rust-analyzer. The course uses Helix. PulseView arrives with Lesson 11.
On a fresh Fedora install, do this section first. It adds what the next section takes for granted: rustup, the libraries probe-rs builds against, picotool, and permission to use the probe without sudo. After it, every command in the course runs unchanged. These steps were checked against Fedora 44 on x86_64.
# Rust's installer, the editor, and what cargo needs to build probe-rs
sudo dnf install rustup helix helix-parsers helix-themes git gcc cmake \
pkgconf-pkg-config libusb1-devel systemd-devel
# Rust itself, into ~/.rustup and ~/.cargo; the default choices are fine
rustup-init
Open a new terminal afterwards so ~/.cargo/bin is on your PATH. The editor's
command is still hx.
Fedora splits Helix into three packages. helix is the editor; helix-parsers
holds the grammars and queries behind syntax highlighting; helix-themes holds
the themes. helix only recommends the other two, and a minimal install skips
recommended packages, so name them. Without helix-parsers, Helix opens files
with no highlighting at all.
On the bare Linux console, with no desktop, only 16 colors exist. Pick a theme
built for that in ~/.config/helix/config.toml:
# ~/.config/helix/config.toml
theme = "term16_dark"
Don't also install Fedora's rust and cargo packages. The workspace's
rust-toolchain.toml only takes effect through rustup's own cargo, so which cargo should print a path under ~/.cargo/bin.
Fedora doesn't package picotool. RPM Fusion's nonfree repository does (version 2.3.1), and the package installs picotool's udev rules too. Enable RPM Fusion the way its documentation shows, which adds the free and nonfree repositories together, then install:
sudo dnf install \
https://mirrors.rpmfusion.org/free/fedora/rpmfusion-free-release-$(rpm -E %fedora).noarch.rpm \
https://mirrors.rpmfusion.org/nonfree/fedora/rpmfusion-nonfree-release-$(rpm -E %fedora).noarch.rpm
sudo dnf install picotool
If you'd rather not add RPM Fusion, use Raspberry Pi's prebuilt picotool from
the releases page linked in the next section. You then copy its udev rules into
/etc/udev/rules.d yourself.
The probe-rs and picotool rules both grant access two ways: to whoever is logged
in at the machine's own screen and keyboard, and to members of the plugdev
group. Over SSH only the group works. Fedora doesn't create plugdev, and
probe-rs says to make it a system group on systemd newer than v258; Fedora 44
ships v259.
Lesson 12's serial port, /dev/ttyACM0, belongs to the dialout group, and
logging in at the machine doesn't open it.
Create the group and join both groups first. udev looks up a group name when it
loads a rules file, and drops the group from the rule if it doesn't exist yet.
Rules loaded before plugdev existed wouldn't use it until udev reloaded them.
sudo groupadd --system plugdev
sudo usermod -aG plugdev,dialout $USER
probe-rs publishes its own udev rules. Install them, then reload, which picks up
picotool's rules from step 2 as well, now that plugdev exists:
sudo curl -fsSL -o /etc/udev/rules.d/69-probe-rs.rules \
https://probe.rs/files/69-probe-rs.rules
sudo udevadm control --reload
sudo udevadm trigger
The Debug Probe has no entry of its own in that file. It matches the rule for any adapter whose USB product name contains CMSIS-DAP.
Log out and back in so the new groups apply, then check:
groups # lists plugdev and dialout
which cargo # a path under ~/.cargo/bin
probe-rs list # once probe-rs is installed: the probe, without sudo
For later lessons. PulseView (Lesson 11) and a serial terminal (Lesson 12)
are both packaged: sudo dnf install pulseview tio. Fedora's openocd and RPM
Fusion's pico-sdk aren't needed; the course uses neither.
Four tools cover the whole course: the Cortex-M33 compile target, LLVM's binary inspectors, probe-rs, and picotool.
# 1. Rust stable plus the RP2350 Arm target (Armv8-M Mainline, hard-float ABI)
rustup update stable
rustup target add thumbv8m.main-none-eabihf
# 2. cargo size / objdump / nm, backed by LLVM's tools
rustup component add llvm-tools
cargo install cargo-binutils
# 3. probe-rs: flashing, logs, memory reads
cargo install --locked probe-rs-tools
Install picotool as well. Its info command and UF2 conversion stay useful
alongside the probe.
picotool version reports 2.x, which added RP2350 support.Check everything answers:
rustup target list --installed
cargo size --version
probe-rs --version
picotool version
Linux permissions. udev rules let probe-rs and picotool run without sudo; on
Fedora, the setup above installs them. On another distribution, follow the same
order: make sure the plugdev group exists and you're in it, then install
probe-rs's rules and picotool's
(from its repository's udev folder) into /etc/udev/rules.d and reload udev.
If either tool ever demands sudo after an OS upgrade, check the rules first.
Editor: Helix. Helix talks to rust-analyzer over LSP, so install the server through rustup and confirm Helix finds it:
rustup component add rust-analyzer
hx --health rust # the rust-analyzer line should show a path, not "Not found"
Helix's debugger support is still experimental, so Lesson 2 single-steps the reset handler from the command line through the probe instead.
Connect ground first, then the signals. The probe's D port carries SWD and its U port carries UART. Raspberry Pi's docs warn that boards on separate power need a common ground before any signal wire goes on.
Raspberry Pi Debug Probe to Pico 2
| Probe port and wire | Pico 2 connection |
|---|---|
| D, black (GND) | Debug header GND |
| D, orange (SC) | Debug header SWCLK |
| D, yellow (SD) | Debug header SWDIO |
| U, black (GND) | Pin 3, GND |
| U, orange (probe TX) | Pin 2, GP1 (UART0 RX) |
| U, yellow (probe RX) | Pin 1, GP0 (UART0 TX) |
A plain Pico 2 has no JST debug connector. Solder a 3-pin header onto the SWCLK, GND and SWDIO pads at the end opposite USB, then use the probe's JST-to-female-header cable on the D port. The UART rows matter from Lesson 12, but wiring them now costs nothing. Power each board from its own USB cable; the shared GND wire ties them together.
Source: Raspberry Pi Debug Probe documentation.
Confirm the host sees the probe, and the probe sees the chip, before you build anything.
probe-rs list # should list a CMSIS-DAP probe
probe-rs info # reads the target's debug port; no Rust involved yet
If info reports an SWD or wire-protocol error, recheck the ground wire first,
then check SC and SD aren't swapped.
Read your chip marking. The top of the RP2350 carries a line such as RP2350A0A2 or RP2350A0A4; a phone camera's zoom helps. The last two characters are the stepping. Steppings A3 and later fix erratum E9, the GPIO input leakage issue Lesson 3 covers (datasheet Appendix C.2.1, p. 1349). Note yours down.
Flash rp-hal's own blinky before writing a line yourself. If the LED blinks, every link in the chain works.
git clone https://github.com/rp-rs/rp-hal.git
cd rp-hal/rp235x-hal-examples
Open .cargo/config.toml and find the [target.thumbv8m.main-none-eabihf]
table. Point its runner line at the probe:
# .cargo/config.toml, in [target.thumbv8m.main-none-eabihf]
runner = "probe-rs run --chip RP235x"
Build and flash:
cargo run --release --bin blinky
cargo run builds the ELF, then calls the runner with the ELF's path as the
last argument. The first build compiles every dependency, so it is the slow one.
With probe-rs, the command stays attached to stream output; Ctrl-C ends the
session.
If it fails
gpio25.Seventeen pieces of this baseline run before or around your code without you
writing them. By Lesson 8, you will have rebuilt every one. Open
.cargo/config.toml, memory.x, build.rs and src/bin/blinky.rs, and find
each piece.
| Piece (where) | What it does | Rebuilt in |
|---|---|---|
Target thumbv8m.main-none-eabihf (.cargo/config.toml) | Armv8-M Mainline, Thumb code, hard-float ABI | 1 |
-Tlink.x and --nmagic (.cargo/config.toml) | Hands the linker cortex-m-rt's script; skips page alignment | 1, then 4 |
memory.x (crate root) | Flash and RAM regions; slots .start_block after the vector table | 1 |
build.rs (crate root) | Copies memory.x to where the linker searches | 1 |
#![no_std], #![no_main] (blinky.rs) | No OS, no runtime-provided main | 1 |
| Vector table (cortex-m-rt) | Initial stack pointer, then handler addresses | 1; device IRQs in 8 |
IMAGE_DEF in .start_block (blinky.rs) | Tells the boot ROM this is a valid Arm executable | 1 |
| Reset handler (cortex-m-rt) | Copies .data, zeroes .bss, enables the FPU, calls main | 2 |
#[hal::entry] (blinky.rs) | rp235x-hal's wrapper around cortex-m-rt's entry attribute | 2, compared in 4 |
The panic_* crate import (blinky.rs) | Supplies the mandatory panic handler | 2 |
Peripherals::take() (blinky.rs) | Hands out each peripheral exactly once | 3 raw, 4 via PAC |
Pins::new, into_push_pull_output (blinky.rs) | Releases IO and pad resets; configures pad and pin function | 3 |
init_clocks_and_plls (blinky.rs) | Crystal, PLLs, clock muxes | 5 |
Timer and delay_ms (blinky.rs) | 64-bit microsecond timer behind embedded-hal's DelayNs | 6 |
set_high, set_low (blinky.rs) | embedded-hal's OutputPin trait | 6 |
-Tdefmt.x (.cargo/config.toml) | Linker support for defmt's compact logging | 4 |
PICOTOOL_ENTRIES in .bi_entries (blinky.rs) | Metadata that picotool info displays | 1, as an exercise |
Names drift between rp-hal versions. If a piece isn't where the table says, note what you found instead; that is part of the exercise.
What IMAGE_DEF is. The boot ROM won't run whatever happens to be in flash.
Before launching anything, it searches the first 4 KB of flash for a small tag
called an image definition, or IMAGE_DEF. The tag says "this is a program,
built for an Arm core, for an RP2350". If the ROM can't find one, it treats the
flash as blank and drops into BOOTSEL mode. The smallest valid tag is 20 bytes:
a start marker, one item describing the image, a closing item, a link to the
next block, and an end marker (RP2350
datasheet
§5.1.4, p. 328, and §5.9.5.1, p. 389). The ROM then finds the vector table at
the start of the image, because no item says otherwise (§5.9.3.3, p. 385).
rp-hal writes the tag for you with ImageDef::secure_exe(). In Lesson 1 you
type out all five words yourself.
The ELF is a container for your tools, not something the chip ever sees. The boot ROM only meets the raw bytes that land in flash. This section opens the container, then follows its bytes to their final addresses.
ELF, the Executable and Linkable Format, wraps one blob of bytes in three kinds
of table. The ELF header says what the file is. Program headers describe
segments: which bytes to load, and where. Section headers name the regions the
linker built, such as .text and .data.
The blinky ELF file Where each part goes
(file offsets increase downward)
+--------------------------------+
| ELF header | 52 bytes: class, machine, entry point
+--------------------------------+
| Program headers | segments: what to load, and where
+--------------------------------+
| Loadable section bytes | flashed Flash at 0x1000_0000
| .vector_table, .start_block, | ----------> probe-rs writes these bytes
| .text, .rodata, and the | at the PhysAddr (LMA) of
| initial values for .data | each segment
+--------------------------------+
| Non-allocated sections | read by Stays on your PC
| .debug_*, .symtab, .strtab, | ----------> debugger: symbols, source
| .comment, .ARM.attributes | tools lines, variable types;
+--------------------------------+ defmt strings later
| Section headers | name, type and flags of every section
+--------------------------------+
probe-rs reads the program headers to decide which bytes to write at which flash addresses. It reads the symbol table and debug sections to show you variables, backtraces and, from Lesson 4, logs.
Look at all three tables from rp-hal/rp235x-hal-examples:
# ELF header: class, endianness, machine, entry point
cargo readobj --release --bin blinky -- --elf-output-style=GNU --file-headers
# Program headers: the loader's view, segment by segment
cargo readobj --release --bin blinky -- --elf-output-style=GNU --program-headers
# Section headers: the linker's view, with flags
cargo readobj --release --bin blinky -- --elf-output-style=GNU --section-headers
In the ELF header
Reset with bit 0 set. The chip never reads it; debuggers
do.eabihf in your target name.In the program headers
.data isn't empty, its segment has a RAM VirtAddr and a flash PhysAddr.
That gap is the copy your reset handler performs. Blinky's .data is empty,
so you won't see this segment until the experiment below..bss.In the section headers
.bss has type NOBITS: it takes RAM but no bytes in the file..debug_*, .symtab,
.strtab, .comment, .ARM.attributes. That is why debug = 2 costs no
flash.What the non-allocated sections hold
.debug_info describes every type, variable and function in DWARF, the
standard debug format. It is how a debugger shows a struct's fields by name..debug_line maps each code address to a source file and line. Stepping
through source, and line numbers in backtraces, both come from here..debug_frame records how to unwind each function's stack frame. probe-rs
uses it to print a backtrace after a panic or fault..debug_str, .debug_abbrev, .debug_ranges and .debug_loc are supporting
tables: shared strings, encoding templates, address ranges, and where each
variable lives as code runs. Exact names vary a little with the DWARF version..symtab is the symbol table that cargo nm prints: name, address, size and
type for every function and static. .strtab holds those names..shstrtab holds the section names themselves..comment records the compiler and linker versions that built the file..ARM.attributes records the architecture, FPU and calling convention. Tools
use it to spot incompatible objects, such as soft-float code in a hard-float
build. Add --arch-specific to the readobj command to see it.Thrown away at link time
.ARM.exidx and .ARM.extab hold unwinding tables for panics that unwind.
This target aborts on panic instead, so cortex-m-rt's linker script discards
them..got, the global offset table, serves position-independent code. cortex-m-rt
keeps it only to fail the link if anything needs one, so it should always be
empty.rp235x-hal blinky linked by cortex-m-rt; addresses increase downward
Flash (XIP) at 0x1000_0000 RAM at 0x2000_0000
+------------------------------+ +--------------------------------+
| Vector table | +->| .data |
| initial SP, then handler | | | statics with initial values |
| addresses | | +--------------------------------+
+------------------------------+ | | .bss |
| .start_block | | | statics that start at zero; |
| IMAGE_DEF, then picotool's | | | zeroed at reset, no flash |
| header; must sit in the | | | copy |
| first 4 KB of flash | | +--------------------------------+
+------------------------------+ | | |
| .text | | | free RAM |
| your code plus the runtime | | | ^ |
+------------------------------+ | +----------------|---------------+
| .bi_entries | | | Stack |
| picotool metadata | | | starts at 0x2008_0000 and |
+------------------------------+ | | grows toward lower addresses |
| .rodata | | +--------------------------------+
| constants and strings | |
+------------------------------+ |
| .data load copy |--+ copied at reset
| initial values for RAM |
| statics |
+------------------------------+
Only .data needs both regions: its initial values ship in flash and are copied
to RAM before main. .bss gets no flash bytes at all.
Section by section, in flash
.vector_table sits at 0x1000_0000 and is 272 bytes long. Word 0 is the
initial stack pointer and word 1 the reset handler. The next 14 words serve
the Cortex-M33's own exceptions (NMI, HardFault, SVCall, PendSV, SysTick and
others), then come 52 words, one per RP2350 interrupt line. The boot ROM reads
the first two words at launch; the CPU reads the rest each time an exception
fires..start_block follows at 0x1000_0110. It holds the 20-byte IMAGE_DEF,
followed in current rp-hal by a 20-byte picotool binary-info header, 40 bytes
in all. Your running code never touches either one. The IMAGE_DEF exists so
the boot ROM accepts the image at all; the header tells picotool where
.bi_entries lives..text is machine code: your functions, the HAL's, core's, and compiler
helpers such as 64-bit division. Each function arrives in its own input
section, so the linker can drop everything unreachable. That is why unused HAL
code costs no flash. The CPU executes it in place from flash, through a cache..bi_entries is a table of pointers to picotool metadata such as the program
name and version. Only picotool reads it..rodata holds constant data: string literals, immutable statics, lookup
tables the compiler generates, and formatting strings when something prints.
It is never written, so it never needs RAM..data load copy holds the starting values for .data, packed after
.rodata. The reset handler reads it exactly once..gnu.sgstubs holds TrustZone entry stubs, used only when firmware is split
into Secure and Non-secure halves. Blinky's is empty..end_block is a reserved spot after everything else, where a signature or
hash block can go for secure boot. Blinky leaves it empty.Section by section, in RAM
.data holds statics that can change and start with nonzero values. It costs
twice: flash for the starting values, RAM for the working copy..bss holds statics whose starting bytes are all zero. The reset handler
zeroes the region, so it costs RAM and no flash. The name comes from a 1950s
IBM 704 assembler directive, Block Started by Symbol..uninit holds statics you deliberately leave uninitialized. Reset neither
copies nor zeroes it, so its contents survive a soft reset. That suits crash
logs, and big buffers you fill anyway._stack_start (0x2008_0000) and
grows down toward .uninit, holding local variables, saved registers, return
addresses, and the frame the CPU pushes when an interrupt fires. Nothing stops
an overflow by default; it silently overwrites your statics. Armv8-M's
stack-limit register, MSPLIM, can turn that into a fault, and Lesson 2 sets
it.__sheap, and Lesson 9 adds a heap for Arc.RAM
region, unused for now. Lesson 9 gives each core its own stack there.Where your Rust ends up
| Rust item | Section | Why |
|---|---|---|
Any fn, method, closure or generic instance | .text | Code runs in place from flash |
"hello", b"bytes", &[1, 2, 3] | .rodata | Constant data, never written |
static LUT: [u8; 256] = [...] | .rodata | Immutable static, so no RAM needed |
const MAX: u32 = 100 | none | A value, inlined wherever it is used |
static N: AtomicU32 = AtomicU32::new(7) | .data | Can change, starts nonzero |
static N: AtomicU32 = AtomicU32::new(0) | .bss | Can change, starts as zero bytes |
static mut BUF: [u8; 4096] = [0; 4096] | .bss | 4 KB of RAM, no flash |
let x = ... inside a function | registers or stack | Lives only while the function runs |
A MaybeUninit static with #[unsafe(link_section = ".uninit.LOG")] | .uninit | Untouched at reset |
The rule: a static lands in RAM only if it can change, through static mut or
interior mutability (atomics, Cell, UnsafeCell). Its starting bytes then
decide between .data and .bss.
Try it: move a static between sections. Add these lines to blinky, rebuild,
and compare cargo size --release --bin blinky -- -A before and after.
// Near the top of src/bin/blinky.rs
static mut BIG: [u8; 8192] = [0; 8192];
// Inside main, before the loop, so the linker keeps BIG
unsafe { core::ptr::write_volatile((&raw mut BIG).cast::<u8>(), 1) };
.bss grows by 8192 bytes and flash stays the same. Now change the initializer
to [1; 8192]: .bss shrinks back, while .data and the flash image each grow
by 8192.
The symbols that connect layout to code. cortex-m-rt's linker script defines symbols at the boundaries it draws. They are addresses with no storage behind them, and the reset handler reads them to know what to copy and what to clear.
| Symbol | Marks |
|---|---|
__sidata | Start of the initial values for .data in flash (its LMA) |
__sdata, __edata | Start and end of .data in RAM |
__sbss, __ebss | Start and end of .bss in RAM |
_stack_start | Initial stack pointer, at the top of RAM |
cargo nm --release --bin blinky \
| grep -E '__s(i)?data|__edata|__[se]bss|_stack_start'
__sidata is where the starting values for .data begin in flash, right after
.rodata. In stock blinky .data is empty, so __sidata equals __erodata
and the program headers have no .data segment to compare with. Run the
experiment above with [1; 8192] and a new LOAD segment appears with VirtAddr
0x2000_0000 and PhysAddr equal to __sidata. In Lesson 2, your own reset
handler copies from __sidata into __sdata..__edata, zeroes __sbss..__ebss,
then calls main.
Run these from rp-hal/rp235x-hal-examples, so its .cargo/config.toml
applies:
# Sections with sizes and addresses: 0x1000_xxxx is flash, 0x2000_xxxx is RAM
cargo size --release --bin blinky -- -A
# Section headers: VMA is where a section runs, LMA is where it is stored
# --show-lma adds the load address column; LLVM's objdump hides it by default
cargo objdump --release --bin blinky -- -h --show-lma
# Raw bytes of the vector table and the boot block
cargo objdump --release --bin blinky -- -s -j .vector_table
cargo objdump --release --bin blinky -- -s -j .start_block
# The 15 biggest symbols, and where key ones landed
cargo nm --release --bin blinky -- --print-size --size-sort | tail -n 15
cargo nm --release --bin blinky | grep -E 'IMAGE_DEF|Reset|main'
What to look for
-h --show-lma output, .data is the one section whose VMA (RAM)
differs from its LMA (flash). With an empty .data, the difference is still
there but covers zero bytes.RAM region in memory.x. objdump prints bytes in
memory order, so it shows as 00000820..start_block holds 40 bytes. The first 20 are the IMAGE_DEF, from
d3deffff to 793512ab: the block's start and end markers, stored
little-endian. Lesson 1 decodes the 12 bytes between them. The next 20, from
f2eb8871 to 90a31ae7, are picotool's binary-info header. They hold the
addresses of __bi_entries_start, __bi_entries_end and a mapping table..text starts right after .start_block, because memory.x sets _stext
there.main. Lesson 2 explains why.Flash holds a plain image, not the ELF. objcopy extracts exactly those bytes, so you can compare the two directly:
cargo objcopy --release --bin blinky -- -O binary blinky.bin
ls -l blinky.bin
find .. -path '*/thumbv8m.main-none-eabihf/release/blinky' -exec ls -l {} \;
xxd blinky.bin | head -n 2 # or: hexdump -C blinky.bin | head -n 2
.bin is small, around 8 KB. The ELF is many times larger, and almost all
of the difference is debug info.blinky.bin is address 0x1000_0000. Its first 8 bytes are the
stack pointer and reset vector you read back in the next section.Try it: add up the FileSiz of every LOAD segment whose PhysAddr is in flash,
then compare the total with the size of blinky.bin. Explain any difference.
Coming in Lesson 4: defmt adds a .defmt section without the A flag. Every
log message's format string lives there, in the ELF on your PC. The chip sends
only a small index, and probe-rs looks the string up.
The bytes objdump showed now sit in flash at 0x1000_0000. Reading them back closes the loop between the file on your disk and the silicon.
# Vector table: initial stack pointer, then the reset vector
probe-rs read --chip RP235x b32 0x10000000 2
# The boot block, at the IMAGE_DEF address cargo nm printed (0x10000110)
probe-rs read --chip RP235x b32 0x10000110 5
probe-rs prints whole 32-bit words, so the stack pointer reads 20080000 here
rather than objdump's 00000820. Same bytes, different presentation. The reset
vector is an odd address just above the vector table, such as 10000139; the
exact value depends on your rp-hal version. The boot block should read
ffffded3 first and ab123579 last.
Bonus: watch a peripheral tick. TIMER0 starts at 0x400b0000. Find the offset of its raw low-word register, TIMERAWL, in the datasheet's TIMER register list, and add it to the base address. The offset alone points into the boot ROM, which never changes. Read the full address twice, a few seconds apart, and estimate the tick rate from the difference. That is your first peripheral register read; Lesson 3 starts writing them.
Create the workspace now so Lesson 1 drops straight into it. Keep it separate from your rp-hal clone.
rp2350-course/
├── .cargo/config.toml
├── .helix/languages.toml
├── Cargo.toml
├── rust-toolchain.toml
└── lessons/ (Lesson 1 adds the first crate)
# Cargo.toml
[workspace]
resolver = "3"
members = []
[workspace.package]
edition = "2024"
[profile.release]
debug = 2 # full symbols in the ELF for probe-rs; none of it reaches flash
# .cargo/config.toml
[build]
target = "thumbv8m.main-none-eabihf"
[target.thumbv8m.main-none-eabihf]
rustflags = ["-C", "target-cpu=cortex-m33"]
runner = "probe-rs run --chip RP235x"
# rust-toolchain.toml
[toolchain]
channel = "stable"
targets = ["thumbv8m.main-none-eabihf"]
components = ["llvm-tools"]
# .helix/languages.toml
# Workspace-local Helix config, merged over your global languages.toml
[language-server.rust-analyzer.config]
check.allTargets = false
Three choices worth noticing:
build.rs passes its
own.target-cpu=cortex-m33 lets the compiler use the M33's DSP instructions and
tune for its pipeline.check.allTargets = false stops rust-analyzer checking test targets, which
need std and can't build for this target. rust-analyzer already picks up the
target from .cargo/config.toml, so Helix needs nothing else. If diagnostics
look wrong, run hx --health rust from inside rp2350-course/.Run git init too. You will want to diff your from-scratch code against
rp-hal's later.
Exercises
src/bin/blinky.rs, for example 100 ms on and 900
ms off, and reflash. Note how much faster the second build is..text sizes from
cargo size -- -A. Explain the gap, and why debug info isn't the cause.cargo nm. Guess what each does from
its name.picotool info -a target/thumbv8m.main-none-eabihf/release/blinky -t elf. Match each line of output to the source that produced it.probe-rs read a few times while watching the LED. Does it keep its
rhythm, and what does that suggest about how the probe reaches memory?Self-check
.data take space in both flash and RAM, while .bss takes RAM
only?memory.x puts .start_block right after the vector table. What would
happen if it landed after 10 KB of .text instead?main is called?Part of Embedded Rust on the RP2350.