k4s.xyz / Embedded Rust on the RP2350
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.
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.
All section numbers are from the RP2350 datasheet, with the page number printed in its footer.
| Section | Pages | Why |
|---|---|---|
| §5.1.4 Image definitions | 328 | What an IMAGE_DEF is for |
| §5.1.5 Blocks and block loops | 328–329 | Markers, items, and the link word |
| §5.1.12 Flash image boot | 331–332 | The first-4-KB rule, and the 20-byte minimum |
| §5.9.3.1 IMAGE_DEF item | 383–384 | The flags field, bit by bit |
| §5.9.3.3 VECTOR_TABLE item | 385 | Without this item, the vector table is assumed to be at the start of the image |
| §5.9.5.1 Minimum Arm IMAGE_DEF | 389 | The exact five words you'll type |
| §2.2 Address map | 27 | Where 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.
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.
A block is a run of 32-bit words (§5.1.5.1):
0xffffded3.LAST item recording how many words of items came before it.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.
| Word | Value | Meaning |
|---|---|---|
| 0 | 0xffffded3 | Start marker |
| 1 | 0x10210142 | IMAGE_TYPE item: byte 0 is type 0x42, byte 1 is the size (1 word), bytes 2–3 are the flags 0x1021 |
| 2 | 0x000001ff | LAST item: byte 0 is type 0xff, bytes 1–2 say the items before it total 1 word |
| 3 | 0x00000000 | Link to the next block: offset 0, a loop of one |
| 4 | 0xab123579 | End marker |
The flags 0x1021 in binary are 0001 0000 0010 0001. Checked against Table
493 (§5.9.3.1):
| Bits | Field | Value | Meaning |
|---|---|---|---|
| 0–3 | Image type | 1 | Executable |
| 4–5 | Security | 2 | Runs in Secure mode |
| 8–10 | CPU | 0 | Arm |
| 11 | Extra security | 0 | Off (ignored before stepping A4) |
| 12–14 | Chip | 1 | RP2350 |
| 15 | Try before you buy | 0 | Off |
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.
The linker script is the contract between your code and the chip's address map. Yours has four parts:
MEMORY names the regions and their sizes.SECTIONS says which input sections go where, in what order.ENTRY names the symbol debuggers treat as the start. The chip ignores it._stack_start are addresses the script defines and your code
can read.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.
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:
#[used] tells the compiler to emit the static even though nothing uses it.#[unsafe(link_section = "...")] puts it in a named section. It's unsafe
because the wrong section can break the program in ways the compiler can't
check.KEEP(...) in the script tells the linker not to garbage-collect that
section.Exercise 3 has you remove one layer and watch what disappears.
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.
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
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.
[workspace] makes the root Cargo.toml a virtual manifest. It owns the
crates under it, but is not a crate itself. All of them share one target/
directory and one Cargo.lock.members lists each crate's directory. A crate is part of the workspace
because it is listed here.resolver = "3" picks the dependency-resolution rules. A crate gets this
default from its edition, but a virtual manifest has no edition of its own, so
it must say it. Version 3 is the one that comes with edition 2024.[workspace.package] holds values that members can inherit.
edition.workspace = true means "take edition from the workspace". That's
why the crate's manifest doesn't say 2024 itself.[package] describes the crate. name is also the default name of the binary
it produces.[[bin]], with double brackets, is TOML's array of tables. Each [[bin]]
header adds one more entry to a list of binary targets. Cargo would find
src/main.rs on its own anyway. This entry exists only to change two settings
on that binary, and since its name matches the package name, it adjusts the
binary Cargo already found instead of creating a new one.test = false and bench = false keep this binary out of cargo test and
cargo bench. Those commands compile it with Rust's test harness (the test
crate), which needs std, so the build would fail on this target.// 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:
build.rs, Cargo
compiles it for your Linux machine, not the Pico, and runs it before compiling
the crate. It's ordinary std Rust, which is why std::env is available
here.cargo: is an instruction. Anything else is ignored. (Recent Cargo also
accepts cargo::, with two colons. Both work.)env::var("CARGO_MANIFEST_DIR") reads an environment variable Cargo sets
for the script: the directory holding this crate's Cargo.toml. Cargo always
sets it, so the unwrap() can't fail.cargo:rustc-link-search=DIR adds -L DIR to the linker command. The
linker looks for the file named by -T in its working directory first, then
in each -L directory. Delete this line and the link fails with rust-lld: error: cannot find linker script link.ld.cargo:rustc-link-arg-bins=FLAG passes FLAG to the linker, but only
when it links this crate's binaries. Tests, examples and the build script
itself are left alone. (rustc-link-arg, without -bins, would apply to
every target.)-Tlink.ld tells the linker to use your script instead of its built-in
default layout.--nmagic turns off page alignment of the program's segments. By default
the linker assumes an operating system will map the file into memory a page at
a time (64 KB pages for this target), so it pads the file to match. Without
the flag, the ELF grows from 5,292 to 70,680 bytes, and each LOAD segment in
readelf -l reports Align 0x10000 instead of 0x4. The 58 bytes that reach
flash are identical either way. So with rust-lld, the flag keeps the ELF
honest about a chip that has no pages; it doesn't save flash. Some guides say
it saves flash, including the config comments in Shawn Hymel's
introduction-to-embedded-rust ("Prevent padding memory between sections"), but
with this linker it doesn't, even with a section at a deliberately unaligned
address.cargo:rerun-if-changed=link.ld narrows when Cargo reruns the script. By
default it reruns whenever any file in the crate changes. Once the script
prints a rerun-if-changed line, Cargo reruns it only when a listed file (or
build.rs itself) changes. Editing link.ld still triggers a relink, which
is the case that matters./* 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");
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:
/* ... */ only. A // line fails the link with unknown directive: //._stack_start = ...; needs its semicolon. Elsewhere
semicolons are optional, and this script uses them anyway for consistency.0x10000000), plus the suffixes K (×1024) and M
(×1024×1024). So 4M is 0x40_0000 and 512K is 0x8_0000.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:
| Piece | Example | Meaning |
|---|---|---|
NAME | .text | The output section's name in the ELF. Only /DISCARD/ is special; any other name is just a label. |
ADDRESS | ORIGIN(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 | > FLASH | Place the section in this region, at its next free address, and advance that address past it. |
AT > REGION | > RAM AT > FLASH | Give 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).
* outside the parentheses matches every input file: your crate's object
file, plus libcore and compiler_builtins. A narrower file pattern is
possible, but this course won't need one..text matches a section named exactly .text. .text.* matches any
name that starts with .text...text. plus the symbol name. Your Reset arrives as
.text.Reset, and the panic handler as .text._RNv…rust_begin_unwind. That's
what lets the linker's --gc-sections (Rust always passes it) delete unused
functions one at a time. *(.text) alone would match none of your code.*(.text) *(.text.*) would instead put all plain .text first, then
everything else. Here the result is the same..rodata, .data and .bss follow the same convention, so each gets the
same two 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.
// 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 {}
}
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
//! is a doc comment for the thing that contains it (here, the whole
crate). /// documents the item that follows it. // is a plain comment.#![...], with the !, is an inner attribute: it applies to whatever
encloses it, here the crate. #[...], without the !, applies to the next
item.#![no_std] links only core, never std. core is the part of the
standard library that needs no operating system: pointers, integers, Option,
slices, formatting. There's no heap, no threads and no files.#![no_main] tells rustc not to generate the usual program entry point, which
sets up std's runtime and then calls your fn main. Without it you get
error[E0601]: `main` function not found in crate `l01_image` . Here, the
vector table is how the program starts.Declaring a symbol from the linker script
unsafe extern "C" {
static _stack_start: u32;
}
extern block declares things defined somewhere else; here, by
link.ld. The compiler trusts the declaration and leaves the linker to supply
the address."C" names the ABI, meaning the C conventions for symbols and calls. It's the
one stable convention Rust shares with linkers and other languages.unsafe extern, because
the compiler can't check that your declaration matches reality. Leave out
unsafe and you get error: extern blocks must be unsafe.u32 is a convenient fiction. Nothing is stored at that
address, and the code never reads it. Taking its address with &raw const
needs no unsafe block, but reading the value would.The vector table type
#[repr(C)] fixes the struct's memory layout to C's rules: fields in the
order written, each at its natural alignment. Rust's default layout is
unspecified, and the compiler may reorder fields. The hardware reads offset 0
as the stack pointer and offset 4 as the reset address, so the order isn't
negotiable.*const u32 is a raw pointer: an address with no lifetime and no borrow
checking. It may be null or dangling, so dereferencing one needs unsafe.
Storing one is safe.extern "C" fn() -> ! is a function pointer type: the address of a function
that takes nothing, uses the C calling convention, and never returns. ! is
the never type. A reset handler has nothing to return to.unsafe impl Sync for VectorTable {}: every static must be Sync, meaning
it's safe to share between threads (and, on this chip, between the two cores
and interrupt handlers). Raw pointers aren't Sync, so neither is the struct.
Delete the line and you get error[E0277]: `*const u32` cannot be shared between threads safely. unsafe impl is you telling the compiler "this
holds" for a property it can't prove. It holds here because the table is never
written.The two statics
static, not const. A static is one object at one fixed address, which is
exactly what a vector table is. A const has no address; the compiler pastes
its value wherever it's used. Change static to const and you get error: `#[used]` attribute cannot be used on constants.#[used] and #[unsafe(link_section = "...")] are explained under "Keeping
the linker from deleting your work". Since edition 2024, attributes that can
break a program the compiler can't check must be wrapped in unsafe(...).
link_section and no_mangle are two of them.&raw const _stack_start creates a raw pointer directly. &_stack_start
would create a reference, which promises a valid, initialized u32 that the
code may read. &raw const makes no such promise. That's the honest
description of a symbol that is only an address.reset: Reset uses the function's name as a value, which turns it into a
function pointer. The compiler can't know the final address, so it leaves a
relocation, and the linker fills in 0x1000_001d.[u32; 5] is an array of five u32s. The underscores in 0xffff_ded3 are
digit separators with no meaning, like commas in 1,000.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
#[unsafe(no_mangle)]: by default, rustc mangles symbol names, encoding the
crate and path so names from different crates can't collide. Without this
attribute your function's symbol is _RNvCs6rdpOkqRjUQ_9l01_image5Reset. The
image still builds and even runs, because the vector table refers to the
function through a relocation, not by name. But the linker warns cannot find entry symbol Reset; not setting start address, and the ELF's entry point
becomes 0x0.#[allow(non_snake_case)] silences the lint that wants function names in
snake_case. Reset is capitalized to match cortex-m-rt's handler of the
same name, and the ENTRY(Reset) in your script.pub extern "C" fn Reset() -> ! uses the C calling convention, because
hardware calls it, not Rust code. -> ! means the function can never return,
and the compiler enforces that: the body must diverge, as the loop does.count.wrapping_add(1) wraps from u32::MAX back to 0 in every build
profile. Plain + would panic on overflow in debug builds.unsafe { ptr::write_volatile(HEARTBEAT, count) } stores count at
0x2000_0000 and promises the compiler it really happens. Writing through a
raw pointer is unchecked, hence unsafe. Here's why volatile matters.
Replace the line with a plain store, unsafe { *HEARTBEAT = count };, and the
release build's entire loop compiles to this: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.
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.
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:
| Section | Address | Size |
|---|---|---|
.vector_table | 0x1000_0000 | 8 bytes |
.start_block | 0x1000_0008 | 20 bytes |
.text | 0x1000_001c | 30 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.
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.
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.
| Your piece | rp-hal's piece |
|---|---|
link.ld | memory.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 VectorTable | cortex-m-rt's 16 exception entries plus the PAC's 52 interrupt entries: 272 bytes |
Reset that loops | cortex-m-rt's Reset, which initializes RAM, enables the FPU, then calls main |
ASSERTs | cortex-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.
.start_block with `.start_block ORIGIN(FLASH)
. 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.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?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?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.LAST item's size field count, and what is it in your block?_stack_start end up in the final image, and who
reads it first?.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.