Skip to content
Select theme

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.

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:

Terminal window
rustup target add thumbv7em-none-eabihf
info: component 'rust-std' for target 'thumbv7em-none-eabihf' is up to date

On 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:

Terminal window
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.

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:

Terminal window
cargo install probe-rs-tools

Three binaries land in ~/.cargo/bin: the probe-rs command-line tool, and the cargo flash and cargo embed subcommands. Both halves report their version:

Terminal window
probe-rs --version
probe-rs 0.32.0 (git commit: 48f5e4d)
Terminal window
cargo flash --version
cargo 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 version

rustup update fixes that.

Nothing flashes until probe-rs can see the probe. Plug the Nucleo into a USB port and run:

Terminal window
probe-rs list
No 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:

Terminal window
probe-rs chip info STM32F401RE
STM32F401RE
Cores (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.

Wherever you keep code:

Terminal window
cargo new firmware
Creating binary (application) `firmware` package
note: see more `Cargo.toml` keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

The 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:

Cargo.toml
[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.

One file decides what cargo build, cargo run and cargo flash all mean in this project:

.cargo/config.toml
[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 default

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

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.x
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 whole firmware is six lines:

src/main.rs
#![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 found

as _ imports nothing; it only marks the crate as used. extern crate panic_halt; does the same in the older spelling.

Terminal window
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.03s

No --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:

Terminal window
file target/thumbv7em-none-eabihf/debug/firmware
target/thumbv7em-none-eabihf/debug/firmware: ELF 32-bit LSB executable, ARM, EABI5 version 1 (SYSV), statically linked, with debug_info, not stripped

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

With the board plugged in:

Terminal window
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:

Terminal window
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.

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:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/embedded-stm32
git checkout stm32-ch02
cargo build

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