Sleep, wake, and battery life
Step 10 of 10, the last step of the track. Step 9 closed by leaving the executor’s idle time on the table: the sleeping was this step’s job, and this is where the bill gets paid back. The firmware gains a Stop mode — the one where every clock stops and the chip draws microamperes — the EXTI line that ends it, and the arithmetic that turns a multimeter reading into a battery life. It starts from tag stm32-ch09 and ends at stm32-ch10.
Everything here was captured on Rust 1.99.0 with edition 2024, the compiler this track is written against. The firmware gains no crates this step — Stop mode is registers, not libraries — and the host block runs against embedded-hal 1.0.0, the crate the firmware’s Button has been generic over since step 5.
The three modes, and what each one costs
Section titled “The three modes, and what each one costs”RM0368, the reference manual for this chip, lists three low-power modes. What separates them is what keeps running:
| Mode | What runs | What wakes it | Current, typical | Wake-up time |
|---|---|---|---|---|
| Sleep | CPU stopped; every clock keeps running | Any interrupt | 5.9 mA at 20 MHz, peripherals enabled | 4 CPU cycles |
| Stop | Every 1.2 V-domain clock stopped; SRAM and registers survive | Any enabled EXTI line | 12 µA at 3.3 V, low-power regulator, flash in deep power-down | 113 µs |
| Standby | 1.2 V domain off; state lost; wakes like a reset | NRST, a WKUP edge, or an RTC event | 2.1 µA at 3.3 V, RTC and LSE off | 314 µs |
The currents and timings are the datasheet’s, not this machine’s: DS10086 Rev 5, Sleep from Table 26, Stop from Table 28, Standby from Table 30, and the wake-up times from Table 34. Two things about the Sleep row are worth saying out loud. The table’s lowest frequency is 20 MHz and this firmware runs at 16 MHz, so 5.9 mA is not this firmware’s number — it is the nearest row the datasheet prints, and the row with the peripherals disabled reads 2.7 mA. And “all peripherals enabled” is this firmware’s situation: TIM2 for the breath, TIM5 for the clock, I2C1 for the sensor, USART2 for the reports.
The Stop row is the one this step enters: LPDS for the low-power regulator, FPDS for the flash in deep power-down. Table 33 of the same datasheet prices the peripherals the firmware keeps clocked — TIM5 at 11.19 µA/MHz, TIM2 the same, I2C1 at 3.10, USART2 at 3.33, and 1.55 for each GPIO block — which at the firmware’s 16 MHz buses comes to just over half a milliampere. All of it stops in Stop mode. That is the trade: 113 µs of wake-up latency and a clock that lost track of the time, bought with half a milliampere and change.
Why a poll keeps the chip awake
Section titled “Why a poll keeps the chip awake”The executor’s idle is where the sleeping would happen, and embassy-executor 0.10.0’s cortex-m thread executor has a short one: poll every task, then execute a wfe instruction (embassy-executor 0.10.0, src/platform/cortex_m.rs, Executor::run). A wfe with the SLEEPDEEP bit clear is Sleep mode — the CPU stops, the clocks run on. So the firmware does sleep, a thousand times a second. The button task’s Timer::after(Duration::from_millis(1)) comes due once a millisecond, the executor polls, the task reads PC13, and the executor goes back to sleep.
The poll interval is the floor. A 1 kHz poll is a 1 kHz wake, and no amount of executor cleverness changes that: the button is read by polling, so something has to wake the chip to read it. That is the honest state of the firmware as step 9 left it, and it is why this step adds a mode the firmware enters deliberately rather than one the executor falls into.
Stopping the chip on purpose
Section titled “Stopping the chip on purpose”The new task owns the two blocks the entry touches, and the entry itself is one register write, one bit, and one instruction:
/// Everything else in the firmware runs whether or not anyone is/// watching. This task waits for a hold, stops the chip, and reports/// the wake-up — the shape a battery-powered build would give the/// whole firmware, with a clock in place of the finger.#[embassy_executor::task]async fn power( mut pwr: pac::PWR, mut scb: SCB, reports: &'static Channel<CriticalSectionRawMutex, Report, 8>,) { loop { SLEEP.wait().await; enter_stop_mode(&mut pwr, &mut scb); reports.send(Report::Awake).await; }}
/// Put the chip in Stop mode, and return when the EXTI line on PC13/// wakes it.////// Stop mode stops every clock in the 1.2 V domain — the CPU, every/// peripheral, and the HSI oscillator the whole firmware runs on —/// while the SRAM and the register contents survive (RM0368 § 5.3.4)./// What brings the chip back is the line armed on PC13 in `main`: any/// enabled EXTI line wakes the chip from Stop mode, and that line is/// enabled.////// The clock the executor keeps time with stops with the chip: TIM5 is/// on APB1, and APB1 is inside the stopped domain. Every `Timer` that/// was pending when the chip stopped comes due late, by however long/// the chip was stopped — the counter resumes where it froze instead/// of catching up, so nothing is lost and nothing is on time. A/// firmware that needs to know the time across a stop has to move its/// time driver to the RTC, which keeps counting on its own crystal;/// that is a bigger change than this step makes, and it is why the/// wake-up report carries no duration.fn enter_stop_mode(pwr: &mut pac::PWR, scb: &mut SCB) { // One write picks the flavour of Stop: PDDS clear for Stop rather // than Standby, LPDS for the low-power regulator, FPDS to power the // flash down with it. That combination is STOP LPFPD in RM0368's // Table 18 — the slowest to wake and the cheapest to run, 12 µA // typical at 3.3 V and 25 °C (DS10086, Table 28) against 42 µA // with the flash left running. The rest of the register — VOS, the // PVD, the backup domain — is read-modify-written back unchanged. pwr.cr() .modify(|_, w| w.pdds().enter_stop().lpds().low_power().fpds().power_down());
// A bounce that arrived while the hold was being recognised has // left a pending bit behind, and WFI with a pending interrupt // returns at once instead of sleeping. Clearing the line's bit and // the NVIC's makes the stop the default; an edge that lands after // this still wins, which is the right outcome — something // happened. NVIC::unpend(pac::Interrupt::EXTI15_10); clear_button_interrupt();
// SLEEPDEEP is what makes the next WFI a deep sleep. The // executor's idle is a `wfe`, which sleeps shallowly; setting the // bit here and clearing it on the way out is what keeps the two // apart, so the executor can never stop the chip by accident. scb.set_sleepdeep(); cortex_m::asm::wfi(); scb.clear_sleepdeep();
// The wake-up flag says the chip came back from a low-power mode. // Clearing it here leaves the next Stop starting from a clean // slate, so a stale flag can never be mistaken for a fresh wake. pwr.cr().modify(|_, w| w.cwuf().clear());}Three bits pick the flavour. PDDS clear means Stop rather than Standby — Standby would be cheaper still, at 2.1 µA, but it powers the 1.2 V domain off and the chip wakes as if from a reset, which throws away every task state the executor is holding. LPDS drops the regulator to its low-power setting and FPDS powers the flash down with it; together they are the row RM0368 calls STOP LPFPD, 12 µA typical, and the price is a wake-up that takes 113 µs instead of the 21 µs a flash left running would need.
SLEEPDEEP is the bit that makes the next wfi a deep sleep. The executor’s idle is a wfe, which sleeps shallowly whatever that bit says, and the firmware sets it here and clears it on the way out — so the two sleeps stay separate, and the executor can never stop the chip by accident. When the EXTI line fires, wfi returns, the bit is cleared, and the power task sends one report. No duration: the clock that would measure it stopped with the chip.
One thing the wake-up does not need is a clock reconfiguration. RM0368 notes that when Stop mode is exited by an interrupt, the HSI RC oscillator is selected as the system clock — and HSI is what the firmware was already running on, with no PLL in the default configuration. The chip comes back on the clock it left on.
The button’s second job
Section titled “The button’s second job”The wake source is the EXTI line behind PC13, armed once in main and left armed. The pin itself stays where step 5 put it, polled by the button task; the line and the poll see the same press, and while the chip is awake only the poll acts on it.
// The two blocks the EXTI setup writes: SYSCFG picks which pin // feeds line 13, and EXTI holds the edge and mask bits themselves. let mut syscfg = dp.SYSCFG.constrain(&mut rcc); let mut exti = dp.EXTI;
// PC13 is the board's user button, wired as step 5 left it. The // EXTI line behind the pin is armed here, once, and stays armed: // it is what wakes the chip from Stop mode, and while the chip is // awake it costs an interrupt whose only job is to clear a pending // bit. The pin itself stays in the button task's hands, polled as // before — the line and the poll see the same press, and only the // poll acts on it. let mut pc13 = gpioc.pc13.into_pull_up_input(); // The line has to be told which pin feeds it: SYSCFG's fourth // external interrupt configuration register maps EXTI13 to port C. pc13.make_interrupt_source(&mut syscfg); // Both edges, so a release wakes the chip too. A press that is // still held when the chip stops is no longer an edge — the line // has already fallen — so without the rising edge the user would // have to keep holding the button to keep the chip asleep. pc13.trigger_on_edge(&mut exti, Edge::RisingFalling); pc13.enable_interrupt(&mut exti); let button = Button::new(pc13); unsafe { NVIC::unmask(pac::Interrupt::EXTI15_10); }Both edges, not one: the press that puts the chip to sleep is still held when the stop happens, so the falling edge has already been and gone. Without the rising edge, releasing the button would do nothing and the user would have to keep holding it to keep the chip asleep. With it, release and the chip is back.
The interrupt handler itself does almost nothing, and that is the design:
/// The button's interrupt: one vector shared by EXTI lines 10 to 15, of/// which this firmware uses one. The handler's whole job is to clear/// the pending bit. While the chip is awake the poll loop in/// `watch_button` is what acts on the press — this interrupt only stops/// itself from re-entering. While the chip is in Stop mode this/// interrupt is the wake-up: the line brings the core back, the handler/// runs, and the firmware continues where `enter_stop_mode` left off.#[cortex_m_rt::interrupt]fn EXTI15_10() { clear_button_interrupt();}
/// Clear the pending bit on EXTI line 13 — PC13's line — without/// owning the pin the line is wired to. The button task owns the pin,/// so the interrupt handler and the stop-mode entry both reach the/// line through the block's address instead.fn clear_button_interrupt() { // Safety: `ptr()` hands out the EXTI block's address without // checking ownership. The block exists at that address from reset // onward. Writing a one to a pending bit is its documented clear // condition, so this write touches line 13 and leaves the others // exactly as they were. unsafe { (*pac::EXTI::ptr()).pr().write(|w| w.bits(1 << 13)); }}The press itself still goes through the debouncer, because the EXTI line fires on every bounce edge and knows nothing about levels. What changes is the second job: the button task now counts how long the debouncer has believed the button is down, and a hold long enough raises SLEEP. The Button struct gains one accessor, pressed(), because a hold is a level and tick reports edges:
/// The button: one poll per millisecond, the same debounce step 5/// settled on, and one signal per accepted press. The hold counter is/// new: a press held for `HOLD_TICKS` raises `SLEEP`, and the power/// task is what acts on it.#[embassy_executor::task]async fn watch_button(mut button: Button<Pin<'C', 13>>) { // Ticks the button has been held. Saturating rather than wrapping: // a button held for an hour is still just a button held, and the // count is not the signal — `HOLD_TICKS` is. let mut held: u32 = 0; loop { if button.tick() { // The debouncer reports one event per accepted press. The // signal carries it to the breath task; a press that // arrives while the breath task is mid-tick waits there // until it looks. PRESS.signal(()); } if button.pressed() { held = held.saturating_add(1); if held == HOLD_TICKS { // A hold is not a press: the breath has already // paused, and this says the chip should stop. SLEEP.signal(()); } } else { held = 0; } Timer::after(Duration::from_millis(1)).await; }}HOLD_TICKS is 2,000 — two seconds of a 1 ms poll. A press still pauses the breath, exactly as before; keep holding and the breath has already paused by the time the chip stops. The behaviour on the board is: hold B1 for two seconds, the LED freezes mid-ramp and the chip stops; let go and press again, and the chip wakes, logs awake on both ports, and the breath resumes where it froze. The freeze is what a pin does in Stop mode — the PWM channel’s output holds its last level because the counter behind it is stopped.
Measuring it
Section titled “Measuring it”The datasheet’s 12 µA is the chip alone, and a multimeter on a Nucleo never sees the chip alone. Where the meter sits decides what it measures.
The easiest measurement is the whole board. Feed E5V (CN7 pin 6, 5 V) from a bench supply through the ammeter, with JP5 moved to pins 2–3 and JP1 removed — the configuration UM1724, the board’s user manual, gives for an external supply — and leave the USB cable connected afterwards so the ST-Link still carries RTT and the virtual COM port. What the meter reads is the board: the ST-Link, the target, LD2, and whatever the sensor module draws. The ST-Link does not stop when the target does, so this measurement has a floor well above 12 µA and is good for comparing modes, not for quoting them.
The measurement that isolates the target feeds its 3.3 V rail directly: an external 3.3 V supply through the ammeter into CN6 pin 4, with SB2 (the board’s 3.3 V regulator) and SB12 (NRST) off, which is the other configuration UM1724 lists for the +3.3V input. The ST-Link is unpowered in that setup, so the programming and debug features are gone with it — flash the firmware first, then move the feed. What the meter reads then is the target side only: the chip, LD2 while it is lit, the BMP280 module, and the pull-ups on the unused pins.
Expect the number to be larger than the datasheet’s, and know why before measuring. LD2 lit through its series resistor draws current in the milliampere range, two orders of magnitude above the chip’s Stop typical. The BMP280 module has its own supply current. And the firmware’s awake current — 5.9 mA on the nearest datasheet row — is mostly the core and the four clocked peripherals, which is exactly what Stop mode gives back.
The battery arithmetic
Section titled “The battery arithmetic”What the meter gives you is two currents and a duty cycle, and the duty cycle is the whole story. The firmware’s new logic runs on this machine the same way the debouncer did in step 5: the same Button, a mock pin replaying a recorded trace, and the hold counter. The battery table at the end takes the two currents and prints what they cost. The article needs the crate:
[dependencies]embedded-hal = "1.0.0"//! The firmware's new low-power logic on this machine: the same Button//! debouncer step 5 tested against a recorded trace, with the hold//! counter this step adds, and the battery arithmetic the firmware's//! schedule implies. The board's registers — PWR_CR, SLEEPDEEP, the//! EXTI line — are the firmware's business; what runs here is the//! decision the firmware makes with them.
use core::convert::Infallible;use embedded_hal::digital::{ErrorType, InputPin};
/// The firmware's debounce window, in ticks: a level has to hold for/// this many consecutive 1 ms ticks before it counts.const DEBOUNCE_TICKS: u8 = 10;
/// The firmware's hold window, in ticks: two seconds at one tick per/// millisecond.const HOLD_TICKS: u32 = 2_000;
/// A pin that replays a recorded trace. `true` is high, `false` is/// low; the trace is consumed one entry per read and holds the last/// level once it runs out, the way a pin does.struct MockPin<'a> { levels: &'a [bool], at: usize,}
impl ErrorType for MockPin<'_> { // The board's pin has this error type too — a read of an input pin // cannot fail on this chip — so the mock keeps the firmware's // `unwrap_or` honest rather than hiding a failure behind it. type Error = Infallible;}
impl InputPin for MockPin<'_> { fn is_high(&mut self) -> Result<bool, Self::Error> { Ok(self.read()) }
fn is_low(&mut self) -> Result<bool, Self::Error> { Ok(!self.read()) }}
impl MockPin<'_> { fn read(&mut self) -> bool { let level = self.levels[self.at.min(self.levels.len() - 1)]; if self.at < self.levels.len() { self.at += 1; } level }}
/// A button that reports one event per accepted press — the firmware's/// struct, unchanged except for the `pressed` accessor the hold counter/// needs.struct Button<I: InputPin> { pin: I, pressed: bool, held: u8,}
impl<I: InputPin> Button<I> { fn new(pin: I) -> Self { Button { pin, pressed: false, held: 0, } }
/// Whether the debouncer believes the button is down right now. /// The hold counter needs this: `tick` reports edges, and a hold is /// not an edge — it is a level that lasts. fn pressed(&self) -> bool { self.pressed }
/// One tick of the poll loop. Returns `true` on the tick a press is /// accepted — not on every tick the button is held. fn tick(&mut self) -> bool { // Active low: the pin reads low while the button is pressed. An // error can't happen on this chip (`Infallible`), so a read that // did fail would keep the last accepted level. let last = self.pressed; let raw = self.pin.is_low().unwrap_or(last); if raw == self.pressed { self.held = 0; false } else { self.held += 1; if self.held >= DEBOUNCE_TICKS { self.pressed = raw; self.held = 0; raw } else { false } } }}
fn main() { // The trace: released for 50 ms, then held for 2.6 s, then // released again — one entry per tick, the way the firmware's 1 ms // poll reads the pin. Active low, so `false` is the button down. let mut levels = Vec::new(); levels.extend(core::iter::repeat(true).take(50)); levels.extend(core::iter::repeat(false).take(2_600)); levels.extend(core::iter::repeat(true).take(50));
let mut button = Button::new(MockPin { levels: &levels, at: 0, }); // The button task's loop, one iteration per millisecond, with the // hold counter this step adds. let mut held: u32 = 0; let mut tick: u32 = 0; while tick < levels.len() as u32 { if button.tick() { println!("[{tick:>5} ms] press accepted, the breath pauses"); } if button.pressed() { held = held.saturating_add(1); if held == HOLD_TICKS { println!("[{tick:>5} ms] hold complete, the chip stops here"); } } else { held = 0; } tick += 1; } println!( "[{tick:>5} ms] trace ends; the release that follows the hold is the rising edge that wakes the chip" );
battery_table();}
/// The defaults are the datasheet's, not a measurement's. Awake:/// DS10086 Table 26's 20 MHz row with all peripherals enabled — the/// table has no 16 MHz row, and the firmware runs at 16 MHz with TIM2,/// TIM5, I2C1 and USART2 clocked, so the enabled row is the honest/// starting point and the disabled row (2.7 mA) the floor. Stopped:/// Table 28's low-power-regulator, flash-in-deep-power-down row at/// VDD = 3.3 V and 25 °C, the STOP LPFPD flavour `enter_stop_mode`/// enters. Replace both with what the multimeter says; that is what/// the table is for.const AWAKE_MA: f64 = 5.9;const STOP_UA: f64 = 12.0;
/// A coin cell's worth of capacity, in milliampere-hours. Replace it/// with the battery's.const CAPACITY_MAH: f64 = 200.0;
/// Average current and battery life for one duty cycle: the fraction/// of the time the chip is awake, against the rest of it stopped.fn budget(awake: f64) -> (f64, f64) { let stop_ma = STOP_UA / 1000.0; let average = AWAKE_MA * awake + stop_ma * (1.0 - awake); let days = CAPACITY_MAH / average / 24.0; (average, days)}
fn battery_table() { println!(); println!("battery life at {CAPACITY_MAH} mAh, {AWAKE_MA} mA awake, {STOP_UA} uA stopped:"); println!("{:>10}{:>14}{:>16}", "awake", "average", "battery life"); for awake in [1.0, 0.1, 0.01, 0.001, 0.000_1] { let (average, days) = budget(awake); println!( "{:>9.3}%{:>11.4} mA{:>13.1} days", awake * 100.0, average, days ); }}cargo run[ 59 ms] press accepted, the breath pauses[ 2058 ms] hold complete, the chip stops here[ 2700 ms] trace ends; the release that follows the hold is the rising edge that wakes the chip
battery life at 200 mAh, 5.9 mA awake, 12 uA stopped: awake average battery life 100.000% 5.9000 mA 1.4 days 10.000% 0.6008 mA 13.9 days 1.000% 0.0709 mA 117.6 days 0.100% 0.0179 mA 465.9 days 0.010% 0.0126 mA 662.0 daysThe trace is the argument for the hold counter: one press accepted after ten ticks of a level that held, one hold completed two seconds later, and nothing else in 2,700 ticks. The release at the end is the rising edge that would wake the chip.
The table is the argument for the whole step. The firmware as it stands never enters Stop mode on its own, so its honest row is the first one: a couple of days on a coin cell, and that with the datasheet’s pessimistic 5.9 mA. Every stop the firmware takes is a row further down. A battery-powered build would arm the stop between reports and wake on a clock — the RTC, which keeps counting while the chip sleeps — instead of on a finger; the shape of the code is the same, and only the trigger changes.
What it costs
Section titled “What it costs”Measured on this machine with /usr/bin/objdump -h on the linked ELF: step 9’s firmware links .text at 0x1c65c (116,316 bytes) and .rodata at 0x4ce4 (19,684). This step’s links .text at 0x1dbd4 (121,812) and .rodata at 0x4ea4 (20,132) — 5,496 more bytes of code and 448 more of read-only data. No new crates, so none of it is a dependency: the stop-mode entry, the EXTI setup, the hold counter, and one more task.
Build it and run it
Section titled “Build it and run it”cargo build Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32) Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.20sOne line, because there are no new crates to compile — the whole of Stop mode is registers the PAC already had. With the board plugged in:
cargo run Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.04s Running `probe-rs run --chip STM32F401RE target/thumbv7em-none-eabihf/debug/firmware`Error: No connected probes were found.That 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 and starts it. What the board does then: the breath runs and the readings arrive as before; hold B1 for two seconds and the chip stops with the LED frozen mid-ramp; release and press again and both ports report awake.
Pitfalls
Section titled “Pitfalls”SLEEPDEEP left set stops the chip the first time it idles
Section titled “SLEEPDEEP left set stops the chip the first time it idles”The bit is what separates the executor’s wfe from a deep sleep, and it is a core register, not a task-local. If it were set and left set, the first time every task was waiting the executor’s idle would put the chip in Stop mode — TIM5 would freeze with it, the clock every Timer waits on would stop advancing, and the firmware would look hung until an EXTI edge arrived. The entry sets it, the return clears it, and nothing else in the firmware touches it.
wfi with a pending interrupt does not sleep
Section titled “wfi with a pending interrupt does not sleep”WFI is specified to fall through when an interrupt is already pending. A bounce that arrived while the two-second hold was being recognised leaves exactly that: a pending EXTI line, and a stop that returns before it began. The entry clears the line’s bit and the NVIC’s before the wfi, which makes the stop the default. An edge that lands in the last instruction before the wfi still wins, and that is the correct outcome — something happened.
The clock is late by however long the chip slept
Section titled “The clock is late by however long the chip slept”TIM5 is on APB1, and APB1 stops with the chip. The counter resumes where it froze, so every Timer that was pending when the chip stopped comes due late by the length of the stop, and the timestamps in the records jump by that much. Nothing is lost — the compare interrupt still fires and the queue still drains — but nothing is on time either, which is why the wake-up report says awake and not how long. A firmware that needs the time across a stop moves its time driver to the RTC.
An APB access delays the entry
Section titled “An APB access delays the entry”RM0368 notes that if an access to the APB domain is ongoing when the stop is requested, the entry waits for it to finish. A writeln! to the UART still in flight when the hold completes delays the stop by a byte or two — a few microseconds at 115200 baud, and the reason the wake-up report is sent after the stop rather than during it.
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-ch10:
git clone https://github.com/hackingwithrust/examples.gitcd examples/embedded-stm32git checkout stm32-ch10cargo buildIf a build misbehaves, checking out that tag and diffing it against your tree shows which line drifted.
That is the track: ten steps from a blinking LED to a chip that sleeps on purpose, with a task per job and a clock of its own. The track overview lists all ten and what each one built.