Async tasks on a microcontroller
Step 9 of 10, and the step that takes the loop apart. Step 8 left the firmware doing four jobs in one loop — polling the button, dimming the LED, reading the sensor, writing two reports per reading — strictly one after another, and the whole firmware stopped for each of them. This step replaces the loop with an embassy executor: four tasks that each own one job, three primitives that carry messages between them, and a timer every one of them can wait on. It starts from tag stm32-ch08 and ends at stm32-ch09.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against, with embassy-executor 0.10.0, embassy-sync 0.8.0, embassy-futures 0.1.2, embassy-time 0.5.1, embassy-time-driver 0.2.2 and embassy-time-queue-utils 0.3.2, alongside stm32f4xx-hal 0.23.0, bmpe280 1.0.5, defmt 1.2.0 and defmt-rtt 1.3.1 from the earlier steps.
What one loop was doing
Section titled “What one loop was doing”The loop from step 8 is honest about what it is: a sequence. The button is polled, the duty is written, a millisecond is burned, and every five hundred of those milliseconds the sensor is read and two reports are written. Nothing overlaps. Three things follow from that, and none of them is about speed.
The first is waiting. Between readings the loop runs five hundred one-millisecond iterations that do nothing but keep time. The chip is awake for all of them. An executor with a real clock can sit in wfi until something is due, and that is the whole basis of step 10’s battery work.
The second is blocking. sensor.measure() is a blocking I2C transfer — seven bytes at 100 kHz, a little over half a millisecond — and for that window the button is not polled and the LED does not advance. Half a millisecond is not a problem. The shape is: any task that holds the CPU holds all of them, and the loop has no way to say otherwise.
The third is structure. Every new job the firmware takes on lands in the same loop, and the loop is where the ordering of everything is decided. Four jobs is where that stops being readable.
Four tasks, one job each
Section titled “Four tasks, one job each”The executor’s entry point replaces #[cortex_m_rt::entry]:
#[embassy_executor::main]async fn main(spawner: Spawner) { let dp = pac::Peripherals::take().unwrap();
// Constrain the RCC and apply the default clock configuration: the // chip's 16 MHz internal oscillator, no PLL. APB1 runs at the same // 16 MHz, which is the clock the time driver divides. let mut rcc = dp.RCC.constrain().freeze(Config::default());
let gpioa = dp.GPIOA.split(&mut rcc); let gpiob = dp.GPIOB.split(&mut rcc); let gpioc = dp.GPIOC.split(&mut rcc);
// TIM5 hangs off APB1 and nothing in the HAL takes it over, so its // clock is enabled here by hand: without it the counter never runs // and every `Timer` in the firmware waits forever. rcc.apb1enr().modify(|_, w| w.tim5en().enabled());
// The clock every task waits on comes up before anything can wait // on it: the driver configures TIM5 and unmasks its interrupt here, // and the first task is not spawned until that is done. DRIVER.init(); NVIC::unpend(pac::Interrupt::TIM5); unsafe { NVIC::unmask(pac::Interrupt::TIM5); }
// PC13 is the board's user button, wired as step 5 left it. let button = Button::new(gpioc.pc13.into_pull_up_input());
// TIM2's channel 1 is muxed onto PA5, the pin LD2 is wired to. The // pin changes jobs here: no longer an output the firmware writes // outright, but a timer output the firmware only loads. One // kilohertz makes a period of 1 ms. let (_, channels) = dp.TIM2.pwm_hz(1.kHz(), &mut rcc); let mut led = channels.0.with(gpioa.pa5.into_alternate::<1>()); // The channel starts with its output disabled: the counter runs and // the waveform is generated, but nothing reaches the pin until this // call sets the channel's output-enable bit. led.enable();
// I2C1's SCL and SDA are muxed onto PB8 and PB9 — the Nucleo's // Arduino header pins D15 and D14, where a sensor module plugs // straight in. The pins are passed in their plain split state: the // bus constructor moves them into alternate mode itself. One // hundred kilohertz is the bus's standard mode. let i2c = dp.I2C1.i2c((gpiob.pb8, gpiob.pb9), 100.kHz(), &mut rcc); let mut sensor = BMP280::new_with_address(i2c, SENSOR_ADDRESS); // The driver cannot fail, so it cannot report one either: a chip // that does not answer leaves it unconfigured, and every reading it // would return after that is a fiction. Ask the chip who it is // before trusting it. if sensor.id() != BMP280_ID { stall_lit(); }
// USART2's TX and RX are muxed onto PA2 and PA3, which the Nucleo // wires to the ST-Link's virtual COM port: the firmware's plain // text output shows up on the host as a serial device, with no // hardware beyond the board's own USB cable. The pins are handed // over plain, as the bus's were, and the constructor moves them // into alternate mode itself. `SerialConfig::default()` is 115200 // baud, eight data bits, no parity, one stop bit. // // The `Result` is real — a baud rate the bus clock cannot produce // is an error — but 16 MHz divided down to 115200 is comfortably // inside the peripheral's range, so a failure here means the port // is unusable and the board says so the only way it can. let serial = match dp.USART2.serial( (gpioa.pa2, gpioa.pa3), SerialConfig::default(), &mut rcc, ) { Ok(serial) => serial, Err(_) => stall_lit(), }; // The port splits in two: the transmitter moves into the task that // owns the logging, and the receiver is dropped — nothing reads it, // as in step 8. let (tx, _rx) = serial.split();
// One job per task, and each task owns the hardware it needs. What // the tasks have to say to each other travels over the statics // above, not through shared borrows that would have to outlive the // executor's poll of them. spawner.spawn(breathe(led, &TEMPERATURE, &REPORTS).unwrap()); spawner.spawn(watch_button(button).unwrap()); spawner.spawn(read_sensor(sensor, &TEMPERATURE, &REPORTS).unwrap()); spawner.spawn(report(tx, &REPORTS).unwrap());}The macro does what #[cortex_m_rt::entry] did — it generates the reset entry point — but the function it wraps is async, and the macro runs it as the executor’s main task. main is a task like any other; it sets up the hardware, spawns the four workers, and its return is the end of the setup, not of the firmware. The executor keeps polling the four tasks forever, which is why the return type of the generated entry is !.
Each spawn moves one owned thing into one task: the PWM channel, the button pin, the sensor, the transmitter. That ownership is the point of the split — the serial port is touched by exactly one task now, so nothing has to lock it, and a reader of the code can see which task talks to which pin without tracing a call graph.
Two details in the setup are new. TIM5’s clock is enabled by hand, because the time driver below owns that timer and nothing in the HAL does. And the driver is started, and its interrupt unmasked, before the first spawn: a task that waits on a Timer before the clock exists waits forever.
Passing messages
Section titled “Passing messages”The four tasks cannot share borrows — a task argument has to be 'static, and the executor outlives any local. What they share instead are three statics, one per kind of message:
/// Reports waiting for the serial port. The logger is the only reader/// and both producers are slower than it is, so eight slots never fill.static REPORTS: Channel<CriticalSectionRawMutex, Report, 8> = Channel::new();
/// The latest temperature reading. A `Watch` rather than a channel/// because the breath wants the current value whenever it looks, not/// every value that ever arrived — one slot, overwritten in place.static TEMPERATURE: Watch<CriticalSectionRawMutex, f64, 1> = Watch::new();
/// A press that has been accepted and not yet acted on. The button/// task raises it; the breath task waits on it.static PRESS: Signal<CriticalSectionRawMutex, ()> = Signal::new();A Channel is a queue: every value sent is received, in order, and the producers here (the sensor and the breath) are far slower than the single consumer, so the eight slots never fill. A Watch holds one value, overwritten in place — the breath asks for the current temperature whenever it looks, and missing the previous one is correct, because the previous one is stale. A Signal is a one-shot event with no payload beyond its own type: the button raises it, the breath waits on it, and a press that arrives while the breath is mid-tick simply waits until the breath looks.
The mutex is CriticalSectionRawMutex — on a single-core Cortex-M, a critical section is the lock, and cortex-m’s critical-section-single-core feature, carried since step 8, is what implements it.
The same three statics, the same select, and the same four tasks run on this machine against a mock button, a mock sensor and the clock embassy-time’s std driver keeps. The article needs the crates:
[dependencies]critical-section = { version = "1.2.0", features = ["std"] }embassy-executor = { version = "0.10.0", features = ["platform-std", "executor-thread"] }embassy-futures = "0.1.2"embassy-sync = "0.8.0"embassy-time = { version = "0.5.1", features = ["std"] }//! The firmware's four tasks, running on this machine: the same three//! statics, the same `select`, a mock button, a mock sensor, and the//! clock embassy-time's `std` driver keeps.
use embassy_executor::Spawner;use embassy_futures::select::{select, Either};use embassy_sync::blocking_mutex::raw::CriticalSectionRawMutex;use embassy_sync::channel::Channel;use embassy_sync::signal::Signal;use embassy_sync::watch::Watch;use embassy_time::{Duration, Instant, Timer};
/// One thing the firmware has to say about itself, as on the board.enum Report { Temperature(f64), Breath(bool),}
static REPORTS: Channel<CriticalSectionRawMutex, Report, 8> = Channel::new();static TEMPERATURE: Watch<CriticalSectionRawMutex, f64, 1> = Watch::new();static PRESS: Signal<CriticalSectionRawMutex, ()> = Signal::new();
const DEFAULT_HALF: u16 = 30;const MIN_HALF: u16 = 5;const MAX_HALF: u16 = 50;const TEMP_STEP: f64 = 1.0;
/// Milliseconds since the process started, from the same clock the/// tasks wait on.fn now() -> u64 { Instant::now().as_millis()}
#[embassy_executor::main]async fn main(spawner: Spawner) { spawner.spawn(breathe(&TEMPERATURE, &REPORTS).unwrap()); spawner.spawn(watch_button().unwrap()); spawner.spawn(read_sensor(&TEMPERATURE, &REPORTS).unwrap()); spawner.spawn(report(&REPORTS).unwrap()); // Long enough for every task to finish what it starts below. Timer::after(Duration::from_millis(250)).await;}
/// The breath, exactly as the firmware runs it: one step of the ramp/// every ten milliseconds, a pause or resume when a press arrives.#[embassy_executor::task]async fn breathe( temperature: &'static Watch<CriticalSectionRawMutex, f64, 1>, reports: &'static Channel<CriticalSectionRawMutex, Report, 8>,) { let mut breath = Breath::new(DEFAULT_HALF); let mut ticks = 0; let mut pauses = 0;
loop { match select(PRESS.wait(), Timer::after(Duration::from_millis(10))).await { Either::First(()) => { breath.toggle(); pauses += 1; reports.send(Report::Breath(breath.running)).await; } Either::Second(()) => { if let Some(t) = temperature.try_get() { breath.set_half(half_for(t)); } breath.advance(); ticks += 1; if ticks == 20 { break; } } } } println!("[{:>4}] breath: {} ticks, {} pauses", now(), ticks, pauses);}
/// The button, standing in for PC13: two presses, at 30 ms and again/// 90 ms later. On the board this task polls the pin instead.#[embassy_executor::task]async fn watch_button() { Timer::after(Duration::from_millis(30)).await; PRESS.signal(()); Timer::after(Duration::from_millis(90)).await; PRESS.signal(());}
/// The sensor, standing in for the BMP280: two readings, 30 ms apart./// On the board the reading is a blocking I2C transfer.#[embassy_executor::task]async fn read_sensor( temperature: &'static Watch<CriticalSectionRawMutex, f64, 1>, reports: &'static Channel<CriticalSectionRawMutex, Report, 8>,) { Timer::after(Duration::from_millis(60)).await; temperature.sender().send(21.5); reports.send(Report::Temperature(21.5)).await; Timer::after(Duration::from_millis(30)).await; temperature.sender().send(22.0); reports.send(Report::Temperature(22.0)).await;}
/// The logger, as on the board: everything said goes through here, in/// order. On the board this task owns the UART and the defmt records/// and loops forever — as does the executor, which never returns from/// `run`. Here it counts the reports it handled so the program has an/// end, and says so on the way out.#[embassy_executor::task]async fn report(reports: &'static Channel<CriticalSectionRawMutex, Report, 8>) { let mut handled = 0; loop { match reports.receive().await { Report::Temperature(t) => println!("[{:>4}] temperature {:.2} C", now(), t), Report::Breath(true) => println!("[{:>4}] breath resumed", now()), Report::Breath(false) => println!("[{:>4}] breath paused", now()), } handled += 1; if handled == 4 { // Let the breath finish its twenty ticks and print its // tally before the process goes. Timer::after(Duration::from_millis(250)).await; println!("[{:>4}] logger done, process exits", now()); std::process::exit(0); } }}
struct Breath { phase: u16, half: u16, running: bool,}
impl Breath { fn new(half: u16) -> Self { Breath { phase: 0, half, running: true, } }
fn toggle(&mut self) { self.running = !self.running; self.phase = 0; }
fn set_half(&mut self, half: u16) { self.half = half; if self.phase > half * 2 { self.phase = 0; } }
// On the board the tick also writes the duty into the PWM channel: // `led.set_duty_cycle_percent(breath.duty())`. There is no channel // here, so `duty` is the one method this copy leaves out.
fn advance(&mut self) { if !self.running { return; } self.phase += 1; if self.phase > 2 * self.half { self.phase = 0; } }}
fn half_for(temperature: f64) -> u16 { (MAX_HALF as f64 - TEMP_STEP * temperature).clamp(MIN_HALF as f64, MAX_HALF as f64) as u16}cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.02s Running `target/debug/demo`[ 30] breath paused[ 60] temperature 21.50 C[ 92] temperature 22.00 C[ 122] breath resumed[ 308] breath: 20 ticks, 2 pauses[ 382] logger done, process exitsRead the output as the argument for the whole step. Two presses and two readings arrive at fixed times; the breath advances between them, twenty ticks in all, and pauses exactly twice — the two Signal deliveries. Nothing waited for anything: the sensor’s first reading at 60 ms landed while the breath was mid-ramp, and the breath did not stop for it. The millisecond numbers move by a few between runs — the std driver keeps its clock with a thread that polls, not the 1 kHz timer the board has — and the order does not.
A clock to wait on
Section titled “A clock to wait on”Timer::after is where every task’s wait ends up, and embassy-time has no clock of its own. It calls out to a driver through two functions, now and schedule_wake, and whoever provides them decides what a tick is and how long it lasts. The firmware provides them from TIM5: a 32-bit counter at 1 kHz, stretched to 64 bits in software, and a queue of pending wakeups shared between the tasks and the timer interrupt.
impl Tim5Driver { /// Configure TIM5 as a 1 kHz tick source and start it. Called once, /// before the first task runs. fn init(&self) { let tim = tim5();
// APB1 carries the 16 MHz internal oscillator under the default // clock configuration, so the prescaler divides by 16 000 for // one tick per millisecond. The prescaler and the reload value // only take effect on an update event, and an update event with // the update interrupt live would fire it at once — so the // counter is stopped, the registers are loaded, and one update // is generated by hand with the update interrupt still muted. tim.cr1().modify(|_, w| w.cen().disabled()); tim.psc().write(|w| w.psc().set(15_999)); // A 32-bit counter at 1 kHz wraps after 2^32 ticks, a little // under 50 days. The wrap is counted here in software rather // than being the timebase itself. tim.arr().write(|w| w.arr().set(0xFFFF_FFFF)); // URS counter-only: the manual update below reloads the // registers without setting the update flag. tim.cr1().modify(|_, w| w.urs().counter_only()); tim.egr().write(|w| w.ug().update()); tim.cr1().modify(|_, w| w.urs().any_event()); // The wrap counter needs the update interrupt. The compare // interrupt stays off until something is actually armed. tim.dier().modify(|_, w| w.uie().enabled()); tim.cr1().modify(|_, w| w.cen().enabled());
self.started.store(true, Ordering::Relaxed); }The prescaler divides the 16 MHz APB1 clock down to one tick per millisecond. The URS dance around the manual update is what loads the prescaler and the reload value without firing the update interrupt on the spot: with URS in counter-only mode, the update event reloads the registers and leaves the flag clear. The counter is 32 bits, so it wraps every 49 days; the update interrupt counts the wraps, and now reads the counter twice around the wrap count, retrying if the two reads disagree:
impl Driver for Tim5Driver { /// Ticks since boot: the software wrap count in the high word, the /// counter in the low one. fn now(&self) -> u64 { if !self.started.load(Ordering::Relaxed) { return 0; } let tim = tim5(); loop { // The counter is read twice around the wrap count. If it // went backwards between the two reads, a wrap raced the // first one and the wrap count just read is stale: start // over. let first = tim.cnt().read().cnt().bits() as u64; let wraps = self.wraps.load(Ordering::Acquire); let second = tim.cnt().read().cnt().bits() as u64; if first <= second { return (wraps as u64) << 32 | second; } } }
fn schedule_wake(&self, at: u64, waker: &core::task::Waker) { critical_section::with(|cs| { let mut queue = self.queue.borrow(cs).borrow_mut(); if queue.schedule_wake(at, waker) { let mut next = queue.next_expiration(self.now()); while !self.set_alarm(next) { next = queue.next_expiration(self.now()); } } }) }}
/// The timer interrupt: one vector for both of TIM5's events, the/// counter wrap and the alarm.#[cortex_m_rt::interrupt]fn TIM5() { let tim = tim5(); let sr = tim.sr().read(); // The status flags clear by writing a zero, so the register is // written back inverted: every flag that was set is cleared and the // ones that were not stay clear. Reading first and writing the // inverse cannot miss a flag that arrived in between. // // Safety: writing a zero to these flags is their documented clear // condition, and the inverted write touches no other field. The // block covers both unsafe calls: the write itself and the // raw-bits writer inside the closure. unsafe { tim.sr().write_with_zero(|w| w.bits(!sr.bits())); }
if sr.uif().bit() { DRIVER.on_wrap(); } if sr.cc1if().bit() { critical_section::with(|cs| DRIVER.on_alarm(cs)); }}schedule_wake is the interesting half. A task that calls Timer::after hands the driver a deadline and a Waker; the driver adds the pair to a queue, looks at the earliest deadline still pending, and arms TIM5’s compare register for it. When the compare fires, the interrupt takes the expired entries out of the queue, wakes their tasks, and arms the next deadline. The compare register is 32 bits wide, like the counter, so a deadline further out than one full wrap is armed for the end of the current period instead — the compare fires, finds nothing due, and arms the next period. One interrupt per 49 days is the price of a compare register that is not 64 bits wide.
Two things this replaces outright. Step 8’s TICKS atomic, incremented once per loop iteration, is gone — defmt::timestamp! now reads the driver, and every record carries the same millisecond count the tasks wait on. And the millisecond constants change scale, because the breath’s tick is now ten milliseconds instead of one: DEFAULT_HALF is 30 ticks where step 8 had 300, which is the same third of a second. The Breath and Button structs are untouched — step 5 and step 6 tested them against recorded traces on a host, and nothing about them depended on the loop they sat in.
What it costs
Section titled “What it costs”The executor is not small, and the honest place to say so is here. Measured on this machine, step 8’s firmware links .text at 0x166c4 (91,844 bytes) and .rodata at 0x3cf4 (15,604). This step’s links .text at 0x1c65c (116,316) and .rodata at 0x4ce4 (19,684) — 24,472 bytes more of flash, and 4,080 more of read-only data. A build of this step’s firmware with the executor, the clock and the primitives but with one empty task in place of the four real ones links .text at 0xae0c (44,524), which is the floor: the executor, the time driver, the queue and the three primitives, before any application code. What the 24.5 KB buys is the structure above and the ability to sleep when nothing is due — and the sleeping is step 10, where the bill gets paid back.
Build it and run it
Section titled “Build it and run it”cargo build Compiling proc-macro2 v1.0.107 Compiling quote v1.0.47 Compiling unicode-ident v1.0.27 Compiling nb v1.1.0 Compiling semver-parser v0.7.0 Compiling embedded-hal v1.0.0 Compiling cortex-m v0.7.9 Compiling critical-section v1.2.0 Compiling void v1.0.2 Compiling cortex-m-macros v0.7.8 Compiling thiserror v2.0.21 Compiling cortex-m-rt v0.7.7 Compiling ident_case v1.0.1 Compiling nb v0.1.3 Compiling vcell v0.1.3 Compiling semver v0.9.0 Compiling strsim v0.11.1 Compiling embedded-hal v0.2.7 Compiling fnv v1.0.7 Compiling litrs v1.0.1 Compiling stable_deref_trait v1.2.1 Compiling volatile-register v0.2.2 Compiling rustc_version v0.2.3 Compiling bitfield v0.13.2 Compiling defmt-macros v1.2.0 Compiling defmt v1.2.0 Compiling bare-metal v0.2.5 Compiling portable-atomic v1.15.0 Compiling heapless v0.9.3 Compiling byteorder v1.5.0 Compiling hash32 v0.3.1 Compiling document-features v0.2.12 Compiling stm32f4 v0.16.0 Compiling gcd v2.3.0 Compiling embassy-time-driver v0.2.2 Compiling embedded-hal-async v1.0.0 Compiling fugit v0.3.9 Compiling futures-core v0.3.34 Compiling embedded-io v0.7.1 Compiling embassy-sync v0.8.0 Compiling embassy-executor-timer-queue v0.1.0 Compiling defmt-rtt v1.3.1 Compiling time-core v0.1.9 Compiling num-conv v0.2.2 Compiling bitflags v1.3.2 Compiling cfg-if v1.0.5 Compiling embassy-executor v0.10.0 Compiling powerfmt v0.2.1 Compiling deranged v0.5.9 Compiling stm32f4xx-hal v0.23.0 Compiling embassy-time-queue-utils v0.3.2 Compiling fugit-timer v0.1.3 Compiling embedded-io-async v0.7.0 Compiling embedded-dma v0.2.0 Compiling embedded-hal-nb v1.0.0 Compiling bare-metal v1.0.0 Compiling rand_core v0.9.5 Compiling futures-sink v0.3.34 Compiling cordyceps v0.3.5 Compiling embedded-io v0.6.1 Compiling rand_core v0.6.4 Compiling embedded-storage v0.3.2 Compiling embassy-time v0.5.1 Compiling bmpe280 v1.0.5 Compiling embassy-futures v0.1.2 Compiling time v0.3.55 Compiling syn v2.0.119 Compiling syn v3.0.9 Compiling darling_core v0.20.11 Compiling thiserror-impl v2.0.21 Compiling cortex-m-rt-macros v0.7.7 Compiling enumflags2_derive v0.7.12 Compiling enumflags2 v0.7.12 Compiling darling_macro v0.20.11 Compiling darling v0.20.11 Compiling defmt-parser v1.1.0 Compiling embassy-executor-macros v0.8.0 Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32) Finished `dev` profile [unoptimized + debuginfo] target(s) in 7.43sThat is the last stretch of a cold build, and the embassy stack is most of it: embassy-executor, embassy-executor-macros, embassy-executor-timer-queue, embassy-futures, embassy-sync, embassy-time, embassy-time-driver, embassy-time-queue-utils and cordyceps, which is the intrusive list the executor’s run queue is built from. darling and syn are proc-macro machinery that runs on the host and never reaches the image. Cargo compiles dependencies in parallel, so the order of the lines varies between runs, and the path after the package name is this machine’s project directory.
With the board plugged in:
cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.13s 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 is the demo’s behaviour with real hardware: every half second a reading arrives as a line of text and a defmt record, B1 pauses and resumes the breath with a record saying so, and the LED keeps breathing between readings without waiting for them.
Pitfalls
Section titled “Pitfalls”NoopRawMutex is not Sync, and a static needs it to be
Section titled “NoopRawMutex is not Sync, and a static needs it to be”embassy-sync 0.8 ships two raw mutexes. NoopRawMutex is the cheap one — no lock at all, for the case where the platform already guarantees exclusion — and it carries a PhantomData<*mut ()> that is not Sync, so static REPORTS: Channel<NoopRawMutex, Report, 8> fails to compile with `*mut ()` cannot be shared between threads safely. On a single-core Cortex-M the honest choice is CriticalSectionRawMutex, which is what a critical section already is. NoopRawMutex is for callers that can prove exclusion some other way; a static cannot.
#[interrupt] needs an interrupt alias, not just the macro
Section titled “#[interrupt] needs an interrupt alias, not just the macro”cortex-m-rt 0.7’s attribute expands the handler with an existence check — it emits interrupt::TIM5; inside the function body, resolved in the handler’s own scope. The macro alone is not enough: something named interrupt must be in scope with the interrupt names in it. The PAC provides it — pub use self::Interrupt as interrupt — so the firmware imports stm32f4xx_hal::pac::interrupt next to the #[cortex_m_rt::interrupt] attribute, and the check compiles.
A blocking transfer holds the whole executor
Section titled “A blocking transfer holds the whole executor”sensor.measure() blocks for the length of the I2C transfer, and while it blocks, no other task runs — there is one thread and the task does not yield. Half a millisecond at 100 kHz is nothing. A driver that blocks for tens of milliseconds in the middle of a measurement is not, and the fix there is an async bus driver, not a faster CPU.
The clock must be started before the first spawn
Section titled “The clock must be started before the first spawn”Timer::after in a task that is polled before the driver’s timer runs waits on a compare interrupt that will never fire — silently, forever. The firmware enables TIM5’s clock, initialises the driver and unmasks the interrupt before spawner.spawn is called once. Order matters here in a way the compiler cannot check.
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-ch09:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch09cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
Step 10, Sleep, wake, and battery life, puts the executor’s idle time to work: the stop modes, what wakes the chip, and what the current draw looks like with a multimeter instead of a guess. It isn’t on the site yet, so there is nothing to link to; the track overview keeps the list of what has landed.