k4s.xyz / Embedded Rust on the RP2350

Lesson 0: Toolchain and a Known-Good Baseline

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.

Goals and how this course works

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:

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.

Course map

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.

#LessonWhat you build
0Toolchain and baselineA proven bench: blinky flashed, ELF inspected, bytes read back
1Anatomy of an RP2350 imageLinker script and a hand-assembled IMAGE_DEF the boot ROM accepts
2Reset and the runtime contractReset handler in a naked function; LED on with raw writes
3GPIO from the registers upYour register layer and type-state pins; blinky plus button
4PAC and runtime cratesYour own PAC generated with svd2rust, then rp235x-pac and cortex-m-rt; defmt logging
5Clocks from scratchXOSC and PLLs to 150 MHz, checked by the frequency counter
6Time, PWM and first trait implsMini-HAL v0.1: DelayNs, OutputPin, SetDutyCycle; the watchdog: enable, feed, reset reason, pause while debugging
7Rust concurrency, laptop firstSmall std programs on move closures, Send and Sync, Rc vs Arc, RefCell vs Mutex, atomics; then what changes without std
8Interrupts and shared stateInterrupt-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
9The 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
10PIO from the instruction upHand-encoded PIO programs, then pio_asm!; side-set, FIFOs, clock dividers
11Build a logic analyzerPIO capture plus your first DMA transfer; captures opened in PulseView
12UART and embedded-ioPL011 driver, interrupt-driven RX ring buffer, command console
13I²C from registersDesignWare I²C driver implementing I2c; bus scanner
14Driver: SHT31-DStandalone driver crate with host-side tests
15SPI and bus sharingPL022 SpiBus and shared-bus devices
16Driver: ILI9340C TFTDisplay driver, your bitmap font, DrawTarget
17Driver: SD cardSPI-mode block driver, FAT32 walked by hand, embedded-sdmmc
18DMA in depthChaining, ring buffers, safe buffer ownership; BMP viewer; analyzer pre-trigger
19rp235x-hal under the hoodHAL swap with your drivers unchanged
20Async to EmbassyToy executor, then embassy-rp, async drivers, an executor per core
21Power: sleep, dormant, power-downClock 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
22Capstone: environment stationSHT31 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
E1TMC2209 stepper (after 12)STEP/DIR ramps, single-wire UART driver, PIO step generator
E2OLED FeatherWing (after 16)Framebuffer driver plus the wing's buttons
S1TrustZone-MSAU setup, a Secure and Non-secure split, calls through .gnu.sgstubs veneers
S2OTPReading the OTP rows, what each controls, and which writes are permanent
S3Hashed and signed imagesHash and signature items in the block loop, signing with picotool, secure boot on the spare board
S4HardeningGlitch detector, debug lockdown, and the published attacks on the A2 stepping

Portfolio projects

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.

AfterRepoWhat it shows
3rp2350-from-resetBoots with zero dependencies: your linker script, IMAGE_DEF, reset handler, GPIO from raw registers
8rp2350-minihalYour HAL implementing embedded-hal 1.0: clocks, timers, PWM, interrupts. New releases at 13, 15 and 18
9rp2350-dual-coreCore 1 booted by hand, a safe spawn API, cross-core messaging, a measured speedup
11pio-logic-analyzerA working tool: PIO and DMA capture, opened in PulseView. Version 2 adds pre-trigger at 18
14sht31A HAL-agnostic driver crate with host-side and mock-bus tests
17tft-breakoutDisplay and SD card drivers for the Adafruit 2.2" breakout. A DMA image viewer joins at 18
22environment-stationThe capstone: both cores, async tasks, live dashboard, SD logging
E1tmc2209Stepper 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

  1. Copy the crate out of the workspace into a fresh directory. The workspace copy carries on into the next lesson untouched.
  2. Make it build alone. Replace each *.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.
  3. Add a README covering what it demonstrates, the wiring, how to flash it, and what you learned building it. Add a license; MIT OR Apache-2.0 is the Rust norm.
  4. From a fresh clone, run cargo fmt --check, cargo clippy, any host tests, and cargo build --release.
  5. Commit, tag v0.1.0, and push to your git host.
  6. Optional CI: a workflow that builds for the RP2350 target and runs the host tests.

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.

What you need

The two essentials are a Pico 2 and a Raspberry Pi Debug Probe. Every lesson flashes and debugs through the probe.

ItemWhenWhy
Raspberry Pi Pico 2NowThe course uses its pinout: 12 MHz crystal, LED on GPIO25
Raspberry Pi Debug ProbeNowFlashes over SWD, halts the core, reads any address; its UART port serves Lesson 12
USB cables that carry data, one per boardNowA charge-only cable fails silently
3-pin header for the Pico 2's debug padsNowConnects the probe's SWD cable
Breadboard, jumpers, LED with 330 Ω resistor, push buttonLesson 3GPIO output and input
Logic analyzerNeverYou build one in Lesson 11
Bench supply that reports current (optional)Lesson 21Powers the Pico 2 through VSYS and logs its current while running and sleeping
Multimeter with a µA rangeLesson 21Dormant and power-down draw tens to hundreds of µA, below what most bench supplies resolve
A second Pico 2Before S3Secure 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.

Fedora setup

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.

1. Packages from Fedora

# 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.

2. picotool from RPM Fusion

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.

3. Probe and serial permissions

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.

Install the toolchain

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.

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.

Wire it up

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 wirePico 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.

Verify the chain

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 the baseline

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

The magic inventory

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 doesRebuilt in
Target thumbv8m.main-none-eabihf (.cargo/config.toml)Armv8-M Mainline, Thumb code, hard-float ABI1
-Tlink.x and --nmagic (.cargo/config.toml)Hands the linker cortex-m-rt's script; skips page alignment1, then 4
memory.x (crate root)Flash and RAM regions; slots .start_block after the vector table1
build.rs (crate root)Copies memory.x to where the linker searches1
#![no_std], #![no_main] (blinky.rs)No OS, no runtime-provided main1
Vector table (cortex-m-rt)Initial stack pointer, then handler addresses1; device IRQs in 8
IMAGE_DEF in .start_block (blinky.rs)Tells the boot ROM this is a valid Arm executable1
Reset handler (cortex-m-rt)Copies .data, zeroes .bss, enables the FPU, calls main2
#[hal::entry] (blinky.rs)rp235x-hal's wrapper around cortex-m-rt's entry attribute2, compared in 4
The panic_* crate import (blinky.rs)Supplies the mandatory panic handler2
Peripherals::take() (blinky.rs)Hands out each peripheral exactly once3 raw, 4 via PAC
Pins::new, into_push_pull_output (blinky.rs)Releases IO and pad resets; configures pad and pin function3
init_clocks_and_plls (blinky.rs)Crystal, PLLs, clock muxes5
Timer and delay_ms (blinky.rs)64-bit microsecond timer behind embedded-hal's DelayNs6
set_high, set_low (blinky.rs)embedded-hal's OutputPin trait6
-Tdefmt.x (.cargo/config.toml)Linker support for defmt's compact logging4
PICOTOOL_ENTRIES in .bi_entries (blinky.rs)Metadata that picotool info displays1, 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.

Look inside the ELF

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.

The container

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

In the program headers

In the section headers

What the non-allocated sections hold

Thrown away at link time

Where the bytes land

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

Section by section, in RAM

Where your Rust ends up

Rust itemSectionWhy
Any fn, method, closure or generic instance.textCode runs in place from flash
"hello", b"bytes", &[1, 2, 3].rodataConstant data, never written
static LUT: [u8; 256] = [...].rodataImmutable static, so no RAM needed
const MAX: u32 = 100noneA value, inlined wherever it is used
static N: AtomicU32 = AtomicU32::new(7).dataCan change, starts nonzero
static N: AtomicU32 = AtomicU32::new(0).bssCan change, starts as zero bytes
static mut BUF: [u8; 4096] = [0; 4096].bss4 KB of RAM, no flash
let x = ... inside a functionregisters or stackLives only while the function runs
A MaybeUninit static with #[unsafe(link_section = ".uninit.LOG")].uninitUntouched 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.

SymbolMarks
__sidataStart of the initial values for .data in flash (its LMA)
__sdata, __edataStart and end of .data in RAM
__sbss, __ebssStart and end of .bss in RAM
_stack_startInitial 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

From ELF to flash image

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

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.

Read it back from the chip

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.

Set up the course workspace

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:

Run git init too. You will want to diff your from-scratch code against rp-hal's later.

Exercises and self-check

Exercises

  1. Change the blink timing in src/bin/blinky.rs, for example 100 ms on and 900 ms off, and reflash. Note how much faster the second build is.
  2. Build blinky in debug and in release, and compare their .text sizes from cargo size -- -A. Explain the gap, and why debug info isn't the cause.
  3. List the five largest functions with cargo nm. Guess what each does from its name.
  4. Run picotool info -a target/thumbv8m.main-none-eabihf/release/blinky -t elf. Match each line of output to the source that produced it.
  5. Run 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

  1. Why does .data take space in both flash and RAM, while .bss takes RAM only?
  2. Why is the initial stack pointer 0x20080000, and which way does the stack grow from there?
  3. Why is the reset vector an odd number?
  4. memory.x puts .start_block right after the vector table. What would happen if it landed after 10 KB of .text instead?
  5. Which pieces in the magic inventory run before main is called?

Part of Embedded Rust on the RP2350.