1. 从一次待机功耗异常说起:PM Core 到底管什么
几年前接手一个 ARM 平台的工控板项目,整机在待机状态下比预期多耗了将近 80mW。硬件同事反复测了电源树,确认各路 DCDC 的静态电流都正常,最后问题落到软件侧。我那时候对 Linux 功耗子系统的理解还停留在"调调cpuidle的 governor、改改opp表"的水平,结果用ftrace抓了一遍suspend流程才发现,设备在进入系统级休眠之前,有一批驱动压根没走runtime PM的引用计数释放,导致PM Core认为设备仍然活跃,整个system suspend被推迟了好几百毫秒,期间 CPU 一直卡在较高的频率上。
那次排查让我意识到一件事:功耗问题很少是某一个驱动写错了,而是整个分层框架里某一层的契约没被遵守。Linux 的功耗子系统(Power Management Subsystem)之所以设计成分层结构,本质上就是为了让"策略"和"机制"解耦——上层决定"什么时候省电",下层决定"怎么省电",中间由 PM Core 做调度和仲裁。这个分层如果理解不透,你调任何单个参数都是盲人摸象。
这篇内容面向的是已经能看懂基本内核代码、做过驱动开发或者系统移植的工程师,尤其是做嵌入式、移动端、工控设备的同行。我会从 PM Core 这个"中枢"切入,把整个功耗框架的分层设计拆开讲清楚:每一层负责什么、层与层之间的接口是什么、为什么这么分、以及在实际项目里怎么顺着这个分层去定位问题。关键词里的 Linux、PM Core、功耗子系统、内核、分层设计,基本就是本文的主线。
需要先说明的是,本文涉及的代码结构以主流内核版本(5.x 到 6.x 区间)为参考,不同版本在细节上会有差异,但分层思想是稳定的。我不会贴大段源码,而是把关键结构体和调用关系讲明白,方便你对照自己手上的代码去看。
2. PM Core 在功耗框架里的坐标:它既不是策略也不是驱动
2.1 一个容易被误解的定位
很多人第一次接触drivers/base/power/这个目录时,会以为 PM Core 就是"功耗功能的实现"。其实不是。PM Core 更像是一个仲裁者和协调者,它自己不决定"该不该休眠",也不直接操作硬件寄存器,它做的是三件事:
- 维护设备与电源管理相关的状态(
dev->power这一坨结构); - 提供统一的回调调用框架(
dev_pm_ops里的suspend、resume、runtime_suspend等); - 在系统级电源状态切换时,按正确的顺序遍历设备树,调用各驱动的回调。
换句话说,PM Core 是"规则制定者 + 流程调度者",真正的省电动作是驱动和平台代码干的。理解这一点非常关键,因为它决定了你排查问题的方向:如果功耗没降下去,先别怀疑 PM Core,先看驱动有没有正确实现回调、有没有正确管理引用计数。
2.2 分层视角下的四个角色
把整个功耗框架按职责纵向切开,大致是这么四层:
| 层次 | 代表组件 | 核心职责 |
|---|---|---|
| 策略层 | cpuidlegovernor、cpufreqgovernor、用户空间接口 | 决定"何时"进入低功耗状态 |
| 框架层(PM Core) | drivers/base/power/、device_pm、dpm_list | 仲裁、排序、统一回调入口 |
| 平台/SoC 层 | platform_suspend_ops、soc_ops、PSCI、ACPI | 提供"怎么进入"的底层操作 |
| 设备驱动层 | 各dev_pm_ops实现 | 保存/恢复上下文、关闭时钟和电源域 |
这四层里,PM Core 处在中间,向上给策略层提供设备状态信息,向下给驱动层规定回调契约。它不越界,但它是所有跨层交互的必经之路。你调echo mem > /sys/power/state的时候,最终触发的那条调用链,就是穿过这四层的。
2.3 为什么必须分层:一个反例
假设没有分层,让每个驱动自己决定什么时候关电、什么时候休眠,会发生什么?最直接的问题是顺序。一个 USB 控制器要休眠,必须先让它下面的 PHY 进入低功耗,再关控制器的时钟;而 PHY 的电源又可能来自某个 PMIC,PMIC 的 I2C 通信又依赖某个 GPIO 控制器还活着。这些依赖关系如果让驱动各自为政,几乎必然出现"先关了电源,后面还想通信"的死锁。
分层之后,PM Core 通过dpm_list这个全局设备链表,按照"先子后父"的顺序做 suspend、反序做 resume,把顺序问题集中管理。这就是分层的价值:把复杂的全局约束收敛到一个地方,让每个驱动只需要关心自己那一小块。
3. 设备模型与 dpm_list:PM Core 的调度骨架
3.1 dpm_list 是怎么排出来的
PM Core 的核心数据结构之一是dpm_list,它是一个全局链表,记录了所有参与电源管理的设备。这个链表不是随便排的,它的顺序由设备在设备树/设备模型中的父子关系和注册顺序共同决定。
设备注册时,device_pm_add()会把它挂到dpm_list的尾部。但真正决定 suspend 顺序的是dpm_list在系统 suspend 前会被dpm_prepare()和dpm_suspend()重新整理。核心规则是:子设备排在父设备前面。这样 suspend 时从链表头往尾走,就是先挂起子设备、再挂起父设备;resume 时反过来,先恢复父设备、再恢复子设备。
这个规则听起来简单,但实际项目里出问题往往就出在"父子关系没建对"。比如一个 I2C 从设备,如果它的parent没有正确指向 I2C 控制器,PM Core 就不知道它俩的依赖关系,suspend 顺序就可能错乱,表现为"resume 时 I2C 通信超时"。
3.2 设备电源状态与运行时状态的区别
这里必须区分两组概念,很多新手会混淆:
- 系统级电源状态:
system suspend(如mem、standby、freeze),影响的是整机; - 运行时电源状态:
runtime PM,影响的是单个设备,与整机状态无关。
PM Core 对这两套流程都有支持,但走的是不同的路径。系统级 suspend 走dpm_suspend()系列,运行时 PM 走pm_runtime_*()系列。两者共享dev_pm_ops里的回调,但触发时机和上下文完全不同。
注意:
runtime PM的回调(runtime_suspend/runtime_resume)可能在中断上下文之外被调用,而系统级 suspend 的回调在 suspend 线程里执行,两者的约束不一样。写驱动时不能假设它们可以互换。
3.3 引用计数:runtime PM 的命门
runtime PM靠引用计数(usage_count)来决定设备是否可以进入低功耗。每次pm_runtime_get()让计数加一,pm_runtime_put()减一,减到零且没有其他阻塞条件时,PM Core 才会尝试调用runtime_suspend。
我踩过的最典型的坑就是引用计数泄漏:某个错误处理分支里pm_runtime_get_sync()之后直接return,忘了put。结果设备永远"忙",永远不休眠。这种问题在功能测试时完全看不出来,只有测功耗才会暴露。排查手段是看/sys/kernel/debug/pm_genpd/或者各设备的runtime_status和runtime_usage节点。
4. dev_pm_ops:驱动与 PM Core 之间的契约
4.1 回调集合的全貌
dev_pm_ops是驱动向 PM Core 注册的回调集合,主要成员包括:
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 (*runtime_suspend)(struct device *dev); int (*runtime_resume)(struct device *dev); int (*runtime_idle)(struct device *dev); };这一堆回调不是随便调的,它们对应系统 suspend 的不同阶段。prepare在最早调用,用来做可能失败的前置检查;suspend是主回调;suspend_late在中断关闭后调用,用来做最后的硬件操作;resume_early和resume是恢复路径的对应阶段。
4.2 为什么要有 prepare 和 complete
prepare的存在是为了解决一个现实问题:有些操作可能失败,而失败必须发生在"不可回退"的动作之前。比如一个存储设备,如果它还有未刷新的数据,suspend 前必须先把数据落盘,这个动作可能失败(磁盘错误),所以放在prepare里,失败了就中止整个 suspend 流程。
complete则是prepare的配对,在 resume 完成后调用,用来做收尾。这种"准备-执行-收尾"的三段式设计,是内核里处理"可能失败的长流程"的常见模式,理解了这个模式,你看其他子系统也会觉得眼熟。
4.3 回调返回值的语义
回调的返回值不是随便返回的,PM Core 会根据返回值决定流程走向:
- 返回
0:成功,继续; - 返回负数:失败,中止当前流程并回退;
- 某些回调返回
1有特殊含义(比如表示"已处理,跳过后续")。
我见过有驱动在runtime_suspend里返回-EBUSY来表示"我现在不能休眠",这本身没错,但如果这个-EBUSY是因为引用计数没释放导致的,那就会陷入"永远不休眠"的循环。所以返回错误码之前,一定要想清楚这个错误是不是暂时的、会不会自愈。
5. 系统级 suspend 的完整调用链拆解
5.1 从写 sysfs 到进入 suspend
用户空间echo mem > /sys/power/state之后,内核侧的流程大致是:
state_store()解析字符串,找到对应的sleep_state;- 调用
pm_suspend(),进入enter_state(); suspend_prepare()阶段:冻结进程、同步文件系统、调用dpm_prepare();suspend_devices_and_enter()阶段:调用dpm_suspend()、关闭中断、调用平台suspend_ops->enter();- 唤醒后走
dpm_resume()和dpm_complete()恢复。
这条链里,PM Core 负责的是第 3、4 步里的dpm_*部分,平台相关的suspend_ops由 SoC 代码提供。
5.2 dpm_suspend 内部做了什么
dpm_suspend()会遍历dpm_list,对每个设备调用device_suspend(),后者再根据设备是否支持 runtime PM、是否有dev_pm_ops,决定调用哪个回调。这里有个细节:如果设备已经处于 runtime suspended 状态,系统 suspend 时可能跳过它的suspend回调,因为 PM Core 认为它已经省电了。这个优化在大多数情况下是对的,但如果驱动的runtime_suspend和system suspend做的事情不一样,就可能出问题。
5.3 平台层的 enter 回调
真正让 SoC 进入低功耗状态的是platform_suspend_ops->enter()。在 ARM 平台上,这通常最终走到 PSCI 的CPU_SUSPEND或者 SoC 自己的电源管理固件接口。这一层是"机制"的最底层,PM Core 不关心它怎么实现,只要求它返回后系统能正常恢复。
提示:调试 suspend 问题时,如果卡在
enter()之后回不来,基本可以判定是平台层或固件的问题,跟 PM Core 和驱动无关。这时候要看的是 SoC 的电源状态配置和唤醒源设置。
6. 顺着分层定位问题:三个真实场景的排查思路
6.1 场景一:系统 suspend 被无限推迟
现象是echo mem之后系统迟迟不进入休眠。排查顺序应该是从上层往下层:
- 先看
/sys/power/state是否可写、/sys/power/pm_test是否被设成了测试模式; - 用
ftrace的pm_*事件看dpm_suspend卡在哪个设备; - 找到设备后,看它的
runtime_status和usage_count,判断是不是引用计数没释放; - 如果是,回到驱动代码找
pm_runtime_get和put的配对。
这个顺序的逻辑是:PM Core 的调度是确定的,卡住一定是某个设备的回调没返回或返回了错误,所以定位到设备就成功了一半。
6.2 场景二:resume 后设备不工作
这类问题通常是 resume 顺序或上下文恢复不完整导致的。重点检查:
- 设备的
parent关系是否正确(决定 resume 顺序); resume回调里是否恢复了所有必要的寄存器;- 是否依赖了某个还没恢复的父设备(比如 I2C 控制器)。
我遇到过一次 resume 后触摸屏失灵,最后发现是触摸屏驱动的resume里调用了 I2C 读,但 I2C 控制器的resume排在它后面。修正方法是在设备树里把触摸屏的parent正确指向 I2C 控制器,让 PM Core 排出正确顺序。
6.3 场景三:runtime PM 导致的功能异常
runtime PM 最坑的地方是它会在你意想不到的时候关掉设备。比如某个驱动在初始化时pm_runtime_put之后,设备进入 runtime suspend,时钟被关,结果后续某个异步操作访问寄存器就挂了。
这类问题的排查要点是:确认所有访问硬件的地方都在pm_runtime_get_sync的保护范围内。这不是 PM Core 的锅,是驱动没有遵守 runtime PM 的使用契约。
7. 分层设计带来的扩展性与它的代价
7.1 扩展性:新平台和新设备怎么接入
分层设计最大的好处是接入成本低。一个新的 SoC 要支持 suspend,只需要实现platform_suspend_ops;一个新的设备要支持 runtime PM,只需要实现dev_pm_ops里的几个回调。PM Core 不用改,策略层也不用改。
这种"对扩展开放、对修改关闭"的设计,是内核能支持这么多平台和设备的基础。你在做产品移植时,大部分工作其实是"填回调",而不是"改框架"。
7.2 代价:调试复杂度上升
分层的代价是问题可能出现在任何一层,而现象往往表现在另一层。功耗降不下去,可能是策略层 governor 选错了,也可能是驱动没实现回调,还可能是设备树父子关系错了。你必须有"分层定位"的意识,才能高效排查。
我的经验是:先确定问题属于哪一层,再深入那一层。判断方法很简单——看现象是"策略不对"(该省电时没省)、"机制失效"(想省电但省不了)还是"顺序错误"(省电动作执行顺序不对)。这三类问题分别对应策略层、驱动层和 PM Core 层。
7.3 一个实用的分层检查清单
| 现象 | 优先排查层 | 关键检查点 |
|---|---|---|
| 待机功耗偏高 | 驱动层 | 各设备 runtime_status、时钟/电源域是否关闭 |
| 无法进入 suspend | PM Core 层 | dpm_list 顺序、引用计数、回调返回值 |
| 进入后立即唤醒 | 平台层 | 唤醒源配置、中断屏蔽 |
| 频率不降 | 策略层 | cpufreq governor、opp 表 |
| resume 后异常 | 驱动层 + PM Core | 父子关系、上下文恢复完整性 |
这张表不是万能的,但它能帮你在面对一个模糊的功耗问题时,快速缩小范围。
8. 我在实际项目里总结的几条经验
第一条,永远先看引用计数,再看回调实现。runtime PM 的问题里,十有八九是引用计数没配对,而不是回调写错了。养成在驱动里成对写get/put的习惯,错误分支也要记得put。
第二条,设备树的父子关系不是可选项。很多人觉得parent只是逻辑上的从属,其实它直接决定 PM Core 的调度顺序。做新板子时,我会专门花时间核对每个设备的parent是否指向了它真正依赖的总线控制器。
第三条,用pm_test分级验证。/sys/power/pm_test可以让你只跑到某个阶段就返回,比如设成devices就只做设备 suspend 不做平台 enter。这在定位"是设备层还是平台层的问题"时非常有用,能省掉大量反复重启的时间。
第四条,别忽略prepare阶段。很多 suspend 失败其实是prepare返回了错误,但因为错误信息不够明显被忽略了。打开CONFIG_PM_DEBUG和CONFIG_PM_SLEEP_DEBUG,能看到更详细的日志。
第五条,功耗优化是系统工程,不是单点调参。我见过团队花大力气调 cpuidle 的 residency,结果发现真正的大头是某个外设的时钟没关。先做全局的功耗分解(用电流探头或者 SoC 内部的功耗计数器),找到大头再动手,比盲目调参数高效得多。
这套分层框架理解透了之后,你会发现它不只适用于功耗。内核里很多子系统都是类似的"策略-框架-平台-驱动"四层结构,比如时钟框架、 regulator 框架、pinctrl 框架。把 PM Core 这一套吃透,再看其他框架会有种"原来都是同一个套路"的感觉。后续如果要做更细的 runtime PM 调优或者 cpuidle governor 定制,也都是在这个分层基础上往下钻。