Zephyr Power Management Framework: Sleep Modes and Wake Sources

Configuring sleep modes and wake sources in the Zephyr RTOS power management framework

Your nRF52840 draws about 3 mA doing absolutely nothing. All peripherals idling, your firmware in a while(1) loop with nothing to do. On a 225 mAh coin cell, that’s 3 days of battery life. The same chip in System ON idle pulls 1.5 µA: over 17 years. The only difference is whether your RTOS knows how to put it to sleep.

Zephyr has a power management framework that can get you there, but it’s layered, and the layers aren’t obvious. You’ve got system PM, a policy manager, and device PM all stacked on top of each other. Miss one layer and you’ll wonder why your current draw won’t budge.

Here’s the full walkthrough: Kconfig to code to current measurement, all on the nRF52840-DK.

The Three Layers of Zephyr Power Management

Zephyr’s PM system is three cooperating layers, and understanding how they talk to each other is the whole ballgame.

┌─────────────────────────────────────────────┐
│              APPLICATION CODE                │
│  (k_sleep, k_timer, idle moments)           │
└──────────────────┬──────────────────────────┘
                   │ kernel enters idle
                   ▼
┌─────────────────────────────────────────────┐
│           SYSTEM PM (kernel/pm.c)           │
│  "Should we sleep? Ask the policy manager." │
└──────────────────┬──────────────────────────┘
                   │ queries
                   ▼
┌─────────────────────────────────────────────┐
│         POLICY MANAGER (pm_policy)          │
│  "Next timeout is 5 s → enter              │
│   suspend-to-idle (min residency met)."     │
└──────────────────┬──────────────────────────┘
                   │ notifies each device
                   ▼
┌─────────────────────────────────────────────┐
│         DEVICE PM (pm_device)               │
│  UART → suspend   SPI → suspend            │
│  Sensor → suspend  GPIO → stays on (wake)  │
└─────────────────────────────────────────────┘
                   │
                   ▼
            ┌──────────┐
            │ HW SLEEP │
            │ (nRF52)  │
            └──────────┘

System PM is the kernel’s idle thread. When there’s no work to do, instead of spinning in a loop, it asks the policy manager what to do.

The Policy Manager looks at how long until the next scheduled kernel event (a timer, a delayed work item) and picks the deepest sleep state where the residency requirement is met. Residency means: don’t enter a sleep state unless you’ll stay there long enough for it to be worth the entry and exit cost. The built-in residency policy handles this automatically.

Device PM is where individual drivers shut down their hardware. The system can be asleep, but if the UART peripheral is still clocked, you’re leaking current. Each driver needs to explicitly handle suspend and resume.

Zephyr Sleep Modes on the nRF52840

The nRF52840 exposes three power states to Zephyr, defined in its Devicetree:

Zephyr StateNordic HW StateRAM RetainedCurrentWake Latency
runtime-idleCPU WFIYes~3 mA< 1 µs
suspend-to-idleSystem ON IdleYes~1.5 µA~5 µs
standbySystem OFFNo~0.3 µAFull reboot

These states live in the Devicetree, not in your C code. Here’s the relevant snippet from the board’s .dts file:

cpu0_sleep: idle {
    compatible = "zephyr,power-state";
    power-state-name = "suspend-to-idle";
    min-residency-us = <100000>;  /* 100 ms */
    exit-latency-us = <5>;
};

min-residency-us tells the policy manager to only enter this state if the system will be asleep for at least 100 ms. If your next timer fires in 50 ms, the policy manager picks a lighter state instead. exit-latency-us tells the scheduler how long waking up takes, so it can fire your timer callback on time rather than 5 µs late.

The key insight: standby (System OFF) loses all RAM. Your program restarts from main(). You have to design for that.

Enabling System PM with Kconfig

The minimal prj.conf to get system-level sleep working:

CONFIG_PM=y

That’s it for basic system PM. With just this line, Zephyr’s idle thread starts entering light sleep states automatically. Many newcomers add this flag and don’t realize it’s already doing something.

To get the deeper benefits, you’ll want the full set:

CONFIG_PM=y
CONFIG_PM_DEVICE=y
CONFIG_PM_DEVICE_RUNTIME=y

Here’s what each one does:

  • CONFIG_PM: Enables the system PM subsystem. The idle thread will now call into the policy manager instead of busy-waiting.
  • CONFIG_PM_DEVICE: Enables the device PM API (pm_device_action_run, etc.). Without this, drivers can’t suspend their hardware.
  • CONFIG_PM_DEVICE_RUNTIME: Enables reference-counted device PM. Devices auto-suspend when nothing is using them. This is the preferred pattern for most projects.

Device Power Management with pm_device

Here’s what catches everyone migrating from bare-metal: the system can be in its deepest sleep state, but if you didn’t explicitly suspend the UART, the SPI controller, or your external sensor, those peripherals are still drawing current. System PM and device PM are independent.

The device PM state machine is simple:

        ┌──────────┐  PM_DEVICE_ACTION_SUSPEND  ┌───────────┐
        │  ACTIVE  │ ──────────────────────────► │ SUSPENDED │
        │          │ ◄────────────────────────── │           │
        └──────────┘  PM_DEVICE_ACTION_RESUME    └───────────┘

You can manually suspend a device like this:

const struct device *uart_dev = DEVICE_DT_GET(DT_NODELABEL(uart0));

/* Suspend UART before sleeping */
pm_device_action_run(uart_dev, PM_DEVICE_ACTION_SUSPEND);

/* ... sleep happens ... */

/* Resume when you need it again */
pm_device_action_run(uart_dev, PM_DEVICE_ACTION_RESUME);

But the better pattern is runtime PM. Instead of manually suspending and resuming, you “get” and “put” references:

/* "I need this device" — wakes it if suspended */
pm_device_runtime_get(uart_dev);

/* Use the UART... */
printk("Hello\n");

/* "I'm done" — suspends automatically when refcount hits 0 */
pm_device_runtime_put(uart_dev);

To enable runtime PM on a device, call this once during init:

pm_device_runtime_enable(uart_dev);

After that call, the device will auto-suspend when no one holds a reference. When multiple subsystems share a peripheral, the last one to call put triggers the suspend.

Configuring Wake Sources

A sleeping chip is useless if it can’t wake up. The wake source you need depends on which sleep state you’re targeting.

GPIO wake (Button 1 on the nRF52840-DK):

For suspend-to-idle, any configured GPIO interrupt will wake the system. Set it up before sleeping:

#define BUTTON_NODE DT_ALIAS(sw0)
static const struct gpio_dt_spec button =
    GPIO_DT_SPEC_GET(BUTTON_NODE, gpios);

/* In your init code: */
gpio_pin_configure_dt(&button, GPIO_INPUT);
gpio_pin_interrupt_configure_dt(&button, GPIO_INT_EDGE_TO_ACTIVE);

/* Set up a callback */
static struct gpio_callback btn_cb;
gpio_init_callback(&btn_cb, button_handler, BIT(button.pin));
gpio_add_callback(button.port, &btn_cb);

With the GPIO interrupt armed, the policy manager allows the system to sleep, and the button press generates a wake event.

RTC / Timer wake:

If you schedule a k_timer, Zephyr’s policy manager already knows about it. The next kernel timeout is exactly what the policy manager uses to pick a sleep state. A k_timer set for 5 seconds from now will naturally wake the system from suspend-to-idle after 5 seconds.

K_TIMER_DEFINE(wakeup_timer, timer_handler, NULL);
k_timer_start(&wakeup_timer, K_SECONDS(5), K_NO_WAIT);

No extra configuration needed. The RTC peripheral stays running in System ON idle.

System OFF wake (standby):

System OFF is the deepest state (0.3 µA), but it requires Nordic-specific GPIO sense configuration. You need to force entry with pm_state_force:

/* Configure GPIO sense for System OFF wake */
nrf_gpio_cfg_sense_set(BUTTON_PIN, NRF_GPIO_PIN_SENSE_LOW);

/* Force System OFF */
pm_state_force(0, &(struct pm_state_info){
    .state = PM_STATE_STANDBY
});

/* Let the idle thread do its thing */
k_sleep(K_FOREVER);

Warning: In System OFF, execution restarts from main(). All your variables and state are gone. If your product needs to remember anything across a System OFF cycle, you need to stash it in non-volatile storage or retained RAM (if using System ON with RAM retention instead).

The Complete Working Example

The device boots, prints a message, suspends the UART, enters suspend-to-idle, wakes on Button 1, resumes the UART, and prints again.

prj.conf:

CONFIG_PM=y
CONFIG_PM_DEVICE=y
CONFIG_GPIO=y
CONFIG_SERIAL=y
CONFIG_CONSOLE=y
CONFIG_UART_CONSOLE=y

main.c:

#include <zephyr/kernel.h>
#include <zephyr/device.h>
#include <zephyr/drivers/gpio.h>
#include <zephyr/pm/device.h>
#include <zephyr/pm/pm.h>

#define BUTTON_NODE DT_ALIAS(sw0)
static const struct gpio_dt_spec button =
    GPIO_DT_SPEC_GET(BUTTON_NODE, gpios);

static volatile bool button_pressed;

static void button_handler(const struct device *dev,
                           struct gpio_callback *cb,
                           uint32_t pins)
{
    button_pressed = true;
}

static struct gpio_callback btn_cb;

int main(void)
{
    const struct device *cons =
        DEVICE_DT_GET(DT_CHOSEN(zephyr_console));

    /* Set up button as wake source */
    gpio_pin_configure_dt(&button, GPIO_INPUT);
    gpio_pin_interrupt_configure_dt(&button,
        GPIO_INT_EDGE_TO_ACTIVE);
    gpio_init_callback(&btn_cb, button_handler,
        BIT(button.pin));
    gpio_add_callback(button.port, &btn_cb);

    while (1) {
        /* Resume UART so we can print */
        pm_device_action_run(cons, PM_DEVICE_ACTION_RESUME);
        printk("Awake! Suspending UART, going to sleep...\n");

        /* Give UART time to flush */
        k_msleep(100);

        /* Suspend UART to save power */
        pm_device_action_run(cons, PM_DEVICE_ACTION_SUSPEND);

        /* Sleep until button press.
         * The idle thread + policy manager handle the rest. */
        button_pressed = false;
        while (!button_pressed) {
            k_msleep(100);
        }
    }
    return 0;
}

Build and flash:

west build -b nrf52840dk_nrf52840 -s app/ -- -DCONF_FILE=prj.conf
west flash

With the UART suspended and no other peripherals active, the board should drop to the low-µA range between button presses.

Measuring Current: Trust the Numbers, Not the Theory

Theory says 1.5 µA. You need to verify it.

The Nordic PPK2 (Power Profiler Kit II) is the standard tool. On the nRF52840-DK, cut the solder bridge on P22 (SB40) and connect the PPK2 in ampere meter mode across that jumper.

Expected readings:

StateExpected Current
Active (printing via UART)5-8 mA
suspend-to-idle (UART suspended)~1.5 µA
standby (System OFF)~0.3 µA

The biggest gotcha: if you’re seeing milliamps when you expect microamps, check whether the J-Link debugger is keeping the chip awake. The debug interface prevents deep sleep. Disconnect the debugger or power the board from a battery while measuring.

Pitfalls That Will Cost You Hours

Forgetting the serial console. The UART is a peripheral. If you don’t suspend it, it keeps clocking, and you’ll never see µA-level current. This is the #1 reason people think Zephyr PM “doesn’t work.”

min-residency-us set too high. If your code wakes every 50 ms but min-residency-us is 100 ms for suspend-to-idle, the policy manager will never pick that state. Check your timer intervals against your Devicetree values.

printk after suspending UART. This causes a hard fault or a silent hang. Resume the UART first, or use RTT (which works over the debug interface without UART).

Debugging PM transitions. Add CONFIG_PM_LOG_LEVEL_DBG=y to your prj.conf. You’ll get kernel log messages every time the system enters or exits a sleep state, making it obvious when the policy manager isn’t picking the state you expect.

Putting This Into Production

Start with CONFIG_PM=y alone and measure. You’ll probably see an immediate drop from “busy-wait idle” to “WFI idle.” Then add CONFIG_PM_DEVICE=y and suspend your peripherals one by one, measuring after each change. The UART will give you the biggest win. SPI and I2C controllers are next.

Once suspend-to-idle is working and verified, only then consider System OFF for ultra-low-power use cases where you can tolerate a full reboot on wake.

The Zephyr PM docs (System PM, Device PM API) are the authoritative reference, but they read more like an API spec than a tutorial. The in-tree samples under samples/subsys/pm/ are also worth studying once you’ve got the mental model from this walkthrough.

A factor of 2,000 separates 3 mA from 1.5 µA. That’s the difference between a product that ships with a massive battery and one that runs for years on a coin cell. The code changes to get there are surprisingly small.


Hubble Network enables Bluetooth connectivity from devices running on coin-cell power budgets—no gateways, no infrastructure. See how it works →