k4s.xyz / Embedded Rust on the RP2350

Lesson 1: Anatomy of an RP2350 Image

2026-10-18

This is the second lesson of Embedded Rust on the RP2350, a course in embedded Rust on the Raspberry Pi RP2350, built from the reset vector up. It follows Lesson 0, which sets up the toolchain and the rp2350-course workspace this lesson adds to.

Goals

You finish with lessons/l01-image, a crate with zero dependencies that the RP2350's boot ROM accepts and runs. Every byte in it is yours:

rp-hal's blinky from Lesson 0 puts 40 bytes in .start_block: a 20-byte IMAGE_DEF followed by a 20-byte picotool header. This lesson builds only the first part.

Your reset handler stays deliberately small. Copying .data, zeroing .bss and turning on the LED are Lesson 2.

Read first

All section numbers are from the RP2350 datasheet, with the page number printed in its footer.

SectionPagesWhy
§5.1.4 Image definitions328What an IMAGE_DEF is for
§5.1.5 Blocks and block loops328–329Markers, items, and the link word
§5.1.12 Flash image boot331–332The first-4-KB rule, and the 20-byte minimum
§5.9.3.1 IMAGE_DEF item383–384The flags field, bit by bit
§5.9.3.3 VECTOR_TABLE item385Without this item, the vector table is assumed to be at the start of the image
§5.9.5.1 Minimum Arm IMAGE_DEF389The exact five words you'll type
§2.2 Address map27Where flash (0x1000_0000) and SRAM (0x2000_0000) live

For comparison after you've built yours: rp235x-hal/src/block.rs in rp-hal, and memory.x in rp235x-hal-examples.

Concepts

What the boot ROM does before your code exists

At power-on the core runs code from its own mask ROM at address 0, not from flash. The boot ROM sets up the QSPI flash in a simple, safe mode, then searches the first 4 KB of flash for a block loop holding a valid IMAGE_DEF (§5.1.12). If it finds one for an Arm executable, it looks for the vector table at the start of the image, because your IMAGE_DEF names no other location (§5.9.3.3). It loads the stack pointer from word 0 and jumps to the address in word 1. If it finds nothing valid, it treats the flash as blank, drops into BOOTSEL mode, and the board appears on your PC as a USB drive.

Lesson 0's diagram showed the vector table at the start of flash. The boot ROM never reads that table until the IMAGE_DEF has vouched for the image.

Blocks, items and loops

A block is a run of 32-bit words (§5.1.5.1):

  1. A start marker, 0xffffded3.
  2. One or more items. The first item's type decides what kind of block it is.
  3. A LAST item recording how many words of items came before it.
  4. A link: the relative offset to the next block in the loop.
  5. An end marker, 0xab123579.

A link of 0 points back at the block itself, a loop of one. Longer loops exist so that tools can append blocks after linking: signing an image, for example, adds a second, larger IMAGE_DEF (§5.1.5.2). The markers were chosen because they're unlikely to appear in real Arm or RISC-V code.

Your IMAGE_DEF, word by word

WordValueMeaning
00xffffded3Start marker
10x10210142IMAGE_TYPE item: byte 0 is type 0x42, byte 1 is the size (1 word), bytes 2–3 are the flags 0x1021
20x000001ffLAST item: byte 0 is type 0xff, bytes 1–2 say the items before it total 1 word
30x00000000Link to the next block: offset 0, a loop of one
40xab123579End marker

The flags 0x1021 in binary are 0001 0000 0010 0001. Checked against Table 493 (§5.9.3.1):

BitsFieldValueMeaning
0–3Image type1Executable
4–5Security2Runs in Secure mode
8–10CPU0Arm
11Extra security0Off (ignored before stepping A4)
12–14Chip1RP2350
15Try before you buy0Off

The image format and the boot ROM's search for it are the same on every RP2350 stepping. Bit 11 is the one difference, and this image leaves it clear anyway (§5.9.3.1, p. 384).

Little-endian storage is why a hex dump of the block shows d3deffff 42012110: the same words, low byte first.

Linker scripts

The linker script is the contract between your code and the chip's address map. Yours has four parts:

Two more pieces protect you. KEEP stops the linker from discarding a section nothing references. ASSERT fails the link with your own message when a rule is broken, which beats debugging a silent board.

Keeping the linker from deleting your work

Nothing in your code calls the vector table or reads the IMAGE_DEF, so the toolchain would happily throw both away. Three layers stop it:

Exercise 3 has you remove one layer and watch what disappears.

The Thumb bit

Cortex-M only runs Thumb code, and bit 0 of a branch target says "Thumb". When you put a function in the vector table, the toolchain sets that bit for you. That's why blinky's reset vector is 0x10000139 while Reset itself sits at 0x10000138.

Build it

Four files go in a new crate, and one line changes in the workspace.

rp2350-course/
├── Cargo.toml            add the crate to members
└── lessons/l01-image/
    ├── Cargo.toml
    ├── build.rs          hands link.ld to the linker
    ├── link.ld           MEMORY, SECTIONS, ENTRY, _stack_start
    └── src/main.rs       vector table, IMAGE_DEF, Reset

1. Register the crate

In the workspace's root manifest:

# Cargo.toml (workspace root)
[workspace]
resolver = "3"
members = ["lessons/l01-image"]

And the crate's own manifest:

# lessons/l01-image/Cargo.toml
[package]
name = "l01-image"
version = "0.1.0"
edition.workspace = true

[[bin]]
name = "l01-image"
test = false
bench = false

How to read these two files. TOML is key = value lines grouped under [table] headers. A dotted key such as edition.workspace is shorthand for a nested table.

2. Hand the script to the linker

// lessons/l01-image/build.rs
use std::{env, path::PathBuf};

fn main() {
    // Let the linker find link.ld in this crate's directory.
    let dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    println!("cargo:rustc-link-search={}", dir.display());

    // Use our script, and skip page-aligning sections
    // (flash isn't paged memory).
    println!("cargo:rustc-link-arg-bins=-Tlink.ld");
    println!("cargo:rustc-link-arg-bins=--nmagic");

    println!("cargo:rerun-if-changed=link.ld");
}

How this file works:

3. Write the linker script

/* lessons/l01-image/link.ld */
/* Lesson 1: the memory map of a zero-dependency RP2350 image. */

MEMORY
{
    /* The Pico 2's QSPI flash, seen through XIP. */
    FLASH : ORIGIN = 0x10000000, LENGTH = 4M
    /* SRAM0-7; the two 4 KB banks above are left alone. */
    RAM   : ORIGIN = 0x20000000, LENGTH = 512K
}

/* Debuggers treat this as the start. The chip ignores it and uses the
   vector table. */
ENTRY(Reset);

/* The stack starts at the top of RAM and grows down. */
_stack_start = ORIGIN(RAM) + LENGTH(RAM);

SECTIONS
{
    /* Word 0: initial stack pointer. Word 1: reset handler.
       Must be first in flash. */
    .vector_table ORIGIN(FLASH) :
    {
        KEEP(*(.vector_table));
    } > FLASH

    /* The IMAGE_DEF block. The boot ROM only searches the first 4 KB of
       flash for it. */
    .start_block : ALIGN(4)
    {
        KEEP(*(.start_block));
    } > FLASH

    .text : ALIGN(4)
    {
        *(.text .text.*);
    } > FLASH

    .rodata : ALIGN(4)
    {
        *(.rodata .rodata.*);
    } > FLASH

    /* Nothing copies .data or zeroes .bss until Lesson 2, so both must
       stay empty. */
    .data : ALIGN(4)
    {
        *(.data .data.*);
    } > RAM AT > FLASH

    .bss (NOLOAD) : ALIGN(4)
    {
        *(.bss .bss.*);
    } > RAM

    /* Unwind tables are for panics that unwind. This target aborts
       instead. */
    /DISCARD/ :
    {
        *(.ARM.exidx .ARM.exidx.* .ARM.extab.*);
    }
}

ASSERT(SIZEOF(.data) == 0 && SIZEOF(.bss) == 0,
       "Lesson 1 has no runtime init: keep mutable statics out until Lesson 2");
ASSERT(ADDR(.start_block) + SIZEOF(.start_block) <= ORIGIN(FLASH) + 4K,
       "the IMAGE_DEF must end within the first 4 KB of flash");

Reading the script, line by line

Cargo hands this file to rust-lld, LLVM's linker, running in GNU mode (-flavor gnu on its command line). It reads the same script language as GNU ld, so the GNU ld manual's "Linker Scripts" chapter is the reference for everything below. These rules apply throughout the file:

MEMORY { ... } declares regions, and nothing else. Each line is NAME : ORIGIN = start, LENGTH = size. A region is a named address range that output sections can be placed into, plus a running "next free address" that starts at ORIGIN. The linker checks fit: if .text grows past LENGTH(FLASH), the link fails with an overflow error instead of producing an image that wraps. GNU syntax also allows attributes such as FLASH (rx) :. They only steer sections the script doesn't mention, and this script mentions everything, so they're left out.

ENTRY(Reset); writes Reset's address into the ELF header's entry-point field. Tools that load the ELF, such as GDB, read it as "where the program starts", and the linker treats it as a garbage-collection root. The chip never sees it: it starts from the vector table.

_stack_start = ORIGIN(RAM) + LENGTH(RAM); defines a symbol, which is a name for an address with no storage behind it. ORIGIN() and LENGTH() are built-in functions that read a region's numbers back, so this is 0x2000_0000 + 0x8_0000 = 0x2008_0000. In Rust you declare it as extern static _stack_start: u32 and only ever take its address (&raw const _stack_start). Reading its value would load whatever word sits at 0x2008_0000, which is the first word of SRAM8.

SECTIONS { ... } is where the work happens. The compiler's object files arrive full of input sections. Each statement inside SECTIONS builds one output section in the final ELF, in the order written. The general shape is:

NAME [ADDRESS] [(TYPE)] : [ALIGN(n)]
{
    FILE-PATTERN(SECTION-PATTERNS)
    ...
} [> VMA-REGION] [AT > LMA-REGION]

Taking each piece in turn:

PieceExampleMeaning
NAME.textThe output section's name in the ELF. Only /DISCARD/ is special; any other name is just a label.
ADDRESSORIGIN(FLASH)An explicit start address. Without it, the section starts at the region's next free address. Only .vector_table uses one, because the boot ROM expects the table at the very start of flash.
(TYPE)(NOLOAD)NOLOAD reserves addresses but writes no bytes to the file. That's right for .bss, which only has to be zero at run time.
ALIGN(n)ALIGN(4)Rounds the start address up to the next multiple of n bytes. The linker already aligns each section to its strictest input (a u32 forces 4), so ALIGN sets a floor regardless of contents. .text ends at 0x1000_003a. A .rodata holding only a [u8; 3] lands at 0x1000_003a without ALIGN(4) and 0x1000_003c with it. Lesson 2's copy loop moves whole words, which is why it needs that floor.
> REGION> FLASHPlace the section in this region, at its next free address, and advance that address past it.
AT > REGION> RAM AT > FLASHGive the section a different load address (LMA). .data runs at RAM addresses (VMA) but its starting bytes are stored in flash, right after .rodata. That's the VMA/LMA split from Lesson 0: virtual memory address (where it runs) versus load memory address (where it's stored).

Inside the braces: *(.text .text.*). This is an input section description, FILE-PATTERN(SECTION-PATTERNS).

Here's what happens if you write *(.text) alone. The link still succeeds, with no warning. .text.Reset becomes an "orphan", a section no rule claims, and the linker places it by its own heuristics as an output section named .text.Reset. It happens to land at 0x1000_001c, so the image even runs. That's the danger: an unmatched section is placed silently, wherever the linker sees fit. Adding -C link-arg=--orphan-handling=warn makes it name every orphan. It also lists the debug sections, which is harmless, because they never reach flash.

KEEP(...) wraps an input section description. Sections inside it are roots for garbage collection, so the linker never removes them. Anything they reference survives too. .text.Reset needs no KEEP because the vector table points at it.

/DISCARD/ : { ... } is the one special output name: anything matched there is thrown away. .ARM.exidx and .ARM.extab are unwind tables that let a panic walk back up the stack. This target aborts on panic, so they're dead weight. rustc still emits a small one for the panic handler.

ASSERT(condition, "message"); at the top level is checked after every address is fixed. If the condition is false, the link fails with your message. ADDR(sec) is a section's start address, and SIZEOF(sec) its size in bytes.

rp-hal's rp235x-hal-examples/memory.x uses one more piece you'll meet in Lesson 2: ., the location counter. Inside a section it means "the current address", so __start_block_addr = .; records where the block begins. Each of its SECTIONS blocks closes with INSERT AFTER, as in } INSERT AFTER .vector_table;, because the file isn't a whole script. The blocks splice .start_block and a few other sections into link.x, the full script the cortex-m-rt crate provides, which defines the vector table and everything else.

4. Write the program

// lessons/l01-image/src/main.rs
//! Lesson 1: an RP2350 image the boot ROM accepts, with zero dependencies.
#![no_std]
#![no_main]

use core::panic::PanicInfo;
use core::ptr;

unsafe extern "C" {
    /// Defined by link.ld at the top of RAM. Only its address means anything.
    static _stack_start: u32;
}

/// The first two words of the Armv8-M vector table. Lesson 8 adds the other 66.
#[repr(C)]
pub struct VectorTable {
    initial_sp: *const u32,
    reset: extern "C" fn() -> !,
}

// Raw pointers aren't `Sync`, so a static holding one needs this promise.
// It holds: nothing ever writes the table, and it lives in flash anyway.
unsafe impl Sync for VectorTable {}

#[used]
#[unsafe(link_section = ".vector_table")]
static VECTOR_TABLE: VectorTable = VectorTable {
    initial_sp: &raw const _stack_start,
    reset: Reset,
};

/// The minimum Arm IMAGE_DEF (RP2350 datasheet §5.9.5.1, p. 389).
#[used]
#[unsafe(link_section = ".start_block")]
static IMAGE_DEF: [u32; 5] = [
    0xffff_ded3, // PICOBIN_BLOCK_MARKER_START
    0x1021_0142, // IMAGE_TYPE item, 1 word: EXE, Secure, Arm, RP2350
    0x0000_01ff, // LAST item: the items before it total 1 word
    0x0000_0000, // link to the next block: 0 means it loops to itself
    0xab12_3579, // PICOBIN_BLOCK_MARKER_END
];

/// A word of RAM you can watch from the probe. A raw address, not a static,
/// because a static here would land in .bss and nothing zeroes .bss yet.
const HEARTBEAT: *mut u32 = 0x2000_0000 as *mut u32;

#[unsafe(no_mangle)]
#[allow(non_snake_case)]
pub extern "C" fn Reset() -> ! {
    let mut count: u32 = 0;
    loop {
        count = count.wrapping_add(1);
        // Volatile, so the compiler can't decide nobody is looking.
        unsafe { ptr::write_volatile(HEARTBEAT, count) };
    }
}

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {}
}

Reading the program, line by line

Each variation below was built to check it. Where removing something breaks the build, the error shown is the real one.

Comments and crate attributes

Declaring a symbol from the linker script

unsafe extern "C" {
    static _stack_start: u32;
}

The vector table type

The two statics

The heartbeat address

const HEARTBEAT: *mut u32 = 0x2000_0000 as *mut u32; casts a plain integer to a raw pointer. A const is right here, because it's just a number. *mut means writing through it is allowed (still only inside unsafe).

The reset handler

1000001c: b580    push {r7, lr}
1000001e: 466f    mov  r7, sp
10000020: e7fe    b    0x10000020 <Reset+0x4>

That's a branch to itself, with no store anywhere. The compiler proved that nothing in the program ever reads that memory, so it deleted the writes. Volatile tells it that each access is visible outside the program (to the probe here, and to hardware from Lesson 3 on), so every write must happen, in order. That's the version that produced the unrolled loop of four str instructions you'll see in the disassembly below.

The panic handler

#[panic_handler] marks the function that runs when code panics. With std that function comes built in. Under no_std you must provide exactly one, and its signature must be fn(&PanicInfo) -> !. This one just spins. Nothing in Reset can panic, so the linker removes the handler entirely, as the --print-gc-sections output below shows.

Watch the linker work

With main.rs in place, the crate has something to link, so you can watch the step 3 script at work. Two flags show you exactly what it did. Run them from the workspace root:

# A map: every output section, the input sections inside it,
# and their addresses
cargo rustc --release -p l01-image --bin l01-image -- \
    -C link-arg=-Map=$PWD/target/l01.map
grep -E '\.(vector_table|start_block|text)|Reset$' target/l01.map

# Garbage collection: every section the linker removed
# (touch forces a relink)
touch lessons/l01-image/src/main.rs
cargo rustc --release -p l01-image --bin l01-image -- \
    -C link-arg=--print-gc-sections 2>&1 | grep -v debug

The map shows .text.Reset inside your .text output section:

     VMA      LMA     Size Align Out     In      Symbol
10000000 10000000        8     4 .vector_table
10000000 10000000        8     4         l01_image-….rcgu.o:(.vector_table)
10000008 10000008       14     4 .start_block
10000008 10000008       14     4         l01_image-….rcgu.o:(.start_block)
1000001c 1000001c       1e     4 .text
1000001c 1000001c       1e     4         l01_image-….rcgu.o:(.text.Reset)
1000001d 1000001d       1e     1                 Reset

The GC list shows what didn't survive: a stray empty .text, the panic handler .text._RNv…rust_begin_unwind, and its .ARM.exidx entry. Nothing in Reset can panic, so nothing references the handler, and the linker deletes it. You wrote a panic handler and it costs zero bytes of flash.

Look before you flash

From the workspace root:

cargo build --release
cargo size --release --bin l01-image -- -A
cargo objdump --release --bin l01-image -- -h --show-lma
cargo objdump --release --bin l01-image -- -s -j .vector_table -j .start_block
cargo objdump --release --bin l01-image -- -d -j .text

-d disassembles, and -j limits any of these to the named section. With Rust 1.97 the sections come out as below; .text may differ a little with another compiler version:

SectionAddressSize
.vector_table0x1000_00008 bytes
.start_block0x1000_000820 bytes
.text0x1000_001c30 bytes
Contents of section .vector_table:
 10000000 00000820 1d000010                    ... ....
Contents of section .start_block:
 10000008 d3deffff 42012110 ff010000 00000000  ....B.!.........
 10000018 793512ab                             y5..

The reset vector is 0x1000001d: Reset at 0x1000_001c, plus the Thumb bit. In the disassembly, the compiler unrolled your loop four times, and every store still goes to 0x2000_0000, because the write is volatile. cargo objcopy --release --bin l01-image -- -O binary l01.bin gives a 58-byte file, against about 8 KB for blinky.

Flash it and prove it runs

probe-rs download writes the ELF's LOAD segments into flash at their physical addresses. Then probe-rs reset restarts the chip, so it boots through the boot ROM as it would at power-on:

probe-rs download --chip RP235x \
    target/thumbv8m.main-none-eabihf/release/l01-image
probe-rs reset --chip RP235x

Now read the heartbeat twice:

probe-rs read --chip RP235x b32 0x20000000 1
probe-rs read --chip RP235x b32 0x20000000 1

Two different values mean the boot ROM accepted your IMAGE_DEF, loaded your stack pointer, and jumped into your Reset. Also read back your tag with probe-rs read --chip RP235x b32 0x10000008 5.

cargo run --release works too, but probe-rs run stays attached waiting for logs that this image never sends, so press Ctrl-C to get the probe back.

Break it on purpose

Change the start marker in IMAGE_DEF from 0xffff_ded3 to 0xffff_ded4, then rebuild, download and reset. The build succeeds, because the linker has no idea what the words mean. The heartbeat stops changing. With the Pico 2's own USB cable connected, run:

lsusb | grep -i 2e8a
lsblk -o NAME,LABEL,SIZE

A Raspberry Pi device (vendor ID 2e8a) appears, along with a small drive labeled RP2350. The boot ROM found no valid block in the first 4 KB and fell back to BOOTSEL. Restore the marker, download, reset, and confirm the heartbeat ticks again. The probe can flash the chip whether or not it's in BOOTSEL, so this is always recoverable.

How rp-hal does it

Your piecerp-hal's piece
link.ldmemory.x (regions, plus .start_block and .bi_entries placement) combined with cortex-m-rt's link.x (everything else)
IMAGE_DEF: [u32; 5]ImageDef::secure_exe() from rp235x-hal/src/block.rs, which builds the same five words
Two-word VectorTablecortex-m-rt's 16 exception entries plus the PAC's 52 interrupt entries: 272 bytes
Reset that loopscortex-m-rt's Reset, which initializes RAM, enables the FPU, then calls main
ASSERTscortex-m-rt asserts its own rules too. Find them in link.x

Open block.rs and check that secure_exe() produces your exact five words.

Exercises

  1. Past the 4 KB line. Move .start_block with `.start_block ORIGIN(FLASH)
    • 4K : ALIGN(4). Your ASSERT` stops the build first. Comment the assert out, predict what the boot ROM does, then flash and check. This is Lesson 0's self-check question 4, answered on hardware.
  2. Trip the other assert. Add static mut SEEN: u32 = 7; and write to it from Reset with ptr::write_volatile(&raw mut SEEN, 1). Read the link error. Which section did SEEN try to land in, and what would go wrong if the linker let it through?
  3. Remove a layer. Delete KEEP from .start_block and rebuild. Then put it back and delete #[used] instead. Check cargo size -A each time. Which change loses the IMAGE_DEF, and why?
  4. Fault with a two-word table. Before the loop in Reset, add unsafe { ptr::read_volatile(0x0100_0000 as *const u32) }, a read from an unmapped address. The bus fault becomes a HardFault, and the core fetches the HardFault handler's address from word 3 of the vector table. Work out what's at 0x1000_000c in your image, then predict whether the heartbeat ever starts.

Self-check

  1. Why does the boot ROM need an IMAGE_DEF, when the vector table already says where the code starts?
  2. What does the LAST item's size field count, and what is it in your block?
  3. Why does the linker accept a wrong start marker without complaint?
  4. Where does the value of _stack_start end up in the final image, and who reads it first?
  5. Your image has no .data and no .bss. What would break if it had them?

Previous: Lesson 0: Toolchain and a Known-Good Baseline.

Part of Embedded Rust on the RP2350.