
1. 从一次待机电流超标说起DPM框架到底管什么做过嵌入式Linux功耗优化的朋友大概率遇到过这种场景整机在echo mem /sys/power/state之后用功耗仪一测待机电流比预期高了十几毫安甚至几十毫安。你翻遍了自己写的驱动suspend回调里该关的时钟关了该断的电源断了regulator也set_suspend_voltage了可电流就是下不去。最后抓出元凶往往不是你的驱动而是某个设备的suspend顺序不对、某个parent设备还没准备好就被子设备抢先挂起或者某个设备压根没被通知到。这些问题的背后站着同一个角色——DPMDevice Power Management设备电源管理框架。它是Linux内核功耗子系统里负责“系统睡眠”这一大块的核心调度器管的是当整个系统决定要睡下去的时候谁先睡、谁后睡、每个设备该做什么、醒来时又按什么顺序恢复。你可以把它理解成一场大型演出的舞台监督设备是演员suspend/resume是上下场而DPM负责排节目单、喊口令、处理突发状况。这篇是“Linux内核功耗子系统”系列的第十三篇前面我们把runtime PM、clock framework、regulator、genpd这些基础件都过了一遍现在终于到了把它们串起来的时刻。DPM框架本身代码量不算特别大但它是理解系统睡眠System Sleep也就是STR/STD/hibernation这一套的必经之路。如果你正在做嵌入式Linux项目、在调待机功耗、在写需要支持suspend的驱动或者单纯想搞明白dpm_list、dpm_suspended_list这些链表到底怎么转的那这篇就是给你准备的。我会按“设计思路→核心数据结构→suspend/resume完整流程→异步与顺序陷阱→常见问题排查”这条线来梳理尽量把内核源码里那些绕来绕去的链表操作讲成人话。文中涉及的具体函数名和结构体都以主流内核版本5.x/6.x为准不同版本细节会有差异但主干逻辑是一致的。2. DPM框架的整体设计与思路拆解2.1 为什么需要一个统一的设备电源管理框架在没有DPM之前系统睡眠的设备管理是散装的。每个子系统自己决定什么时候挂起自己的设备顺序靠约定同步靠运气。问题很快就暴露出来一个USB控制器挂起了但它下面的PHY还没挂起一个I2C控制器睡了但挂在它上面的传感器还想在suspend回调里发一笔传输。这种依赖关系如果靠每个驱动自己维护几乎不可能不出错。DPM的核心设计思想其实很朴素把设备之间的依赖关系显式建模成一棵树然后按树的顺序做拓扑排序保证子设备永远先于父设备挂起父设备永远先于子设备恢复。这个“树”就是设备树device tree注意这里说的是内核里的struct device构成的层次结构不是硬件描述用的那个Device Tree虽然两者经常对应。为什么是“子先父后”挂起、“父先子后”恢复道理和生活里关电脑一样你要关掉一台带外设的机器得先关显示器、关打印机这些挂在主机上的外设最后才关主机电源开机则反过来先给主机上电主机再去初始化外设。设备层面同理父设备比如一个总线控制器往往是子设备挂在总线上的器件工作的前提挂起时子设备先停父设备才能安全地停恢复时父设备先起来子设备才有条件恢复。2.2 同步与异步两种挂起路径的取舍DPM支持两种设备挂起方式同步synchronous和异步asynchronous。同步就是老老实实一个设备一个设备地走前一个的suspend回调返回了才处理下一个。异步则是把没有依赖关系的设备丢到工作队列里并行处理加快整体挂起速度。这个取舍很实际。系统里可能有几百个设备如果每个suspend回调平均耗时1ms同步走完就是几百毫秒用户按下电源键到屏幕熄灭的体感延迟就出来了。异步能把没有依赖的设备并行起来理论上能把总时间压到最长依赖链的长度。但异步的代价是复杂度你得保证有依赖的设备不会被并行处理得处理异步回调失败后的回滚还得保证dpm_list的顺序在异步场景下依然正确。内核里的做法是默认走异步路径async_suspend但通过dpm_list的拓扑顺序和async_synchronize_full()这类同步点来保证依赖。设备驱动可以通过device_enable_async_suspend()显式开启异步或者用pm_sleep_disable_async()之类的接口控制。实际项目里我一般建议先全同步跑通确认功能没问题再逐步对耗时长的设备开异步别一上来就全异步出了问题很难定位。2.3 DPM在系统睡眠中的位置要理解DPM得先知道它在整个系统睡眠流程里站在哪。系统睡眠以STRSuspend-to-RAM为例的大致流程是用户态写/sys/power/state触发state_store()。内核进入pm_suspend()先做dpm_suspend_start()也就是DPM开始挂起设备。设备全部挂起后进入dpm_suspend_end()做“late”阶段的挂起比如关中断、停非boot CPU。调用平台相关的enter()真正让SoC进入低功耗状态。唤醒后走dpm_resume_start()→平台恢复→dpm_resume_end()把设备逐个唤醒。可以看到DPM横跨了suspend和resume两大阶段而且分了“early”和“late”两个子阶段。early阶段处理大部分普通设备late阶段处理那些必须在最后才能挂起、最先恢复的设备比如中断控制器、时钟源。这个分阶段设计是为了给平台代码留出操作窗口属于DPM框架里比较容易被忽略但很关键的一环。3. 核心数据结构dpm_list与设备链表的三态转换3.1 dpm_list全局设备挂起顺序的总账DPM框架里最重要的数据结构就是dpm_list它是一个全局链表把所有参与电源管理的设备按拓扑顺序串起来。每个struct device里都有一个struct list_head power.entry就是挂在这个链表上的节点。dpm_list的顺序不是随便排的它遵循一个铁律父设备在子设备之前子设备在父设备之后。也就是说从链表头往尾走是先父后子挂起时从尾往头走先子后父恢复时从头往尾走先父后子。这个顺序在设备注册时通过device_pm_add()建立在设备移动或删除时通过device_pm_move_to_tail()、device_pm_remove()维护。我见过不少人在调试suspend顺序问题时第一反应是去翻自己的驱动其实更快的办法是直接看dpm_list的实际顺序。内核提供了/sys/kernel/debug/devices_deferred和/sys/power/pm_debug之类的调试接口配合dmesg里DPM打印的PM: suspend of device ...日志能很快定位到是哪个设备的顺序不对。3.2 dpm_suspended_list与dpm_prepared_list除了dpm_listDPM还维护了几个辅助链表用来记录设备当前处于哪个阶段dpm_prepared_list已经完成prepare阶段也就是-prepare回调返回成功的设备。dpm_suspended_list已经完成suspend阶段-suspend回调成功的设备。dpm_late_suspended_list完成了late suspend的设备。这几个链表的意义在于回滚。假设系统在挂起第100个设备时失败了DPM需要把前面99个已经挂起的设备恢复回来这时候就靠这些链表知道“哪些设备已经动过了”。没有它们回滚就无从谈起。这里有个容易踩的坑设备的prepare和suspend是两个独立阶段prepare阶段允许睡眠可以拿mutex、可以等IOsuspend阶段则要求尽量快、尽量不阻塞。很多驱动把耗时的准备工作放在prepare里把真正的硬件操作放在suspend里这个分工是对的。但如果你在prepare里做了修改设备状态的操作而prepare失败回滚时又没恢复就会留下脏状态。3.3 设备状态机与PM域每个设备在DPM眼里有一个状态大致可以分成DPM_ACTIVE正常运行、DPM_SUSPENDING正在挂起、DPM_SUSPENDED已挂起、DPM_RESUMING正在恢复。这些状态通过dev-power.status记录配合dev-power.lock自旋锁保护。另外DPM和**PM域PM Domaingenpd**是协同工作的。genpd负责一组设备的整体电源域管理DPM在遍历设备时会先处理设备所属的PM域。如果设备挂在某个genpd上DPM会调用genpd的-suspend/-resume由genpd决定域内设备的实际电源操作。这个协同关系是理解现代SoC功耗管理的关键因为很多SoC的电源域是硬件划分好的DPM只是软件层面的调度者。4. suspend流程全解析从prepare到late suspend4.1 prepare阶段允许睡眠的准备工作dpm_suspend_start()是DPM挂起的总入口它内部先调用dpm_prepare()。dpm_prepare()遍历dpm_list对每个设备调用device_prepare()最终触发驱动的-prepare回调。prepare阶段的特点是允许睡眠所以驱动可以在这里做需要等待的操作比如flush工作队列、等待DMA完成、获取mutex。这个阶段的目标是让设备进入一个“随时可以挂起”的状态但还没真正断电。一个典型的prepare实现长这样static int mydev_prepare(struct device *dev) { struct mydev *m dev_get_drvdata(dev); /* 停止接受新的IO请求 */ mutex_lock(m-io_lock); m-suspended true; mutex_unlock(m-io_lock); /* 等待正在进行的IO完成 */ flush_workqueue(m-wq); return 0; }注意这里用mutex是安全的因为prepare允许睡眠。但如果你在prepare里调用了pm_runtime_get_sync()之类的runtime PM接口要小心它可能触发runtime resume反而把设备唤醒造成逻辑冲突。我的经验是prepare里尽量只做“关门”动作别做“开门”动作。4.2 suspend阶段真正动手挂起硬件prepare全部成功后dpm_suspend()开始遍历dpm_suspended_list此时还是空的实际是遍历dpm_list的逆序对每个设备调用device_suspend()触发-suspend回调。suspend阶段的特点是不允许睡眠准确说是要求快速返回不能长时间阻塞所以驱动里不能用mutex要用spinlock或者干脆不加锁因为此时IO已经停了。这个阶段做的是真正的硬件操作关时钟、断电源、保存寄存器。static int mydev_suspend(struct device *dev) { struct mydev *m dev_get_drvdata(dev); /* 保存关键寄存器 */ mydev_save_regs(m); /* 关时钟 */ clk_disable_unprepare(m-clk); /* 断电源如果有独立regulator */ regulator_disable(m-vdd); return 0; }这里有个细节clk_disable_unprepare()和regulator_disable()都是可以睡眠的操作严格来说放在-suspend里不太合规。但实际内核里很多驱动这么干因为-suspend的“不允许睡眠”更多是约定而非硬性限制取决于CONFIG_PM_SLEEP的具体实现和平台。稳妥的做法是把这些操作放到-suspend_late或者-suspend_noirq里或者用clk_disable()这种非睡眠版本。这个取舍要看具体平台我在实际项目里一般遵循“能在prepare做的准备工作就prepare做suspend里只做最必要的硬件操作”。4.3 late suspend与noirq阶段最后的收尾dpm_suspend()完成后dpm_suspend_late()开始处理late阶段。这个阶段调用-suspend_late回调特点是中断已经被禁用准确说是非boot CPU的中断被禁用所以驱动里绝对不能做任何可能睡眠的操作也不能依赖中断。late阶段处理的是那些必须在最后挂起、最先恢复的设备典型的是中断控制器、时钟源、timer。这些设备如果早挂了系统就没法正常运转了。所以DPM把它们单独拎出来放在late阶段。再往后还有dpm_suspend_noirq()处理-suspend_noirq回调这个阶段连boot CPU的中断都关了是最底层的挂起。一般驱动不需要实现noirq回调除非你写的是非常底层的平台代码。4.4 挂起失败的回滚机制如果某个设备的-suspend返回了错误DPM不会直接放弃而是启动回滚把已经挂起的设备按相反顺序恢复回来。这个逻辑在dpm_suspend()里通过dpm_resume()实现遍历dpm_suspended_list此时里面是已经挂起的设备逐个调用-resume。回滚的正确性依赖前面提到的链表维护。如果链表顺序错了回滚顺序也会错可能导致父设备先于子设备恢复子设备在恢复时访问已经断电的父设备直接挂死。这也是为什么DPM对dpm_list的拓扑顺序要求这么严格。5. resume流程与异步处理的那些坑5.1 resume的对称性设计resume流程基本是suspend的镜像先dpm_resume_noirq()再dpm_resume_early()再dpm_resume()最后dpm_complete()。顺序上父设备先恢复子设备后恢复和挂起正好相反。这个对称性设计的好处是逻辑清晰坏处是任何在suspend里做的状态修改都必须在resume里对称地恢复。我见过最常见的bug就是suspend里把某个寄存器改了resume里忘了改回来结果设备唤醒后行为异常。调试这种问题的技巧是在suspend和resume里各打一条日志把关键寄存器的值dump出来对比一眼就能看出哪里不对称。5.2 异步resume的同步点异步resume比异步suspend更微妙。因为resume时父设备必须先于子设备恢复如果父设备的resume是异步的子设备的resume就必须等父设备完成。内核通过async_synchronize_full()和每个设备dev-power.async_suspend标志来管理这个依赖。具体来说如果一个设备开启了异步它的resume会被丢到异步队列但它的子设备在resume前会调用device_pm_wait_for_dev()等待父设备的异步操作完成。这个等待是通过dev-power.completion完成的父设备resume完成后会complete()子设备就能继续。这里有个坑如果父设备的异步resume卡住了比如等一个永远不会来的中断子设备就会一直等整个resume流程hang住。所以异步resume的调试关键是看dmesg里哪个设备的PM: resume of device ...日志没打出来那个设备就是卡住的地方。5.3 常见问题速查表现象可能原因排查方向待机电流偏高某设备未挂起或挂起不彻底看dmesg里是否有设备suspend失败检查dpm_list顺序resume后设备异常suspend/resume不对称dump关键寄存器对比检查时钟/电源是否恢复挂起过程hang住某设备suspend回调阻塞看最后一条PM: suspend of device日志定位卡住的设备异步resume卡死父设备异步未完成检查dev-power.completion看哪个设备没complete回滚失败链表顺序错误检查device_pm_add/move_to_tail调用是否正确6. 实操心得与避坑经验6.1 调试DPM问题的三板斧第一板斧是开日志。内核的CONFIG_PM_DEBUG和CONFIG_PM_SLEEP_DEBUG打开后dmesg里会有详细的设备挂起/恢复日志包括每个设备的名称、回调返回值和耗时。这是定位问题的第一手资料。第二板斧是看链表。通过/sys/kernel/debug/下的PM调试接口或者直接在代码里加dump_stack()打印dpm_list能确认设备顺序是否符合预期。顺序不对后面全是白搭。第三板斧是单设备隔离。如果怀疑某个设备有问题可以在它的-suspend里加msleep(1000)看系统挂起是不是卡在这里。这个土办法虽然粗暴但定位阻塞问题非常有效。6.2 写suspend/resume回调的几条铁律prepare里做减法suspend里做断电prepare负责让设备“安静下来”suspend负责真正断电。别在prepare里断电也别在suspend里做需要等待的操作。suspend和resume必须对称suspend里关了什么resume里就开什么顺序也要对称。建议把suspend和resume写成一对中间用注释标出对应关系。别在suspend里调用可能睡眠的函数mutex_lock、kmalloc(GFP_KERNEL)、wait_for_completion这些在suspend里都是雷。要用spin_lock、kmalloc(GFP_ATOMIC)。处理好错误路径suspend失败要能干净地回滚别留下半挂起状态。resume失败要能报告别默默吞掉。6.3 一个真实的排查案例之前有个项目系统挂起后待机电流正常但唤醒后触摸屏偶尔失灵。查了半天发现是触摸屏的-resume里先使能了I2C控制器但I2C控制器的-resume还没跑完因为异步导致触摸屏的初始化I2C传输失败。解决办法是给触摸屏设备调用device_pm_wait_for_dev()显式等待I2C控制器恢复完成。这个案例说明异步resume的依赖关系必须显式声明不能靠运气。7. 从DPM看系统睡眠的整体设计哲学DPM框架看起来只是一堆链表和回调的调度但它体现的设计哲学很值得琢磨。第一是显式建模依赖设备之间的父子关系不是隐含的而是通过dpm_list的拓扑顺序显式表达这样调度器才能做出正确决策。第二是分阶段处理prepare/suspend/late/noirq四个阶段把不同约束的操作分开让每个阶段只做自己该做的事。第三是可回滚任何阶段的失败都能干净回滚保证系统不会卡在半死不活的状态。这三点放到任何复杂的系统设计里都适用。我自己在写其他系统代码时也经常拿DPM的这套思路来对照依赖关系建模清楚了吗阶段划分合理吗失败能回滚吗想清楚这三个问题很多设计上的坑就能提前避开。DPM框架的代码值得反复读尤其是drivers/base/power/main.c这个文件它是整个系统睡眠设备管理的核心。读的时候别急着看细节先把握住dpm_list的流转和四个阶段的划分剩下的就是水到渠成的事。