Blinking an LED
Step 3 of 10, and the first one that makes the board do something. Step 2 ended with an empty loop {} running on the chip: the toolchain, the probe and the board each did their job, and there was nothing to look at. This step gives the loop something to do — the green user LED, LD2, flashes on and off, half a second at a time, forever. It starts from tag stm32-ch02 and ends at stm32-ch03.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against, with stm32f4xx-hal 0.23.0 and embedded-hal 1.0.0.
The LED and its pin
Section titled “The LED and its pin”LD2 is the Nucleo’s green user LED, and it is already wired: the board connects it to pin PA5 of the microcontroller. No breadboard, no resistor, nothing to solder. In the HAL’s naming the pin is pa5 — port A, pin 5 — and driving it is the whole of this step.
The three crates
Section titled “The three crates”Three crates do the work, and it pays to know which is which:
stm32f4xx-halis the hardware abstraction layer for the STM32F4 family. It wraps the chip’s registers in typed pins, clocks and timers, and it implements theembedded-haltraits for them.embedded-halis the trait crate. It definesOutputPin,DelayNsand the rest; HALs implement them, and firmware written against them compiles for any chip whose HAL does.cortex-mholds the Cortex-M core pieces, the ones that live outside the chip’s own datasheet: SysTick, the interrupt controller. This step wants SysTick.
Three commands add them:
cargo add stm32f4xx-hal --features stm32f401cargo add embedded-halcargo add cortex-mAs of writing those resolve to stm32f4xx-hal 0.23.0 — which pulls in the stm32f4 register crate at 0.16.0 — embedded-hal 1.0.0 and cortex-m 0.7.9. The --features stm32f401 half of the first command is not decoration; the pitfalls below show what happens without it.
The loop, before the board
Section titled “The loop, before the board”Traits are the point of a HAL, and they can be exercised without the chip. The block below is the firmware’s blink function with the pin and the delay swapped for stand-ins that record what they were asked to do. It needs one crate:
[dependencies]embedded-hal = "1.0"use embedded_hal::delay::DelayNs;use embedded_hal::digital::{ErrorType, OutputPin};
/// Stands in for PA5 on a machine with no board attached: it records/// every transition instead of driving a pin.struct TraceLed { log: Vec<&'static str>,}
impl ErrorType for TraceLed { type Error = core::convert::Infallible;}
impl OutputPin for TraceLed { fn set_low(&mut self) -> Result<(), Self::Error> { self.log.push("low"); Ok(()) }
fn set_high(&mut self) -> Result<(), Self::Error> { self.log.push("high"); Ok(()) }}
/// A delay that returns at once: the point is the pin, not the wait.struct NoDelay;
impl DelayNs for NoDelay { fn delay_ns(&mut self, _ns: u32) {}}
/// The firmware's blink function. Two things change for the host: the/// `for` replaces the `loop` so the program can finish, and the pin and/// the delay are the stand-ins above.fn blink<P: OutputPin, D: DelayNs>(led: &mut P, delay: &mut D, times: u32) { for _ in 0..times { led.set_high().unwrap(); delay.delay_ms(500); led.set_low().unwrap(); delay.delay_ms(500); }}
fn main() { let mut led = TraceLed { log: Vec::new() }; let mut delay = NoDelay; blink(&mut led, &mut delay, 2); println!("{:?}", led.log);}["high", "low", "high", "low"]Two transitions per cycle, high then low — the same four calls the firmware makes, in the same order. On the board the delay is half a second of SysTick; here it returns at once, and the for and the times argument exist so the program can finish and print its work. The calls are the firmware’s; the pin, the delay and the stop condition are what the host version adds.
The firmware
Section titled “The firmware”The whole program is one file. This is the complete src/main.rs at this step’s tag, comments included:
#![no_std]#![no_main]
use panic_halt as _;
use cortex_m::delay::Delay;use cortex_m_rt::entry;use embedded_hal::delay::DelayNs;use embedded_hal::digital::OutputPin;use stm32f4xx_hal::pac;use stm32f4xx_hal::prelude::*;use stm32f4xx_hal::rcc::Config;
#[entry]fn main() -> ! { let dp = pac::Peripherals::take().unwrap(); let cp = cortex_m::peripheral::Peripherals::take().unwrap();
// Constrain the RCC and apply the default clock configuration: the // chip's 16 MHz internal oscillator, no PLL. let mut rcc = dp.RCC.constrain().freeze(Config::default()); let clocks = rcc.clocks;
// Splitting a port hands out its pins one at a time; the `&mut rcc` // is what turns the port's clock on. let gpioa = dp.GPIOA.split(&mut rcc); let mut led = gpioa.pa5.into_push_pull_output();
// The delay counts SysTick ticks against the AHB frequency. let mut delay = Delay::new(cp.SYST, clocks.hclk().to_Hz());
blink(&mut led, &mut delay)}
/// The loop, written against the two traits instead of the chip's pin and/// timer types: `P` is whatever drives the LED, `D` is whatever counts the/// time.fn blink<P: OutputPin, D: DelayNs>(led: &mut P, delay: &mut D) -> ! { loop { led.set_high().unwrap(); delay.delay_ms(500); led.set_low().unwrap(); delay.delay_ms(500); }}The setup, in order:
pac::Peripherals::take().unwrap()hands out the device peripherals — RCC, GPIOA, the rest. They exist exactly once in the program, sotake()returns anOption; a second call would giveNone, and theunwrapis the usual way of saying “this is the first”.cortex_m::peripheral::Peripherals::take().unwrap()is the same idea for the core peripherals. This program wants SysTick out of that set, so it takes the whole thing.dp.RCC.constrain().freeze(Config::default())wraps the reset and clock controller in the HAL’s type, then applies a configuration.Config::default()is the chip’s 16 MHz internal oscillator with no PLL, which is plenty for an LED.freezewrites it to the hardware and returns theRcccarrying the frequencies it actually set.rcc.clocksis that record of frequencies, kept for the delay below.dp.GPIOA.split(&mut rcc)hands out port A’s pins one at a time. The&mut rccargument is what turns port A’s clock on; the HAL needs the RCC to do it.gpioa.pa5.into_push_pull_output()takes the pin, which starts life as a floating input, and returns it configured as a push-pull output. The mode lives in the type: the input methods no longer resolve on the value that comes back.Delay::new(cp.SYST, clocks.hclk().to_Hz())points SysTick at the AHB frequency the frozen clocks report, 16 MHz. Everydelay_ms(500)after this is half a second of real time, give or take the oscillator’s accuracy.blink(&mut led, &mut delay)hands the loop the two things it needs.
blink is generic over OutputPin and DelayNs, which is why its body can read led.set_high().unwrap(): through the trait, set_high returns a Result, and the pin’s error type is Infallible, so the unwrap never fires. The generic parameter is not decoration either — the pitfalls below show what the concrete pin does to that line.
Build it
Section titled “Build it”cargo build Compiling proc-macro2 v1.0.107 Compiling quote v1.0.47 Compiling unicode-ident v1.0.27 Compiling semver-parser v0.7.0 Compiling nb v1.1.0 Compiling cortex-m-rt v0.7.7 Compiling cortex-m v0.7.9 Compiling cortex-m-macros v0.7.8 Compiling portable-atomic v1.15.0 Compiling vcell v0.1.3 Compiling embedded-hal v1.0.0 Compiling void v1.0.2 Compiling nb v0.1.3 Compiling volatile-register v0.2.2 Compiling stm32f4 v0.16.0 Compiling gcd v2.3.0 Compiling critical-section v1.2.0 Compiling semver v0.9.0 Compiling embedded-hal v0.2.7 Compiling bitfield v0.13.2 Compiling fugit v0.3.9 Compiling powerfmt v0.2.1 Compiling rustc_version v0.2.3 Compiling num-conv v0.2.2 Compiling deranged v0.5.9 Compiling stm32f4xx-hal v0.23.0 Compiling stable_deref_trait v1.2.1 Compiling litrs v1.0.1 Compiling time-core v0.1.9 Compiling embedded-dma v0.2.0 Compiling bare-metal v0.2.5 Compiling fugit-timer v0.1.3 Compiling embedded-hal-nb v1.0.0 Compiling embedded-storage v0.3.2 Compiling embedded-io v0.6.1 Compiling rand_core v0.9.5 Compiling bare-metal v1.0.0 Compiling rand_core v0.6.4 Compiling panic-halt v1.0.0 Compiling document-features v0.2.12 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.52sNo --target — step 2’s .cargo/config.toml already pins it. The Compiling lines are the dependency closure the HAL pulls in, and they only appear on a cold build; what matters is that firmware is among them, with no warnings after it. The path after the package name is this machine’s project directory; yours says firmware wherever you cloned the repository. The build time is this machine’s too.
Flash it
Section titled “Flash it”With the Nucleo plugged in:
cargo flash --chip STM32F401RE Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s 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 writes the image, resets the chip and starts it. What the board does then: PA5 goes high for half a second, low for half a second, and LD2 flashes once a second, forever.
cargo run reaches the same place through the runner step 2 configured:
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.Pitfalls
Section titled “Pitfalls”The feature selects the chip
Section titled “The feature selects the chip”Forget --features stm32f401 and the build dies inside the register crate’s build script, before a line of the firmware is read:
error: failed to run custom build command for `stm32f4 v0.16.0`
Caused by: process didn't exit successfully: `/Users/rango/www/hackingwithrust/code/embedded-stm32/target/debug/build/stm32f4-d5668ff246b948c9/build-script-build` (exit status: 101) --- stdout cargo:rustc-link-search=/Users/rango/www/hackingwithrust/code/embedded-stm32/target/thumbv7em-none-eabihf/debug/build/stm32f4-5675e1683aaa1a66/out
--- stderr
thread 'main' (3980321) panicked at /Users/rango/.cargo/registry/src/rsproxy.cn-e3de039b2554c837/stm32f4-0.16.0/build.rs:20:13: No device features selected. Avaliable device features are: ["stm32f401", "stm32f405", "stm32f407", "stm32f410", "stm32f411", "stm32f412", "stm32f413", "stm32f427", "stm32f429", "stm32f446", "stm32f469"] note: run with `RUST_BACKTRACE=1` environment variable to display a backtraceThe paths are this machine’s project directory and registry cache, the thread number and the hash in the build-script path vary per run, and the typo in “Avaliable” is the crate’s own. The same build also fails the HAL’s own build script, with the shorter No stm32xx Cargo feature enabled. Both say the same thing: the register crate covers the whole F4 family, and the feature says which member of the family this board carries.
unwrap on (): the pin’s own methods
Section titled “unwrap on (): the pin’s own methods”Older tutorials put the loop straight into main and call set_high() on the pin itself. With this HAL that shape no longer compiles. The error below is from a build with the loop inline in main, so the line numbers are that file’s, not the listing above:
error[E0599]: no method named `unwrap` found for unit type `()` in the current scope --> src/main.rs:28:24 |28 | led.set_high().unwrap(); | ^^^^^^ method not found in `()` |note: method `set_high` modifies its receiver in-place --> src/main.rs:28:13 |28 | led.set_high().unwrap(); | ^^^^^^^^ this call modifies `led` in-placePin carries its own inherent set_high and set_low, which return () — there is nothing to unwrap. An inherent method is found before a trait method, so on the concrete pin the trait’s Result-returning set_high is never called at all, whatever is in scope. The same error follows for set_low.
Two ways out. Name the trait in the call, OutputPin::set_high(&mut led), or write the loop against a generic parameter, as blink does — which is also what keeps the loop runnable without the board, as the section above the firmware shows.
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-ch03:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch03cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
Step 4 — Understanding no_std — looks at what the firmware gave up when it dropped the standard library: no allocator, no panic message, no libc. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.