1. 从一次功耗异常排查说起:PM Core 到底管什么
去年冬天调一块 ARM64 开发板,待机电流死活降不到规格书标称的 2mA,实测总在 18mA 上下晃。用示波器抓电源轨、拿热成像扫板子,硬件侧翻了个底朝天也没找出问题。最后把/sys/kernel/debug/pm_genpd/和/sys/kernel/debug/clk/clk_summary两个节点一对比,发现有个显示控制器的时钟在系统进入 suspend 之后压根没被关掉——设备驱动注册了dev_pm_ops,但它的父级电源域(genpd)没把power_off回调挂上,导致整个域一直挂在 ON 状态。这件事让我重新把 Linux 功耗子系统从 PM Core 往上捋了一遍,也促成了这篇总结。
Linux 内核功耗子系统(Power Management Subsystem)是内核里负责整机与设备级省电的一整套框架,核心入口就是PM Core(drivers/base/power/目录下那堆代码)。它要解决的问题很朴素:一块 SoC 上有几十上百个设备、十几个电源域、几百个时钟,系统要休眠时,谁先关、谁后关、谁不能关、关错了怎么回滚,这些顺序和依赖关系必须有个统一的地方来管。PM Core 就是那个“总调度”。
这篇文章适合三类人看:一是做嵌入式 Linux 驱动、经常和suspend/resume打交道的工程师;二是做系统功耗优化、需要读懂/sys/kernel/debug下各种 PM 节点的同学;三是准备内核相关面试、想把“功耗框架分层”这件事讲清楚的人。我会从分层设计的角度,把 PM Core、Device PM、genpd、Runtime PM、System Sleep 这几层的关系拆开讲,配上我实际调试时用的命令和踩过的坑。读完你应该能自己判断:一个设备不休眠,问题到底出在哪一层。
2. 功耗框架为什么要分层:一次设计取舍的复盘
2.1 不分层会怎样:一个反例
早期内核里设备省电是各驱动自己实现的,suspend回调里想关时钟就关时钟,想切 regulator 就切 regulator。听起来自由,实际是灾难。我见过一个老平台,网卡驱动在suspend里把 PHY 电源关了,但 MAC 的时钟还开着,结果 resume 时 PHY 上电时序不对,链路要 3 秒才能重新协商。更麻烦的是,两个驱动共享一个 regulator,A 驱动 suspend 时把它关了,B 驱动还在用,直接挂死。
问题的根子在于:省电操作天然有依赖和顺序。电源域有父子关系,时钟有树状结构,设备有共享资源。让每个驱动各自为政,等于把全局约束下放给局部,必然冲突。所以内核的做法是抽出一层 PM Core,把“什么时候调用哪个回调”这件事集中管起来,驱动只负责实现“我该怎么省电”,不负责“我该在什么时候省电”。
2.2 分层的边界怎么划
PM Core 的分层不是拍脑袋定的,它对应的是三种不同的省电粒度和触发时机:
| 层级 | 目录/接口 | 管什么 | 触发时机 |
|---|---|---|---|
| System Sleep | kernel/power/ | 整机 suspend/hibernate | 用户写/sys/power/state |
| Device PM | drivers/base/power/main.c | 设备 suspend/resume 回调编排 | 系统睡眠或 runtime 触发 |
| Runtime PM | drivers/base/power/runtime.c | 设备空闲时单独下电 | 驱动主动pm_runtime_put |
| genpd | drivers/base/power/domain.c | 电源域整体开关 | 域内设备全部 idle |
| Clock/Regulator | drivers/clk、drivers/regulator | 具体硬件资源 | 被上层调用 |
这张表是我自己捋的时候画的,它解释了一个常见困惑:为什么suspend回调里不能随便关时钟?因为时钟属于最底层资源,它的开关应该由 genpd 或 Runtime PM 根据“还有没有设备在用”来决定,而不是某个驱动在suspend里越级操作。分层设计的本质是把决策权和执行权分开:PM Core 决定顺序,驱动执行动作,genpd 管资源聚合。
2.3 分层带来的两个直接好处
第一个好处是可回滚。系统睡眠时如果第 5 个设备 resume 失败,PM Core 知道前面 4 个已经 resume 了,会按相反顺序把它们重新 suspend 回去,保证状态一致。这个逻辑在dpm_resume_noirq那一串函数里,自己写驱动是绝对做不到这么严谨的。
第二个好处是可观测。因为所有 PM 操作都过 PM Core,内核就能在/sys/kernel/debug/pm_genpd/、/sys/power/这些地方把状态暴露出来。我排查开头那个 18mA 问题,靠的就是这些节点。如果省电逻辑散在各驱动里,根本没有统一的观测点。
提示:分层不是越多越好。我见过有人给一个只有两个设备的简单平台硬套 genpd,结果多了一层间接调用,调试反而更绕。分层要匹配硬件的实际电源拓扑,SoC 有几个独立电源域就分几层,别为了“架构好看”硬加。
3. PM Core 的核心数据结构:读懂这几个结构体就懂了一半
3.1 dev_pm_ops:驱动和 PM Core 的契约
驱动要参与功耗管理,第一步就是填dev_pm_ops。这个结构体定义在include/linux/pm.h,里面是一堆回调函数指针。新手最容易懵的是:为什么有suspend、suspend_noirq、suspend_late这么多相似的?它们不是重复,而是对应系统睡眠的不同阶段。
static const struct dev_pm_ops mydev_pm_ops = { .suspend = mydev_suspend, .resume = mydev_resume, .runtime_suspend = mydev_runtime_suspend, .runtime_resume = mydev_runtime_resume, };系统睡眠的完整阶段顺序是:prepare→suspend→suspend_late→suspend_noirq→ (硬件睡眠)→resume_noirq→resume_early→resume→complete。为什么这么设计?因为有些操作必须在中断关闭前做(比如要等一个中断确认设备已停),有些必须在中断关闭后做(比如写寄存器不能被中断打断)。_noirq后缀的就是中断已关的阶段。
我个人的经验是:大部分驱动只需要实现suspend/resume和runtime_suspend/runtime_resume四个。_late、_noirq这些留给真正有严格时序要求的设备,比如需要先停 DMA 再关时钟的控制器。乱实现_noirq回调很容易在 resume 阶段踩到“中断还没恢复就访问设备”的坑。
3.2 dev_pm_info:每个设备的状态账本
每个struct device里都内嵌了一个struct dev_pm_info,这是 PM Core 记录设备功耗状态的账本。关键字段有:
power_state:当前电源状态(DPM_ON、DPM_SUSPENDING、DPM_SUSPENDED等)runtime_status:Runtime PM 状态(RPM_ACTIVE、RPM_SUSPENDED)usage_count:Runtime PM 的引用计数disable_depth:Runtime PM 是否被禁用child_count:有多少子设备依赖它
usage_count和disable_depth是排查 Runtime PM 问题的核心。我遇到过设备一直不进入 runtime suspend,最后发现是某个驱动pm_runtime_get之后忘了pm_runtime_put,usage_count一直是 1。这种问题用/sys/kernel/debug/pm_genpd/看不出来,得直接读设备节点:
cat /sys/devices/platform/soc/xxx/power/runtime_status cat /sys/devices/platform/soc/xxx/power/usage_count cat /sys/devices/platform/soc/xxx/power/controlcontrol字段是auto还是on也很关键。如果驱动调了pm_runtime_forbid,这里会显示on,设备永远不会自动挂起。
3.3 设备树里的 power-domains 属性
分层设计在设备树里的体现就是power-domains属性。一个设备挂到哪个电源域,不是驱动代码里写死的,而是设备树声明的:
gpu: gpu@ff9a0000 { compatible = "vendor,gpu"; power-domains = <&power RK3399_PD_GPU>; clocks = <&cru ACLK_GPU>; };这样 PM Core 在设备 probe 时就能通过dev->pm_domain找到它所属的 genpd,后续 runtime suspend 时自动触发域的下电判断。这个设计的好处是硬件拓扑和驱动逻辑解耦:同一份 GPU 驱动,换个 SoC 只要改设备树,不用改代码。
注意:
power-domains和clocks是两个独立维度。设备可能属于某个电源域,但它的时钟由另一个时钟控制器管。排查时两个都要看,别只盯着一个。
4. Runtime PM 与 System Sleep:两套机制怎么配合
4.1 Runtime PM 的引用计数模型
Runtime PM 的核心是引用计数。驱动要用设备时pm_runtime_get_sync,用完pm_runtime_put,计数归零且设备空闲时,PM Core 自动调用runtime_suspend。这个模型的好处是自动聚合:多个调用者共享一个设备,只要还有人在用,设备就不会被关。
/* 使用设备前 */ pm_runtime_get_sync(dev); /* 访问硬件寄存器 */ readl(dev->base + REG); /* 用完后 */ pm_runtime_put(dev);但这里有个经典陷阱:pm_runtime_get_sync在原子上下文里不能用,因为它可能睡眠。中断处理里要用pm_runtime_get(不等待)配合pm_runtime_put_autosuspend。我踩过一次坑:在中断里调了get_sync,系统直接报scheduling while atomic。
另一个坑是autosuspend。很多驱动用pm_runtime_use_autosuspend+pm_runtime_set_autosuspend_delay做延迟挂起,避免频繁开关。延迟设多少有讲究:设太短省电效果差,设太长响应变慢。我的经验是按设备的最短空闲间隔来定,比如触摸屏设 50ms,网卡设 200ms,具体看实测。
4.2 System Sleep 如何“接管”Runtime PM
系统要整体睡眠时,Runtime PM 得先让路。PM Core 在dpm_suspend之前会调用pm_runtime_resume把所有设备唤醒到 active,然后再走系统睡眠流程。为什么?因为系统睡眠的回调假设设备处于已知状态,如果设备已经 runtime suspended,直接调suspend回调可能操作到未上电的寄存器。
这个“先唤醒再睡眠”的过程在日志里能看到:
dmesg | grep -i "pm_runtime" # 会看到 suspend 前一堆 runtime resume 的记录理解这一点对排查“系统睡眠后设备不工作”很关键。如果某个设备在系统 resume 后状态不对,先确认它在 suspend 前是否被正确 resume 到 active,再看它的suspend回调有没有正确保存状态。
4.3 两套机制的优先级关系
| 场景 | Runtime PM 状态 | System Sleep 行为 |
|---|---|---|
| 设备 active | RPM_ACTIVE | 正常调 suspend 回调 |
| 设备已挂起 | RPM_SUSPENDED | 先 resume 再 suspend |
| Runtime PM 被禁用 | disable_depth > 0 | 仍参与系统睡眠 |
| 设备标记 no_callbacks | 不参与 runtime | 仍参与系统睡眠 |
这张表说明一个容易误解的点:Runtime PM 被禁用不等于设备不参与系统睡眠。disable_depth只影响 runtime 自动挂起,系统睡眠是另一条路径。我见过有人为了“让设备不休眠”调了pm_runtime_forbid,结果系统 suspend 时设备照样被关,白忙一场。
5. genpd:电源域聚合与分层下电的实操
5.1 genpd 的工作模型
genpd(Generic Power Domain)是 PM Core 里管电源域的那一层,代码在drivers/base/power/domain.c。它的模型很简单:一个电源域包含若干设备,域内所有设备都 idle 时,域才能下电。域还有父子关系,子域全关父域才能关。
/* 典型的 genpd 注册 */ static struct generic_pm_domain gpu_pd = { .name = "GPU_PD", .power_off = gpu_pd_power_off, .power_on = gpu_pd_power_on, };power_off回调里通常做三件事:关时钟、切 regulator、拉电源开关。注意顺序:先关时钟再切电源,反过来会有瞬间的时钟毛刺。这个顺序在power_on里要反过来:先上电再开时钟。
5.2 用 debugfs 看电源域状态
/sys/kernel/debug/pm_genpd/是我最常用的调试入口。每个域一个目录,里面有current_state、device_count、subdomain_count等文件。
# 看所有电源域状态 for d in /sys/kernel/debug/pm_genpd/*/; do echo "=== $d ===" cat $d/current_state cat $d/device_count donecurrent_state显示on或off。如果系统 suspend 后某个域还是on,说明域内有设备没挂起,或者域本身没实现power_off。我开头那个 18mA 问题,就是靠这个发现显示控制器所在的域一直是on。
5.3 域内设备全部 idle 才下电的验证
genpd 下电的条件是域内所有设备都 runtime suspended。验证方法:
# 看域内设备列表 ls /sys/kernel/debug/pm_genpd/GPU_PD/ # 逐个检查设备 runtime 状态 cat /sys/devices/platform/soc/gpu/power/runtime_status如果某个设备是active,域就不会下电。这时候要顺着usage_count查是谁没put。我一般用ftrace抓pm_runtime事件:
echo 1 > /sys/kernel/debug/tracing/events/power/enable cat /sys/kernel/debug/tracing/trace_pipe这样能看到每次pm_runtime_get/put的调用栈,谁多调了一次一目了然。
实操心得:genpd 的
power_off回调里不要做耗时操作。我见过有人在里面加msleep(10)等电源稳定,结果系统 suspend 时间被拉长到几百毫秒。正确做法是用regulator_set_voltage的异步接口,或者把等待放到suspend_late阶段。
6. 常见问题与排查技巧实录
6.1 系统 suspend 失败怎么定位
suspend 失败最常见的原因是某个设备的suspend回调返回了错误码。PM Core 会打印是哪个设备:
dmesg | grep -i "suspend.*failed\|PM:.*error" # 典型输出:PM: Device xxx failed to suspend: error -16拿到设备名后,去/sys/devices/.../power/下看它的状态,再结合驱动代码看suspend回调里哪一步可能返回-EBUSY。-EBUSY通常意味着设备还在忙,比如 DMA 没停、有 pending 的中断。
6.2 设备 resume 后不工作
这类问题八成是suspend时状态没保存好,或者resume顺序不对。排查步骤:
- 确认
resume回调被调用了(加dev_info或看 ftrace) - 确认
resume里恢复了所有suspend时改动的寄存器 - 确认依赖的资源(时钟、regulator)在
resume前已就绪
第 3 点最容易出问题。如果设备 A 依赖设备 B 的时钟,而 B 的resume排在 A 后面,A 就会访问到没时钟的寄存器。解决办法是用device_link声明依赖:
device_link_add(consumer, supplier, DL_FLAG_PM_RUNTIME);这样 PM Core 会自动调整 resume 顺序。
6.3 常见问题速查表
| 现象 | 可能原因 | 排查命令 |
|---|---|---|
| 待机电流偏高 | 某电源域未下电 | cat /sys/kernel/debug/pm_genpd/*/current_state |
| 设备不进入 runtime suspend | usage_count 不为 0 | cat .../power/usage_count |
| suspend 返回 -EBUSY | 设备仍忙 | dmesg看设备名,查驱动回调 |
| resume 后寄存器值丢失 | suspend 未保存状态 | 对比 suspend/resume 寄存器 dump |
| 系统无法进入 suspend | 有 wakeup 源活跃 | cat /sys/kernel/debug/wakeup_sources |
| 时钟未关闭 | genpd 未触发 | cat /sys/kernel/debug/clk/clk_summary |
6.4 几个我踩过的坑
第一个坑:pm_runtime_get_sync在 probe 里调用后忘了put,设备永远 active。这个错误很隐蔽,因为功能正常,只是费电。建议 probe 里用pm_runtime_enable+pm_runtime_set_active的组合,让 PM Core 自己管初始状态。
第二个坑:genpd 的power_off回调里访问了已经被关的时钟。原因是回调顺序没理清,power_off里先关了时钟,后面又去读一个需要时钟的寄存器。正确做法是把所有寄存器操作放在关时钟之前。
第三个坑:设备树里power-domains写错了域,设备挂到了错误的电源域。这种问题编译不报错,运行时才暴露,而且现象诡异——设备能用,但省电行为不对。排查时用cat /sys/kernel/debug/pm_genpd/*/对照设备树确认。
7. 分层设计给驱动开发的启示
把 PM Core 这套分层捋清楚之后,我改驱动的方式变了。以前是“我要省电,我在 suspend 里关时钟”,现在是先问三个问题:这个设备属于哪个电源域?它的时钟和 regulator 由谁管?它和别的设备有没有共享资源?想清楚这三个问题,代码自然就写对了——驱动只管实现runtime_suspend/runtime_resume,资源聚合交给 genpd,顺序编排交给 PM Core。
这套分层还有个隐性好处:它让功耗问题变得可定位。因为每一层都有对应的 debugfs 节点,问题出在哪一层,看节点状态就能缩小范围。我现在的排查习惯是:先看/sys/power/state能不能写进去,再看/sys/kernel/debug/pm_genpd/哪个域没关,最后看具体设备的runtime_status和usage_count。三步下来,八成问题能定位。
最后分享一个我常用的验证脚本,系统 suspend 前后各跑一次,对比差异:
#!/bin/bash # pm_snapshot.sh - 抓取 PM 状态快照 echo "=== genpd ===" for d in /sys/kernel/debug/pm_genpd/*/; do echo "$(basename $d): $(cat $d/current_state)" done echo "=== clk ===" grep -E "enable_cnt" /sys/kernel/debug/clk/clk_summary | awk '$2 != 0' echo "=== wakeup ===" cat /sys/kernel/debug/wakeup_sources | awk '$6 != 0'suspend 前跑一次存成before.txt,resume 后跑一次存成after.txt,diff一下,哪个域、哪个时钟、哪个 wakeup 源状态不对,一目了然。这个脚本帮我省了无数次手动翻节点的时间,你也可以根据自己的平台改改直接用。