Chapter 10: build.rs, Makefile, and Flashing
September 17, 2026 · View on GitHub
Introduction
Producing a bootable .uf2 requires several stages: compiling target Rust,
running the linker with our memory.x, generating the boot image metadata, and
finally invoking probe-rs to flash the RP2350. This chapter walks through the
two automation files every driver ships: build.rs and the Makefile.
build.rs — The Build Script
A Rust crate can ship a special file named build.rs at the crate root. Cargo
compiles and runs it before building the crate, and anything it prints with
the cargo: prefix becomes build instructions. Our build.rs is small and
crucial:
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn main() {
let out = &PathBuf::from(env::var_os("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
.unwrap()
.write_all(include_bytes!("memory.x"))
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x");
}
Reading it line by line:
env::var_os("OUT_DIR")— Cargo gives build scripts a private output directory. The linker script is copied there.include_bytes!("memory.x")— Embeds the file's bytes at compile time and writes them intoOUT_DIR/memory.x.cargo:rustc-link-search=...— Tells the linker to search that directory, so the startup crate's-Tlink.xcan find ourmemory.x.cargo:rerun-if-changed=memory.x— Ifmemory.xchanges, rebuild instead of reusing a stale result.
The net effect: the linker consumed a memory.x that lives inside the
repository, not a hidden system default. One source of truth.
The Makefile
A Makefile wraps the common operations behind short, memorable targets. The
full file also carries the MIT header and @file/@brief/@author/@date
docstring block — proof the standard applies to every file, even build
automation:
.PHONY: test build clean flash
test:
cargo test --lib --target $(shell rustc --print host-tuple) --no-default-features
build:
cargo build --release
flash: build
probe-rs run --chip RP2350 target/thumbv8m.main-none-eabihf/release/rp2350-blink
clean:
cargo clean
make test — Host Tests
cargo test --lib --target $(shell rustc --print host-tuple) --no-default-features
--target $(shell rustc --print host-tuple)— Build for the host, not the RP2350, so the tests run as a normal desktop program.--no-default-features— Drop Embassy (embassy-executor,embassy-rp,cortex-m,cortex-m-rt,panic-halt). Only the pure, testable logic is compiled — which is precisely why the design keeps logic in the library (Chapter 18) and whylib.rsisno_stdonly when not testing.--lib— Test the library target, which is where all the state machines live.
This command is Chapter 25 in one line. It is the whole feedback loop: write a driver, run the state machine on the host, and see 20+ tests pass in a second.
make build — Release Firmware
cargo build --release compiles optimized firmware. The release profile in
Cargo.toml is tuned for a microcontroller:
[profile.release]
debug = true
lto = true
opt-level = "z"
panic = "abort"
debug = true— Keep debug info soprobe-rscan resolve names.lto = true— Link-time optimization across crates; significant size savings.opt-level = "z"— Optimize for size. Flash is measured in kilobytes.panic = "abort"— No unwinding metadata; panics halt immediately (matchespanic-halt).
make flash — probe-rs
probe-rs run both flashes and opens an on-chip debug session:
probe-rs run --chip RP2350 target/thumbv8m.main-none-eabihf/release/rp2350-blink
From Pico 1 to Pico 2 the chip name changed: RP2040 becomes RP2350.
probe-rs run needs the debug probe (a Pico with the "debugprobe" firmware, or
a Picoprobe) on one USB port, and the target Pico on another. This is the "flash
and go" experience from each driver's README.
Summary
build.rscopies ourmemory.xinto the build output and tells the linker where to find it.make testruns the state-machine tests on the host with--no-default-features.make buildproduces a size-optimized release image with LTO.make flashusesprobe-rsand theRP2350chip profile to flash and run.make cleanremoves build artifacts.
Now we look at the idea underneath all of this: memory-mapped I/O, and how firmware touches hardware at all.