1. 从一次功耗异常排查说起
去年冬天调一块嵌入式板子的时候,遇到一个挺典型的问题:设备在待机状态下电流比预期高了将近40毫安,跑整机功耗测试怎么都压不下去。一开始怀疑是某个外设驱动没进低功耗,挨个模块关,关到最后发现根因在系统级挂起流程里——某个设备在suspend回调里返回了错误码,导致整个挂起链路回滚,系统压根没真正睡下去。这件事让我重新把Linux内核的功耗子系统从头到尾捋了一遍,尤其是PM Core这一层的设计逻辑。
Linux内核的功耗管理不是某一个单独的驱动,而是一整套分层的框架。PM Core(Power Management Core)位于这套框架的中枢位置,向上对接用户空间的各种功耗策略接口,向下协调各个设备驱动、总线、时钟、电源域的执行顺序。它解决的问题很具体:当系统要进入低功耗状态时,谁先睡、谁后睡、谁负责保存上下文、谁负责唤醒、失败了怎么回滚,这些顺序和状态机全部由PM Core统一编排。
这篇文章适合两类人看。一类是做嵌入式Linux、Android底层或者服务器功耗优化的工程师,需要理解内核功耗框架的整体结构;另一类是正在读内核源码、被dpm_list、dev_pm_ops、pm_genpd这些概念绕晕的学习者。我会从分层设计的角度切入,把PM Core的骨架拆开,讲清楚每一层为什么这么分、实际调试时怎么定位问题。内容基于内核通用实现和常见实践整理,具体版本细节可能因内核版本不同略有差异,阅读时以手头源码为准。
2. 功耗框架为什么要分层
2.1 分层不是炫技,是被硬件逼出来的
很多人第一次看功耗子系统会觉得层次太多:用户空间有sysfs接口,中间有PM Core,下面还有设备驱动、总线类型、电源域、时钟框架。为什么不干脆写一个大函数,从头到尾把设备挨个关掉?
原因在于硬件的异构性。一块SoC上可能同时存在几十上百个设备,它们分属不同的电源域(Power Domain),共享不同的时钟源,有的设备挂在I2C总线上,有的挂在PCIe上,有的直接挂在内存映射总线上。这些设备的休眠依赖关系是一张有向图,不是一条简单的链。比如某个传感器要休眠,前提是它依赖的I2C控制器还活着,能接收最后的配置命令;而I2C控制器要休眠,又得等它下面所有从设备都先睡。
如果把这些依赖关系硬编码在一个大函数里,每换一颗芯片就得重写一遍,维护成本无法承受。分层设计的核心思路是:把"什么时候做什么"和"具体怎么做"解耦。PM Core负责编排顺序和状态机,具体设备的行为交给各自的驱动通过回调注册进来。这样换硬件时,只需要改驱动层的回调实现,PM Core的逻辑基本不动。
2.2 四层结构的职责边界
从整体上看,Linux功耗框架大致可以分成四层,每一层的职责边界相当清晰:
| 层次 | 代表组件 | 核心职责 |
|---|---|---|
| 用户接口层 | sysfs、ioctl、debugfs | 提供进入/退出低功耗状态的触发入口和策略配置 |
| PM Core层 | drivers/base/power/ | 维护设备电源状态机、编排挂起/恢复顺序、处理依赖 |
| 框架/子系统层 | Runtime PM、Generic PM Domain、Clock Framework | 提供通用机制,管理运行时功耗和电源域 |
| 设备驱动层 | 各设备驱动的dev_pm_ops | 实现具体的寄存器保存、时钟关闭、电源切换 |
这个分层最关键的一点是:上层不关心下层的实现细节,下层不关心上层的调度策略。用户空间写一个mem到/sys/power/state,它不需要知道有多少设备、每个设备怎么睡;驱动实现suspend回调,也不需要知道自己是第几个被调用的。中间这层"翻译"和"编排"的工作,全部由PM Core承担。
2.3 系统级挂起与运行时功耗的分工
功耗管理其实有两个不同维度的场景,分层设计把它们区分得很清楚。
一个是系统级挂起(System Suspend),也就是整机进入睡眠,CPU停转、大部分外设断电,靠唤醒源把系统叫醒。这条路径走的是/sys/power/state,最终触发dpm_suspend系列函数,按顺序遍历所有设备。
另一个是运行时功耗管理(Runtime PM),系统还在正常运行,但某个设备暂时不用,就单独把它关掉。比如屏幕熄灭后摄像头模块没在用,可以独立进入低功耗,不影响CPU和其他设备。这条路径由设备驱动主动调用pm_runtime_put触发,走的是另一套引用计数机制。
这两套机制共享同一批dev_pm_ops回调,但调用时机和上下文完全不同。系统级挂起是"全局同步"的,所有设备一起走流程;运行时功耗是"局部异步"的,单个设备独立决策。PM Core的价值就在于用一套统一的回调接口,同时支撑这两种截然不同的调度模式。理解这一点,后面看代码时就不会把两条路径搞混。
3. PM Core的核心机制拆解
3.1 设备电源状态机与dpm_list
PM Core内部维护着一个全局链表dpm_list,所有注册了电源管理能力的设备都会挂到这个链表上。这个链表的顺序不是随便排的,它遵循一个原则:依赖者排在被依赖者前面。也就是说,如果一个设备A的休眠依赖设备B还活着,那么A必须排在B的前面,挂起时先处理A,恢复时后处理A。
这个顺序是怎么建立的?设备注册时,PM Core会根据设备树或者总线枚举的父子关系,把子设备插到父设备前面。比如一个挂在I2C控制器下的传感器,传感器是子设备,I2C控制器是父设备,那么传感器会排在控制器前面。挂起时先让传感器睡,再让控制器睡;恢复时反过来,先唤醒控制器,再唤醒传感器。这个顺序保证了任何时候子设备操作时,它的父总线都是可用的。
设备在PM Core里有一个明确的状态机,主要状态包括:
PM_SUSPEND_PREPARE:准备阶段,通知各子系统即将挂起PM_SUSPEND_ENTER:正式进入挂起,设备回调被依次调用PM_RESUME_EARLY:恢复早期,部分设备先醒PM_RESUME_COMPLETE:恢复完成,所有设备就绪
状态机的意义在于,它把一次挂起/恢复拆成了多个阶段,每个阶段有明确的语义。驱动可以在不同阶段做不同的事,比如在prepare阶段申请资源,在suspend阶段真正关电,在resume阶段恢复寄存器。这种分阶段设计避免了"一刀切"式的粗暴处理,也给错误处理留出了回旋余地。
3.2 dev_pm_ops回调集合
每个设备驱动通过一个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 (*suspend_late)(struct device *dev); int (*resume_early)(struct device *dev); int (*suspend_noirq)(struct device *dev); int (*resume_noirq)(struct device *dev); int (*runtime_suspend)(struct device *dev); int (*runtime_resume)(struct device *dev); int (*runtime_idle)(struct device *dev); };这些回调不是随便挑一个实现就行,它们对应挂起流程的不同阶段,调用时机和上下文有严格区别。prepare和complete在进程上下文调用,可以睡眠,适合做内存分配、互斥锁操作。suspend和resume也在进程上下文,但此时其他子系统可能已经部分挂起,要小心依赖。suspend_noirq和resume_noirq在中断关闭的上下文调用,绝对不能睡眠,只能做最底层的寄存器操作。
我见过不少驱动出问题,就是因为把该在prepare里做的事塞进了suspend_noirq,结果在中断关闭时调用了可能睡眠的函数,触发内核警告甚至死锁。这个坑很隐蔽,因为功能测试时不一定复现,只有在特定时序下才暴露。
3.3 挂起流程的完整时序
一次完整的系统挂起,PM Core的调用顺序大致是这样的:
- 用户空间写入
mem到/sys/power/state - PM Core冻结用户进程和部分内核线程
- 调用所有设备的
prepare回调,按dpm_list顺序 - 调用所有设备的
suspend回调 - 关闭非引导CPU
- 调用所有设备的
suspend_late回调 - 进入
suspend_noirq阶段,关闭中断 - 调用所有设备的
suspend_noirq回调 - 执行平台相关的底层挂起操作
- 系统睡眠,等待唤醒源
恢复流程基本是挂起的逆序,但阶段划分略有不同,多了resume_early和resume_noirq的区分。这个时序里每一步都有对应的错误处理:如果某个设备的suspend返回错误,PM Core会立即停止后续设备的挂起,并反向调用已经挂起设备的resume回调,把系统恢复到一致状态。这就是我开头遇到的那个问题的根源——一个设备返回错误,整条链路回滚,系统没睡下去,功耗自然降不下来。
3.4 运行时功耗的引用计数
运行时功耗管理的核心是引用计数。每个设备有一个usage_count,驱动在使用设备前调用pm_runtime_get增加计数,用完调用pm_runtime_put减少计数。当计数降到零,并且没有其他阻止条件时,PM Core会尝试调用runtime_idle,进而可能触发runtime_suspend。
这套机制的关键在于异步。pm_runtime_put不会立即挂起设备,而是把挂起请求排入工作队列,由后台线程在合适的时机执行。这样做的好处是避免在原子上下文或者中断里做耗时的挂起操作,同时给驱动留出"批量操作"的空间——如果短时间内多次get/put,可以合并成一次挂起。
引用计数最容易踩的坑是计数不平衡。多get一次少put一次,设备就永远进不了低功耗;反过来多put一次,设备可能在还在使用时被挂起,导致访问寄存器失败。调试这类问题时,/sys/kernel/debug/pm_genpd/和/sys/kernel/debug/rpm/下面的调试信息非常有用,能看到每个设备的当前计数和状态。
4. 分层设计在代码里的落地
4.1 从sysfs到dpm_suspend的调用链
用户空间触发挂起,最直接的入口是/sys/power/state。往这个文件写字符串,会进入内核的state_store函数,最终调用pm_suspend。pm_suspend做的事情是:校验目标状态是否支持,然后调用enter_state。
enter_state是系统级挂起的主干函数,它依次做几件事:同步文件系统、冻结进程、调用dpm_suspend、执行平台挂起、恢复时反向操作。其中dpm_suspend就是PM Core编排设备挂起的入口,它会遍历dpm_list,对每个设备调用device_suspend,而device_suspend内部再根据当前阶段选择调用prepare、suspend还是suspend_late。
这条调用链体现了分层的价值:sysfs层只管接收用户输入,pm_suspend管状态校验和整体流程,dpm_suspend管设备编排,device_suspend管单个设备的回调分发。每一层职责单一,出问题时可以快速定位是哪一层的逻辑出了偏差。
4.2 Generic PM Domain如何管理电源域
电源域(Power Domain)是硬件层面的概念,一颗SoC通常划分成若干个电源域,每个域可以独立开关电。Generic PM Domain(genpd)是PM Core里负责管理电源域的框架,它把硬件电源域抽象成软件对象,让设备可以挂载到域上,由域统一控制开关。
genpd的核心数据结构是generic_pm_domain,里面维护了属于这个域的设备列表、域的电源状态、以及域自己的dev_pm_ops。当一个域内所有设备都进入低功耗后,genpd会尝试关闭整个域的电源;当有设备需要唤醒时,先给域上电,再唤醒设备。
这里有个很重要的设计:域的开关电顺序和设备的挂起顺序是联动的。设备挂起时,先处理域内设备,再关域电源;设备恢复时,先开域电源,再恢复域内设备。genpd通过genpd_suspend和genpd_resume回调把这个顺序固化下来,驱动不需要关心自己属于哪个域、域什么时候开关,只需要实现自己的回调即可。
4.3 设备树里的power-domains属性
在设备树里,设备通过power-domains属性声明自己属于哪个电源域:
i2c1: i2c@40012000 { compatible = "vendor,i2c"; power-domains = <&pd_i2c1>; status = "okay"; }; pd_i2c1: power-domain@1 { compatible = "vendor,power-domain"; #power-domain-cells = <0>; };内核解析设备树时,会根据这个属性把设备和域关联起来,自动建立父子依赖。这样PM Core在编排顺序时,就知道i2c1设备属于pd_i2c1域,挂起时要先处理设备再处理域。这种"声明式"的配置方式,比在代码里硬编码依赖关系要清晰得多,也更容易维护。
实际项目中,电源域的划分往往和硬件设计强相关。有的域管CPU集群,有的域管GPU,有的域管显示子系统。划分粒度太粗,省不了多少电;划分太细,域切换的开销又上去了。这个平衡点需要结合具体芯片的功耗数据来定,没有通用答案。
4.4 时钟框架与功耗的联动
功耗管理和时钟管理是紧密耦合的。设备要进低功耗,通常得先关时钟;设备要工作,得先开时钟。Linux的Common Clock Framework(CCF)提供了时钟的引用计数和开关接口,PM Core在挂起设备时,会通过clk_disable_unprepare关闭设备时钟,恢复时再clk_prepare_enable打开。
这里有个容易忽略的点:时钟的开关顺序也有依赖。比如某个设备有多个时钟输入,主时钟和接口时钟,关的时候要先关接口时钟再关主时钟,开的时候反过来。如果顺序错了,可能出现设备还没完全停止就断了主时钟,导致状态异常。这类问题在驱动里通常通过devm_clk_get获取时钟后,在runtime_suspend里按正确顺序操作来解决。
5. 实操调试与问题排查
5.1 用debugfs观察设备电源状态
内核提供了丰富的debugfs节点来观察功耗状态,最常用的几个:
/sys/kernel/debug/pm_genpd/:查看各个电源域的状态、域内设备、开关次数/sys/kernel/debug/rpm/:查看运行时功耗的引用计数和状态/sys/kernel/debug/wakeup_sources:查看唤醒源统计,定位谁把系统唤醒的/sys/kernel/debug/suspend_stats:查看挂起成功/失败次数和失败原因
我排查功耗问题时,第一步通常是cat /sys/kernel/debug/suspend_stats,看fail计数和last_failed_dev。如果挂起失败,这里会直接告诉你哪个设备返回了错误,省去大量猜测。然后再去/sys/kernel/debug/pm_genpd/看域的状态,确认是不是某个域没关掉。
5.2 常见挂起失败原因速查
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 挂起立即返回失败 | 某设备prepare或suspend返回错误 | 查suspend_stats的last_failed_dev |
| 挂起后立即被唤醒 | 唤醒源未正确配置 | 查wakeup_sources,确认中断是否被设为唤醒源 |
| 挂起成功但功耗不降 | 某设备未真正断电或时钟未关 | 查pm_genpd状态,用万用表测各路电源 |
| 恢复后设备异常 | 上下文保存/恢复不完整 | 检查resume回调是否恢复了所有必要寄存器 |
| 运行时功耗不生效 | 引用计数不平衡 | 查rpm状态,确认get/put配对 |
这张表是我自己踩坑总结的,实际排查时按这个顺序走,大部分问题能快速定位。特别是第一行,last_failed_dev这个信息太关键了,很多人不知道有这个节点,白白浪费很多时间。
5.3 一个真实的挂起回滚案例
回到开头那个问题。设备待机电流偏高,suspend_stats显示挂起失败,last_failed_dev指向一个传感器驱动。去看那个驱动的suspend回调,发现它在关闭电源前要读一次寄存器确认状态,但读操作依赖I2C总线,而此时I2C控制器可能已经被挂起了,读操作超时返回错误,导致整个挂起回滚。
解决办法有两个方向:一是调整dpm_list顺序,让传感器排在I2C控制器前面,保证传感器挂起时总线还活着;二是在传感器驱动里把这次读操作移到prepare阶段,因为prepare阶段所有设备都还正常。我选了第二个方案,因为改驱动比改全局顺序风险小,而且prepare阶段本来就适合做这类"挂起前的最后检查"。
这个案例说明了一个道理:PM Core的分层设计给了驱动作者很大的自由度,但自由度也意味着责任。驱动必须清楚自己的回调在哪个阶段被调用、此时系统处于什么状态、能依赖哪些资源。搞不清楚这些,就很容易写出时序相关的bug。
5.4 调试运行时功耗的实用技巧
运行时功耗的问题往往比系统级挂起更难查,因为它不涉及整机状态切换,问题表现更隐蔽。几个实用技巧:
第一,打开CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG,这两个配置会暴露更多debugfs节点,包括每个设备的运行时状态和调用历史。
第二,用echo 1 > /sys/kernel/debug/rpm/...之类的接口手动触发挂起,观察设备反应。有些设备在自动流程里不挂起,手动触发却能挂起,说明是引用计数或者阻止条件的问题。
第三,关注runtime_status字段,它反映设备当前的运行时状态。如果一直是active,说明有引用计数没释放;如果是suspended但功耗没降,说明挂起回调没做实际动作。
第四,注意autosuspend_delay这个参数。很多驱动设置了自动挂起延迟,设备空闲后要等一段时间才真正挂起。如果测试时发现设备迟迟不睡,先检查这个延迟是不是设得太长。
6. 分层设计带来的扩展性
6.1 新增一颗芯片要改哪些地方
理解了分层设计,就能明白移植功耗管理到新芯片时,工作量应该花在哪里。按照分层结构,需要改的地方是:
- 设备驱动层:实现各设备的
dev_pm_ops回调,这是主要工作量 - 电源域描述:在设备树里定义电源域和设备的归属关系
- 平台挂起代码:实现SoC特定的底层挂起操作,比如设置唤醒源、配置PMIC
- 时钟和稳压器配置:确保时钟和电源的开关顺序正确
PM Core本身基本不需要改,除非新芯片有特殊的依赖关系或者状态机需求。这种"改下不改上"的特性,正是分层设计追求的目标。我移植过几款不同的芯片,PM Core部分几乎没动过,主要精力都花在驱动回调和设备树配置上。
6.2 子系统如何接入PM Core
不只是设备驱动,整个子系统也可以接入PM Core。比如显示子系统、音频子系统,它们内部有多个设备,但对外可以作为一个整体参与挂起。接入的方式是实现子系统级的dev_pm_ops,在回调里协调内部设备的挂起顺序。
这种"子系统级"的功耗管理,比单个设备管理更高效,因为子系统内部知道自己的依赖关系,可以做出更优的编排。PM Core对此是支持的,它允许设备有"parent"关系,子系统的挂起回调会在内部设备之前被调用,形成层次化的挂起树。
6.3 分层设计的边界与代价
分层设计不是没有代价的。层次多了,调用链就长,调试时要在多个文件之间跳转;抽象多了,理解成本就高,新手容易迷失在层层封装里。而且分层带来的灵活性,也意味着驱动作者要承担更多责任,写错了不容易被发现。
但从工程角度看,这些代价是值得的。一颗SoC上几十上百个设备,如果没有统一框架,每个设备各自为政,功耗管理的复杂度会指数级上升。分层设计用"约定优于配置"的思路,把共性逻辑收敛到PM Core,把个性逻辑下放到驱动,整体上降低了系统的复杂度。这也是为什么这套框架能沿用这么多年,成为Linux功耗管理的事实标准。
7. 我个人的几点实操体会
调功耗这几年,最大的体会是:功耗问题从来不是单点问题,而是系统问题。一个设备没睡,可能是它自己的回调写错了,也可能是它依赖的总线没睡,还可能是唤醒源配置不对导致系统反复被唤醒。排查时不能只盯着一个设备看,要顺着依赖链往上往下捋。
第二个体会是善用工具,别硬猜。内核提供的debugfs节点已经相当完善,suspend_stats、pm_genpd、wakeup_sources这几个节点能解决大部分定位问题。我见过有人遇到挂起失败,不看日志不查节点,直接改代码试,试了半天没进展。其实last_failed_dev早就把答案写在那里了。
第三个体会是理解顺序比理解单个回调更重要。PM Core的精髓在于编排,在于什么阶段调用什么回调、按什么顺序调用。把顺序搞清楚了,写驱动时就知道该把代码放在哪个回调里,调试时就知道该从哪个阶段入手。这个顺序不是死记硬背的,而是理解依赖关系后自然推导出来的。
最后分享一个小技巧:如果怀疑某个设备导致挂起失败,可以临时在它的suspend回调里直接返回0,看挂起是否能成功。如果能成功,说明问题就在这个设备;如果还是失败,说明还有别的设备有问题。这个方法简单粗暴,但在快速定位时很有效。当然,定位完记得把代码改回来,别把临时改动提交上去。