1. 从一次待机功耗异常说起:PM Core 到底管什么
几年前接手一个嵌入式项目,设备在待机状态下电流比预期高了将近一倍。硬件同事反复测了各路供电,没发现漏电;应用层也排查了,没有后台线程在跑。最后把问题定位到内核的功耗管理子系统上——某个外设驱动在系统进入低功耗状态时没有正确响应,导致整个 SoC 无法进入深度睡眠。那次排查让我意识到,很多人对 Linux 功耗框架的理解停留在“调个 governor”“改个频率”的层面,但真正决定功耗行为是否正确的,是底层那套分层协作的机制。
Linux 内核的功耗管理子系统(Power Management,简称 PM)是整个系统省电行为的“总调度”。它不是一个单一模块,而是一组相互配合的子系统集合,包括系统级睡眠、运行时电源管理、时钟框架、稳压器框架、频率调节等。而PM Core是这套体系的中枢,负责定义设备与系统之间的功耗状态转换规则、维护设备树中的电源域关系、协调各个驱动在挂起和恢复时的行为顺序。
这篇文章面向的是已经有一定 Linux 驱动开发基础、想深入理解功耗框架内部运作机制的读者。如果你写过字符设备驱动、了解设备模型和 sysfs,但对dev_pm_ops、runtime_suspend、genpd这些概念只有模糊印象,那这篇内容正好帮你把这条链路串起来。我会从 PM Core 的分层设计入手,把每一层的职责边界、调用关系、以及实际调试中容易踩的坑讲清楚。功耗框架的分层不是学术上的分类游戏,它直接决定了你在写驱动时该把省电逻辑放在哪里、什么时候会被调用、以及出问题时该从哪一层开始查。
2. PM Core 的分层骨架:谁在什么时候管什么事
2.1 三层结构的职责划分
Linux PM 框架从下到上大致可以分成三层,每一层的关注点和作用范围完全不同。
最底层是硬件抽象层,包括时钟框架(Common Clock Framework)、稳压器框架(Regulator Framework)、引脚控制(pinctrl)等。这一层管的是“给某个硬件模块断电、关时钟、切引脚状态”这类具体动作。它不关心系统整体在做什么,只负责执行单个硬件资源的开关。
中间层是设备级电源管理,也就是 PM Core 直接管辖的范围。这一层管的是“某个设备现在能不能进入低功耗状态”“进入之前要做什么准备”“恢复时按什么顺序来”。核心机制包括运行时电源管理(Runtime PM)和系统睡眠(System Suspend)两条路径。设备驱动通过dev_pm_ops结构体向 PM Core 注册回调,PM Core 在合适的时机调用它们。
最上层是系统级电源管理,管的是整个系统进入睡眠、休眠、关机等全局状态。这一层要协调所有设备,确保在系统状态切换时,每个设备都按正确的顺序挂起和恢复。它依赖中间层提供的设备级回调,同时还要处理 CPU 热插拔、非启动 CPU 的关闭、中断控制器的状态切换等全局事务。
这三层之间的关系不是简单的上下级命令传递,而是通过引用计数、状态机和回调链来协作。理解这一点很关键:PM Core 不直接操作硬件寄存器,它只负责调度和协调。真正干活的是底层框架和驱动自己注册的回调。
2.2 设备模型如何支撑功耗管理
PM Core 能工作的前提是 Linux 的设备模型。每个设备在内核里都是一个struct device,这个结构体里嵌了一个struct dev_pm_info,专门用来记录这个设备的电源管理状态。这个设计很巧妙——功耗信息和设备本身绑定在一起,不需要额外维护一张全局表。
dev_pm_info里几个关键字段值得记住:
power_state:记录设备当前的电源状态,比如DPM_ON、DPM_SUSPENDING、DPM_SUSPENDED等。runtime_status:运行时 PM 的状态,取值包括RPM_ACTIVE、RPM_SUSPENDING、RPM_SUSPENDED。usage_count:运行时 PM 的引用计数,只有计数归零设备才允许挂起。child_count:子设备计数,父设备必须等所有子设备挂起后才能挂起。
这些字段不是孤立的,它们共同构成一个状态机。比如一个设备要进入运行时挂起,PM Core 会先检查usage_count是否为零、child_count是否为零,然后才调用驱动的runtime_suspend回调。如果回调返回成功,状态才从RPM_SUSPENDING变成RPM_SUSPENDED。
注意:
usage_count的增减必须成对出现。我见过不少驱动在 probe 里调了pm_runtime_get_sync却忘了在 remove 或错误路径里pm_runtime_put,结果设备永远无法进入低功耗状态,系统待机功耗居高不下。
2.3 设备树中的电源域描述
现代 SoC 的功耗管理越来越依赖设备树来描述硬件之间的电源依赖关系。典型的是power-domains属性,它把一个设备关联到一个或多个电源域(genpd,generic power domain)。PM Core 在设备注册时会解析这些属性,建立起设备与电源域之间的父子关系。
这个关系的意义在于:当系统要进入低功耗状态时,PM Core 会按照设备树的层级从叶子节点往上依次挂起。一个电源域下的所有设备都挂起后,电源域本身才允许关闭。如果某个设备没有正确挂起,整个电源域就无法断电,省电效果大打折扣。
设备树里还有一个常见属性是wakeup-source,标记这个设备是否具备唤醒系统的能力。带这个标记的设备在挂起时,其中断会被配置为唤醒源,系统进入睡眠后如果该设备产生中断,可以触发系统恢复。这个机制在按键、触摸屏、传感器等场景中非常常用。
3. 运行时 PM 与系统睡眠:两条路径的协作与冲突
3.1 Runtime PM 的引用计数模型
运行时电源管理(Runtime PM)解决的是“设备在系统运行期间空闲时自动进入低功耗”的问题。它的核心是一个引用计数模型:驱动在需要使用设备时增加计数,用完减少计数,计数归零时 PM Core 自动触发挂起。
这套机制的设计意图是让省电行为对驱动开发者尽可能透明。你不需要手动判断“现在能不能关”,只需要如实报告“我现在用不用”。但实际写驱动时,有几个细节必须注意。
第一,pm_runtime_get_sync和pm_runtime_get的区别。前者会阻塞等待设备真正恢复到活跃状态,后者只是增加计数不等待。在中断上下文或原子上下文中只能用后者。我早期写 I2C 设备驱动时在中断处理里调了get_sync,直接触发睡眠非法警告,排查了半天才发现是这个问题。
第二,pm_runtime_put和pm_runtime_put_sync的区别。前者是异步减少计数,后者是同步等待挂起完成。在 suspend 回调里通常用同步版本,确保设备在返回前已经挂起。
第三,pm_runtime_set_active和pm_runtime_mark_last_busy的配合使用。前者用于在 probe 阶段手动设置设备为活跃状态,后者用于更新“最后忙碌时间”,配合autosuspend_delay实现延迟自动挂起。延迟挂起很有用,可以避免设备频繁开关造成额外开销。
3.2 系统睡眠的全局协调流程
系统睡眠(System Suspend)和 Runtime PM 不同,它是一次全局操作,所有设备都要参与。整个流程大致分为三个阶段:冻结进程、挂起设备、进入低功耗。
冻结进程阶段,内核会停止用户空间的所有活动,把进程状态保存起来。这一步是后续设备挂起的前提,因为如果有进程还在访问设备,挂起就会失败。
挂起设备阶段,PM Core 按照设备树的层级,从最深的子设备开始,依次调用每个设备的suspend回调。回调的返回值决定流程是否继续:返回 0 表示成功,返回负数表示失败,PM Core 会中止挂起并恢复已经挂起的设备。
进入低功耗阶段,所有设备挂起完成后,系统会关闭非启动 CPU、保存 CPU 上下文、配置唤醒源,最后执行平台相关的低功耗指令。
恢复流程正好相反:先恢复 CPU 和核心基础设施,然后按挂起时的逆序调用每个设备的resume回调,最后解冻进程。
这里有一个容易忽略的点:系统睡眠的 suspend 回调和 Runtime PM 的 runtime_suspend 回调是两个不同的入口。很多驱动只实现了其中一个,导致系统睡眠时设备没有正确挂起,或者运行时设备无法自动省电。正确的做法是两个都实现,并且在系统睡眠的 suspend 回调里先确保设备处于 Runtime PM 活跃状态,避免状态冲突。
3.3 两条路径的状态冲突与处理
Runtime PM 和系统睡眠之间最典型的冲突场景是:设备在系统睡眠触发时正处于 Runtime PM 挂起状态。这时候 PM Core 调用suspend回调,驱动如果直接操作硬件可能会出问题,因为设备已经处于低功耗状态了。
标准做法是在suspend回调开头调用pm_runtime_resume把设备唤醒到活跃状态,然后再执行系统睡眠的挂起逻辑。恢复时在resume回调末尾调用pm_runtime_put让设备回到运行时挂起状态。这样两条路径就不会互相干扰。
另一个常见问题是pm_runtime_force_suspend和pm_runtime_force_resume这对辅助函数的使用。它们的作用是在系统睡眠时强制把 Runtime PM 状态同步过来,避免驱动自己处理状态转换。如果你的驱动 Runtime PM 实现得比较完整,直接用这对函数可以省不少事。
4. 驱动开发者必须掌握的 dev_pm_ops 实操细节
4.1 dev_pm_ops 结构体的字段含义
struct dev_pm_ops是驱动向 PM Core 注册回调的入口。这个结构体字段很多,但常用的就那么几个,按使用频率排个序:
| 回调字段 | 触发时机 | 典型用途 |
|---|---|---|
runtime_suspend | Runtime PM 计数归零 | 关闭设备时钟、断电 |
runtime_resume | Runtime PM 计数从零增加 | 恢复时钟、上电、重新初始化 |
suspend | 系统睡眠挂起阶段 | 保存寄存器、配置唤醒源 |
resume | 系统睡眠恢复阶段 | 恢复寄存器、清除唤醒状态 |
suspend_late | 挂起后期,中断已关闭 | 需要关中断后执行的操作 |
resume_early | 恢复早期,中断尚未开启 | 需要开中断前执行的操作 |
freeze | 休眠(hibernation)冻结阶段 | 类似 suspend 但用于休眠 |
poweroff | 关机阶段 | 设备下电前的清理 |
runtime_suspend和suspend的区别前面已经讲过,这里补充一点:suspend_late和resume_early的使用场景比较特殊,通常只在需要严格控制中断开关时序时才用。大多数驱动只需要实现runtime_suspend、runtime_resume、suspend、resume这四个就够了。
4.2 回调实现中的常见错误
我审过不少驱动代码,在 PM 回调实现上反复出现几类问题。
第一类是在runtime_suspend里做了不该做的事。比如调用了可能睡眠的函数、访问了用户空间、或者试图和用户进程通信。runtime_suspend可能在原子上下文中被调用,任何可能引起调度的操作都是危险的。
第二类是忘记处理返回值。runtime_resume如果失败,PM Core 会把设备状态标记为错误,后续所有 Runtime PM 操作都会失败。所以runtime_resume里每一步都要检查返回值,失败时回滚已经做的操作。
第三类是在suspend回调里没有保存足够的状态。系统睡眠会丢失大部分硬件寄存器内容,恢复时必须重新初始化。我见过一个 SPI 控制器驱动,suspend时只保存了控制寄存器,忘了保存波特率分频寄存器,恢复后通信速率全乱了。
第四类是唤醒源配置错误。如果设备标记为wakeup-source,suspend回调里必须调用enable_irq_wake使能唤醒功能,resume里对应调用disable_irq_wake。漏掉这一步,设备就无法唤醒系统。
4.3 用 sysfs 和 debugfs 验证 PM 行为
写完驱动后怎么验证 PM 行为是否正确?最直接的工具是 sysfs 和 debugfs。
sysfs 里每个设备目录下都有power/子目录,里面几个文件很有用:
power/runtime_status:查看当前 Runtime PM 状态,值是active、suspending、suspended或error。power/runtime_usage:查看引用计数。power/control:写入auto启用自动挂起,写入on禁止自动挂起。power/wakeup:查看或设置设备是否作为唤醒源。
debugfs 里/sys/kernel/debug/pm_genpd/目录可以看到各个电源域的状态和下属设备。/sys/kernel/debug/devices_deferred可以查看哪些设备因为依赖关系没满足而延迟 probe。
实测时我通常这样操作:先把power/control设为auto,然后观察runtime_status是否在设备空闲后变成suspended。如果一直是active,就去查runtime_usage是不是不为零,或者有没有子设备没挂起。
5. 电源域(genpd)与设备树的配合实战
5.1 genpd 的工作机制
通用电源域(generic power domain,genpd)是 PM Core 里比较独立的一个子系统,专门用来管理一组共享电源开关的设备。它的核心思想是:多个设备可能挂在同一个电源域下,只有当所有设备都空闲时,电源域才能关闭。
genpd 的实现依赖设备树的power-domains属性。内核启动时会解析这些属性,把设备挂到对应的电源域上。每个电源域有自己的genpd结构体,里面定义了power_on和power_off回调,由平台代码实现。
当某个设备要进入 Runtime PM 挂起时,PM Core 会先检查它所属的电源域。如果电源域下还有其他设备活跃,电源域不能关,但设备本身可以进入低功耗。如果所有设备都挂起了,电源域才会执行power_off。
这个机制的价值在于,它把“设备级省电”和“电源域级省电”解耦了。驱动只需要管好自己的设备,电源域的开关由 genpd 自动协调。
5.2 设备树中的电源域描述实例
一个典型的设备树片段长这样:
power: power-controller@12340000 { compatible = "vendor,power-controller"; reg = <0x12340000 0x1000>; #power-domain-cells = <1>; }; uart0: serial@12350000 { compatible = "vendor,uart"; reg = <0x12350000 0x100>; power-domains = <&power 0>; clocks = <&clk_uart0>; };这里power-domains = <&power 0>表示 uart0 挂在 power 控制器的第 0 号电源域下。内核解析后,uart0 的dev->pm_domain会指向对应的 genpd。
调试时如果发现设备没有正确挂到电源域上,先检查power-domains属性的 phandle 和参数是否正确,再确认电源域控制器的驱动是否已经 probe 成功。电源域控制器本身也是一个设备,它必须比下属设备先初始化。
5.3 电源域调试中的典型问题
电源域相关的问题往往比较隐蔽,因为涉及多个设备的状态联动。我遇到过几次比较典型的情况。
一种是电源域下有设备没有实现 Runtime PM。这个设备永远不会挂起,导致电源域永远无法关闭。解决办法是给这个设备补上 Runtime PM 支持,或者在电源域配置里把它标记为always-on。
另一种是电源域的power_off回调执行时间过长。有些平台的电源域关闭需要等待硬件确认,如果回调里用了忙等待,会阻塞整个 PM 流程。正确的做法是用完成量或中断来异步等待。
还有一种是设备树里电源域层级嵌套过深。虽然 genpd 支持嵌套,但层级太深会让状态传播变得复杂,调试困难。建议尽量扁平化,除非硬件确实有严格的层级依赖。
6. 从 PM Core 视角排查功耗问题的完整链路
6.1 确认问题出在哪一层
功耗异常时,第一步是判断问题出在系统级、设备级还是硬件级。我的排查顺序通常是这样的:
先看系统能不能进入预期的低功耗状态。读/sys/power/state支持哪些状态,然后手动触发echo mem > /sys/power/state,观察串口输出有没有报错。如果系统根本进不去,问题在系统级协调或某个设备的 suspend 回调。
如果系统能进去但功耗还是高,就查是哪个设备阻止了深度睡眠。用/sys/kernel/debug/pm_genpd/看各个电源域的状态,找到还处于on状态的域,再看它下面哪个设备还是active。
如果所有设备都挂起了但功耗依然高,那可能是硬件本身的问题,比如某个外设的时钟没关、引脚状态不对、或者电源轨有漏电。
6.2 用 ftrace 跟踪 PM 回调执行
ftrace 是排查 PM 问题的利器。可以这样跟踪所有 PM 回调的调用:
cd /sys/kernel/debug/tracing echo function_graph > current_tracer echo '*pm*' > set_ftrace_filter echo 1 > tracing_on echo mem > /sys/power/state echo 0 > tracing_on cat trace这样能看到每个 PM 回调的调用顺序、执行时间、返回值。如果某个回调耗时特别长,或者返回了错误,一眼就能看出来。
我常用这个办法定位“某个设备 suspend 失败导致系统无法睡眠”的问题。trace 里会清楚显示是哪个设备的哪个回调返回了非零值。
6.3 常见功耗问题的根因归纳
根据我这些年的排查经验,功耗问题大致可以归成几类:
| 现象 | 可能根因 | 排查方向 |
|---|---|---|
| 系统无法进入睡眠 | 某设备 suspend 回调返回错误 | ftrace 跟踪回调返回值 |
| 能睡眠但功耗高 | 电源域未关闭 | 查 genpd debugfs |
| 运行时功耗高 | 设备未启用 Runtime PM | 查 power/control 和 runtime_status |
| 唤醒后功能异常 | resume 回调状态恢复不完整 | 对比 suspend 前后寄存器 |
| 频繁唤醒 | 唤醒源配置过敏感 | 查 /sys/kernel/debug/wakeup_sources |
这张表不是穷举,但覆盖了大部分常见场景。实际排查时往往需要结合多种工具交叉验证。
7. 写 PM 相关代码时我踩过的那些坑
7.1 引用计数泄漏的隐蔽性
Runtime PM 引用计数泄漏是最难查的问题之一,因为它的表现是“设备永远不挂起”,而不是报错。泄漏可能发生在任何错误路径上:probe 失败、中断处理异常、用户操作取消。
我的经验是,在驱动里对pm_runtime_get的每一次调用都要问自己:对应的put在哪里?如果是在错误处理路径上,有没有覆盖到?用静态分析工具或者代码审查很难完全避免,最好的办法是在关键路径上加日志,运行时观察计数变化。
7.2 回调顺序依赖的陷阱
PM 回调的执行顺序是有严格规定的,但很多驱动开发者不清楚这一点。比如父设备的runtime_suspend一定在子设备之后调用,runtime_resume一定在子设备之前调用。如果驱动假设了错误的顺序,就会出现访问已关闭资源的问题。
另一个容易忽略的是suspend和suspend_late之间的顺序。suspend阶段中断还是开着的,suspend_late阶段中断已经关了。如果某个操作需要中断参与,就必须放在suspend里;如果需要在无中断环境下执行,就放在suspend_late里。
7.3 平台差异带来的适配问题
不同 SoC 平台的 PM 实现差异很大。有的平台把电源域控制放在 ATF(Arm Trusted Firmware)里,内核只能通过 SMC 调用;有的平台在 kernel 里直接操作寄存器。这导致同一份驱动在不同平台上可能需要不同的 PM 配置。
我的做法是在驱动里尽量只实现通用的 PM 回调,把平台相关的操作抽象成dev_pm_domain或者genpd的回调,由平台代码去实现。这样驱动本身保持平台无关,移植时只需要适配平台层。
8. 理解分层设计对实际开发的意义
回到开头那个待机功耗异常的问题。最后查出来的原因是:一个传感器驱动在runtime_suspend里只关了时钟,没有把设备放到低功耗模式,导致它所属的电源域无法关闭。修复方法是在runtime_suspend里补上设备低功耗寄存器的配置,同时在runtime_resume里恢复。
这个问题之所以难查,是因为它跨越了三个层次:驱动层的回调实现不完整、设备级的 Runtime PM 状态不对、电源域级的关闭条件不满足。如果只盯着其中一层看,很难找到根因。
PM Core 的分层设计不是为了增加复杂度,而是为了让每一层的职责清晰、可独立调试。驱动开发者只需要管好自己的设备级回调,电源域的协调由 genpd 负责,系统级的顺序由 PM Core 保证。理解这个分工,才能在出问题时快速定位到正确的层,而不是在错误的层次上浪费时间。
实际开发中我的建议是:先把 Runtime PM 实现正确,确保设备在空闲时能自动挂起;再补上系统睡眠的回调,处理好两条路径的状态同步;最后检查设备树里的电源域配置,确认设备挂到了正确的域下。这个顺序从下往上,每一步都有明确的验证方法,比一上来就调系统睡眠要高效得多。