1. 从一次待机电流超标说起:DPM框架到底管什么
做过嵌入式Linux功耗优化的朋友大概率遇到过这种场景:整机在echo mem > /sys/power/state之后,用功耗仪一测,待机电流比预期高了十几毫安,甚至几十毫安。你翻遍了自己写的驱动,suspend回调里该关的时钟关了,该断的电源断了,regulator也set_suspend_voltage了,可电流就是下不去。最后抓出元凶,往往不是你的驱动,而是某个设备的suspend顺序不对、某个parent设备还没准备好就被子设备抢先挂起,或者某个设备压根没被通知到。
这些问题的背后,站着同一个角色——DPM(Device 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,得先知道它在整个系统睡眠流程里站在哪。系统睡眠(以STR,Suspend-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_list,DPM还维护了几个辅助链表,用来记录设备当前处于哪个阶段:
dpm_prepared_list:已经完成prepare阶段(也就是->prepare回调返回成功)的设备。dpm_suspended_list:已经完成suspend阶段(->suspend回调成功)的设备。dpm_late_suspended_list:完成了late suspend的设备。
这几个链表的意义在于回滚。假设系统在挂起第100个设备时失败了,DPM需要把前面99个已经挂起的设备恢复回来,这时候就靠这些链表知道“哪些设备已经动过了”。没有它们,回滚就无从谈起。
这里有个容易踩的坑:设备的prepare和suspend是两个独立阶段,prepare阶段允许睡眠(可以拿mutex、可以等IO),suspend阶段则要求尽量快、尽量不阻塞。很多驱动把耗时的准备工作放在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 Domain,genpd)**是协同工作的。genpd负责一组设备的整体电源域管理,DPM在遍历设备时会先处理设备所属的PM域。如果设备挂在某个genpd上,DPM会调用genpd的->suspend/->resume,由genpd决定域内设备的实际电源操作。这个协同关系是理解现代SoC功耗管理的关键,因为很多SoC的电源域是硬件划分好的,DPM只是软件层面的调度者。
4. suspend流程全解析:从prepare到late suspend
4.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的流转和四个阶段的划分,剩下的就是水到渠成的事。