干了这么多年Linux功耗管理,越到后面越觉得,真正拉开差距的往往不是某个驱动怎么调,而是系统里有条不紊的秩序。系统睡眠这个场景尤其明显:一觉睡下去,板子上所有设备都要在这几十毫秒内进入约定状态;有一个设备不配合,轻则睡眠失败,重则唤醒后摄像头烧掉、存储分区读不出来。而这套秩序的维护者,就是今天要聊的DPM框架(Device Power Management,设备电源管理)。
DPM是整个系统睡眠流程里的核心转运层。它不负责具体某款芯片怎么断电,而是负责“什么时候断、按什么顺序断、断了之后如何分级恢复”。这个框架对任何做嵌入式Linux、Android底层或者服务器功耗优化的人都值得吃透。文章会先从设计动机讲起,再拆链表结构、回调阶段和状态迁移,最后给出实际排查睡眠问题的三板斧。希望你看完能直接拿这套思路去分析自己的板子。
1. DPM框架解决什么问题
1.1 为什么“按顺序”关设备这么难
外行看系统睡眠,觉得无非是CPU进WFI、DDR自刷新,外围设备断电而已。但一台真实设备上,主控、存储、Wi-Fi/蓝牙模组、电源IC、传感器之间盘根错节:存储依赖eMMC控制器,控制器依赖时钟和稳压器,Wi-Fi模组的唤醒引脚又挂在某个GPIO上。如果你直接粗暴断电,轻则唤醒信号传不进来,重则设备正在写Flash时被掐电,数据损坏甚至固件丢失。
所以DPM框架的第一个使命,就是把“依赖关系”转换成可执行的顺序。挂起时先让依赖方停止,再让被依赖方停止;唤醒时严格反过来。例如SD卡控制器和SD卡本身,睡眠时一定是先让SD卡停止访问,再关控制器的时钟和电源。唤醒则必须先把控制器时钟打开,再去初始化卡。这套顺序在Linux里并不是靠驱动自觉遵守,而是靠DPM框架的链表遍历次序和父子关系强制保证的。
1.2 DPM在整个功耗子系统里的位置
很多初学者会把DPM和Runtime PM(运行时电源管理)混在一起。准确说:Runtime PM管的是设备“不用时自动关闭”的动态过程,比如USB设备空闲3秒自动挂起;DPM管的是系统级睡眠这种全局状态切换。在系统睡眠时,DPM会强制把所有设备纳入管理,不管它平时是否开了Runtime PM。
从代码层次看,Linux功耗管理大致分三层:cpuidle管CPU空闲,cpufreq管CPU频率,DPM则管设备群的整体下沉。再往下还有syscore机制,负责在设备全部停掉之后,处理时钟、电源域等最基础的“最后一批”节点。DPM框架主要实现在drivers/base/power/main.c,是Linux通用PM核心(PM Core)最主要的工作模块之一。
1.3 为什么拆成多个“小阶段”,而不是一把梭
DPM不是一次性把所有设备挂起,而是设计了多个小阶段,最典型的是suspend、suspend_late、suspend_noirq。这背后的原因很实在:不同阶段,系统对中断、时钟、电源的可用性不同,设备的可操作空间也不同。
| 阶段 | 中断可用性 | 驱动此时能做什么 |
|---|---|---|
| suspend | 中断可用 | 正常向设备发睡眠命令、保存上下文、停止DMA |
| suspend_late | 大部分中断可用,调度受限 | 处理需要关闭门控、清理总线的设备 |
| suspend_noirq | 中断基本掩蔽,中断控制器冻结前 | 最后一批设备,配置唤醒源、复位相关逻辑 |
设计成多阶段还有一个好处:唤醒时可以按严格的逆序恢复。最先挂起的设备最后恢复,最后挂起的设备最先恢复。这样能保证,任何设备恢复运行时,它所依赖的底层基础已经就绪。在深度睡眠(S3,suspend-to-RAM)和冻结(S2idle)两种模式下,这套框架是同一套,只是平台固件参与深度不同。
2. 硬核机制:全局链表、回调矩阵与状态迁移
2.1 DPM那几张全局链表到底在管什么
看DPM代码,首先接触的就是全局链表。它们不是简单的“所有设备列表”,而是设备睡眠状态的“分组标签”。设备挂起过程其实就是不停把自己从当前链表摘下来,挂到下一张链表上。内核里定义的核心链表有这几条:
LIST_HEAD(dpm_list); LIST_HEAD(dpm_prepared_list); LIST_HEAD(dpm_suspended_list); LIST_HEAD(dpm_late_early_list); LIST_HEAD(dpm_noirq_list);dpm_list:系统里所有注册设备的完整名单。设备注册时通过device_add()挂进来,睡眠期间的遍历都以这张表为起点。dpm_prepared_list:已经完成prepare阶段、允许进入睡眠的设备。dpm_suspended_list:完成常规suspend阶段、停在第一级睡眠状态的设备。dpm_late_early_list:完成late_suspend的设备,或者唤醒时已完成early_resume的设备,看当前方向。dpm_noirq_list:完成noirq挂起的设备。唤醒时这批设备会最先被恢复。
每次从dpm_list上摘节点、挂到下一张链表,都对应一次回调调用。如果某步失败,DPM会启动回滚,把已经挂到深处列表里的设备重新唤回正常状态。这个“摘下来、挂上去”的迁移动作,就是整个框架最核心的运转方式。
2.2 dev_pm_ops回调矩阵怎么选
驱动侧提供的电源管理回调,定义在struct dev_pm_ops里。常规睡眠、休眠到磁盘(hibernation)、系统关机这三种场景,会映射到不同的回调组合。
| 场景 | 挂起阶段回调 | 唤醒阶段回调 |
|---|---|---|
| 普通睡眠S3/S2idle | suspend、suspend_late、suspend_noirq | resume_noirq、resume_early、resume |
| 休眠到磁盘时保存镜像 | freeze、freeze_late、freeze_noirq | thaw_noirq、thaw_early、thaw |
| 休眠到磁盘后真正断电 | poweroff、poweroff_late、poweroff_noirq | restore_noirq、restore_early、restore |
并不是每个设备都需要实现全部回调。很多驱动只实现suspend和resume,框架会把其它阶段自动跳过。设备也可以选择在prepare阶段返回-EBUSY,主动拒绝进入睡眠。子系统级的PM回调优先级高于驱动自身回调,这是内核一贯的“子系统统管、驱动补充”思路。
2.3 状态迁移中的辅助变量
设备在睡眠过程中有几个关键标志位,排查问题时经常要跟它们打交道:
dev->power.is_suspended:设备当前处于挂起状态。dev->power.direct_complete:允许设备跳过部分睡眠流程,前提是它没有任何唤醒依赖。dev->power.may_async:允许异步睡眠。dev->power.wakeup_path:该设备是否处于唤醒路径上,决定唤醒源是否需要保持。
这些标志位会在链表迁移前后被设置或清除。调试时如果发现某个设备没有执行预期回调,第一件事就是查这几个标志有没有被异常置位。比如direct_complete被置位后,设备直接不走suspend,这在很多老旧驱动上会引发“明明睡眠了数据却丢了”的奇怪问题。
3. 一次完整睡眠中DPM经历的全过程
3.1 从echo mem到DPM接管
用户空间通常执行echo mem > /sys/power/state触发睡眠。这个动作最终走到kernel/power/suspend.c里的suspend_devices_and_enter()。这个函数会依次组织DPM各阶段、平台固件睡眠调用,以及唤醒后的DPM恢复。
大致调用链如下:
pm_suspend() → enter_state() → suspend_devices_and_enter() → dpm_suspend_start() // 内部包含 prepare + suspend + suspend_late → 平台固件进入睡眠(ACPI/ATF相关操作) → dpm_suspend_noirq() → 处理器真正进入低功耗态 → dpm_resume_noirq() → dpm_resume_end() // 内部包含 resume_early + resume + complete这里有个容易误读的点:并非所有平台都在同一个位置执行dpm_suspend_noirq。有的平台在关闭非引导CPU之后才调用,有的则在平台固件入口之前。理解这个差异,对阅读具体平台代码很有帮助。
3.2 三个挂起批次,每次翻转一批链表的顺序
DPM把设备挂起分批进行,每批结束,对应的全局链表内容就“翻转”一次。第一轮从dpm_list出发,反向遍历,逐个执行prepare和suspend。为什么反向?因为dpm_list是设备注册顺序,父设备先注册,子设备后注册。反向遍历可以保证后注册的子设备先挂起,符合“先挂起依赖方”的原则。
第二轮执行dpm_suspend_late,此时设备已经离开dpm_suspended_list,进入dpm_late_early_list。第三轮再执行dpm_suspend_noirq,设备进入dpm_noirq_list。唤醒时反过来,先从dpm_noirq_list正序遍历恢复,再逐级回到dpm_list。
3.3 异步睡眠是怎么保证不乱序的
现代内核为了提高睡眠速度,引入了异步挂起。设备在may_async标志允许时,不一定严格按照链表顺序等待,而是由工作队列并发执行。为了不破坏依赖关系,DPM引入了dpm_wait()机制:一个设备开始挂起前,会等待它依赖的父设备或消费者设备到达指定状态;没有依赖关系的设备才真正并行。
这意味着设备睡眠完成次序不再严格等于链表顺序,但依赖关系始终被保证。驱动开发者在写异步睡眠代码时,不要自己另搞一套并发控制,直接用框架的async_schedule()相关接口,否则很容易跟DPM的等待逻辑打架。
3.4 唤醒流程的“逆向恢复”逻辑
唤醒流程是挂起的严格镜像。从唤醒中断到达开始,内核先恢复noirq阶段的设备,再恢复late_early阶段的设备,最后恢复常规resume设备并执行complete。这样设计的原因也很简单:一个设备要工作,它依赖的控制器、时钟、电源域必须先准备好。比如网卡唤醒系统后,内核第一件事就是恢复中断控制器和时钟框架,接下来才轮到网卡驱动去查唤醒原因。
这里有一个很值得推荐的实践:设备驱动在resume回调里不要急着访问硬件,先用dev_pm_ops里的resume_early把最必要的时钟和电源恢复,再把复杂初始化丢给resume。这样能显著降低唤醒阶段的时序风险。
4. 挂起失败排查三板斧:日志、pm_test 与 ftrace
4.1 先看 dmesg,失败现场都写在里面
设备睡眠失败时,内核打印的日志含金量极高。最常见的格式是:
[ 123.456] PM: dpm_run_callback(): usb_dev_suspend+0x0/0x1c returns -110 [ 123.456] PM: Device 1-2: failed to suspend: error -110 [ 123.456] PM: Some devices failed to suspend, or early wake event detected看到这类日志,第一件事就是去查对应驱动在usb_dev_suspend里为什么会返回-110(超时)。多数原因是设备正在传输数据,或者固件没有在预期时间内回ACK。遇到这种问题,先检查该设备是否存在未完成的异步IO或DMA。
还有一个容易被忽略的点:如果系统开启CONFIG_PM_DEBUG,可以通过调试接口打开更细粒度的DPM日志:
echo enabled > /sys/power/pm_debug_messages开启之后,核心会打印每个设备的挂起、跳过、错误详情。这对定位“哪个设备拖慢了整个睡眠流程”非常有用。
4.2 用 /sys/power/pm_test 把睡眠拆成舞台剧
很多板子睡眠失败,问题并不在DPM本身,而是平台固件那一层。为了区分,内核提供了pm_test接口。它的原理是:在睡眠流程的不同位置主动打断,模拟“走到某一步就立即唤醒”,从而把问题范围缩小。
echo core > /sys/power/pm_test # 只做核心流程,不真正掉电 echo process > /sys/power/pm_test echo devices > /sys/power/pm_test echo platform > /sys/power/pm_test echo none > /sys/power/pm_test我习惯按“platform → devices → process → core”从外到内逐级测试。如果platform模式就失败,说明平台固件和内核对接有问题;如果devices模式失败,才轮到去查DPM里的具体设备。这个接口极大减少了“唤醒后谁也没日志”的排查黑盒。注意测试完毕务必恢复成none,否则真实睡眠流程会一直被打断。
4.3 用 ftrace 看设备睡眠时序
当问题指向DPM本身,比如“有个设备睡眠特别慢”或者“两个设备顺序错了”,我最常用的工具是ftrace。跟踪suspend_resume事件,可以直接看到设备在睡眠与唤醒时的时间戳。
cd /sys/kernel/tracing echo 0 > tracing_on echo 1000 > buffer_size_kb echo suspend_resume > set_event echo 1 > tracing_on sleep 5 echo mem > /sys/power/state echo 0 > tracing_on cat trace | grep "dpm\|suspend_time"通过打印出来的时间戳,能非常直观地看到哪个设备耗时最多。我遇到过某个触摸屏驱动睡眠要花800ms,整个睡眠流程被它拖到1秒以上。换用ftrace一眼锁定,后来发现是该驱动在suspend里做了无意义的固件刷新等待。
4.4 唤醒失败与唤醒源排查
唤醒问题的典型特征是“睡眠成功了,但怎么都唤不醒”。这时DPM已经不是主角,要重点看唤醒源配置。检查这些信息的快捷路径:
cat /sys/kernel/debug/wakeup_sources这个文件会列出所有注册的唤醒源、激活次数和最近一次唤醒时间。如果一个设备本应能唤醒系统,但该表里没有它,说明设备驱动没有正确注册唤醒源。另一类问题是唤醒中断被错误路由,设备虽然注册了唤醒源,但中断控制器在睡眠时把它屏蔽了。这种问题往往要用irq相关的trace去追。
4.5 常见问题速查表
| 症状 | 可能原因 | 建议动作 |
|---|---|---|
某个设备failed to suspend -110 | 设备忙、DMA未停止 | 检查异步IO,强制停流后再睡眠 |
| 睡眠耗时过长 | 某设备suspend回调等待太久 | 用ftrace锁定时序热点 |
| 唤醒后外设无响应 | 恢复顺序错误或未恢复电源域 | 检查resume_early是否恢复时钟/电源 |
direct_complete跳过回调导致状态丢失 | 设备被错误标记为可直接完成 | 检查设备依赖与dev->power.direct_complete |
| 唤醒中断到达但系统不醒 | 唤醒源未注册或中断路由问题 | 查wakeup_sources和中断控制器配置 |
pm_test恢复不了真实睡眠 | 测试模式未关闭 | 置为none |
5. 我在实际项目中反复踩过的几个坑
5.1 不看电源域,先把时钟关了
早年调试一个MMC设备睡眠问题,驱动在suspend里关闭了时钟,但电源域还在供电。结果唤醒后寄存器全读回来为0,驱动以为自己被复位了,重新初始化一遍,本来20ms的唤醒硬是拖到300ms。DPM框架只负责调设备顺序,它并不知道某个时钟对某个电源域意味着什么。真正了解硬件约束、确保时钟和电源域关闭顺序合理的,还是驱动自己。所以写睡眠回调前,先把SoC的电源域拓扑画清楚。
5.2 异步挂起的“假并发”问题
有段时间我为了让睡眠更快,给多个设备开启了async_suspend。结果发现每次睡眠都会随机出现某个设备丢唤醒事件。查到最后,原因是两个设备共享同一个电源域,异步挂起后它们的时序不再严格同步,一个设备把电源域关了,另一个设备还在发命令。框架的异步机制保证的是“依赖顺序”,不保证“同一电源域内的兄弟设备同步”。遇到共享电源域的设备,建议要么关闭异步挂起,要么在驱动里自己加同步锁。
5.3 唤醒源没勾选,整晚白睡
另一个深刻的教训:某次用户反馈设备功耗异常高,拿示波器一测,系统根本没进底层睡眠。查日志发现睡眠流程走到平台固件阶段就返回了,原因是有一个触摸屏把enable_irq_wake注册在了驱动加载阶段,但在睡眠前被Runtime PM自动关闭了电源域,导致唤醒中断物理上已经无法到达。这个问题看wakeup_sources根本查不出来,因为唤醒源还在,只是电源域没了。最后是在驱动的suspend_noirq阶段重新确保唤醒中断对应的电源域保持供电,才算解决。
5.4 调试DPM的一个心法
最后分享一个经验之谈:DPM框架本身已经很成熟,绝大多数问题都出在驱动对硬件状态的理解上。遇到诡异问题,不要急着怀疑框架,先问三个问题:设备当前在哪个链表?电源域和时钟是否满足当前阶段的要求?唤醒中断路径上有没有东西被提前掐断。把这三件事捋清楚,90%的睡眠问题都能定位到具体驱动。
我自己跟踪这个系列的时候,每次重读main.c里的链表操作都会有新的体会。DPM看着复杂,说到底就是一套“先挂起的先恢复、后挂起的先挂起”的秩序管理。你觉得它简单时,往往说明某个依赖在你没注意的地方埋了雷。