This post is a practical, code-first walkthrough of Device PM and Power Domains in the latest Zephyr. We'll go from zero to a working setup with full Devicetree, Kconfig, and C driver examples.
The Big Picture
Zephyr device PM has two modes:
- System-Managed
- Device Runtime PM
The preferred approach for anything non-trivial is Device Runtime PM
Power Domains layer on top of either mode. They model a shared power rail (a regulator, a load switch, an SoC internal domain) and propagate power on/off events to all child devices.
We'll focus on Device Runtime PM + Power Domains, since that's what real-world products use.
Step 1: Kconfig - Enabling the Subsystem
# prj.conf
# Core device PM support
CONFIG_PM_DEVICE=y
# Reference-counted runtime PM (get/put API)
CONFIG_PM_DEVICE_RUNTIME=y
# Power domain support
CONFIG_PM_DEVICE_POWER_DOMAIN=y
# Optional: helpful during development
CONFIG_PM_DEVICE_SHELL=y
CONFIG_DEVICE_SHELL=y
Step 2: Devicetree - Defining Your Power Domain and Devices
Let's say you have a board with a load switch on GPIO P0.14 that powers an I2C bus with a temperature sensor and an accelerometer. Here's the complete DTS:
/ {
/*
* A GPIO-controlled load switch powering the sensor rail.
* 'power-domain-gpio' is a built-in Zephyr driver.
*/
sensor_rail: sensor-power-rail {
compatible = "power-domain-gpio";
#power-domain-cells = <0>;
enable-gpios = <&gpio0 14 GPIO_ACTIVE_HIGH>;
startup-delay-us = <1500>; /* time for rail to stabilize */
off-on-delay-us = <10000>; /* minimum off time before re-enabling */
/*
* Let Zephyr auto-enable runtime PM for this domain.
* Without this, you'd need to call pm_device_runtime_enable()
* manually during init.
*/
zephyr,pm-device-runtime-auto;
};
};
/* The I2C bus behind the load switch */
&i2c1 {
status = "okay";
clock-frequency = <I2C_BITRATE_FAST>;
/* Declare this bus is powered by our domain */
power-domains = <&sensor_rail>;
zephyr,pm-device-runtime-auto;
/* Temperature sensor on the bus */
temp_sensor: tmp117@48 {
compatible = "ti,tmp117";
reg = <0x48>;
power-domains = <&sensor_rail>;
};
/* Accelerometer on the same bus */
accel: lis2dh@19 {
compatible = "st,lis2dh";
reg = <0x19>;
irq-gpios = <&gpio0 22 GPIO_ACTIVE_HIGH>;
power-domains = <&sensor_rail>;
};
};
Key things to notice:
-
Both the bus and the devices declare
power-domains = <&sensor_rail>. When all children release their references, the domain powers down automatically. -
zephyr,pm-device-runtime-autoon the domain and bus means Zephyr enables runtime PM right after successful init - no extra code needed. -
startup-delay-usgives the rail time to stabilize before child devices try to communicate. This prevents I2C NACK storms after power-up.
Step 3: The PM Action Callback - Heart of Every PM-Aware Driver
Every driver that supports power management must implement a callback that handles four actions:
TURN_ON ──► SUSPENDED ──► ACTIVE
▲ │
│ │
SUSPEND ◄────────┘
│
▼
TURN_OFF
-
TURN_ON: Power domain came alive. Configure pins/hardware into a known suspended state. -
RESUME: Move from suspended to active. Initialize hardware, enable interrupts. -
SUSPEND: Move from active to suspended. Disable interrupts, put hardware to sleep. -
TURN_OFF: Power domain is going down. Disconnect pins to prevent backpowering through GPIOs.
Here's a complete example for a fictional gas sensor driver:
struct gas_sensor_config {
struct i2c_dt_spec bus;
struct gpio_dt_spec alert_gpio;
};
struct gas_sensor_data {
struct gpio_callback alert_cb;
uint16_t last_reading;
};
/* ──────────────── PM helpers ──────────────── */
static int gas_sensor_hw_resume(const struct device *dev)
{
const struct gas_sensor_config *cfg = dev->config;
struct gas_sensor_data *data = dev->data;
int ret;
/* Get the bus so we can talk to the device */
ret = pm_device_runtime_get(cfg->bus.bus);
if (ret < 0) {
return ret;
}
/* Wake the sensor from its internal sleep mode */
uint8_t wake_cmd = 0x01;
ret = i2c_write_dt(&cfg->bus, &wake_cmd, 1);
if (ret < 0) {
pm_device_runtime_put(cfg->bus.bus);
return ret;
}
/* Enable the alert interrupt */
gpio_add_callback(cfg->alert_gpio.port, &data->alert_cb);
gpio_pin_interrupt_configure_dt(&cfg->alert_gpio, GPIO_INT_EDGE_TO_ACTIVE);
/* Release bus -- we only need it during transactions */
pm_device_runtime_put(cfg->bus.bus);
return 0;
}
static int gas_sensor_hw_suspend(const struct device *dev)
{
const struct gas_sensor_config *cfg = dev->config;
struct gas_sensor_data *data = dev->data;
int ret;
/* Disable the alert interrupt */
gpio_pin_interrupt_configure_dt(&cfg->alert_gpio, GPIO_INT_DISABLED);
gpio_remove_callback(cfg->alert_gpio.port, &data->alert_cb);
/* Put sensor into its internal low-power mode */
ret = pm_device_runtime_get(cfg->bus.bus);
if (ret < 0) {
return ret;
}
uint8_t sleep_cmd = 0x00;
ret = i2c_write_dt(&cfg->bus, &sleep_cmd, 1);
pm_device_runtime_put(cfg->bus.bus);
return ret;
}
static int gas_sensor_hw_turn_on(const struct device *dev)
{
const struct gas_sensor_config *cfg = dev->config;
/*
* Power domain is ON. Configure pins into a known state.
* The device starts suspended -- we just need safe pin states.
*/
gpio_pin_configure_dt(&cfg->alert_gpio, GPIO_INPUT);
return 0;
}
static int gas_sensor_hw_turn_off(const struct device *dev)
{
const struct gas_sensor_config *cfg = dev->config;
/*
* Power domain is going OFF.
* Disconnect GPIOs to prevent backpowering the sensor
* through its alert pin when the rail is dead.
*/
if (gpio_pin_configure_dt(&cfg->alert_gpio, GPIO_DISCONNECTED)) {
/* Fallback if the GPIO driver doesn't support DISCONNECTED */
gpio_pin_configure_dt(&cfg->alert_gpio, GPIO_INPUT);
}
return 0;
}
/* ──────────────── PM action callback ──────────────── */
static int gas_sensor_pm_action(const struct device *dev,
enum pm_device_action action)
{
switch (action) {
case PM_DEVICE_ACTION_RESUME:
return gas_sensor_hw_resume(dev);
case PM_DEVICE_ACTION_SUSPEND:
return gas_sensor_hw_suspend(dev);
case PM_DEVICE_ACTION_TURN_ON:
return gas_sensor_hw_turn_on(dev);
case PM_DEVICE_ACTION_TURN_OFF:
return gas_sensor_hw_turn_off(dev);
default:
return -ENOTSUP;
}
}
/* ──────────────── Public API ──────────────── */
static int gas_sensor_read(const struct device *dev, uint16_t *ppm)
{
const struct gas_sensor_config *cfg = dev->config;
struct gas_sensor_data *data = dev->data;
int ret;
/* Activate the sensor (and implicitly, the bus + power domain) */
ret = pm_device_runtime_get(dev);
if (ret < 0) {
return ret;
}
/* Now the device is ACTIVE -- perform the read */
ret = pm_device_runtime_get(cfg->bus.bus);
if (ret < 0) {
goto out;
}
ret = i2c_burst_read_dt(&cfg->bus, 0x10, (uint8_t *)ppm, sizeof(*ppm));
pm_device_runtime_put(cfg->bus.bus);
out:
/* Release. If no other users, sensor suspends, domain may power off. */
pm_device_runtime_put(dev);
return ret;
}
/* ──────────────── Init & instantiation ──────────────── */
static int gas_sensor_init(const struct device *dev)
{
const struct gas_sensor_config *cfg = dev->config;
struct gas_sensor_data *data = dev->data;
if (!i2c_is_ready_dt(&cfg->bus)) {
return -ENODEV;
}
if (!gpio_is_ready_dt(&cfg->alert_gpio)) {
return -ENODEV;
}
gpio_init_callback(&data->alert_cb, gas_sensor_alert_handler,
BIT(cfg->alert_gpio.pin));
/*
* MUST be the last call in init.
* This initializes the PM context and puts the device into the
* correct initial state via the action callback (TURN_ON → RESUME).
*/
return pm_device_driver_init(dev, gas_sensor_pm_action);
}
/* Define the PM context for instance 0 */
PM_DEVICE_DT_INST_DEFINE(0, gas_sensor_pm_action);
DEVICE_DT_INST_DEFINE(
0,
gas_sensor_init, /* init */
PM_DEVICE_DT_INST_GET(0), /* pm */
&gas_sensor_data_0, /* data */
&gas_sensor_cfg_0, /* config */
POST_KERNEL,
CONFIG_SENSOR_INIT_PRIORITY,
&gas_sensor_api
);
Practical Tips & Gotchas
1. The boot-time chattering problem
When runtime PM is enabled, each device's init function may briefly get and put the power domain. With 10 devices on a domain, this can toggle the physical power rail 10+ times during boot. Solution: hold the domain active during init:
static const struct device *domain = DEVICE_DT_GET(DT_NODELABEL(sensor_rail));
static int hold_domain_during_boot(void)
{
/* Keep the rail on through the entire POST_KERNEL init phase */
return pm_device_runtime_get(domain);
}
static int release_domain_after_boot(void)
{
/* All drivers are initialized -- let the domain idle */
return pm_device_runtime_put(domain);
}
SYS_INIT(hold_domain_during_boot, POST_KERNEL, 0);
SYS_INIT(release_domain_after_boot, APPLICATION, 99);
2. Always guard TURN_OFF against backpowering
When a power rail goes down, any GPIO pin driving HIGH into a de-powered device will backpower it through its ESD diodes. Always disconnect or tri-state your outputs in TURN_OFF:
case PM_DEVICE_ACTION_TURN_OFF:
/* Prefer GPIO_DISCONNECTED; fall back to GPIO_INPUT */
if (gpio_pin_configure_dt(&cfg->cs_gpio, GPIO_DISCONNECTED) != 0) {
gpio_pin_configure_dt(&cfg->cs_gpio, GPIO_INPUT);
}
return 0;
3. Use k_can_yield() guard in PM callbacks
If system-managed device PM is also enabled, your PM callback might be invoked from the idle thread, which cannot block. Guard blocking operations:
static int my_pm_action(const struct device *dev, enum pm_device_action action)
{
if (!k_can_yield()) {
return -ENOTSUP;
}
/* Safe to do blocking I2C/SPI operations here */
/* ... */
}
4. Async put for bursty workloads
If your device does many short transactions with brief gaps, synchronous put/get on every transaction is wasteful. Use pm_device_runtime_put_async() with a delay to keep the device active through bursts:
/* Keep device active for 50ms after the last transaction, then suspend */
pm_device_runtime_put_async(dev, K_MSEC(50));
5. Shell debugging is your best friend
uart:~$ device list
devices:
- sensor-power-rail (active, usage=2)
- i2c@40003000 (active, usage=1)
- accel@19 (active, usage=1)
- temp_sensor@48 (suspended, usage=0)
- radio@0 (suspended, usage=0)
The usage=N count tells you exactly how many references are held. If a device is stuck active with usage=1 when you expect 0, you have a reference leak in a driver.
Summary
The full setup checklist:
-
Kconfig: Enable
PM_DEVICE,PM_DEVICE_RUNTIME, and optionallyPM_DEVICE_POWER_DOMAIN -
DTS: Define your power domain node, add
power-domains = <&domain>to child devices, usezephyr,pm-device-runtime-autowhere appropriate -
Driver: Implement a PM action callback handling all four actions (
RESUME,SUSPEND,TURN_ON,TURN_OFF) -
Driver init: End with
pm_device_driver_init(dev, my_pm_action)and instantiate withPM_DEVICE_DT_INST_DEFINE -
Consumers: Bracket every hardware access with
pm_device_runtime_get()/pm_device_runtime_put()
The device runtime PM model with power domains is one of Zephyr's strongest features. It takes some effort to set up correctly, but once it's in place, your system only burns power on peripherals that are actually doing work - and that's the whole game in battery-powered products.
