How to Implement Power State Machines for Battery Devices

Engineer examining power management code on laptop screen with battery-powered embedded devices on workbench

You call __WFI() in your main loop, flash the firmware, connect your ammeter, and read 4.2 mA. The datasheet says deep sleep should pull 1.9 µA. That’s not a rounding error. It’s a 2,000× gap, and sprinkling more sleep calls across your codebase won’t close it.

The problem isn’t that you’re not sleeping. It’s that nobody is in charge of sleeping. Your UART driver doesn’t know the accelerometer just started a DMA transfer. Your BLE stack doesn’t know the sensor task finished two seconds ago. Each module makes local decisions, and the system-level result is that something is always holding the MCU awake, or worse, you enter a sleep mode that kills a peripheral mid-transaction.

This article gives you a concrete, platform-agnostic architecture for a low power state machine that centralizes power decisions, handles peripheral retention and context save/restore, manages wake sources, and shows you how to verify it works with real measurements. The patterns apply whether you’re on bare metal, Zephyr, FreeRTOS, or a vendor SDK. Nordic nRF series hardware appears as a concrete reference point, but the architecture translates to any MCU with multiple sleep modes.

What this article is not: a Zephyr PM API tutorial or a chip-specific register walkthrough. Those resources exist. This is the architectural layer that makes them useful.

Power States Are Not Application States

A quick reframe before we build anything. Your application has states: sampling, advertising, connected, firmware update. Your low power state machine is orthogonal to all of that. It doesn’t care whether you’re sampling or advertising. It cares about three questions:

  1. Wake latency tolerance — How fast must the system respond to the next event?
  2. Peripheral availability — What hardware needs to stay powered right now?
  3. Current budget — What can the battery afford in this mode?

These three axes determine your power states, not the number of sleep modes in your MCU’s datasheet. An MCU might offer six sleep modes. Your product probably needs three or four power states, mapped to the modes that match your requirements.

Here’s a reference model that works for most battery devices:

Power StateWake LatencyTypical CurrentWhat’s RetainedExample Wake Source
ActiveImmediate3–15 mAEverythingN/A (already awake)
Idle< 10 µs50–500 µACPU + all peripheralsAny interrupt
Deep Sleep1–5 ms1–10 µARAM + RTCGPIO pin, RTC alarm
System OffFull reboot0.3–1 µAWake-source logic onlyGPIO pin, NFC field

Your product might not need System Off. Or maybe Idle and Deep Sleep collapse into one state because your wake latency tolerance is generous. The point is: derive states from product requirements, then map them onto MCU capabilities, not the other way around.

Where the Low Power State Machine Lives in Your Architecture

The power manager is a dedicated module, not code sprinkled inside main(), not hidden in your BSP layer, not an RTOS hook you hope fires at the right time. It’s a policy engine that the system consults before entering any sleep mode.

Inputs: activity timers (time since last event), peripheral busy flags, battery level, currently valid wake sources, upcoming scheduled events (next BLE connection interval, next sensor sample).

Outputs: target power state selection, clock configuration, peripheral enable/disable commands, voltage regulator mode, wake source configuration.

Here’s the structural skeleton:

typedef enum {
    POWER_STATE_ACTIVE,
    POWER_STATE_IDLE,
    POWER_STATE_DEEP_SLEEP,
    POWER_STATE_SYSTEM_OFF
} power_state_t;

power_state_t power_manager_evaluate(void) {
    /* Any peripheral actively transferring data? Stay active. */
    if (peripheral_manager_any_busy())
        return POWER_STATE_ACTIVE;

    uint32_t idle_ms = get_idle_duration_ms();
    uint32_t next_event_ms = scheduler_next_event_ms();

    /* Not idle long enough — stay in idle, not deep sleep */
    if (idle_ms < DEEP_SLEEP_IDLE_THRESHOLD_MS)
        return POWER_STATE_IDLE;

    /* Next scheduled event is soon — deep sleep wake cost not worth it */
    if (next_event_ms < DEEP_SLEEP_MIN_DURATION_MS)
        return POWER_STATE_IDLE;

    /* Battery critically low and no user activity */
    if (battery_is_critical() && idle_ms > SYSTEM_OFF_THRESHOLD_MS)
        return POWER_STATE_SYSTEM_OFF;

    return POWER_STATE_DEEP_SLEEP;
}

This function gets called from your idle hook (RTOS) or main loop (bare metal). Zephyr implements this same concept via pm_policy_next_state(). If you’re on Zephyr, you can plug your logic into that framework rather than building the dispatch yourself. The decisions are identical regardless of platform.

Peripheral Retention and Context Save/Restore: Where the Bugs Hide

This is where 90% of power management bugs live. Not in the state machine logic itself, but in what happens to your hardware when you actually enter a low-power mode.

The core problem: Most MCUs have sleep modes that retain RAM but power down peripheral blocks. Your UART baud rate configuration, your SPI clock polarity, your I2C address: gone. When the MCU wakes, those registers are reset to defaults. If your firmware doesn’t know this happened, it tries to use a peripheral that’s no longer configured. You get silent failures, corrupted data, or, most insidiously, a peripheral stuck in a state that draws milliamps.

The pattern: Register every peripheral driver with the power manager using save/restore callbacks.

typedef struct {
    const char *name;
    int (*save_context)(void *ctx);
    int (*restore_context)(void *ctx);
    void *driver_ctx;       /* Points to driver-specific saved state */
    uint8_t restore_order;  /* Lower = restored first */
} peripheral_power_entry_t;

/* Drivers register at init time */
void power_manager_register_peripheral(peripheral_power_entry_t *entry);

Each driver owns a struct containing the register values it needs to survive deep sleep. The UART driver saves its baud rate register, flow control config, and pin assignments. The SPI driver saves clock phase, polarity, and chip select state.

Here’s what the deep sleep entry/exit sequence looks like:

void enter_deep_sleep(void) {
    /* 1. Save all peripheral contexts (registered callbacks) */
    for (int i = 0; i < num_registered; i++) {
        peripherals[i].save_context(peripherals[i].driver_ctx);
    }

    /* 2. Configure ALL GPIOs to known safe states */
    gpio_configure_for_sleep();  /* See GPIO section below */

    /* 3. Configure wake sources for this power state */
    wake_source_configure(POWER_STATE_DEEP_SLEEP);

    /* 4. Enter MCU deep sleep (platform-specific) */
    platform_enter_deep_sleep();

    /* === MCU is asleep. Execution resumes here on wake. === */

    /* 5. Restore clocks first, then peripherals in dependency order */
    platform_restore_clocks();
    for (int i = 0; i < num_registered; i++) {  /* sorted by restore_order */
        peripherals[i].restore_context(peripherals[i].driver_ctx);
    }
}

The restore order matters. Clocks before buses, buses before peripherals, peripherals before application tasks. Get this wrong and you’ll write to peripheral registers before the bus clock is running, another silent failure.

The GPIO Trap That Will Ruin Your Power Budget

A single floating GPIO can draw 50–500 µA in sleep. Multiply by 30 unaccounted pins, and your “1.9 µA deep sleep” becomes 2 mA of real-world current.

The gpio_configure_for_sleep() function must account for every pin on the MCU, not just the ones your firmware uses. Every pin needs to be in a defined state: input with internal pull-up or pull-down (matched to the external circuit), or output driven to a level that doesn’t back-power external chips through ESD diodes.

On Nordic nRF52/nRF5340 parts, System OFF mode latches GPIO states automatically, but System ON sleep modes do not. Your firmware must handle this explicitly. This is exactly the kind of chip-specific behavior that the power state machine’s pre-sleep hook abstracts away from application code.

Audit every pin. Create a table during hardware design that specifies each pin’s sleep state. If your hardware engineer didn’t provide one, make one now. This table becomes the implementation spec for gpio_configure_for_sleep().

Wake Source Management and Transition Guards

Each power state must declare which wake sources are valid. Deep Sleep might allow RTC alarm and a single GPIO button. Idle might allow any interrupt. System Off might only allow a button press or NFC field detection.

But configuring wake sources isn’t enough. You need transition guards, checks that run before entering a deeper state:

bool can_enter_deep_sleep(void) {
    if (dma_any_active())            return false;  /* Active transfer */
    if (ble_connection_event_soon()) return false;  /* BLE timing */
    if (uart_tx_pending())           return false;  /* Data in buffer */
    if (idle_duration_ms() < 200)    return false;  /* Hysteresis */
    return true;
}

That last check, the hysteresis timer, prevents rapid sleep/wake cycling. Entering and exiting deep sleep has a current cost (restoring clocks, re-initializing PLLs, restoring peripheral context). If you deep-sleep for 5 ms, wake for 2 ms, and repeat, you may burn more average current than simply staying in Idle. A 100–500 ms idle timeout before allowing a deeper transition is a reasonable starting point. Tune it based on your measured transition costs.

Measuring What Actually Happens on Real Hardware

Your low power state machine is only as good as your measurements prove it is. Datasheet current numbers assume all peripherals are off, all pins are configured correctly, and no software is doing anything unexpected. Your firmware lives in the real world.

A multimeter won’t cut it. A multimeter shows average current, which hides everything interesting. You need current over time: a power profiler (Nordic Power Profiler Kit II, Qoitech Otii Arc, Joulescope) or an oscilloscope measuring voltage drop across an inline shunt resistor (1–10 Ω, depending on expected current range).

What to Measure

  • Steady-state current in each declared power state. Does your Deep Sleep state actually hit single-digit µA? Does Active match your expectations?
  • Transition current and duration. How much energy does the sleep→wake transition cost? This determines the minimum useful sleep duration.
  • Peak wake-up current. Important for capacitor sizing on your power supply. A 15 mA spike on a supply rated for 5 mA means brownout resets.
  • Average current over a full duty cycle. This is the number that determines battery life. Measure over multiple complete wake/process/sleep cycles.

Reading the Power Trace: Common Failures

Flat elevated baseline in “deep sleep”: Your steady-state sleep current is 400 µA instead of 2 µA. A peripheral clock is still running, or a GPIO is floating. Check your pre-sleep hooks.

Periodic current spikes with no software trigger: Likely a watchdog timer waking the system, an RTC event that’s not being handled efficiently, or a debug interface (SWD/JTAG) that’s still active.

Sleep current looks great, but battery life is terrible: You’re not spending enough time asleep. The transitions are eating the budget. Look at your hysteresis timers and check whether your wake-to-sleep path is as fast as it should be.

The fix for every one of these is in the power state machine: the transition logic, the pre-sleep hooks, or the peripheral save/restore callbacks. This is exactly why power management belongs in a centralized module rather than scattered across your codebase. When measurement reveals a problem, you know where to look.

Building Your Implementation, Step by Step

  1. Audit your MCU’s sleep modes. Read the power management chapter (not just the summary table). Note which peripherals are retained in each mode and what the wake sources are.
  2. Define your power states based on wake latency, peripheral needs, and current budget, not MCU sleep modes directly.
  3. Implement the power manager as a dedicated module with a policy function (power_manager_evaluate()).
  4. Register all peripheral drivers with save/restore context callbacks.
  5. Audit and configure every GPIO for each sleep state. Build the pin-state table.
  6. Declare valid wake sources per state and implement transition guards.
  7. Add idle timeouts and hysteresis to prevent rapid sleep/wake cycling.
  8. Measure current in every state and across every transition with a power profiler.
  9. Iterate until measured average current meets your battery life target.

This pattern scales. Multi-core SoCs (like the nRF5340 with its application and network cores) add a coordination layer where each core runs its own low power state machine and a supervisor manages the shared power domains. RF SoCs add radio timing constraints to the transition guards. But the architecture, centralized policy engine, registered peripherals, measured validation, stays the same.

Start with step 1. Audit your sleep modes tonight. By the end of the week, you’ll have a power manager module that replaces every scattered sleep call in your codebase, and current measurements that actually match the datasheet.


Hubble Network connects Bluetooth devices directly to satellites, so your optimized power state machine translates into years of battery life without any terrestrial infrastructure. See how it works →