ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Linux内核DPM框架解析:系统睡眠中的设备电源管理实战

Linux内核DPM框架解析:系统睡眠中的设备电源管理实战 身边的人都知道我一直在折腾 Linux 内核功耗这块系统睡眠更是重灾区。前不久帮朋友看一块开发板明明按了睡眠键屏幕也关了可整机电流纹丝不动用热成像一扫某个触摸控制器芯片还在工作SCL 上还源源不断在吐时钟。最后定位下来就是驱动没有实现系统睡眠回调设备压根不知道自己该睡了。这个问题背后牵出来的正是 Linux 内核功耗子系统里最容易被忽略、却决定整机能否真正睡下去的 DPMDevice Power Management框架。今天这篇把 DPM 框架从头到尾撸一遍顺便聊聊我在实战里用它排查睡眠问题的一些经验和教训适合正在搞内核功耗、嵌入式 BSP 和电源管理的朋友参考。1. 睡眠不是断电DPM框架要解决的真实问题刚开始接触内核电源管理的人很容易把“系统睡眠”理解成“把电断了就行”。真这么干板子大概率是废了。系统睡眠尤其是 suspend to RAMSTRCPU 并没有完全断电内存还在保持供电只是所有外设必须停止对外产生总线活动。这要求系统中的每一台设备在睡眠前都处于一个“静止但可恢复”的状态。1.1 设备状态与系统睡眠状态的映射关系系统睡眠状态有好几级S1、S2、S3挂起到内存、S4挂起到磁盘。ARM 的 SoC 平台还有自己的一套 idle/suspend 分级。但不管哪一级内核都要求设备驱动实现一套dev_pm_ops回调告诉内核在系统睡眠的某个阶段你这台设备该干什么。设备本身也有电源状态的概念比如 PCI 设备用 D0、D1、D2、D3hot、D3cold 表示大多数外设在系统睡眠时至少要进 D3hot。不过要特别注意这套“系统睡眠”的回调跟平时驱动里用的运行时电源管理runtime PMautosuspend 那种完全是两码事。runtime PM 是设备在系统保持运行状态下自己进低功耗模式系统睡眠是整机所有设备一起按剧本走。DPM 框架管的就是后者——它是整机睡眠时设备级动作的总指挥。很多驱动作者只在 runtime PM 上做文章忽略了dev_pm_ops里的 suspend/resume结果就是“系统睡着了我还醒着”跟文章开头那个触摸控制器一个下场。1.2 没有DPM框架时的灾难场景想象一块板子上挂着多个设备它们共用一条 I2C 总线或者共用同一个电源轨。如果不按顺序控制可能出现这种情况触摸屏驱动在 suspend 时先关了自己的中断但 I2C 控制器后一步才停控制器还没来得及把 FIFO 里的数据清空主机这边时钟已经没了总线直接挂死。更糟的是电源轨先被切了设备还有 pending 的 DMA 传输没完成跑到一半的数据永远找不回来了。我自己遇到过一个最典型的案例eMMC 控制器在 suspend 时没做 flush cache 操作resume 之后文件系统直接报错整个根文件系统损坏到必须重刷。就是因为那个驱动作者图省事suspend 回调直接 return 0等于把闪存状态丢在半空中。DPM 框架统一编排设备暂停顺序、强制执行驾驶员回调、统计每个设备的等待时间就是为了尽量避免这类“设备各睡各的醒了各找各妈”的灾难。2. dpm_list是怎么搭起来的设备注册与四个阶段回调DPM 框架的核心数据结构不是设备本身而是挂在每个struct device上的dev-power类型是struct dev_pm_info。驱动模型在device_add()的时候会给设备的 power 字段初始化并把设备挂到全局链表dpm_list上。设备被删除时从链表摘下来。这就是 DPM 框架的地基一个记录了所有系统可见设备、按注册顺序排列的全局链表。2.1 dev_pm_info与dpm_list的构建细节dev_pm_info这个结构体平时不太起眼但它承担了 DPM 的核心状态管理entry链表节点、power_state当前电源状态、async_suspend看要不要走异步流程、is_prepared、is_suspended这类标志位。每个设备 PM 域struct dev_pm_domain、总线的dev_pm_ops、驱动本身的dev_pm_ops最后会汇总成一套可调用的回调。dpm_list链表在drivers/base/power/main.c中维护是一个全局双链表。新设备注册时device_pm_add把它追加到链表尾部所以链表的遍历顺序基本等价于设备的注册顺序。由于驱动模型本身是先注册父设备再注册子设备probe 父设备时才扫描总线枚举子设备所以dpm_list的顺序大体上是父在前、子在后。这个顺序在 suspend 阶段作用很大后面讲调用链时会重点说。2.2 四个阶段的回调用例为什么需要那么多回调dev_pm_ops里的回调数量不少看起来吓人实际上就是四组prepare/complete、suspend/resume、suspend_late/resume_early、suspend_noirq/resume_noirq。它们各自对应系统睡眠流程中不同阶段核心差别在于中断是否还开着、调度器是否还正常工作、哪些系统服务还能用。回调阶段调用时机中断/调度状态典型用途prepare睡眠准备初期进程还没完全冻结中断正常、可睡眠检查设备是否支持睡眠、阻止不可睡的条件suspendprepare 后进程已冻结中断正常、可睡眠保存寄存器、停止 DMA、flush cachesuspend_late二次暂停中断即将关闭中断正常、可睡眠但条件受限最后清理不紧急的硬件状态suspend_noirq本地中断已禁用中断已关、不可睡眠关掉会导致总线毛刺的设备、最后一条命令resume_noirq唤醒最开始中断还没恢复中断已关、不可睡眠最小化恢复让中断能安全打开resume_early中断未完全恢复时特殊阶段恢复时钟、电源域resume中断已恢复中断正常、可睡眠恢复完整设备状态、重启 DMAcompleteresume 完成后全部正常释放 prepare 阶段的资源、通知睡眠结束我第一次看这个表格就一个感觉至于搞这么复杂吗后来在公司调试一款量产设备时被现实教育了。有一台传感器在suspend阶段把中断关了但同一个中断控制器上还有一个网络控制器在suspend_late里才停如果网络控制器在关闭前发出一个中断传感器已经把中断屏蔽了这个中断就被吞掉resume之后网络控制器就一直卡在等待中断的状态。这时候就需要在 noirq 阶段再做一次统一收尾所以每个阶段都有存在的意义。3. 从prepare到noirq一条suspend请求的完整调用链理解了四个阶段接下来要看一条真实的 suspend 请求是怎么在 DPM 框架里穿针引线的。这里面的调用链直接决定了整个睡眠过程的体验时间——从用户按下睡眠键到设备完全静止往往就差在几台慢速设备上。3.1 suspend路径dpm_suspend_start到dpm_suspend_end系统睡眠的入口在kernel/power/suspend.c的suspend_enter()它做的事大致是先冻结用户态进程和内核线程然后挂起控制台接着进入 DPM 已经在等着的 world调用dpm_suspend_start()。dpm_suspend_start()实际上做了两件事内部先调dpm_prepare()再调dpm_suspend()。dpm_prepare()遍历dpm_list逐个调用设备的prepare回调如果这台设备准备失败比如返回-EBUSY会把它从后续的 suspend 列表里剔除并标记失败打印类似PM: Device xxx failed to prepare: error -16的错误。dpm_suspend()继续遍历剩余设备调用suspend回调把设备从运行状态带到睡眠状态。这一步做完所有设备已经进入第一层睡眠状态。接下来更关键dpm_suspend_end()实际上是dpm_suspend_late()加dpm_suspend_noirq()。这两个阶段会在syscore_suspend()之前完成把系统中最后一批还能动的设备停住然后才关非引导 CPU、进入平台自己的低功耗寄存器设置。整个流程顺序我们可以理成下面这样冻结进程、挂起控制台dpm_prepare()所有设备 prepare收集睡眠意愿dpm_suspend()所有设备 normal suspend保存寄存器、停 DMAdpm_suspend_late()晚阶段暂停dpm_suspend_noirq()关中断后暂停syscore_suspend()核心系统寄存器保存关闭非引导 CPU进入平台睡眠如 ACPI S3在dpm_suspend()里有一个容易被忽视的细节遍历是沿着dpm_list从头到尾正序进行的所以先注册的设备先 suspend。前面提到父设备先注册因此自然形成了“父设备先睡子设备后睡”的顺序这个顺序可以确保 USB 控制器父先停挂在上面的鼠标键盘子再停不会出现子设备还在发请求、父设备已经断电的尴尬情况。3.2 resume路径dpm_resume_end的逆序恢复唤醒流程与 suspend 是镜像的。平台相关代码先从睡眠状态返回恢复 syscoresyscore_resume()然后调用dpm_resume_end()。这个名字很迷惑它其实包含了四级操作dpm_resume_noirq()、dpm_resume_early()、dpm_resume()、dpm_complete()。一个重要差异resume 遍历dpm_list用的是逆序也就是从链表尾向头走。原因很直白——后睡的先醒。子设备挂在父设备后面所以它们会先被 resume等子设备已经把基本状态恢复好了父设备再起来去服务它们就不会出现父设备已经恢复发现子设备还没起来导致枚举时序错乱的问题。逆序恢复的另一个好处在异步场景中尤其明显。多个设备并行恢复时如果抱枕关联逆序可以减少等待时间这在下一个章节专门展开。4. resume的逆序逻辑与异步加速设备依赖怎么保证每次系统唤醒肉眼可见的延迟大多来自设备 resume 时间。尤其是存储类设备有的 eMMC 初始化要上百毫秒WiFi 模块更夸张。DPM 提供了一条加速路径异步 resume。但它不是免费的午餐设备之间的依赖关系搞错了轻则时序漂移重则协议错误。4.1 你看到的是“一起醒来”async_suspend 的实现驱动可以通过调用device_enable_async_suspend()或dev_pm_set_async()在自己的 probe 阶段把自己标记为允许异步 suspend/resume 的设备。框架内部利用async_schedule_domain()把设备的 suspend/resume 任务放进一个异步域里并行执行。内核给了一个总开关/sys/power/pm_async写 0 关闭所有异步写 1 开启。系统默认是开启的。这个开关特别适合排障遇到时序问题第一时间关掉异步如果问题消失那基本可以断定是异步并发导致两个设备打架了。我自己在板子上实测过一次开 async 的情况下一个 SD 卡控制器和一个 eMMC 控制器同时 resume整机唤醒时间从 320ms 降到 210ms省了 110ms。这两个设备挂在不同总线上没有任何资源竞争属于典型的适用场景。但如果两个设备共享一条 I2C 总线又都标记了 async_suspend那并发访问同一总线控制器问题就来了——I2C 控制器自己还在恢复过程中两个客户端就已经往上面提交传输请求了。4.2 设备依赖怎么表达dpm_wait 与 device_linksDPM 不是傻乎乎一股脑并行。框架内部有dpm_wait()等待机制在 resume 的每一阶段设备会先等待它依赖的“供应方”完成对应动作。这个依赖关系在现代内核中主要来自device_links设备链接也就是设备模型中 supplier-consumer 关系。比如一个 MIPI DSI 显示面板依赖 DSI 控制器、背光控制器又依赖电源管理 IC链接关系建立后DPM 在恢复时会让依赖方先走被依赖方后走。设备链接这块DL_FLAG_PM_RUNTIME标志管的是 runtime PM而系统睡眠 DPM 流程中无论有没有显式的链接框架还会通过dpm_wait()做一轮“等异步域里所有任务排空”的栅栏操作保证同一个阶段的异步操作都完成才会进下一个 noiraq/resume_early 阶段。这里给个排障经验如果你在某个设备 rake 里看到它卡住先查它和相邻设备之间是否存在device_links没有的话思考 async 并发是否把一个“隐藏的依赖”放大了——这种问题用dmesg里打印的顺序不一定能直接看出来要靠 device_del 和 firewall 模式去对照验证。为了避免这些坑我现在的习惯是新板子 bringup 阶段先把/sys/power/pm_async关掉等所有设备的 suspend/resume 都稳定了再逐个设备开异步每次开一个实测一轮唤醒时间确认没有副作用再开下一个。不要一把梭哈全开不然出了问题真的不知道是哪两个设备在打架。5. suspend和hibernate的DPM差异两条路径不是一回事这里必须专门澄清一个容易混淆的概念suspendSTR和 hibernateSTD在 DPM 框架里的处理方式完全不同。写驱动的时候不能指望一套回调走天下否则在 hibernate 路径上会出现莫名其妙的问题。5.1 两条路径的设备处理流程对比suspend 流程前面已经详细说了它是一条“暂停一切外部活动但寄存器状态还保留在设备里”的路径。hibernate 则复杂得多它要把整个内存镜像写到磁盘然后完全断电这需要设备配合两次 DPM 流程。拿 x86 ACPI 平台举例hibernate 大致是这样走的冻结进程创建内存镜像此时设备还是活的第一次dpm_suspend_start()dpm_suspend_end()暂停设备然后快照内存写入 swap/镜像文件写入完成后设备重新dpm_resume_end()短暂恢复再次冻结进程第二次dpm_suspend_start()dpm_suspend_end()设备再次全部暂停完全断电S5对比项suspend (STR)hibernate (STD)设备暂停次数1 次2 次镜像前后设备恢复次数1 次2 次寄存器是否保留在设备中保留resume 后快速恢复最终会掉电第二次 suspend 后不恢复驱动要求保存/恢复关键寄存器要能接受多次挂起/恢复循环典型嵌入设备使用率高低从驱动角度看最难受的是第 4 步和第 2 步之间。第一次暂停设备后驱动把设备状态保存好了结果内核写镜像又要把设备恢复一遍然后马上再暂停一次。如果驱动里 static 变量、状态机没有处理好第二次 suspend 时可能基于脏状态执行表现就是 hibernate 偶尔成功、偶尔失败指示灯和打印都正常就是某些设备第二次恢复时行为诡异。5.2 hibernate路径上常见的驱动翻车现场说实话嵌入式 Linux 平台上大量产品直接弃用 hibernateDPM 框架本身没毛病主要是驱动扛不住。常见的翻车点是网络控制器和显示控制器。WiFi 模块往往有自己的固件状态和 PCIe 链路训练状态第一次 resume 之后固件运行得好好的第二次 suspend 时驱动以为它还在第一次 suspend 的低功耗状态直接跳过初始化结果掉电后固件丢失resume 完发现网卡怎么都起不来。显示控制器类似内存中保存的 framebuffer 内容在第二次 suspend 后其实已经不需要了但驱动还在尝试刷新导致死等。我的建议很简单如果你的产品不需要 hibernate在内核配置里直接关掉CONFIG_HIBERNATION不要留隐患如果必须支持那就把驱动里的 suspend/resume 写成幂等的允许连续调用两次并且在第二次 suspend 时不要依赖第一次残留的设备状态全部重新读硬件寄存器来决策。6. 实战定位pm_print_times、suspend_stats与常见睡眠失败讲了半天框架最终还是要落地到“怎么用它解决问题”。系统睡眠出问题最有代表性的症状就是睡不下去、睡不深、睡下去醒不来。这里分享一套我每次必用的调试流程基本能覆盖大多数场景。6.1 打开pm_print_times每个设备睡眠耗时一目了然DPM 框架自带一个非常实用的调试开关/sys/power/pm_print_times或者内核启动参数pm_print_times1。打开之后每次 suspend/resume 时dmesg会打印每台设备的操作耗时格式类似PM: 周公之礼 mmc0:0001 suspend, latency 85 ms PM: usb 1-1 resume, latency 12 ms这个输出可以精确告诉你一台设备在某次 suspend/resume 到底花了多少毫秒是谁在最前面占了最长的时间。加上initcall_debug还能看到驱动初始化顺序两者对照就能判断是不是设备初始化时序和睡眠时序互相影响。另一个文件是/sys/power/suspend_stats里面记录了历史睡眠失败次数、失败阶段、最后一个失败设备。我碰到过一种很隐蔽的问题设备级别 suspend 始终成功但系统还是睡不下去最后看 suspend_stats 发现是suspend_noirq阶段一次超时导致整个 suspend 被中止这个超时设备往往在 pm_print_times 里打印的时间特别长比如超过 100ms 还迟迟不返回然后才触发错误。6.2 三个真实案例睡眠失败的常见原因案例一某 I2C 触摸屏控制器suspend()回调里为了读一个寄存器把 I2C 传输函数调了进来但 I2C 控制器比它先被 suspend前文说过父先子后于是触摸屏在一个停机的控制器上做传输直接超时返回-ETIMEDOUT整个系统睡眠被中止。解决方法是把触摸屏的寄存器读取放到prepare阶段或者干脆不做。案例二USB 设备 wakeup source 配置不合理。wakeup_source还挂着设备在 suspend 过程中不断触发中断系统刚进睡眠就被唤醒看起来“睡住了但又秒醒”。这种问题 DPM 本身看不出来要用cat /sys/kernel/debug/wakeup_sources查哪个源还在活跃计数。案例三suspend回调里调用了msleep()甚至调用了printk()结果打印串口的驱动也已经被暂停了printk 阻塞在串口 FIFO 上。这个典型到我都懒得吐槽内核睡眠路径上是禁止做这类事的因为锁的顺序和进程上下文都跟正常运行不一样。遇到这种情况看dmesg最后一条日志停在哪个设备基本就能锁定。6.3 给自家驱动补全DPM回调的最小操作如果你手头的驱动完全没有实现dev_pm_ops至少要补齐以下几点一是 suspend 里把 DMA 停掉、把 pending 中断清干净二是 resume 里重新初始化硬件到可用状态三是别在回调里 sleep用mdelay代替或者直接返回错误让系统睡眠中止但不要死等四是如果驱动里拿了某个锁确保 suspend/resume 路径上的锁顺序一致。static const struct dev_pm_ops mydev_pm_ops { .prepare mydev_prepare, .suspend mydev_suspend, .resume mydev_resume, .complete mydev_complete, .suspend_late mydev_suspend_late, .resume_early mydev_resume_early, }; static struct platform_driver mydev_driver { .driver { .name mydev, .pm mydev_pm_ops, }, };挂在.driver.pm里的这套 ops 会在系统睡眠流程中被 DPM 框架自动调用。如果你的设备是挂在某个子系统I2C、SPI、PCI下的子系统总线驱动通常会提供一层默认 PM 逻辑你只需要在struct i2c_driver或同类结构里的.driver.pm把它们挂好即可。我在实际调试中最大的体会是DPM 框架本身其实不难它的价值在于逼着驱动作者把“设备状态机”和“系统睡眠状态机”对齐。写新驱动时先实现 system sleep 的 suspend/resume再去优化 runtime PM 的 autosuspend顺序反了就特别容易翻车。最后再分享一个小技巧遇到睡眠问题先看dmesg里的时序别急着翻代码看返回值——大多数 sleep 问题到最后都是顺序问题和超时问题这两者用pm_print_times一眼就能区分开。
返回列表