Your Zephyr Device Wakes Up But Won't Go Back to Sleep: Debugging Stuck Wake Sources

Debugging Zephyr devices that wake but won't return to low-power sleep

You’re staring at a PPK2 trace. The device drops into System OFF, sits at a few microamps, then a button press wakes it. Fine. Except the current never comes back down. It settles at 1.2 mA and just… stays there. The firmware looks idle. The logs go quiet. But the battery math you promised your PM is now off by a factor of 300.

This is the “wakes but won’t re-sleep” bug, and it’s maddening because everything looks asleep. This piece assumes you already know pm_state, the system PM subsystem, and device PM basics. What you need is a diagnostic path, not a tutorial. We’ll build a reproducible test loop, instrument it, and walk three concrete nRF52840 culprits. Targeting Zephyr 3.7 LTS; I’ll flag where the API shifted.

Why a device won’t re-sleep: the two blockers

When the CPU goes idle, Zephyr calls pm_policy_next_state() to pick a low-power state, then suspends devices before entering it. Two things break that chain.

A pending or unmasked interrupt keeps the CPU out of idle. If a peripheral latched an event and nobody cleared it, the IRQ line stays asserted. The idle thread wakes immediately, re-enters, wakes again. The core never actually stops. You get a busy loop that reads as elevated steady-state current.

A non-zero device PM refcount vetoes suspend. Zephyr’s device runtime PM tracks a usage count per device. If any device still shows a positive refcount, the system can’t suspend it, and the deepest state gets ruled out. The policy manager quietly settles on a shallower state (or stays in PM_STATE_ACTIVE) and you burn current.

Both failure modes show the same symptom on a current graph, so guessing wastes hours. Measure and log instead.

The Zephyr power management docs cover the policy chain. We’re skipping straight to the bug.

Build a reproducible test loop

You can’t fix an intermittent bug. Make it happen on demand.

The pattern: force a known sleep state, inject exactly one wake event, then check whether current returns to baseline. Force the state with pm_policy_state_lock_get() on everything shallower than your target, so the policy manager has no choice but to pick the deep state.

#include <zephyr/pm/pm.h>
#include <zephyr/pm/policy.h>

void test_sleep_loop(void)
{
    while (1) {
        /* Block all states shallower than SUSPEND_TO_RAM */
        pm_policy_state_lock_get(PM_STATE_STANDBY, PM_ALL_SUBSTATES);

        printk("about to idle\n");
        k_sleep(K_SECONDS(5));   /* long enough to enter deep state */

        printk("woke, re-idling\n");
        pm_policy_state_lock_put(PM_STATE_STANDBY, PM_ALL_SUBSTATES);
        k_sleep(K_MSEC(100));
    }
}

Flash it. Wake the device with a single button press during the k_sleep. Watch the trace. On a clean device, current spikes for the wake handler, then drops back to your System OFF/ON idle baseline. On a stuck device, it steps up and holds.

Using one wake event at a time isolates the offender instead of drowning you in overlapping IRQs. If a button press leaves current stuck but an RTC wake doesn’t, you’ve narrowed it to the GPIO path in under a minute.

Instrument it: current plus logging for zephyr sleep debugging

Two tracks run in parallel. Neither alone tells you enough.

Current measurement. On an nRF52840 DK in System ON idle with RAM retention, expect roughly 1.5 to 3 µA depending on retained peripherals. System OFF sits around 0.4 µA. If you’re seeing tens of µA to low mA after a wake, something’s held on. (The nRF5340 splits work across two cores; the network core adds its own baseline, so don’t compare its numbers to a 52840 directly.)

Use a PPK2 in source-meter mode, or a series resistor with a scope if that’s what you have. The current measurement guide covers PPK2 setup. What you’re looking for is shape, not just magnitude:

Current (uA)
  ^
  |      wake
  |       |
  |     __v___
  |    |      |          CLEAN: returns to baseline
  |____|      |________________
  |
  +----------------------------> time

  |      wake
  |       |
  |     __v_______________
  |    |                          STUCK: never re-sleeps
  |____|
  |
  +----------------------------> time

Logging what vetoes suspend. Turn on CONFIG_PM_LOG_LEVEL_DBG=y. The policy manager logs the state it lands on. If you locked out everything shallow and it still reports a shallow state, a device veto is forcing its hand.

Then poll suspect devices directly:

enum pm_device_state st;
pm_device_state_get(uart_dev, &st);
printk("uart pm state: %d\n", st);  /* expect PM_DEVICE_STATE_SUSPENDED */

Any suspect still reading PM_DEVICE_STATE_ACTIVE after your idle window is your offender, or close to it.

Here’s the decision flow the subsystem walks every time it tries to sleep:

CPU idle
   |
   v
pm_policy_next_state() --> state chosen?
   |                          |
   | no (IRQ pending)         v
   v                    suspend devices
 stay awake  <----- refcount > 0? --- yes
   |                          | no
   |                          v
   +--------------------  enter low-power state

Walkthrough: three common nRF culprits for residual current draw

On nRF52/nRF53, the same three peripherals cause most stuck wakes. Check them in this order.

GPIOTE. This is the classic. If you configured a wake pin with GPIO_INT_LEVEL_ACTIVE and the line stays at that level after wake, GPIOTE holds the event latched and the IRQ never clears. The core keeps re-entering the handler.

Confirm it by reading the GPIOTE EVENTS_IN[n] (or EVENTS_PORT) register after wake. If it’s still 1, the event wasn’t cleared. The fix is usually switching from level to edge triggering (GPIO_INT_EDGE_TO_ACTIVE) if your logic allows, or explicitly clearing the latch in the callback. The GPIO interrupt configuration guide has the flag details. Level-triggered wakes are legitimate, but you have to deassert the source before returning, or handle it edge-style.

UARTE. If RX is left enabled, UARTE holds a request on the HFCLK, and the clock can’t stop. Your current sits elevated because the 64 MHz oscillator is running while you think you’re asleep. This one tends to hide in the async UART API: you never call uart_rx_disable() before sleep, or you don’t handle the ENDRX/RX_DISABLED events cleanly.

Check pm_device_state_get() on the UART device. If it’s ACTIVE while everything else is suspended, that’s it. Disable RX before you sleep and re-enable on wake. If you’re on device runtime PM, make sure the UART’s refcount actually drops. (In 3.7 the async RX disable flow is stable; earlier trees had rougher edges around RX_BUF_RELEASED ordering.)

Unbalanced pm_device refcount. Every pm_device_runtime_get() needs a matching put(). Miss one and the device never suspends, which vetoes the deep state for the whole system. These leaks almost always hide in error paths: you get() the device, a transfer fails, you return early, and the put() at the bottom of the function never runs.

Find it by logging the refcount around suspect operations. The device runtime PM guide covers the get/put contract. Audit every early return and goto in functions that touch a device, and wrap the get/put in a cleanup pattern so error exits can’t skip the release.

Confirming the fix

Re-run the exact test loop. Inject the same single wake event. The current should spike for the handler, then fall back to your 1.5 to 3 µA baseline within your idle window. If it does, you’re done for that source. Run each wake type (button, RTC, UART byte) through the loop separately to be sure you didn’t fix one and miss another.

Guard against regressions. Drop a lightweight check in a low-priority thread that reads the deepest state actually reached, or asserts that key devices report PM_DEVICE_STATE_SUSPENDED after a fixed idle period:

__ASSERT(reached_state <= PM_STATE_STANDBY,
         "device failed to reach deep sleep");

It’s cheap, and it turns a silent battery leak into a build-time or test-time failure the next time someone adds a peripheral.

A repeatable checklist for stuck wake sources

Next time current won’t return to baseline after a wake, work this order instead of disabling suspects at random:

  1. Reproduce. Lock the policy to your target deep state, inject one wake event, watch the trace.
  2. Read the state. Enable CONFIG_PM_LOG_LEVEL_DBG and check which state the policy manager settled on.
  3. Check refcounts and device states. Call pm_device_state_get() on every suspect; anything still ACTIVE is a lead.
  4. Walk the nRF three. GPIOTE latched event, UARTE RX left enabled, unbalanced pm_device get/put in an error path.
  5. Confirm and guard. Re-run the loop until current returns to baseline, then add an assert on residual state.
SymptomSuspectConfirm withFix
Current stuck after button wakeGPIOTE latched eventEVENTS_IN[n] still setEdge trigger or clear latch
~1 mA hold, HFCLK runningUARTE RX activepm_device_state_get() = ACTIVEuart_rx_disable() before sleep
Deep state never chosenUnbalanced refcountLog get/put countFix put() in error path

Most fragmented answers out there solve one board’s one case. This loop solves the class. Point it at your own peripherals, and you’ll find the offender in minutes instead of an afternoon.


Hubble Network keeps low-power fleets connected over satellite without forcing devices to abandon their sleep budgets for a signal. See how it works →