Skip to content
rusthacks.dev
Systems

Rust on STM32 Microcontrollers With Probe RS Tooling

Program an STM32 in Rust with probe-rs: pick the target, create a project from the cortex-m template, use embassy-stm32 or a PAC and HAL, flash, log and debug.

14 min read
Summarize in AI
Flat illustration for the post “Rust on STM32 Microcontrollers With Probe RS Tooling”, composed around tooling.

STM32 is the largest family of ARM microcontrollers, and Rust support for it is good once you have made four decisions in the right order: which compiler target matches your chip's core, which crate drives the peripherals, how the program gets onto the chip, and how you see what it is doing. This guide makes those decisions on a common development board, the Nucleo-F411RE (an STM32F411RE with a Cortex-M4F core, 512 KB of flash and 128 KB of RAM, and an ST-LINK debugger built into the board), and builds two programs that blink an LED and react to the user button: one with the async Embassy HAL and one with the blocking family HAL. Both were built for thumbv7em-none-eabihf with Rust 1.99 and the versions in the table. Where something here needs the physical board (flashing, logs), the text says so; the build, link and size results are from the compiler.

Decision 1: the target follows the core

The Rust target is chosen by the processor core inside the chip, not by the STM32 family name. Look up your part number in ST's datasheet, find the core, and map it:

CoreTypical STM32 familiesRust target
Cortex-M0, M0+F0, G0, L0thumbv6m-none-eabi
Cortex-M3F1, F2, L1thumbv7m-none-eabi
Cortex-M4 (no FPU)rare variantsthumbv7em-none-eabi
Cortex-M4F, M7F (single-precision FPU)F4, G4, L4, F7, H7thumbv7em-none-eabihf
Cortex-M33L5, U5, H5, WBthumbv8m.main-none-eabihf

The F411RE has a Cortex-M4F, so it takes thumbv7em-none-eabihf. The hf suffix means "hard float": floating-point arithmetic uses the FPU registers and instructions. Choosing the wrong one is a classic first-day error. Building for a core older than the real one runs, slowly, with floating point emulated in software. Building for a newer one than the chip has produces a program that hard-faults on its first unsupported instruction, which looks like a dead board. Install the target with rustup target add thumbv7em-none-eabihf.

Decision 2: which crate talks to the hardware

There are three layers, and you pick one to work at. Knowing what each is saves hours of reading the wrong documentation.

LayerCrate exampleWhat you writeUse it when
PAC (peripheral access crate)stm32f4, or the PAC inside embassy-stm32Register reads and writes with type-safe fieldsYou need a feature no HAL covers, or you are writing a HAL
Family HALstm32f4xx-hal 0.23Blocking drivers shaped like pa5.into_push_pull_output()A specific family, a classic blocking style, RTIC projects
Async multi-family HALembassy-stm32 0.6.await on pins, timers, UART, SPI, I2C, DMANew projects, mixed peripherals, low power

For a new project, embassy-stm32 is the usual answer: it covers a few hundred chips behind one API selected by a Cargo feature, its drivers are async so waiting on a UART does not stop the rest of the program, and it runs on the Embassy executor with timers and sleep built in. The family HALs are older, more conservative and widely used; their drivers block. The two sections below build the same blink in both so you can compare.

Decision 3: how it gets onto the chip

The Nucleo board has an ST-LINK debug probe on its USB port that is wired to the chip's SWD pins, so you need nothing extra. probe-rs speaks to ST-LINK, CMSIS-DAP and J-Link probes, flashes the program and streams logs, and replaces the older OpenOCD-plus-GDB setup for most work. Install it once:

Shell
rustup target add thumbv7em-none-eabihf
cargo install probe-rs-tools --locked
probe-rs list
probe-rs chip list | grep -i f411

probe-rs list shows the connected probes, and probe-rs chip list prints the chips it knows. The name you give --chip must match its list exactly: for the Nucleo-F411RE it is STM32F411RETx, with the package and temperature suffix. A wrong name is the most common reason the first flash fails, and the cure is the second command above, never guessing.

Decision 4: how you see what happened

A microcontroller has no terminal. The replacement is defmt, a logging framework where the log strings are not stored on the chip: the program sends compact numeric identifiers over RTT (a ring buffer in RAM that the probe reads while the chip runs), and probe-rs on your computer looks the strings up in the ELF file. A call to info!("pressed {}", n) costs a few bytes of flash and a few microseconds, which is why it is practical to leave logging in. The panic handler panic-probe sends a panic message and location through the same channel before halting. The details and the debugging workflow are in debugging embedded Rust with defmt.

Program 1: Embassy, async

Create a crate and add these files. The versions below are the ones this guide was built with; Embassy changes between releases, so pin them and update deliberately.

Cargo.tomlTOML
[package]
name = "nucleo-blink"
version = "0.1.0"
edition = "2024"

[dependencies]
embassy-stm32 = { version = "0.6", features = ["stm32f411re", "defmt", "time-driver-any", "exti", "memory-x"] }
embassy-executor = { version = "0.10", features = ["platform-cortex-m", "executor-thread", "defmt"] }
embassy-time = { version = "0.5", features = ["defmt", "tick-hz-32_768"] }
cortex-m = { version = "0.7", features = ["critical-section-single-core"] }
cortex-m-rt = "0.7"
defmt = "1"
defmt-rtt = "1"
panic-probe = { version = "1", features = ["print-defmt"] }

[profile.release]
debug = 2
lto = true
opt-level = "s"
.cargo/config.tomlTOML
[build]
target = "thumbv7em-none-eabihf"

[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F411RETx"
rustflags = ["-C", "link-arg=-Tlink.x", "-C", "link-arg=-Tdefmt.x"]

[env]
DEFMT_LOG = "info"

The feature names carry the chip selection. stm32f411re picks the device, which compiles in the right peripheral definitions and pin names. time-driver-any lets Embassy choose a general-purpose timer to drive embassy-time; you can name one (time-driver-tim2) to keep the others free. exti enables external interrupts for pin-change awaits. memory-x is the convenient one: it generates the memory.x linker file for the chip from the metadata, so you do not write the memory map by hand, a step that the RP2040 project in the Pico guide has to do manually.

src/main.rsrust
#![no_std]
#![no_main]

use defmt::info;
use embassy_executor::Spawner;
use embassy_stm32::exti::{ExtiInput, InterruptHandler};
use embassy_stm32::gpio::{Level, Output, Pull, Speed};
use embassy_stm32::{bind_interrupts, interrupt};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

bind_interrupts!(struct Irqs {
    EXTI15_10 => InterruptHandler<interrupt::typelevel::EXTI15_10>;
});

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
    let mut button = ExtiInput::new(p.PC13, p.EXTI13, Pull::Up, Irqs);

    info!("ready");
    loop {
        button.wait_for_falling_edge().await;
        led.set_high();
        Timer::after_millis(200).await;
        led.set_low();
        info!("button pressed");
    }
}

The new thing is bind_interrupts!. In recent Embassy releases (0.6 here), the program must say explicitly which interrupt service routine handles each interrupt, so that the hardware vector is connected to Embassy's handler at compile time. PC13 lives on EXTI line 13, and lines 10 to 15 share one interrupt vector, EXTI15_10. Leaving this out is a compile error with a clear complaint that the function takes four arguments and three were supplied; the older tutorials that call ExtiInput::new(pin, channel, pull) predate it. The pin assignments are board specific: PA5 drives the green LED and PC13 is the blue user button on the Nucleo-64 boards.

Shell
cargo run --release

That builds, flashes through the ST-LINK and attaches the log stream. Press the blue button and the LED lights for 200 ms, with button pressed in the terminal. The release build came to 9,876 bytes of flash (the vector table is 408 bytes because the M4 has more interrupts than a small core) and 1,312 bytes of RAM, from a 512 KB and 128 KB chip. Like the Pico example, the core sleeps with WFE whenever no task is ready.

Program 2: the blocking family HAL

The same hardware, driven by stm32f4xx-hal. This version needs a memory.x in the crate root, since the HAL does not generate one, plus a build.rs that puts it on the linker path (the same file shown in the Pico guide):

memory.xText
MEMORY
{
  FLASH : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   : ORIGIN = 0x20000000, LENGTH = 128K
}
src/main.rsrust
#![no_std]
#![no_main]

use cortex_m::peripheral::Peripherals as CorePeripherals;
use cortex_m_rt::entry;
use stm32f4xx_hal::rcc::Config;
use stm32f4xx_hal::{pac, prelude::*};
use {defmt_rtt as _, panic_probe as _};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let cp = CorePeripherals::take().unwrap();

    let mut rcc = dp.RCC.freeze(Config::hsi().sysclk(48.MHz()));
    let gpioa = dp.GPIOA.split(&mut rcc);
    let mut led = gpioa.pa5.into_push_pull_output();
    let mut delay = cp.SYST.delay(&rcc.clocks);

    loop {
        led.toggle();
        delay.delay_ms(500);
    }
}

Three things to notice. Peripherals::take() returns the peripherals once and None afterwards, which is how the singleton ownership of hardware is enforced at run time. rcc.freeze(Config::hsi().sysclk(48.MHz())) configures the clock tree from the internal oscillator to 48 MHz (the 0.23 API; earlier versions used rcc.cfgr.sysclk(...).freeze(), and copying that fails to compile with a message that the field is private). And the delay blocks: the core spins in a loop for 500 ms, so while it waits it cannot read a button or serve a UART. For a blinker that is fine. For anything with two jobs you would add timer interrupts and a framework such as RTIC, whose structure is compared with Embassy in RTIC versus Embassy. This version built to 12,304 bytes of flash and 1,096 bytes of RAM, a little more flash than the async version despite doing less, because of the HAL's generic clock code.

RTT logging is for you; a product usually speaks a real interface. The Nucleo-64 boards wire the chip's USART2 (PA2 transmit, PA3 receive) to the ST-LINK, which presents it to your computer as a virtual serial port, so a UART program needs no extra hardware. This version formats a line with core::fmt::Write into a fixed buffer, with no heap, and sends it every half second:

src/main.rsrust
#![no_std]
#![no_main]

use core::fmt::Write;

use defmt::info;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_stm32::usart::{Config, Uart};
use embassy_time::Timer;
use {defmt_rtt as _, panic_probe as _};

struct Line {
    data: [u8; 32],
    len: usize,
}

impl Write for Line {
    fn write_str(&mut self, s: &str) -> core::fmt::Result {
        let bytes = s.as_bytes();
        let end = self.len + bytes.len();
        self.data.get_mut(self.len..end).ok_or(core::fmt::Error)?.copy_from_slice(bytes);
        self.len = end;
        Ok(())
    }
}

#[embassy_executor::main]
async fn main(_spawner: Spawner) {
    let p = embassy_stm32::init(Default::default());
    let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
    let mut uart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, Config::default()).unwrap();

    let mut count = 0u32;
    loop {
        led.toggle();
        count += 1;

        let mut line = Line { data: [0; 32], len: 0 };
        write!(line, "tick {count}\r\n").unwrap();
        uart.blocking_write(&line.data[..line.len]).unwrap();
        info!("sent line {}", count);

        Timer::after_millis(500).await;
    }
}

The Line type implements core::fmt::Write over a 32-byte array, and returns an error if the text does not fit instead of writing past the end, so write!(line, ...) is bounded and cannot overflow. That is the standard way to format text on a chip without an allocator; the heapless crate packages the same idea as heapless::String. Open the port on your computer at 115200 baud, 8N1 (the default of Config) with screen /dev/tty.usbmodem* 115200 on macOS, tio /dev/ttyACM0 on Linux, or PuTTY on Windows, and you should see tick 1, tick 2, and so on. The program built to 10,376 bytes of flash and 1,300 bytes of RAM.

Two caveats. Uart::new_blocking blocks the core while a byte is shifted out: at 115200 baud a 10-byte line takes about 0.9 ms (each byte is 10 bits on the wire, so 100 bits at 115200 bits per second), and the other tasks wait. That is fine for a debug line and wrong for a busy device. The async constructor, Uart::new, takes DMA channels and the interrupt bindings and hands the transfer to the DMA controller so the core is free during it; it needs a bind_interrupts! entry for the UART and one for each DMA channel, and the channel choices come from a table in the reference manual (for USART2 on the F411, the request mapping lists the exact DMA stream and channel). A mistake in them produces the long "trait bound not satisfied" error that names RxDma and Binding, which in plain terms says that the DMA channel you passed cannot serve this UART or that its interrupt is not bound. Fix the mapping from the manual before touching the code.

Clocks, speed and power

A fresh STM32F4 program runs from its internal 16 MHz RC oscillator (HSI) unless you configure otherwise. That is deliberate and safe, and it is slow: the F411 can reach 100 MHz through its PLL. Embassy's default Config keeps the 16 MHz clock, so a loop that is CPU-bound runs six times slower than the chip allows until you set up the clock tree through the rcc field of embassy_stm32::Config. Raising the speed has costs: current draw rises roughly in proportion, flash needs wait states (the HAL handles that), and some peripherals (USB needs an exact 48 MHz) force particular PLL settings. Decide by the job: a sensor logger sampling once a second gains nothing from 100 MHz and loses battery life, and a signal-processing loop needs every megahertz. The same reasoning applies to the family HAL, where Config::hsi().sysclk(48.MHz()) in the blocking example selects 48 MHz explicitly.

For power, the async model already gives you the main saving, because the core sleeps between events. Beyond that, the STM32 has deeper stop and standby modes that gate clocks and power domains; they cut the current from milliamps to microamps and cost wake-up time and lost state, and Embassy exposes them through low-power features of the executor on supported chips. They are a project of their own, with a measured current profile on a power analyser, and not something to turn on by feature flag and hope.

Reading pins and peripherals off the datasheet

Every pin on an STM32 can serve several functions (GPIO, a timer channel, a UART pin, an ADC input), selected through an alternate-function number. The HALs encode the valid combinations in types: Uart::new_blocking(p.USART2, p.PA3, p.PA2, ...) compiles only because PA3 can be USART2's receive pin and PA2 its transmit pin. Pass PB7 and the compiler rejects it, with an error that lists the pins that would work. That check replaces the datasheet's alternate-function table for the common case. When you need the table (to plan a board layout, say), it is in the device datasheet, not the reference manual, under "Alternate function mapping". Treat the compiler's complaint as the first line of defence and the datasheet as the authority on what the silicon can do.

Debugging and the first things to check

When something does not work, go through this in order. Most failures in the first week are the same dozen.

SymptomLikely causeWhat to do
probe-rs cannot find the chipChip name does not match its databaseRun probe-rs chip list, copy the exact string into the runner
"No probe found" or a USB errorCable is power-only, driver or udev rules missing, ST-LINK firmware oldTry another cable; on Linux install the probe-rs udev rules; update the ST-LINK firmware with ST's tool
Flash works, nothing runsWrong core target (hard fault at start), or a wrong clock setupCheck the table above; enable defmt and look for a panic or hard fault message
this function takes 4 arguments but 3 were supplied on ExtiInput::newOld tutorial, new EmbassyAdd bind_interrupts! and pass Irqs
Timer functions never returnNo time driver feature, or the chosen timer is used elsewhereEnable time-driver-any; do not use that TIM yourself
Linker error about memory.xFamily HAL project without a memory mapAdd memory.x and build.rs; Embassy projects can use the memory-x feature
"Flash is protected" or erase failsRead-out protection set on the chipprobe-rs erase --chip STM32F411RETx (this wipes the chip) or reset protection with ST's tool
Works under the probe, fails on batteryDebug features keep clocks running, or brown-out on the supplyTest the standalone path; check supply voltage under load

For a hard fault, panic-probe will not always run (a fault is not a panic). Print the fault registers with the cortex-m-rt HardFault handler, or run probe-rs attach and read the stack. A habit that avoids many of these: keep a known-good blink in a separate crate. When a larger project misbehaves, flash the blink first; if that works, the board, cable and probe are fine and the fault is in your code.

Which approach for which project

Pick Embassy for new work that touches more than one peripheral at a time (a UART, a sensor on I2C and a display), for anything battery powered, and when you want to run on several STM32 families without rewriting. Pick a family HAL with RTIC if you need hard priority-based preemption and an established, conservative stack, or if your team already has experience with it. Work at the PAC level when you hit a peripheral feature the HAL does not expose; the register-level view is explained in memory-mapped registers and PACs. Whatever you choose, write drivers for external parts (sensors, displays) against the embedded-hal traits so that they do not care which HAL is underneath, as in portable drivers.

A checklist before you write real firmware

Before the first sensor driver goes in, spend ten minutes on four things that are cheap now and expensive later. Pin the toolchain and the crate versions in the repository, so a build in six months produces the same binary. Turn on the overflow checks you want in release (overflow-checks = true costs a little flash and catches arithmetic bugs that otherwise wrap silently). Decide how a panic should end: panic-probe halts and prints, which suits development, while a product usually resets the chip with a watchdog or a reset panic handler. And write down the pin map in a table next to the code, since the board schematic is the one source that cannot be recovered from the firmware.

What this guide does not cover

It does not cover boards without a built-in probe, where you wire an external ST-LINK or a CMSIS-DAP probe to the SWD pins, nor flashing a finished product over USB DFU, UART bootloader or an over-the-air updater. It does not cover the large chips (the H7 family has two cores on some parts and multiple power domains that need careful clock configuration), RTOS integration, or safety-certified use. For those, start from the manufacturer's reference manual for your exact part and the example directory of the HAL you chose, and keep the build reproducible by pinning the toolchain in a rust-toolchain.toml, the practice described in rust-toolchain.toml.

Also inTooling
Taggedstm32
Share

Keep reading

Get new posts by email

We send one email when a new post is published. No spam.

Unsubscribe in one click. No spam, ever.