Embedded Rust From Blinking an LED to Real Firmware
A guide to embedded Rust from the first blinking LED to firmware structure: no_std, the HAL traits, runtimes, flashing, debugging and size.

The first embedded program is always a blinking LED, and it is a better exercise than it looks. In a dozen lines it forces you to meet every difference between firmware and an ordinary Rust program: there is no operating system, no main in the usual sense, no heap, no standard library, no println!, and a panic has nowhere to print its message. Rust handles all of that with a small number of explicit pieces, and once you can name them, a datasheet-sized problem becomes an ordinary programming task.
This guide builds a blinking-LED firmware for an ARM Cortex-M4F microcontroller from nothing, using no vendor library, so each piece is visible. It also does something most introductions skip: it structures the code so that the logic runs and is tested on your laptop, and only a few lines touch hardware. The firmware was built for thumbv7em-none-eabihf with Rust 1.99 and edition 2024, using cortex-m 0.7.10, cortex-m-rt 0.7.7, embedded-hal 1.0.0 and panic-halt 1.0.0. The build and size numbers are real. The code was not flashed to a board for this guide, so confirm the register addresses against your chip's reference manual before you rely on them.
What changes when there is no operating system
A program on a laptop is a guest. The operating system loads it, sets up its stack and heap, gives it standard input and output, handles its panics by printing and exiting, and cleans up when it ends. A microcontroller runs your code directly. So something has to do each of those jobs, and in Rust each one becomes a visible piece.
| On a laptop, the OS does this | On a microcontroller, you provide this |
|---|---|
Starts your main | A reset handler, supplied by cortex-m-rt, that sets up memory and then calls your function marked #[entry] |
| Provides the standard library | #![no_std]: you get core, the part of Rust that needs no OS |
Gives you a heap for Vec and String | Nothing, unless you add an allocator. Fixed-size buffers are the norm |
| Prints a panic message and exits | A #[panic_handler] you choose, often a loop that halts |
| Decides where code and data live | A linker script describing the chip's flash and RAM |
| Talks to hardware through drivers | You read and write memory-mapped registers, directly or through a library |
Keep that table in mind. Every error message you meet in the next section maps onto one of its rows.
Set up the toolchain
The compiler needs a target for the chip's instruction set. Cortex-M parts come in a few flavors, and choosing the right triple is the only decision here.
| Core | Example chips | Target triple |
|---|---|---|
| Cortex-M0, M0+ | RP2040, STM32F0 | thumbv6m-none-eabi |
| Cortex-M3 | STM32F1 | thumbv7m-none-eabi |
| Cortex-M4, M7 without FPU | some STM32F4 configurations | thumbv7em-none-eabi |
| Cortex-M4F, M7 with FPU | STM32F4, STM32H7 | thumbv7em-none-eabihf |
The hf suffix means hard float: the code uses the chip's floating-point unit. Pick the one that matches the part, because mismatched float ABI choices fail at link time. Install the target and create a project:
rustup target add thumbv7em-none-eabihf
cargo new --vcs none blink
cd blink
cargo add cortex-m cortex-m-rt panic-halt embedded-halThree small files configure the build. The first tells Cargo which target to build for by default and passes the linker script from cortex-m-rt:
[build]
target = "thumbv7em-none-eabihf"
[target.thumbv7em-none-eabihf]
rustflags = ["-C", "link-arg=-Tlink.x"]The second describes the chip's memory. This is the one piece of information the linker cannot guess. The numbers are for an STM32F4 with 1 MiB of flash at 0x08000000 and 128 KiB of RAM at 0x20000000, which you read from the chip's datasheet:
MEMORY
{
FLASH : ORIGIN = 0x08000000, LENGTH = 1M
RAM : ORIGIN = 0x20000000, LENGTH = 128K
}The third is a small build script that copies memory.x to where the linker will look for it, and tells Cargo to rebuild when it changes:
use std::{env, fs, path::PathBuf};
fn main() {
// Put memory.x where the linker looks, and rebuild when it changes.
let out = PathBuf::from(env::var_os("OUT_DIR").unwrap());
fs::copy("memory.x", out.join("memory.x")).unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x");
}Separate the logic from the hardware
The temptation in a first embedded program is to put everything in main, poking registers inline. That works for a blinky and fails for anything real, because you cannot test it without a board. The better structure uses embedded-hal, a set of traits that describe hardware generically. OutputPin means "something that can be driven high or low". The blink logic is written against that trait and knows nothing about any chip, which is what makes it testable on a laptop.
Put the logic in a library, and make it no_std for the firmware but allow the standard library when tests run:
#![cfg_attr(not(test), no_std)]
use embedded_hal::digital::OutputPin;
/// Blink logic that knows nothing about any chip: it only needs something that
/// implements `OutputPin`. That is what lets it run in tests on a laptop.
pub struct Blinker<P: OutputPin> {
pin: P,
lit: bool,
}
impl<P: OutputPin> Blinker<P> {
pub fn new(pin: P) -> Self {
Self { pin, lit: false }
}
pub fn toggle(&mut self) -> Result<(), P::Error> {
if self.lit {
self.pin.set_low()?;
} else {
self.pin.set_high()?;
}
self.lit = !self.lit;
Ok(())
}
pub fn is_lit(&self) -> bool {
self.lit
}
}
#[cfg(test)]
mod tests {
use super::*;
use embedded_hal::digital::{ErrorType, PinState};
/// A pin that records every level it was driven to.
struct FakePin {
history: Vec<PinState>,
}
impl ErrorType for FakePin {
type Error = core::convert::Infallible;
}
impl OutputPin for FakePin {
fn set_low(&mut self) -> Result<(), Self::Error> {
self.history.push(PinState::Low);
Ok(())
}
fn set_high(&mut self) -> Result<(), Self::Error> {
self.history.push(PinState::High);
Ok(())
}
}
#[test]
fn toggling_alternates_high_and_low() {
let mut b = Blinker::new(FakePin { history: vec![] });
for _ in 0..4 {
b.toggle().unwrap();
}
assert_eq!(b.pin.history, [PinState::High, PinState::Low, PinState::High, PinState::Low]);
assert!(!b.is_lit());
}
}#![cfg_attr(not(test), no_std)] is the line that does the double duty: in a normal build the crate is no_std, and under cargo test it is an ordinary crate that can use Vec in the test. The Blinker is generic over any P: OutputPin. It keeps its own record of whether the LED is lit, so it never has to read the pin back, which many pins cannot do.
The test supplies a FakePin that records every level the blinker drives. It runs on your host machine, in microseconds, with no hardware:
cargo test --lib --target aarch64-apple-darwin.
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s(The --target flag overrides the default target from .cargo/config.toml; use your host's triple, for example x86_64-unknown-linux-gnu.) The assertion checks that four toggles produce High, Low, High, Low. If you later add a blink pattern, a debounce or a state machine, you test it the same way, and the hardware-facing code stays small enough to inspect by eye.
The hardware layer: a pin from raw registers
Now the firmware binary, which has to touch the chip. On an STM32F4, a GPIO pin is controlled through registers at fixed addresses. To drive pin PA5 you enable the clock for its port, configure the pin as an output, and write to the bit set/reset register. This implementation does that directly, using volatile reads and writes so the compiler cannot optimize them away or reorder them:
#![no_std]
#![no_main]
use core::ptr::{read_volatile, write_volatile};
use blink::Blinker;
use cortex_m::asm;
use cortex_m_rt::entry;
use embedded_hal::digital::{ErrorType, OutputPin};
use panic_halt as _;
// STM32F4 register addresses, from the reference manual.
const RCC_AHB1ENR: *mut u32 = 0x4002_3830 as *mut u32;
const GPIOA_MODER: *mut u32 = 0x4002_0000 as *mut u32;
const GPIOA_BSRR: *mut u32 = 0x4002_0018 as *mut u32;
/// PA5, the user LED on many STM32 Nucleo boards.
struct Led;
impl Led {
fn new() -> Self {
unsafe {
// Enable the GPIOA clock, then set PA5 as a general purpose output (01).
write_volatile(RCC_AHB1ENR, read_volatile(RCC_AHB1ENR) | 1);
let moder = read_volatile(GPIOA_MODER);
write_volatile(GPIOA_MODER, (moder & !(0b11 << 10)) | (0b01 << 10));
}
Led
}
}
impl ErrorType for Led {
type Error = core::convert::Infallible;
}
impl OutputPin for Led {
fn set_high(&mut self) -> Result<(), Self::Error> {
unsafe { write_volatile(GPIOA_BSRR, 1 << 5) }; // low half sets
Ok(())
}
fn set_low(&mut self) -> Result<(), Self::Error> {
unsafe { write_volatile(GPIOA_BSRR, 1 << (5 + 16)) }; // high half resets
Ok(())
}
}
#[entry]
fn main() -> ! {
let mut blinker = Blinker::new(Led::new());
loop {
blinker.toggle().ok();
asm::delay(4_000_000); // about 250 ms at 16 MHz
}
}Read the file in the order the machine does. The first two attributes, #![no_std] and #![no_main], say that there is no standard library and no ordinary main. cortex_m_rt::entry marks the function the reset handler calls; its return type ! means it never returns, which is correct, because there is nowhere to return to. use panic_halt as _; pulls in a crate whose only job is to define the panic handler as an infinite loop. You never call anything from it, which is why the import is renamed to _.
write_volatile and read_volatile are the heart of it. A normal write to a variable can be removed if the compiler sees no later read. A write to a hardware register has a side effect the compiler cannot see: it turns an LED on. Volatile access tells the compiler to emit exactly this access, in this order, every time. Each volatile call is unsafe, because the compiler cannot check that 0x4002_0018 is a valid register; the type Led wraps them so that the unsafe code lives in two small functions and the rest of the program, including Blinker, is safe.
The bit set/reset register deserves a sentence. Writing 1 << 5 to its low half sets pin 5 and writing 1 << (5 + 16) to its high half resets it, and writes of zero have no effect. That is a hardware design for atomic updates: you never read-modify-write the output register, so an interrupt that touches another pin of the same port cannot corrupt your change.
Build it and look at the size
cargo build --releaseThe build finishes in a few seconds. A release binary for a microcontroller is an ELF file, and the useful size figures come from the sections that are actually loaded onto the chip. The LLVM size tool, installed with rustup component add llvm-tools, reports them:
section size addr
.vector_table 1024 134217728
.text 192 134218752
.data 0 536870912
.bss 0 536870912
Total 1429The firmware occupies 1,216 bytes of flash: 1,024 bytes for the interrupt vector table, which cortex-m-rt places at the start of flash because the chip reads it at reset, and 192 bytes of code. It uses no RAM beyond the stack. (The .comment and attribute sections in the full listing are metadata that never reach the chip.) Vendor libraries add startup code and drivers you may not need, so a blinky built on one is larger. Writing the registers directly gives close to the smallest possible number, and the point is not to do that forever. It is that Rust has no hidden runtime: what you did not write is not there.
The file Cargo produced is 70,304 bytes, which is more than the 1,429 shown above. The difference is debug information and symbol tables that stay on your computer for the debugger. They are not flashed. Confusing the file size with the flash footprint is a very common beginner worry.
The errors you will meet, and what they mean
Each of the errors below is easy to cause on purpose and each maps to a row of the first table.
Forget #![no_std] and the compiler tries to use the standard library on a target that has none:
error[E0463]: can't find crate for `std`
|
= note: the `thumbv7em-none-eabihf` target may not support the standard library
= note: `std` is required by `blink` because it does not declare `#![no_std]`Remove the panic_halt import and the program has no answer to "what happens on a panic":
error: `#[panic_handler]` function required, but not foundBuild the firmware for your laptop's target by mistake, and the compiler rejects the panic strategy before it gets anywhere:
error: unwinding panics are not supported without stdThat last one is a hint, not a bug: a no_std binary cannot be built for a hosted target without extra work, which is why the library holds the logic and only the firmware binary is target-specific. There is a fourth error you will see sooner or later, a linker complaint that it cannot find memory.x. It means the build script did not run or the file is not at the crate root. The message names the file, which is a kindness.
Getting it onto a board
With a board and a debug probe connected, probe-rs is the standard tool: cargo install probe-rs-tools, then probe-rs run --chip STM32F401RETx target/thumbv7em-none-eabihf/release/blink flashes the binary and runs it. Setting runner = "probe-rs run --chip STM32F401RETx" in .cargo/config.toml makes a plain cargo run --release flash and start the program. This guide did not run those commands against hardware, so read the probe-rs documentation for the chip name your board needs. For logging without a UART, the defmt crate and its RTT transport print formatted messages through the same probe connection, at a fraction of the size of core::fmt.
Where the vendor layers come in
Writing registers by hand is how you learn, and it does not scale. The ecosystem has three layers above it. A peripheral access crate (PAC), generated from the chip's SVD description file, gives every register and bit field a typed name, so gpioa.bsrr().write(|w| w.bs5().set_bit()) replaces the raw address. A hardware abstraction layer (HAL) such as stm32f4xx-hal builds ownership-checked APIs on top: a pin you have configured as an output is a different type from one you have not, and using it wrongly is a compile error. And the Embassy framework adds async tasks and timers on the same chips. Because Blinker is written against OutputPin, moving from the hand-rolled Led to the HAL's pin type is a one-line change in main, and every test still passes. That is the payoff of the trait boundary, and the reason to start with it.
Replace the busy delay with a timer interrupt
asm::delay(4_000_000) burns CPU cycles, and its duration changes with the clock speed and the optimizer. A real program counts time with a hardware timer. The Cortex-M core has one built in, SysTick, which counts down from a reload value and raises an interrupt when it reaches zero. Configure it to fire every millisecond, count the interrupts, and the rest of the program can ask what time it is.
#![no_std]
#![no_main]
use core::cell::Cell;
use cortex_m::interrupt::{self, Mutex};
use cortex_m::peripheral::syst::SystClkSource;
use cortex_m::peripheral::Peripherals;
use cortex_m_rt::{entry, exception};
use panic_halt as _;
/// Milliseconds since start. The interrupt handler writes it and `main` reads it,
/// so access goes through a critical section instead of a bare `static mut`.
static MILLIS: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));
#[exception]
fn SysTick() {
interrupt::free(|cs| {
let now = MILLIS.borrow(cs);
now.set(now.get().wrapping_add(1));
});
}
fn millis() -> u32 {
interrupt::free(|cs| MILLIS.borrow(cs).get())
}
#[entry]
fn main() -> ! {
let mut core = Peripherals::take().unwrap();
core.SYST.set_clock_source(SystClkSource::Core);
core.SYST.set_reload(16_000 - 1); // 16 MHz core clock: one tick per millisecond
core.SYST.clear_current();
core.SYST.enable_counter();
core.SYST.enable_interrupt();
let mut next = 250;
loop {
// wrapping_sub keeps the comparison correct when the counter rolls over.
if millis().wrapping_sub(next) < u32::MAX / 2 {
next = next.wrapping_add(250);
// toggle the LED here
}
cortex_m::asm::wfi(); // sleep until the next interrupt
}
}This compiled for the same target into 1,428 bytes of flash and 8 bytes of RAM. The new idea is the shared counter. An interrupt handler can run at any moment, even between two instructions of main, so a static mut read by main and written by the handler is a data race. Rust refuses to let you do that casually: static mut access needs unsafe, and with the 2024 edition even taking a reference to one is a lint. The idiom in the listing is Mutex<Cell<u32>> from cortex_m::interrupt. The "mutex" does not block; on a single core it works by disabling interrupts for the length of the closure given to interrupt::free, and the token cs it passes in is the proof, checked by the compiler, that you are inside that critical section. The cost is a few cycles of interrupt latency, so keep the closure short.
Two details in main are the kind of bug embedded code is full of. The comparison uses wrapping_sub, because a 32-bit millisecond counter rolls over after about 49.7 days; millis() >= next would be wrong across the rollover, while the difference of two wrapped values is correct as long as they are less than half the range apart. And the loop ends in wfi(), wait for interrupt, which puts the core to sleep until the next tick. A loop that spins without it works and draws several times the current, which matters the moment the device runs on a battery.
Debugging what you cannot print
With no console, bugs show up as a stuck LED. Four habits shorten the search. Build with debug symbols in release (debug = true in the release profile costs nothing on the chip, since symbols stay in the ELF file) so the debugger can map addresses to source. Use defmt for logging, since it sends compact binary messages instead of formatted text. Turn on overflow checks in release while developing (overflow-checks = true), because silent integer wraparound in a sensor calculation produces values that look plausible and are wrong. And treat every unwrap as a decision: in firmware a panic is typically a halted device, so a fault that a laptop program would report and survive takes the product offline. Prefer returning errors up to a top-level handler that decides whether to reset, retry or enter a safe state.
What to do next, in order
- Replace
asm::delaywith a hardware timer, so the blink rate does not depend on the optimization level. - Read a button as an
InputPinand toggle on press, testing the debounce logic against a fake pin. - Add
defmtlogging and watch the toggles on the host. - Move from raw registers to the PAC, then the HAL, keeping
Blinkeruntouched. - Enable an interrupt for the timer and share state with the main loop safely, using
cortex_m::interrupt::freeor a crate such ascritical-section.
When this approach does not fit
If your device runs Linux, as a Raspberry Pi does, you are writing ordinary Rust with std, and GPIO is a library call; none of the no_std machinery applies. If you need a network stack, a file system and a graphical interface, an embedded operating system or Linux is the sensible base. And when a vendor SDK already solves your problem in a mature way, such as a radio stack with certification, wrapping it from Rust is often cheaper than replacing it. Choose bare-metal Rust when memory and timing matter, when you want the compiler to catch peripheral misuse, and when you value logic you can test without a board. For going further on a specific board, read Rust on the Raspberry Pi Pico with Embassy, and for shrinking a binary, Rust firmware size optimization.


