Skip to content
Select theme

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.

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.

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.

The new task owns the two blocks the entry touches, and the entry itself is one register write, one bit, and one instruction:

src/main.rs, the power task and the stop-mode entry
/// 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 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.

src/main.rs, arming the wake-up line
// 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:

src/main.rs, the interrupt and the line's clear
/// 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:

src/main.rs, the button task with the hold counter
/// 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.

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.

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"
Terminal window
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 days

The 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.

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.

Terminal window
cargo build
Compiling firmware v0.1.0 (/Users/rango/www/hackingwithrust/code/embedded-stm32)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 1.20s

One 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:

Terminal window
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.

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.

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.

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:

Terminal window
git clone https://github.com/hackingwithrust/examples.git
cd examples/embedded-stm32
git checkout stm32-ch10
cargo build

If 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.