1. 从一次待机电流超标说起:runtime pm 到底在管什么
前阵子帮朋友看一块嵌入式板子,现象很典型:系统跑起来功能都正常,但只要一进待机,整板电流就比规格书标称的高出一大截,电池撑不住。抓了几天波形、翻了一堆寄存器,最后定位到的不是硬件漏电,而是几个外设的时钟和电源域压根没在空闲时被关掉——驱动里 suspend/resume 回调写得挺全,可运行时那套动态管理根本没接上。这个问题逼着我把 Linux 内核功耗子系统里的runtime pm从头到尾捋了一遍,也就是这篇要聊的东西。
先把概念摆正。Linux 的电源管理大致分两条线:一条是系统级睡眠(system suspend / hibernation),整机进低功耗,所有设备跟着走一遍 suspend 回调;另一条就是runtime pm,它管的是系统还在正常运行时,单个设备在“空闲”和“活跃”之间来回切换。你可以把它理解成每个设备自带的一个小开关:没人用它的时候,自动把时钟关掉、电源域降下来;一旦有人要用,立刻唤醒。它和系统级睡眠最大的区别在于——粒度是单个设备,触发时机是运行中,而不是整机挂起。
为什么需要这么一套东西?因为现代 SoC 上挂的外设太多了,屏幕、摄像头、WiFi、各种传感器、存储控制器,如果全都等到整机睡眠才省电,那系统正常跑着的时候功耗就白白浪费了。runtime pm 的价值就在于把省电这件事下沉到“设备空闲的每一刻”。对做嵌入式、做移动端、做任何对功耗敏感的 Linux 产品的人来说,这套机制是绕不开的基本功。
这篇内容适合谁看?如果你已经写过字符设备驱动、知道probe和remove大概怎么回事,但对pm_runtime_get_sync和pm_runtime_get的区别、对runtime_status那几个状态到底怎么流转一直模模糊糊,那这篇就是给你准备的。我会从struct device里那几个和 pm 相关的字段讲起,把 PM Core 的调用链、引用计数机制、和系统级睡眠的交互、以及实际调试时最容易踩的坑,一条条拆开。全程按“为什么这么设计”来讲,而不是干巴巴列 API。
2. struct device 里藏着的 runtime pm 家当
要理解 runtime pm,第一步得知道它的状态存在哪。答案就在每个设备都有的struct device里。这个结构体是内核设备模型的基石,runtime pm 的所有运行时信息都挂在它身上,不需要额外分配一个“pm 对象”。这种设计的好处是设备生命周期和 pm 状态天然绑定,设备没了,pm 状态自然也就没了。
2.1 三个核心字段:power、links、parent
打开include/linux/device.h,和 runtime pm 直接相关的字段主要有这么几组。第一组是struct dev_pm_info power,这是个内嵌的结构体,里面装着当前电源状态、是否可以被 runtime 管理、引用计数、回调函数指针等等,可以说是 runtime pm 的“主控台”。第二组是struct dev_pm_domain *pm_domain,指向设备所属的电源域,很多 SoC 会把一组设备的电源管理打包成一个 domain 统一处理。第三组是struct device *parent,这个字段本身不是为 pm 设计的,但 runtime pm 会利用设备树的父子关系做级联管理——子设备活跃时,父设备不能被 runtime 挂起。
dev_pm_info里面又细分了一堆字段,我挑几个最关键的:
enum rpm_status runtime_status:当前运行时状态,取值有RPM_ACTIVE、RPM_RESUMING、RPM_SUSPENDED、RPM_SUSPENDING。注意后两个是过渡态,表示正在切换过程中。int runtime_error:上一次 resume 失败留下的错误码,这个字段在排查“设备起不来”时特别有用。atomic_t usage_count:引用计数,这是 runtime pm 的核心机制,后面单独讲。unsigned int disable_depth:禁用深度,大于 0 表示 runtime pm 被临时关掉了。unsigned int runtime_auto:是否允许自动挂起,对应 sysfs 里的control文件。const struct dev_pm_ops *ops:指向驱动的 pm 回调集合,runtime_suspend、runtime_resume、runtime_idle都在里面。
2.2 为什么状态要放在 device 而不是驱动私有数据里
这里有个设计上的取舍值得说。理论上驱动完全可以自己在私有结构体里维护一个“我是不是空闲”的标志,但内核选择把它统一放进struct device,原因是PM Core 需要在不了解具体驱动的前提下做统一调度。比如设备树里的父子级联、比如 sysfs 暴露的power/目录、比如和系统级睡眠的协调,这些都需要一个全局可见的状态源。如果状态散落在各个驱动里,PM Core 就没法统一管理了。
提示:调试时可以直接
cat /sys/devices/.../power/runtime_status看当前状态,cat .../power/control看是 auto 还是 on,这两个文件是排查 runtime pm 问题的第一入口。
2.3 设备树里的父子关系如何影响 pm
设备树天然是一棵树,struct device的parent指针就来自这棵树。runtime pm 利用这个关系实现了一个很重要的约束:只要有一个子设备处于 active,父设备就不能进入 suspended。这个约束是通过引用计数实现的——子设备 resume 的时候,会顺带把父设备的计数加一。这样设计是为了保证电源域的正确性:如果父设备代表一个电源域,子设备还在工作,父域当然不能关。
理解这一点很关键,因为很多“明明设备空闲了却挂不下去”的问题,根因就是某个子设备或者某个没释放的引用把父设备顶住了。后面讲排查的时候会再回到这里。
3. 引用计数:runtime pm 的心脏
runtime pm 最核心、也最容易用错的东西,就是引用计数。它决定了设备什么时候该被挂起、什么时候必须保持活跃。搞懂它,runtime pm 就懂了一大半。
3.1 usage_count 的加减逻辑
usage_count是一个原子计数,初始值通常是 1(表示设备刚 probe 完是活跃的)。每次调用pm_runtime_get系列函数,计数加一;每次调用pm_runtime_put系列函数,计数减一。只有当计数减到 0 时,PM Core 才会尝试去挂起设备。换句话说,计数大于 0 就意味着“有人正在用这个设备,别动它”。
这个模型非常直观:谁要用设备,谁就 get 一下;用完了,put 一下。多个使用者可以同时持有引用,互不干扰。比如一个 I2C 控制器可能同时被触摸屏和传感器驱动使用,两边各自 get/put,只要还有一方在用,控制器就不会被挂起。
3.2 get 和 get_sync 的区别,别再用错
这是新手最容易混淆的地方,我见过太多驱动里随手写pm_runtime_get然后直接访问寄存器,结果偶发挂死。区别在于:
pm_runtime_get/pm_runtime_get_noresume:只增加计数,不等待设备真正 resume。如果设备当前是 suspended,调用后它只是被“标记为需要活跃”,实际 resume 是异步的。pm_runtime_get_sync:增加计数,并且同步等待设备完成 resume,返回时设备一定处于 active 状态。
所以规则很简单:如果你 get 之后马上就要访问硬件寄存器,必须用get_sync。用get的话,设备可能还没上电,你访问寄存器就是访问一片未初始化的地址,轻则读到垃圾数据,重则总线挂死。
/* 正确:访问硬件前用 sync 版本 */ ret = pm_runtime_get_sync(dev); if (ret < 0) { pm_runtime_put_noidle(dev); return ret; } /* 此时设备已 active,可以安全读写寄存器 */ val = readl(dev->base + REG_CTRL); /* 用完释放 */ pm_runtime_put(dev);注意上面错误处理里的pm_runtime_put_noidle。当get_sync失败时,计数其实已经被加过了,必须用_noidle版本减回去,否则计数泄漏,设备永远挂不下去。这个细节很多人不知道,是实打实的坑。
3.3 put 的几种变体和 idle 触发
put 系列也有讲究:
pm_runtime_put:计数减一,如果减到 0,会触发一次 idle 检查,可能异步挂起设备。pm_runtime_put_sync:计数减一,并同步等待挂起完成。pm_runtime_put_autosuspend:计数减一,但走 autosuspend 延迟挂起路径,不会立即挂。pm_runtime_put_noidle:只减计数,不触发 idle 检查,专门用于错误回滚。
什么时候用哪个?一般用完设备用pm_runtime_put或pm_runtime_put_autosuspend就够了。put_sync用在需要确保设备立刻下电的场景,比如你要手动控制上电时序。noidle基本只出现在错误处理路径里。
3.4 autosuspend:给设备一个“冷静期”
有些设备频繁地被短暂使用,比如每次读一个传感器值就 get/put 一次。如果每次 put 都立刻挂起,那 resume 的开销(时钟稳定、寄存器重配)可能比省下的电还多。这时候就要用autosuspend。
机制是这样的:调用pm_runtime_use_autosuspend(dev)开启,再用pm_runtime_set_autosuspend_delay(dev, delay_ms)设置延迟。之后每次pm_runtime_put_autosuspend,PM Core 不会立即挂起,而是启动一个延迟定时器,等delay_ms毫秒内没有新的 get,才真正挂起。如果这期间又有 get,定时器取消,设备保持活跃。
/* 在 probe 里配置 */ pm_runtime_set_autosuspend_delay(dev, 200); /* 200ms 冷静期 */ pm_runtime_use_autosuspend(dev); pm_runtime_set_active(dev); pm_runtime_enable(dev); /* 使用后 */ pm_runtime_mark_last_busy(dev); /* 更新最后活跃时间 */ pm_runtime_put_autosuspend(dev);pm_runtime_mark_last_busy这个调用容易被忽略,它的作用是刷新“最后活跃时间戳”,让延迟从这一刻重新算起。如果你 put 之前不 mark,延迟可能从更早的时间点算,设备会提前挂起。
注意:autosuspend 的延迟值不是拍脑袋定的。要结合设备的 resume 耗时来算——延迟至少应该大于 resume 时间,否则省电收益会被频繁唤醒抵消。我一般先用 100~500ms 试,再根据实测电流曲线调。
4. PM Core 的调用链:一次 get_sync 背后发生了什么
光知道 API 怎么用还不够,真正排查问题时你得知道调用链走到哪一步了。这一节把pm_runtime_get_sync到驱动回调的完整路径捋一遍。
4.1 从 pm_runtime_get_sync 到 __pm_runtime_resume
pm_runtime_get_sync是个内联包装,最终会调到__pm_runtime_resume(dev, RPM_GET_PUT)。这个函数做几件事:先检查disable_depth,如果 runtime pm 被禁用了,直接返回;然后原子地增加usage_count;接着判断当前状态,如果已经是 active 就直接返回,否则进入 resume 流程。
resume 流程的核心是rpm_resume。它会先把状态置为RPM_RESUMING,然后调用__rpm_callback,最终走到驱动的runtime_resume回调。如果设备有 parent,还会先确保 parent 是 active 的——这就是前面说的级联约束。
4.2 runtime_resume 回调里该做什么
驱动的runtime_resume回调职责很明确:把设备从低功耗状态恢复到可工作状态。典型操作包括使能时钟、打开电源域、恢复寄存器上下文、重新初始化总线。注意这里不要做太重的初始化,因为 runtime resume 可能很频繁,重活应该放在 probe 里做一次。
static int mydev_runtime_resume(struct device *dev) { struct mydev *d = dev_get_drvdata(dev); int ret; ret = clk_prepare_enable(d->clk); if (ret) return ret; /* 恢复关键寄存器 */ mydev_restore_regs(d); return 0; }对应的runtime_suspend就是反过来:保存上下文、关时钟、断电源。这两个回调必须成对、可重入,因为 PM Core 可能在任何时候调用它们。
4.3 runtime_idle 的角色
runtime_idle是个容易被忽略的回调。当usage_count减到 0 时,PM Core 先调用runtime_idle,而不是直接 suspend。这个回调给了驱动一个“决定要不要挂起”的机会。默认行为(不实现该回调)是直接触发 suspend。有些驱动会在这里判断设备是否真的空闲,或者启动一个延迟。
大多数情况下你不需要实现runtime_idle,用 autosuspend 就够了。但如果你有特殊的空闲判断逻辑,可以在这里做。
4.4 状态机全貌
把状态流转画成一张表更清楚:
| 当前状态 | 触发动作 | 目标状态 | 说明 |
|---|---|---|---|
| RPM_ACTIVE | put 且计数归零 | RPM_SUSPENDING | 进入挂起流程 |
| RPM_SUSPENDING | suspend 回调成功 | RPM_SUSPENDED | 挂起完成 |
| RPM_SUSPENDING | suspend 回调失败 | RPM_ACTIVE | 回滚,记录 runtime_error |
| RPM_SUSPENDED | get | RPM_RESUMING | 进入恢复流程 |
| RPM_RESUMING | resume 回调成功 | RPM_ACTIVE | 恢复完成 |
| RPM_RESUMING | resume 回调失败 | RPM_SUSPENDED | 回滚,记录 runtime_error |
这张表在排查“状态卡在 RESUMING”这类问题时特别有用。如果你看到runtime_status一直是RPM_RESUMING,基本可以断定 resume 回调里卡住了,多半是在等某个锁或者某个时钟没起来。
5. runtime pm 和系统级睡眠的交接
runtime pm 不是孤立存在的,它必须和系统级睡眠(suspend to RAM 那套)协调好。这两者的关系如果没理清,很容易出现“系统睡眠时设备状态错乱”的问题。
5.1 系统睡眠时 runtime pm 怎么处理
当整机要进入系统级睡眠时,PM Core 会遍历所有设备,对每个设备调用系统级的suspend回调。但这里有个前提:如果设备当前是 runtime active 的,系统级 suspend 之前需要先把它 runtime 挂起,或者至少保证状态一致。
内核的处理方式是:在系统 suspend 流程中,会先调用pm_runtime_resume确保设备处于已知状态,然后再走系统级 suspend。反过来,系统 resume 之后,设备不会自动回到 runtime suspended,而是保持 active,等下一次 idle 再挂起。
5.2 为什么有些驱动要区分 runtime 和 system 回调
dev_pm_ops里有两套回调:runtime_suspend/resume和suspend/resume(系统级)。很多简单驱动会让它们指向同一个函数,但严格来说两者语义不同:
- runtime 回调:设备空闲时调用,可能非常频繁,要求快速、轻量。
- system 回调:整机睡眠时调用,频率低,可以做更彻底的省电操作,比如完全断电。
如果一个设备在 runtime suspend 时只是关时钟,而在 system suspend 时需要彻底断电,那就必须分开实现。混用会导致要么 runtime 太耗电,要么 system 恢复太慢。
5.3 一个真实的交接 bug
我遇到过一个案例:某驱动在runtime_suspend里把电源域关了,但系统级suspend回调里又去访问了这个电源域下的寄存器,结果系统睡眠时直接挂死。根因就是没理清两套回调的执行顺序——系统 suspend 之前,设备可能已经被 runtime 挂起、电源已断,此时再访问寄存器就是访问死区。
修复方式是在系统级suspend回调开头先pm_runtime_get_sync把设备唤醒,操作完再 put。或者更规范的做法是让系统级回调不依赖硬件状态,只做纯软件的状态保存。
提示:判断一个驱动是否处理好了交接,看它在系统级 suspend/resume 里有没有考虑 runtime 状态。如果完全没有
pm_runtime_*调用,多半有隐患。
6. 调试 runtime pm 的实战套路
理论讲完,落到实操。runtime pm 的问题往往表现为“设备该睡不睡”或者“该醒不醒”,排查起来需要一套系统的方法。
6.1 先看 sysfs,再看计数
第一步永远是 sysfs。/sys/devices/.../power/目录下有这几个关键文件:
runtime_status:当前状态,active 还是 suspended。runtime_usage:当前 usage_count 的值。runtime_active_kids:有多少子设备是 active 的。control:auto 或 on,on 表示禁止 runtime 挂起。autosuspend_delay_ms:当前 autosuspend 延迟。
如果runtime_status是 active 但设备明明没人用,先看runtime_usage。如果它大于 0,说明有引用没释放,去找谁 get 了没 put。如果等于 0 但还是 active,看runtime_active_kids,可能是子设备顶住了。
6.2 用 ftrace 追调用链
sysfs 只能看结果,要看过程得上 ftrace。内核的 pm 子系统有专门的 tracepoint:
# 开启 pm runtime 相关 trace echo 1 > /sys/kernel/debug/tracing/events/power/enable cat /sys/kernel/debug/tracing/trace_pipe你会看到pm_runtime_resume、pm_runtime_suspend、pm_runtime_idle这些事件的完整调用记录,包括设备名、耗时、调用者。排查“谁在频繁唤醒设备”时,这个输出直接告诉你答案。
6.3 常见问题对照表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 设备一直 active | usage_count 泄漏 | 检查 get/put 是否配对,错误路径是否漏 put |
| 设备一直 active | 子设备顶住 | 看 runtime_active_kids,逐层往下查 |
| 设备一直 active | control 是 on | 检查是否有人写了 on,或 disable_depth > 0 |
| 设备频繁 resume | autosuspend 延迟太短 | 调大 delay,或检查是否有人频繁 get |
| resume 失败 | 时钟/电源未就绪 | 看 runtime_error,检查 resume 回调 |
| 状态卡在 RESUMING | resume 回调死锁 | 用 ftrace 看卡在哪个函数 |
6.4 一个计数泄漏的定位过程
说个我实际踩的坑。某驱动在 probe 里调了pm_runtime_get_sync,但 remove 路径里忘了 put,导致设备卸载后计数还挂着。表现是设备明明已经 remove 了,父设备的runtime_active_kids还是 1,整个电源域关不掉。
定位方法是打开 ftrace,过滤这个设备的 pm 事件,发现只有 resume 没有 suspend。再回头看代码,probe 里 get 了,但错误分支和 remove 里都没 put。修复就是在 remove 和所有错误分支补上pm_runtime_put_noidle或pm_runtime_put_sync。
这个坑的教训是:get 和 put 必须成对出现在所有代码路径上,包括错误路径。写驱动时我习惯把 get 放在函数开头,然后用 goto 统一处理错误,确保每条路径都会 put。
7. 把 runtime pm 接进驱动的完整姿势
最后把前面所有东西串起来,讲一个驱动从零接入 runtime pm 的标准流程。这套流程我用了很多次,基本可以照抄。
7.1 probe 里的初始化顺序
顺序很重要,错了会导致状态不一致:
static int mydev_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; struct mydev *d; int ret; d = devm_kzalloc(dev, sizeof(*d), GFP_KERNEL); if (!d) return -ENOMEM; platform_set_drvdata(pdev, d); /* 1. 先做硬件初始化,此时设备是 active 的 */ ret = mydev_hw_init(d); if (ret) return ret; /* 2. 配置 autosuspend */ pm_runtime_set_autosuspend_delay(dev, 200); pm_runtime_use_autosuspend(dev); /* 3. 标记当前为 active,因为硬件已经初始化好了 */ pm_runtime_set_active(dev); /* 4. 最后使能 runtime pm */ pm_runtime_enable(dev); return 0; }关键点:pm_runtime_set_active必须在pm_runtime_enable之前调用。因为 enable 之后 PM Core 会认为设备处于 suspended 状态(默认),如果实际硬件是 active 的,就会状态不一致。先 set_active 告诉内核“我现在是活的”,再 enable,状态才对得上。
7.2 remove 里的清理
remove 要保证设备被正确挂起,计数清零:
static int mydev_remove(struct platform_device *pdev) { struct device *dev = &pdev->dev; pm_runtime_disable(dev); pm_runtime_set_suspended(dev); pm_runtime_put_noidle(dev); mydev_hw_deinit(dev_get_drvdata(dev)); return 0; }pm_runtime_disable会阻止后续的 runtime 操作,set_suspended把状态归位,put_noidle清掉 probe 时可能残留的引用。这三步做完,设备才算干净地退出。
7.3 运行时使用的标准模板
在真正干活的函数里,模式是固定的:
static int mydev_do_something(struct device *dev) { int ret; ret = pm_runtime_get_sync(dev); if (ret < 0) { pm_runtime_put_noidle(dev); return ret; } /* 访问硬件 */ ret = mydev_access_hw(dev); pm_runtime_mark_last_busy(dev); pm_runtime_put_autosuspend(dev); return ret; }这个模板覆盖了 90% 的场景。记住三件事:get_sync 后判返回值、访问完 mark_last_busy、用 put_autosuspend 释放。
7.4 几个反复踩的坑
第一个坑是在中断上下文里用 get_sync。get_sync会睡眠等待,中断上下文不能睡眠,会直接报错。中断里要用pm_runtime_get(异步版),或者干脆在中断上半部只做标记,下半部再处理。
第二个坑是忘记处理 resume 失败。get_sync返回负值表示 resume 失败,此时设备不可用,必须回滚计数并返回错误。我见过驱动忽略返回值直接访问寄存器,结果 resume 失败时访问了未上电的硬件。
第三个坑是autosuspend 和手动 suspend 混用。如果开了 autosuspend,就不要再手动调pm_runtime_suspend,两者会打架。统一走 put_autosuspend 让 PM Core 调度。
第四个坑是父设备没使能 runtime pm。子设备 resume 时会尝试唤醒父设备,如果父设备没 enable,整个级联就断了。确保设备树路径上所有设备都正确接入了 runtime pm。
7.5 验证是否真的省电了
代码写完不代表就省电了。验证方法是:让设备进入空闲,用电流表或者 SoC 内部的功耗计数器看实际电流有没有降下来。如果代码逻辑都对但电流没降,可能是时钟没真正关、电源域没断,或者有其他设备在偷偷唤醒。
我一般会配合 ftrace 看一段时间内 suspend/resume 的次数。如果次数远高于实际使用频率,说明 autosuspend 延迟太短或者有异常唤醒源,需要进一步查。
8. 写在最后的一点个人体会
runtime pm 这套机制刚接触时觉得 API 挺多、状态挺绕,但用熟之后会发现它的设计其实很克制——核心就是引用计数加状态机,剩下的都是围绕这两样东西的配套。真正难的不是记住 API,而是理解“什么时候该 get、什么时候该 put、状态什么时候会变”这三件事背后的因果。
我自己最大的教训是早期写驱动时把 runtime pm 当成可选项,觉得不接也能跑。结果就是设备功耗一直下不来,等到产品要过功耗测试才回头补,那时候改动面已经很大了。所以如果你现在正在写新驱动,建议从第一版就把 runtime pm 接进去,哪怕一开始只是最简单的 get_sync/put 配对,也比后面补要省事得多。
另外提一句,不同内核版本的 runtime pm 实现细节有差异,比如 autosuspend 的默认行为和 tracepoint 的名字在 4.x 和 5.x、6.x 之间都有变化。看代码时以你实际用的内核版本为准,别拿网上的老文章硬套。遇到状态对不上,先cat runtime_status和runtime_usage,再上 ftrace,基本没有查不出来的问题。