Setting up the embedded toolchain
Step 2 of 10, and the first one at the keyboard. Step 1 was the shopping list: a NUCLEO-F401RE, its built-in ST-Link probe, a mini-USB cable. This step puts the software on the machine and ends with a program running on the board — an empty loop, but one that got there through the whole chain: rustc, the linker, the probe, the chip. Every later step grows out of the project this one leaves behind.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler the whole track is written against, and probe-rs 0.32.0. A newer probe-rs prints different version numbers; the commands are the same.
Install the cross target
Section titled “Install the cross target”The chip is not a Mac. Code for it has to be compiled for its architecture, and rustup ships that as a separate component called a target:
rustup target add thumbv7em-none-eabihfinfo: component 'rust-std' for target 'thumbv7em-none-eabihf' is up to dateOn a machine that doesn’t have the target yet, the same command downloads it — more output, same result. This machine already had it.
The triple reads as four parts. thumbv7em is the instruction set and architecture: Thumb-2 code for ARMv7-M with the DSP extensions, which is what the F401’s Cortex-M4 core implements — probe-rs calls the core Armv7em, as its chip listing shows further down. none is the operating system: there isn’t one, and nothing sits between the program and the chip. eabi is the embedded ABI. hf is hardware floating point: the M4 has an FPU and this target uses it. rustc states the same facts from its side:
rustc --target thumbv7em-none-eabihf --print cfg | grep -E "target_(abi|arch|os|pointer_width|vendor)"target_abi="eabihf"target_arch="arm"target_os="none"target_pointer_width="32"target_vendor="unknown"The full output also carries panic="abort" — no unwinding on a chip with 96 KB of RAM.
Install probe-rs
Section titled “Install probe-rs”probe-rs talks to the ST-Link probe over USB. It writes firmware into flash, resets the chip, starts it, and — from step 8 on — carries the log stream back. One cargo command installs the whole toolset:
cargo install probe-rs-toolsThree binaries land in ~/.cargo/bin: the probe-rs command-line tool, and the cargo flash and cargo embed subcommands. Both halves report their version:
probe-rs --versionprobe-rs 0.32.0 (git commit: 48f5e4d)cargo flash --versioncargo flash 0.32.0 (git commit: 48f5e4d)The command compiles from source, so the first install takes a while; the releases page has prebuilt archives if you’d rather not wait. probe-rs 0.32.0 needs rustc 1.89 or newer, and cargo refuses an older toolchain outright. This is that error, from a machine whose default rustc is 1.79.0:
error: cannot install package `probe-rs-tools 0.32.0`, it requires rustc 1.89 or newer, while the currently active rustc version is 1.79.0`probe-rs-tools 0.31.0` has an unspecified minimum rustc versionrustup update fixes that.
Check the probe and the chip
Section titled “Check the probe and the chip”Nothing flashes until probe-rs can see the probe. Plug the Nucleo into a USB port and run:
probe-rs listNo debug probes were found.That is this machine’s output: no board attached. With the Nucleo connected, the same command lists its ST-Link. An empty result is also what a loose cable or an unpowered board looks like, which makes this the first thing to run when a flash fails.
The chip has a name, and probe-rs’s is not quite the one printed on the package. The Nucleo’s chip is an STM32F401RET6; probe-rs calls it STM32F401RE — family, pin count, flash size, without the package code. chip info prints what it knows:
probe-rs chip info STM32F401RESTM32F401RECores (1): - main (Armv7em)NVM: 0x08000000..0x08080000 (512.0 KiB)NVM: 0x1fff7800..0x1fff7a10 (528 B)RAM: 0x20000000..0x20018000 (96.0 KiB)NVM: 0x1fffc000..0x1fffc004 (4 B)Two of those numbers matter now: flash starts at 0x08000000 and holds 512 KB, RAM starts at 0x20000000 and holds 96 KB. They match the datasheet figures from step 1, and the linker needs them in a moment.
Create the project
Section titled “Create the project”Wherever you keep code:
cargo new firmware Creating binary (application) `firmware` packagenote: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.htmlThe generated src/main.rs gets replaced wholesale below. Cargo.toml needs two crates: cortex-m-rt, the runtime that provides the vector table and the #[entry] attribute, and panic-halt, the panic handler every #![no_std] program needs — what that handler does is step 4’s subject. The manifest ends up as:
[package]name = "firmware"version = "0.1.0"edition = "2024"repository = "https://github.com/hackingwithrust/examples"
[dependencies]cortex-m-rt = "0.7"panic-halt = "1.0"The lockfile pins what those mean today: cortex-m-rt 0.7.7, panic-halt 1.0.0.
Point cargo at the chip
Section titled “Point cargo at the chip”One file decides what cargo build, cargo run and cargo flash all mean in this project:
[build]target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]runner = "probe-rs run --chip STM32F401RE"rustflags = ["-C", "link-arg=-Tlink.x"]Three lines, three jobs. [build] target makes every cargo command build for the chip, so no command needs --target again. runner says what cargo run does with a binary the host can’t execute: hands it to probe-rs, which flashes it and starts it, with the chip name baked in. rustflags is the line that is easy to miss.
cortex-m-rt ships the linker script that places the vector table at the start of flash, but it does not tell the linker to use it — that is the project’s job, and -C link-arg=-Tlink.x is how it is done. Skip the line and the build still reports success, with one warning:
warning: linker stderr: rust-lld: cannot find entry symbol _start; not setting start address | = note: `#[warn(linker_messages)]` on by defaultand produces an image with no code in it: no vector table, no .text, nothing but debug information. Nothing in the chain complains, because nothing downstream can tell the image is empty. The warning is the only hint, and it does not look like an error.
Give the linker a memory map
Section titled “Give the linker a memory map”The linker script cortex-m-rt generates includes one file from the project directory, and that file is the memory map. Its numbers come straight from probe-rs chip info above:
MEMORY{ FLASH : ORIGIN = 0x08000000, LENGTH = 512K RAM : ORIGIN = 0x20000000, LENGTH = 96K}Move it, rename it, or get an address wrong and the link stops with the script’s own complaint (the middle of the message, which echoes the linker command line, is left out; the path is the cortex-m-rt crate’s build directory, and the hash at the end of it differs per machine and per build):
error: linking with `rust-lld` failed: exit status: 1 = note: rust-lld: error: /Users/rango/www/hackingwithrust/code/embedded-stm32/target/thumbv7em-none-eabihf/debug/build/cortex-m-rt-85eae0cf6188ff09/out/link.x:23: cannot find linker script memory.x >>> INCLUDE memory.x >>> ^The program
Section titled “The program”The whole firmware is six lines:
#![no_std]#![no_main]
use panic_halt as _;
use cortex_m_rt::entry;
#[entry]fn main() -> ! { loop {}}#![no_std] drops the standard library — there is no filesystem, no threads, no allocator under this program, only core. #![no_main] drops fn main as well: on a chip the program does not start at main, it starts at the reset vector, and #[entry] is the attribute that generates that path, sets up the stack, and calls this function. It returns ! because there is nowhere to return to.
use panic_halt as _; is the line with a story. A #![no_std] binary must provide a panic handler, and panic-halt is in Cargo.toml — but listing a crate is not enough on its own. Something in the code has to name it, or its panic handler never arrives, and the build fails with:
error: `#[panic_handler]` function required, but not foundas _ imports nothing; it only marks the crate as used. extern crate panic_halt; does the same in the older spelling.
Build it
Section titled “Build it”cargo build Compiling proc-macro2 v1.0.107 Compiling unicode-ident v1.0.27 Compiling quote v1.0.47 Compiling cortex-m-rt v0.7.7 Compiling panic-halt v1.0.0 Compiling syn v3.0.9 Compiling cortex-m-rt-macros v0.7.7 Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32) Finished `dev` profile [unoptimized + debuginfo] target(s) in 4.03sNo --target — .cargo/config.toml already said. The path after the package name is where the project lives on this machine; yours says firmware, wherever you ran cargo new. The build time varies by machine. The binary lands at target/thumbv7em-none-eabihf/debug/firmware, the target name sitting in the path so builds for different architectures don’t collide. What it is:
file target/thumbv7em-none-eabihf/debug/firmwaretarget/thumbv7em-none-eabihf/debug/firmware: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not strippedAn ARM executable, not a Mach-O — the host can’t run it and doesn’t need to. The image is small: the vector table at the start of flash, its first two entries the initial stack pointer and the reset handler, then 128 bytes of code and an empty loop.
Flash it
Section titled “Flash it”With the board plugged in:
cargo flash --chip STM32F401RE Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s Flashing /Users/rango/www/hackingwithrust/code/embedded-stm32/target/thumbv7em-none-eabihf/debug/firmware Error No connected probes were found.The last line is this machine again: no board attached. It is the same error a loose cable gives, and it appears before anything is written. Plug the Nucleo in and the same command flashes the image, resets the chip, and starts it. The Flashing path is this machine’s project directory. cargo flash needed no --target — it reads the same .cargo/config.toml cargo does.
cargo run does the same through the runner:
cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.01s Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`Error: No connected probes were found.What the board does when it works: nothing. No LED, no output, no way to see it running except that the flash succeeded. That is exactly what this step is for — the toolchain, the probe, the cable and the chip each did their job. Step 3 gives the empty loop something to do.
Where the code lives
Section titled “Where the code lives”The project lives in embedded-stm32/ in the examples repository, one commit and one tag per article. This article’s end state is stm32-ch02:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch02cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
Step 3 — Blinking an LED — adds the board’s HAL crate and makes PA5 flash. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.