Skip to content
Select theme

Understanding no_std

Step 4 of 10, and the one that takes the magic apart. Step 3 left a blinking LED on the board, built on three lines that arrived without explanation: #![no_std], #![no_main], and use panic_halt as _;. This step opens all three up — what the firmware gave up when it dropped the standard library, and what it has to provide in return. It starts from tag stm32-ch03 and ends at stm32-ch04, where the firmware carries a panic handler of its own.

Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against.

The attribute drops the standard library from the crate. In practice that means the prelude: Vec, String, Box, println!, format!, none of which resolve, because all of them live in std, and std needs an operating system underneath it. The chip has none: the target triple’s middle field is none, which step 2 took apart. What remains is core, and it is most of what matters: Option, Result, iterators, closures, slices, pattern matching, core::fmt. The firmware’s own loop leans on Option and Result already: Peripherals::take() returns an Option, and every .unwrap() in the loop is a Result.

Reach into the missing half and the compiler says so. This is what gets appended to the end of src/main.rs:

appended to src/main.rs
fn probe() {
let mut v: Vec<u32> = Vec::new();
v.push(1);
let _ = v;
}

The build stops with:

error[E0425]: cannot find type `Vec` in this scope
--> src/main.rs:67:16
|
67 | let mut v: Vec<u32> = Vec::new();
| ^^^ not found in this scope
error[E0433]: cannot find type `Vec` in this scope
--> src/main.rs:67:27
|
67 | let mut v: Vec<u32> = Vec::new();
| ^^^ use of undeclared type `Vec`
Some errors have detailed explanations: E0425, E0433.
For more information about an error, try `rustc --explain E0425`.
error: could not compile `firmware` (bin "firmware") due to 2 previous errors

Two errors, one line: the first marks the type position, the second the call, and the cause is the same. Vec is nowhere in scope, and no help line points at alloc, because that crate is not linked into the firmware at all. The line numbers are from the file with those lines appended at the end; the columns move if the line moves.

A #![no_std] binary has no panic handler: std supplied one, and std is gone. The compiler requires one. A function marked #[panic_handler] is a lang item, and a binary without one fails to build, which is why step 2’s first firmware ended with use panic_halt as _;.

What that line buys is the handler below, and that is the whole of panic-halt 1.0.0, docs aside:

panic-halt v1.0.0, src/lib.rs
#[inline(never)]
#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
loop {}
}

The message is dropped on the floor: _info is ignored. On a board with no console that is a defensible choice (a panic ends the program either way), but it means a panicked board and a working one look the same from outside.

What is in the message is worth seeing, because that is what the firmware gave up. Here is the standard library’s handler doing its job, on the host:

PA5
PA6
PA7
thread 'main' (4642980) panicked at src/main.rs:4:24:
index out of bounds: the len is 3 but the index is 3
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

The file, the line and the message are all std’s panic machinery, and none of it exists under #![no_std]. (The bounds check itself has its own article; the loop here is only a way to reach a panic at run time. The number in brackets after thread 'main' is the OS thread ID and changes every run.)

A no_std handler receives the same material (PanicInfo carries the location and the message), but writing it anywhere is the program’s job, and this firmware has nowhere to write it yet: the serial output that could carry it is step 8’s subject. What a handler can do is change the state of the board, and that is what this step’s handler does.

src/main.rs changes in two places. One line leaves the imports:

Terminal window
cargo remove panic-halt
Removing panic-halt from dependencies

and this arrives at the end of the file:

src/main.rs
/// What runs when the firmware panics. Step 2 filled this slot with the
/// `panic-halt` crate; this step writes the handler out instead.
///
/// `blink` owns the LED pin and a panic ends that loop for good, so there
/// is no pin to reach for here. GPIOA sits at a fixed address for as long
/// as the chip has power, and the pin is still configured as a push-pull
/// output — mode bits survive. Setting bit 5 of the port's bit set/reset
/// register lights LD2 and leaves it lit: a panic now freezes the board
/// with the LED on instead of mid-blink, which tells the two apart.
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
// Safety: `ptr()` returns the register block's address without
// checking ownership. GPIOA exists at that address from reset onward,
// nothing else runs once a panic has fired, and BSRR is write-only:
// a set-bit write cannot race another reader or writer of the pin.
unsafe {
(*pac::GPIOA::ptr()).bsrr().write(|w| w.bs5().set_());
}
loop {}
}

blink owns the LED pin, and on this target a panic does not unwind (step 2’s rustc --print cfg output said panic="abort"), so the loop is finished and there is no pin object to reach for. GPIOA, though, sits at address 0x4002_0000 for as long as the chip has power, and the pin is still configured as a push-pull output: the mode bits survive. GPIOA::ptr() hands out that address without checking ownership, and setting bit 5 of the port’s bit set/reset register drives PA5 high: LD2 lights and stays lit. A panic now freezes the board with the LED on instead of mid-blink, which tells the two states apart from across the room.

The unsafe block says what it needs to. The pointer is valid because the register block exists at that address from reset onward, nothing else runs once a panic has fired, and BSRR is a write-only register, so a set-bit write cannot race anything. This is the shape where reaching past the HAL’s typed API is the right call: the HAL’s safety comes from take() handing each peripheral out once, and the panic path is exactly where that guarantee has already been spent. The pin was moved into blink, and blink is not coming back.

Vec was the first thing to go, and the reason is deeper than a missing import: something has to hand out the memory. std brings a global allocator; no_std does not. An allocator is a type marked #[global_allocator] that implements GlobalAlloc, and it is ordinary Rust. This one is a bump allocator over a static buffer, and it runs on the host:

["high", "low", "high"] len=3 capacity=4 heap=64

Three pushes, one allocation: the capacity went from 0 to 4 and the heap offset advanced by exactly 64 bytes (four &str slots), so the Vec’s growth came out of the static buffer, not out of the system allocator. Nothing in the allocator touches std: it is core types and one atomic, which is why the same code compiles for the chip. Adding extern crate alloc; and use alloc::vec::Vec; is the whole firmware-side delta, and that build compiles for thumbv7em-none-eabihf too; it was run while this step was written.

The heap is a static mut rather than the UnsafeCell you might reach for first. UnsafeCell is not Sync at any size, so a static holding one does not compile; and once the heap is a static mut, edition 2024 refuses to take a reference to it. addr_of_mut! is the door the compiler leaves open, and it does not need an unsafe block of its own.

The firmware has no allocator and this step does not give it one. The blinker has nothing to put in a heap, and an allocator that never frees is a poor fit for a program that loops forever. Firmware that does allocate reserves a region in the linker script and reaches for a proper allocator crate; the budget is the 96 KB of RAM the F401 has, from step 2’s chip info.

std sits on libc on a hosted machine, and the chip has neither. There are no syscalls: no files, no sockets, no clocks. The firmware binary is a static ARM ELF, as step 2’s file output showed, and nothing in it comes from a C library. On a Mac a no_std binary still links libSystem (the C runtime is what calls main), but the chip has none of it and no main to call: the reset vector is the entry point, which is what #![no_main] and #[entry] were for. The practical consequence for this track is that output has to be earned. println! does not exist, nothing writes to stderr, and that is why the panic handler spends its information on the LED while step 8 spends a crate on getting text off the chip at all.

Terminal window
cargo build
Compiling time v0.3.55
Compiling syn v2.0.119
Compiling syn v3.0.9
Compiling cortex-m-rt-macros v0.7.7
Compiling enumflags2_derive v0.7.12
Compiling enumflags2 v0.7.12
Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 8.01s

The middle of a cold build is the dependency closure step 3 listed, one crate shorter: there is no Compiling panic-halt line this time. No --target: .cargo/config.toml pins it, as it has since step 2. The path after the package name is this machine’s project directory, and the build time is this machine’s too.

With the board plugged in:

Terminal window
cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s
Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`
Error: No connected probes were found.

The last line is this machine again: no board attached, the same error a loose cable gives. Plug the Nucleo in and the same command writes the image, resets the chip and starts it. What the board does then: LD2 blinks once a second, as in step 3. If the firmware ever panics, LD2 lights and stays lit.

The tempting order of work is to write the handler first and clean up the import later, or not at all. Two panic handlers in one binary is a duplicate lang item, and the wording is exact. The block below is the firmware’s shape with the entry point spelled out, because this machine is not the chip, and it needs the crate in scope:

[dependencies]
panic-halt = "1.0"
error[E0152]: found duplicate lang item `panic_impl`
--> src/main.rs:7:1
|
7 | / fn panic(_: &core::panic::PanicInfo) -> ! {
8 | | loop {}
9 | | }
| |_^
|
= note: the lang item is first defined in crate `panic_halt` (which `verify` depends on)
= note: first definition in `panic_halt` loaded from /Users/rango/www/hackingwithrust/.verify/target/debug/deps/libpanic_halt-ae54548ed5e01e2d.rlib
= note: second definition in the local crate (`verify`)
For more information about this error, try `rustc --explain E0152`.
error: could not compile `verify` (bin "verify") due to 1 previous error

Lang items belong to the crate, not the target, so the firmware’s own build fails the same way, with firmware where the local crate’s name appears here. The paths in the notes are the sandbox’s build directory, and the hash at the end of the .rlib name varies per build. The fix is one line: drop use panic_halt as _;, which leaves the crate’s handler out of the link and the firmware’s in.

The project lives in embedded-stm32/ in the examples repository, one commit and one tag per article. This article’s end state is stm32-ch04:

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

If a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.

Step 5, Reading a button with GPIO input, gives the loop something to react to: pull-ups, debounce, and polling versus interrupts. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.