Linux DPM设备电源管理框架:系统睡眠挂起恢复调度机制与调试实战 1. 从一次待机唤醒失败说起DPM框架到底管什么去年冬天调一块嵌入式板子设备进入待机后按电源键没反应串口也死了只能拔电重启。当时第一反应是电源管理芯片配置有问题查了两天寄存器没找到毛病最后把/sys/power/state的写入流程从头跟了一遍才发现问题出在设备挂起顺序上——某个外设的-suspend回调里做了耗时操作把整个系统睡眠流程卡在了中间态。这件事让我意识到很多人做功耗优化时盯着CPU调频、盯着时钟门控却忽略了系统睡眠这条链路上真正起调度作用的角色DPMDevice 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_opsstruct 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这份文档值得反复读里面把各阶段回调的约束讲得很清楚比看代码猜要靠谱得多。