1. 从一次待机唤醒失败说起:DPM框架到底管什么
去年冬天调一块嵌入式板子,设备进入待机后按电源键没反应,串口也死了,只能拔电重启。当时第一反应是电源管理芯片配置有问题,查了两天寄存器没找到毛病,最后把/sys/power/state的写入流程从头跟了一遍,才发现问题出在设备挂起顺序上——某个外设的->suspend回调里做了耗时操作,把整个系统睡眠流程卡在了中间态。这件事让我意识到,很多人做功耗优化时盯着CPU调频、盯着时钟门控,却忽略了系统睡眠这条链路上真正起调度作用的角色:DPM(Device Power Management,设备电源管理)框架。
DPM是Linux内核功耗子系统里负责"系统级睡眠"的核心机制。它要解决的问题很具体:当系统决定进入Suspend-to-RAM、Suspend-to-Disk或者更深度的低功耗状态时,成百上千个设备不能一窝蜂地同时挂起,也不能随便挑一个先挂。谁先谁后、父子设备怎么协调、挂起失败怎么回滚、唤醒时按什么顺序恢复,这些都需要一套统一的规则来管。DPM就是这套规则的执行者。
这篇文章适合三类人看:一是做嵌入式Linux功耗优化的工程师,你迟早要跟dpm_list打交道;二是写设备驱动的开发者,你的dev_pm_ops回调什么时候被调用、被谁调用,DPM说了算;三是想深入理解内核电源管理子系统的学习者,DPM是绕不开的一环。我会把DPM的链表结构、状态机、挂起/恢复的完整调用链、以及实际调试中踩过的坑都摊开讲,尽量让你看完能直接对着代码和/sys节点复现。
需要先说明一点:DPM不是孤立的,它和Runtime PM、System Sleep、Wakeup Source这几个机制互相咬合。本文聚焦在系统睡眠(System Sleep)场景下DPM的调度逻辑,Runtime PM只在必要的地方带一句,避免话题发散。
2. dpm_list与dpm_suspended_list:两条链表撑起整个调度
2.1 设备是怎么被挂到dpm_list上的
内核里每个struct device都有一个成员叫power,类型是struct dev_pm_info。这个结构体里藏着DPM调度需要的所有信息,其中最关键的是两个链表节点:
struct dev_pm_info { ... struct list_head entry; /* 挂在dpm_list或dpm_suspended_list上 */ struct list_head sibling; /* 挂在父设备的power.children上 */ ... pm_message_t power_state; unsigned int can_wakeup:1; unsigned int async_suspend:1; ... };设备注册的时候,device_pm_add()会被调用,把设备的power.entry挂到全局链表dpm_list的尾部。注意这里是尾部插入,而且父设备一定比子设备先注册,所以dpm_list天然形成了一个"父在前、子在后的拓扑序"。这个顺序不是随便定的,它是后面挂起顺序的基础。
我见过有人问:为什么不用红黑树或者别的结构?答案很简单,设备挂起本质上是一个有依赖关系的线性序列问题,链表插入删除O(1),遍历O(n),对于几百上千个设备的系统完全够用,没必要上更复杂的数据结构。内核在这类地方一向务实。
2.2 dpm_suspended_list的登场时机
系统开始进入睡眠时,dpm_suspend()会遍历dpm_list,对每个设备调用其->suspend回调。每成功挂起一个设备,就把它从dpm_list摘下来,挂到dpm_suspended_list上。这个动作看着不起眼,但意义重大:
dpm_list上剩下的都是还没挂起的设备;dpm_suspended_list上都是已经挂起的设备;- 如果中途某个设备挂起失败,回滚时只需要遍历
dpm_suspended_list,把已经挂起的按相反顺序恢复,不用去碰还没挂起的部分。
这两条链表的关系,我用一个表格说清楚:
| 链表 | 挂载内容 | 遍历方向 | 典型使用场景 |
|---|---|---|---|
dpm_list | 所有已注册设备(未挂起的) | 正向(父→子) | 系统睡眠挂起阶段 |
dpm_suspended_list | 已成功挂起的设备 | 反向(子→父) | 挂起失败回滚、系统恢复阶段 |
dpm_prepared_list | 已完成prepare的设备 | 反向 | prepare失败回滚 |
dpm_off_list | 已关闭的设备(关机路径) | 反向 | 关机回滚 |
注意:
dpm_list的遍历方向是"从链表头到链表尾",而由于父设备先注册,头部的设备往往是根设备(比如PCI host bridge),尾部的设备是叶子设备。所以挂起顺序是根先挂、叶子后挂,恢复顺序反过来。
2.3 为什么挂起顺序是"父先子后"
这个顺序初看有点反直觉。直觉上你会觉得,应该先把叶子设备(比如USB键盘)挂起,再挂它的父控制器(USB host controller),最后挂根总线。但DPM的设计恰恰相反。
原因在于:父设备在挂起时,可能需要访问子设备来完成某些操作。举个实际例子,一个PCIe网卡在挂起时,它的->suspend回调里可能要读网卡寄存器保存状态,而这个寄存器访问要经过PCIe控制器。如果控制器先挂了,网卡就读不了寄存器了。所以必须父设备先挂起,但父设备挂起时不能切断对子设备的访问通路,等所有子设备都挂完了,父设备才真正进入低功耗。
这个逻辑在代码里的体现是:dpm_suspend()正向遍历dpm_list,但每个设备的->suspend回调只是"准备挂起",真正的电源切断往往在->suspend_late或者->suspend_noirq阶段。而恢复时反向遍历,先恢复父设备,让访问通路先通,再恢复子设备。
3. 系统睡眠的完整调用链:从write到/sys/power/state开始
3.1 用户态触发路径
用户态让系统进入睡眠,最直接的方式是:
echo mem > /sys/power/state这一行命令背后,内核走了一条相当长的路径。我把它拆成几个关键节点:
state_store()接收写入的字符串"mem",解析成PM_SUSPEND_MEM;- 调用
pm_suspend(PM_SUSPEND_MEM); pm_suspend()先做合法性检查,然后调用enter_state();enter_state()是核心,它依次调用suspend_prepare()、suspend_devices_and_enter()、suspend_finish()。
其中suspend_devices_and_enter()是DPM真正干活的地方。它的调用序列大致是:
dpm_suspend_start(PMSG_SUSPEND); // prepare + suspend dpm_suspend_end(PMSG_SUSPEND); // suspend_late + suspend_noirq syscore_suspend(); // 系统核心设备挂起 // ... 进入平台相关的低功耗代码 ... syscore_resume(); dpm_resume_start(PMSG_RESUME); // resume_noirq + resume_early dpm_resume_end(PMSG_RESUME); // resume + complete3.2 dpm_suspend_start做了什么
dpm_suspend_start()内部先调dpm_prepare(),再调dpm_suspend()。这两个阶段的分工是:
- prepare阶段:调用每个设备的
->prepare回调。这个回调的语义是"我要开始睡眠了,你检查一下自己能不能睡"。如果某个设备返回错误,整个睡眠流程在这里就中止,还没真正挂起任何设备,回滚成本最低。 - suspend阶段:调用
->suspend回调,设备开始保存状态、关闭部分功能。这个阶段失败的话,需要回滚已经挂起的设备。
我实际调试时发现,很多驱动作者会把耗时的操作放在->suspend里,比如写Flash、等硬件响应。这是不推荐的,因为->suspend阶段系统还在正常运行,中断还开着,耗时操作会拖长整个睡眠流程,还可能因为中断竞争出问题。正确的做法是把耗时操作放到->prepare里,或者用异步挂起(async_suspend标志)。
3.3 suspend_late与suspend_noirq的边界
dpm_suspend_end()调用dpm_suspend_late()和dpm_suspend_noirq()。这两个阶段的区别在于中断是否关闭:
suspend_late阶段:中断还开着,但其他设备可能已经挂了,不能依赖其他设备;suspend_noirq阶段:本地中断已关闭,不能睡眠,不能调用可能睡眠的函数。
这个边界非常重要。我踩过一次坑:在某个I2C设备的->suspend_noirq回调里调用了i2c_transfer(),结果系统直接卡死。原因是i2c_transfer()内部会睡眠等完成量,而noirq阶段不允许睡眠。后来把这段逻辑挪到->suspend里才解决。
经验:
->suspend_noirq回调里只能做寄存器读写、内存拷贝这类不会睡眠的操作。任何可能引起调度的函数调用都是禁忌。
4. 设备挂起回调的四种形态与选型逻辑
4.1 dev_pm_ops结构体全貌
一个设备驱动要参与DPM调度,需要提供struct dev_pm_ops:
struct dev_pm_ops { int (*prepare)(struct device *dev); void (*complete)(struct device *dev); int (*suspend)(struct device *dev); int (*resume)(struct device *dev); int (*freeze)(struct device *dev); int (*thaw)(struct device *dev); int (*poweroff)(struct device *dev); int (*restore)(struct device *dev); int (*suspend_late)(struct device *dev); int (*resume_early)(struct device *dev); int (*freeze_late)(struct device *dev); int (*thaw_early)(struct device *dev); int (*poweroff_late)(struct device *dev); int (*restore_early)(struct device *dev); int (*suspend_noirq)(struct device *dev); int (*resume_noirq)(struct device *dev); int (*freeze_noirq)(struct device *dev); int (*thaw_noirq)(struct device *dev); int (*poweroff_noirq)(struct device *dev); int (*restore_noirq)(struct device *dev); ... };看着很多,其实可以按两个维度分类:睡眠类型(suspend/freeze/poweroff)和阶段(普通/late/noirq)。系统睡眠(Suspend-to-RAM)走的是suspend系列,冬眠(Suspend-to-Disk)走的是freeze系列,关机走poweroff系列。
4.2 各阶段回调的职责划分
我把suspend系列四个阶段的职责整理成表:
| 回调 | 中断状态 | 能否睡眠 | 典型职责 |
|---|---|---|---|
->prepare | 开 | 能 | 检查设备状态,申请资源,拒绝睡眠 |
->suspend | 开 | 能 | 保存寄存器,关闭非必要功能 |
->suspend_late | 开 | 能 | 关闭时钟,准备断电 |
->suspend_noirq | 关 | 不能 | 最后的寄存器操作,进入低功耗 |
恢复时顺序完全相反:->resume_noirq→->resume_early→->resume→->complete。
4.3 什么时候该用哪个回调
这是驱动开发者最常问的问题。我的经验是:
- 如果只是保存几个寄存器,放
->suspend就够了; - 如果要关时钟、关电源域,放
->suspend_late; - 如果要在中断关闭后做最后的硬件操作,放
->suspend_noirq; - 如果只是检查能不能睡,放
->prepare。
有个反直觉的点:不是所有驱动都需要实现全部回调。很多简单设备只实现->suspend和->resume就能正常工作。内核的dpm_run_callback()会检查回调是否存在,不存在就跳过,不会报错。
但要注意,如果你实现了->suspend_noirq,就必须实现对应的->resume_noirq,否则恢复时状态对不上。这个对称性要求在内核文档里写得很清楚,但实际开发中还是有人漏掉。
5. 异步挂起:让睡眠流程快起来的双刃剑
5.1 async_suspend标志的作用
设备结构体里有个async_suspend标志,置位后该设备的->suspend回调会在一个工作队列里异步执行,不阻塞主流程。对于挂起耗时较长的设备(比如要等硬件响应的存储设备),这能显著缩短整体睡眠时间。
启用方式很简单,在驱动里:
device_enable_async_suspend(dev);或者在设备树里配置。但异步挂起有个前提:该设备不能有子设备依赖它的挂起顺序。因为异步执行意味着它的挂起完成时间不确定,如果子设备需要等它挂完才能挂,就会出问题。
5.2 异步挂起的同步点
内核用dpm_suspend()里的async_synchronize_full()来等待所有异步挂起完成。这个调用点很关键:它保证在进入suspend_late阶段之前,所有异步挂起的设备都已经挂完了。
我实测过一个场景:一块板子上有8个USB设备,全部启用异步挂起后,整体睡眠时间从120ms降到了45ms。但代价是调试变难了——如果某个设备挂起失败,错误信息可能和主流程的日志交错,不容易定位。
经验:异步挂起适合挂起耗时且独立的设备。如果设备之间有依赖关系,老老实实用同步挂起,别为了省几十毫秒引入难查的竞态问题。
5.3 异步挂起的错误处理
异步挂起失败时,错误码会被记录在设备的power.async_error里,主流程在async_synchronize_full()之后检查这个值。如果非零,整个睡眠流程会中止并回滚。这个机制保证了异步挂起不会"悄悄失败"。
但有个坑:如果异步挂起的设备在回调里睡眠太久,async_synchronize_full()会一直等,表现为系统卡在睡眠流程里不动。这时候用echo w > /proc/sysrq-trigger能看到工作队列的堆栈,定位是哪个设备卡住了。
6. 唤醒源与DPM的交互:谁能把系统叫醒
6.1 wakeup source的注册与使能
不是所有设备都能唤醒系统。设备要成为唤醒源,需要:
device_init_wakeup(dev, true);这会设置dev->power.can_wakeup标志,并把设备注册到唤醒源链表。用户态可以通过/sys/devices/.../power/wakeup查看和修改使能状态:
cat /sys/devices/platform/serial0/power/wakeup echo enabled > /sys/devices/platform/serial0/power/wakeup6.2 唤醒源在挂起流程中的特殊处理
DPM在挂起设备时,对唤醒源有特殊照顾。如果一个设备是唤醒源且已使能,它的->suspend回调不应该完全关闭设备,而要保留唤醒能力。这通常通过enable_irq_wake()来实现:
static int my_suspend(struct device *dev) { if (device_may_wakeup(dev)) enable_irq_wake(dev->irq); else disable_irq(dev->irq); return 0; }device_may_wakeup()会同时检查can_wakeup和wakeup使能状态。这个判断很重要,如果漏了,要么唤醒源失效,要么非唤醒源设备在睡眠中产生中断导致系统立即唤醒。
6.3 唤醒后的恢复顺序
系统被唤醒源叫醒后,DPM按dpm_suspended_list的反向顺序恢复设备。这里有个细节:唤醒源设备本身会最先被恢复,因为它的中断处理需要设备处于可用状态。内核在dpm_resume_noirq()里会优先处理唤醒源。
我遇到过一个问题:某个GPIO按键作为唤醒源,唤醒后按键事件丢失。排查发现是按键驱动的->resume_noirq回调里没有重新使能中断,导致唤醒后的第一次按键被漏掉。修复方法是在->resume_noirq里调用enable_irq()。
7. 调试DPM问题的实战手法
7.1 用ftrace跟踪挂起调用链
DPM的调用链很长,光看代码容易迷路。我习惯用ftrace抓实际执行路径:
cd /sys/kernel/debug/tracing echo function_graph > current_tracer echo dpm_* > set_ftrace_filter echo 1 > tracing_on echo mem > /sys/power/state # 唤醒后 cat trace > /tmp/dpm_trace.txt这样能看到每个dpm_*函数的调用顺序和耗时。如果某个设备的回调特别慢,在function_graph输出里会显示为一大块,一眼就能看出来。
7.2 查看设备挂起状态
/sys/kernel/debug/pm_genpd/和/sys/kernel/debug/devices_deferred能提供一些线索,但最直接的还是看dpm_list。内核没有直接导出这个链表,但可以通过/sys/devices/.../power/下的文件间接判断。
更实用的方法是打开CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG,然后:
cat /sys/power/pm_test # 输出类似:none core platform devices freezerpm_test可以让你在睡眠流程的某个阶段停下来,不真正进入低功耗,方便调试。比如:
echo devices > /sys/power/pm_test echo mem > /sys/power/state这样系统会走完设备挂起流程,但不进入平台低功耗代码,然后自动恢复。如果问题出在设备挂起阶段,用这个模式能稳定复现,不用担心唤醒失败。
7.3 常见问题与排查表
我把这些年遇到的DPM相关问题整理成表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 系统卡在睡眠流程 | 某个->suspend回调阻塞 | ftrace看哪个函数没返回 |
| 唤醒后设备不工作 | ->resume回调缺失或顺序错 | 检查dev_pm_ops对称性 |
| 睡眠后立即唤醒 | 非唤醒源设备产生中断 | 检查/sys/.../power/wakeup |
| 唤醒源失效 | enable_irq_wake未调用 | 检查device_may_wakeup分支 |
| 异步挂起超时 | 工作队列被阻塞 | sysrq-trigger看堆栈 |
7.4 一个真实的排查案例
回到开头那个待机唤醒失败的问题。我用pm_test模式复现后,ftrace显示卡在某个I2C触摸屏驱动的->suspend回调里。进去看代码,发现它在->suspend里调用了一个自定义的msleep(200)等触摸屏固件进入低功耗。问题是这个msleep在中断开启的情况下被其他中断打断,实际等了超过500ms,而系统的睡眠超时是300ms,导致流程被中止。
修复方案是把这200ms的等待挪到->suspend_late里,并且改用usleep_range(),同时把触摸屏的async_suspend打开,让它在后台等,不阻塞主流程。改完后待机唤醒正常,整体睡眠时间还缩短了。
这个案例的教训是:DPM回调里的任何延时都要慎重,能用异步就用异步,能挪到后面的阶段就往后挪。
8. 写驱动时容易忽略的几个DPM细节
8.1 回调的返回值语义
->prepare返回0表示可以睡眠,返回-EBUSY表示拒绝。但->suspend返回非零会导致整个睡眠流程回滚,这个代价很大。所以->suspend里应该尽量避免返回错误,能容错就容错。
我见过一个驱动在->suspend里检查某个可选硬件状态,状态不对就返回-EIO,结果导致整个系统无法待机。正确的做法是打印警告但返回0,让睡眠继续。
8.2 恢复顺序与资源释放
恢复时设备按反向顺序恢复,这意味着子设备先恢复,父设备后恢复。如果子设备的->resume里需要访问父设备的资源,就会出问题。所以驱动设计时要注意:->resume里不要依赖父设备已经完全恢复。
8.3 电源域的协同
现代SoC普遍有电源域(Power Domain)概念,多个设备共享一个电源域。DPM在挂起设备时,如果电源域里还有设备没挂,就不能关这个域。这个逻辑由genpd框架处理,但驱动作者需要确保自己的设备正确加入了电源域,否则可能出现"设备挂了但电源域没关"的功耗泄漏。
检查方法:
cat /sys/kernel/debug/pm_genpd/pm_genpd_summary输出会显示每个电源域的状态和其中的设备。
8.4 与Runtime PM的边界
系统睡眠时,DPM会先调用pm_runtime_resume()确保设备处于活跃状态,然后再走系统睡眠流程。这个设计是为了避免设备在Runtime PM挂起状态下再被系统睡眠挂起,导致状态混乱。
驱动里如果同时实现了Runtime PM和系统睡眠回调,要注意两者的状态机不要冲突。常见做法是在系统睡眠的->suspend里调用pm_runtime_disable(),在->resume里调用pm_runtime_enable()。
9. 关于DPM框架演进的一点个人观察
DPM框架从早期的简单链表调度,发展到今天支持异步挂起、电源域协同、唤醒源精细管理,代码量翻了好几倍,但核心思想没变:用一条有序链表把设备的挂起/恢复串起来,用阶段划分处理不同中断上下文的需求。理解了这两点,再看drivers/base/power/下的代码就不会迷路。
我个人的习惯是,拿到一个新平台做功耗调试,先看dpm_list的构建顺序,再看各设备的dev_pm_ops实现,最后用pm_test逐阶段验证。这套流程走下来,大部分睡眠问题都能定位。真正难查的是那些涉及硬件时序和竞态的边界情况,那种只能靠ftrace加打印一点点啃。
如果你正在调系统睡眠,建议先把CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG打开,这两个配置能省你很多事。另外,Documentation/driver-api/pm/devices.rst这份文档值得反复读,里面把各阶段回调的约束讲得很清楚,比看代码猜要靠谱得多。