Ramblings of an aging IT geek
← Ramblings of an aging IT geek
rust

blinking an LED the hard way, in Rust

A weekend with embedded Rust on a cheap STM32 board, where the borrow checker turns hardware peripherals into something you can only own once, and that turns out to be the point.

Code on a screen next to a microcontroller

I bought a cheap STM32 board on a whim and spent a weekend learning that embedded Rust is both more pleasant and more pedantic than I expected, and that those are the same property wearing two hats. The thing that makes desktop Rust feel safe is exactly the thing that makes the first hour of embedded Rust feel like the compiler is being deliberately obtuse about a microcontroller it has never met.

The headline: the borrow checker treats hardware peripherals as values you own. There is exactly one GPIO port A in the universe of your program, and the type system enforces that. You take it once, out of a struct, and after that it's moved. You cannot accidentally configure the same pin from two places, because the second place doesn't have the pin, you do.

let dp = pac::Peripherals::take().unwrap();
let gpioc = dp.GPIOC.split();
let mut led = gpioc.pc13.into_push_pull_output();

Peripherals::take() returns an Option, and it returns Some exactly once. Call it twice and the second call is None. That's the whole singleton problem solved at the type level, and the first time it stopped me doing something stupid I was annoyed, and then about ten seconds later I understood why it was right.

the part that fought me

The fight was the build setup, not the code. Embedded Rust wants a target triple, a linker script that knows your chip's memory map, and a runtime that sets up the vector table before your main runs. None of that is hard once it works, and all of it is opaque while it doesn't.

# .cargo/config.toml
[target.thumbv7m-none-eabi]
runner = "probe-rs run --chip STM32F103C8"
rustflags = ["-C", "link-arg=-Tlink.x"]

[build]
target = "thumbv7m-none-eabi"

The crate is #![no_std], because there's no operating system and no heap to lean on, and #![no_main], because the entry point comes from the runtime crate rather than a normal fn main. Those two attributes at the top of the file are the moment it stops being the Rust you know and starts being Rust talking directly to silicon.

A breadboard with a microcontroller and jumper wires

Once it built, flashing was the genuinely good bit. probe-rs over a cheap debug probe, one cargo run, and the LED blinked. And because of the way the peripheral access crate is generated from the chip's own description, the register names in my code match the names in the datasheet exactly. When I wanted to set a clock prescaler I could read the reference manual, find the field, and type its name, and the compiler knew whether I'd got it right.

loop {
    led.set_high();
    delay.delay_ms(500u16);
    led.set_low();
    delay.delay_ms(500u16);
}

was it worth it

For a blinking LED, objectively no, an Arduino sketch is four lines and works in two minutes. But the question isn't the LED. It's what happens when the program gets big enough to have two things fighting over one timer, and on a microcontroller with no memory protection and no OS to catch you, that fight is normally a silent, intermittent, weeks-of-your-life bug. Here it's a compile error before the board is even plugged in.

That's the trade. You pay up front, in build setup and in a compiler that refuses to let you share a peripheral you've already moved, and in return the entire category of "two bits of code clobbered the same register" stops existing. On a desktop that category is an annoyance. On bare metal it's the difference between a device that works and a device that hangs once a fortnight for reasons you'll never reproduce on the bench. I'll take the pedantry.