1. Runtime PM 到底在解决什么问题
第一次接触runtime pm的人,十有八九会被它那一堆回调函数和引用计数绕晕。我在带新人的时候,最常听到的抱怨就是"设备明明已经没人用了,为什么功耗还是下不去"。这个问题恰恰就是 runtime pm 存在的意义。
先说结论:runtime pm 是 Linux 内核功耗子系统里负责设备级动态电源管理的那一层。它要干的事情很朴素——当某个设备空闲时,自动把它挂起省电;当有人要用它时,再自动唤醒。整个过程对驱动开发者透明,对上层应用也透明。
为什么需要这么一层?因为现代 SoC 上挂的外设太多了。屏幕、摄像头、WiFi、蓝牙、SD 卡、各种传感器,如果每个设备都一直保持上电状态,待机功耗会非常难看。但你又不能简单粗暴地一刀切,因为有些设备随时可能被唤醒,有些设备挂起恢复的代价很高。runtime pm 就是在这中间做权衡的那套机制。
它和系统级的 suspend/resume 是两码事。系统级 suspend 是整个机器进入低功耗状态,用户能感知到"睡眠";runtime pm 是单个设备层面的,用户完全无感。你可以理解为:系统 suspend 是整栋楼熄灯,runtime pm 是某个房间没人就关灯。
这套机制的核心数据结构是struct dev_pm_info,它嵌在struct device里面。也就是说,每一个 device 天生就带着 runtime pm 的能力,用不用是另一回事。这个设计很关键,后面讲引用计数的时候会反复用到。
适合谁来读这篇?如果你在做嵌入式 Linux 驱动开发,尤其是涉及电源管理、外设驱动、SoC 平台适配,那 runtime pm 是绕不过去的。如果你只是做应用层开发,了解一下概念就够了,不用深挖回调实现。我下面会从框架设计讲到实操细节,尽量让不同基础的人都能拿到东西。
2. 核心设计思路与关键数据结构拆解
2.1 为什么用引用计数而不是简单的开关
很多人第一反应是:设备空闲就挂起,有人用就唤醒,搞个布尔标志不就行了?实际上不行,因为一个设备可能同时被多个使用者引用。
举个实际例子:一个 I2C 控制器上挂了温度传感器和 EEPROM 两颗芯片。温度传感器在周期性采样,EEPROM 偶尔被读写。如果只用布尔标志,温度传感器采样完把设备挂起,EEPROM 正在读写就被打断了。引用计数就是为了解决这种"多方共享"的问题——只要还有任何一个使用者持有引用,设备就不能挂起。
内核里的实现是dev->power.usage_count,类型是atomic_t。每次pm_runtime_get加一,pm_runtime_put减一。当计数归零,并且没有其他阻止条件时,才会触发挂起流程。
这里有个容易踩的坑:usage_count是原子操作,但判断是否挂起的逻辑不是原子的。所以内核用了dev->power.lock自旋锁来保护状态机。你在写驱动的时候不需要直接操作这个锁,但要知道它的存在,因为它决定了回调函数的执行上下文。
2.2 struct dev_pm_info 里到底装了什么
struct dev_pm_info是 runtime pm 的核心,我把它里面和 runtime 相关的字段挑出来说:
| 字段 | 作用 | 备注 |
|---|---|---|
usage_count | 引用计数 | 原子变量,get/put 操作的对象 |
child_count | 活跃子设备计数 | 父设备挂起前要确保子设备都挂起 |
disable_depth | 禁用深度 | 大于 0 表示 runtime pm 被禁用 |
runtime_error | 错误标志 | 回调返回错误时置位 |
runtime_status | 当前状态 | RPM_ACTIVE / RPM_SUSPENDED 等 |
runtime_auto | 自动挂起开关 | 用户空间可通过 sysfs 控制 |
request_pending | 待处理请求 | 有异步请求排队时置位 |
irq_safe | 中断上下文安全 | 标记回调能否在原子上下文调用 |
runtime_status是个枚举,取值包括RPM_INVALID、RPM_ACTIVE、RPM_RESUMING、RPM_SUSPENDED、RPM_SUSPENDING。状态机就是围绕这几个值转的。
disable_depth这个字段值得单独说。它是个计数器,不是布尔值。为什么?因为可能有多个地方同时想禁用 runtime pm,比如系统正在做 suspend 流程,同时某个驱动也想临时禁用。用计数就能保证所有禁用方都释放后才真正启用。
2.3 回调函数的职责划分
runtime pm 给驱动提供了三个核心回调,定义在struct dev_pm_ops里:
runtime_suspend:设备空闲时调用,负责把设备挂起runtime_resume:设备被唤醒时调用,负责恢复设备runtime_idle:设备空闲但还没挂起时调用,可以做延迟挂起
这三个回调不是必须全部实现。如果你只实现 suspend 和 resume,idle 走默认逻辑也行。但我要提醒一句:runtime_idle的默认行为是直接调用runtime_suspend,如果你的设备需要延迟一段时间再挂起(比如等某个操作彻底完成),就必须自己实现 idle 回调。
回调的执行上下文也有讲究。默认情况下,这些回调在进程上下文执行,可以睡眠。但如果你设置了pm_runtime_irq_safe,回调就可能在中断上下文执行,这时候就不能睡眠了。这个标志要慎用,用错了会导致难以排查的死锁。
3. 状态机与引用计数的实操细节
3.1 状态流转的完整路径
runtime pm 的状态机看起来复杂,但核心路径就两条:挂起和唤醒。
挂起路径是这样的:设备引用计数归零,runtime_idle被调用(如果有),然后进入runtime_suspend。如果 suspend 成功,状态变成RPM_SUSPENDED;如果失败,状态回到RPM_ACTIVE,并且runtime_error置位。
唤醒路径反过来:有人调用pm_runtime_get,如果设备处于挂起状态,触发runtime_resume,成功后状态变成RPM_ACTIVE。
这里有个细节很多人忽略:pm_runtime_get是异步的。它只是增加引用计数并标记需要唤醒,实际的 resume 操作可能稍后才执行。如果你需要确保设备已经唤醒,得用pm_runtime_get_sync,它会等待 resume 完成。
我见过不少驱动在中断处理里调用pm_runtime_get_sync,然后抱怨系统卡死。原因就是中断上下文不能睡眠,而get_sync会等待,等待过程可能睡眠。这种场景要么用pm_runtime_get(不等待),要么把操作挪到工作队列里。
3.2 引用计数的配对原则
引用计数最怕的就是不配对。get 了不 put,设备永远不挂起;put 多了,计数变成负数,内核会报 warning。
我总结了几条实操原则:
第一,谁 get 谁 put。在函数入口 get,在函数所有返回路径上都要 put。C 语言里多个 return 很容易漏,建议用goto统一清理。
第二,跨函数传递要小心。如果 A 函数 get 了设备,然后调用 B 函数,B 函数里又 get 一次,那 B 返回后要 put,A 返回后也要 put。这种嵌套引用是合法的,但很容易搞混。
第三,错误路径也要 put。很多人只在成功路径 put,忘了错误分支。结果就是设备在出错后永远不挂起,功耗下不去。
内核提供了pm_runtime_get_sync和pm_runtime_put_sync的配对,也有pm_runtime_get和pm_runtime_put的配对。sync 版本会等待操作完成,非 sync 版本只是标记。选择哪个取决于你是否需要立即生效。
3.3 自动挂起与手动控制的切换
runtime_auto这个标志控制设备是否允许自动挂起。默认值是 true,意思是引用计数归零后自动挂起。用户空间可以通过 sysfs 的control文件修改这个值。
# 查看当前控制模式 cat /sys/devices/.../power/control # 设置为 on 表示禁止自动挂起 echo on > /sys/devices/.../power/control # 设置为 auto 表示允许自动挂起 echo auto > /sys/devices/.../power/control调试的时候这个接口很有用。比如你怀疑某个设备挂起导致问题,可以临时设成 on,看问题是否消失。但要注意,这只是调试手段,产品里不能依赖用户空间去控制。
还有一个pm_runtime_forbid和pm_runtime_allow的内核接口,效果类似,但供驱动内部使用。forbid会增加disable_depth,allow会减少。这两个接口在驱动初始化阶段常用,比如设备还没准备好接受挂起时先 forbid,准备好后再 allow。
4. 驱动中集成 runtime pm 的完整流程
4.1 初始化阶段的准备工作
在驱动 probe 函数里集成 runtime pm,第一步是初始化。内核提供了pm_runtime_enable来启用,但在调用它之前,通常要先设置一些标志。
典型的初始化顺序是这样的:
static int my_driver_probe(struct platform_device *pdev) { struct device *dev = &pdev->dev; int ret; /* 1. 先禁止自动挂起,等设备完全初始化后再允许 */ pm_runtime_forbid(dev); /* 2. 初始化硬件 */ ret = my_hw_init(dev); if (ret) return ret; /* 3. 启用 runtime pm */ pm_runtime_enable(dev); /* 4. 设置设备为活跃状态,因为刚初始化完设备是开着的 */ pm_runtime_set_active(dev); /* 5. 允许自动挂起 */ pm_runtime_allow(dev); /* 6. 标记最后一次使用,触发空闲检查 */ pm_runtime_mark_last_busy(dev); pm_runtime_put_autosuspend(dev); return 0; }这个顺序不是随便定的。先 forbid 是为了防止在硬件还没初始化完的时候就被挂起。pm_runtime_set_active很重要,它告诉框架"设备现在是开着的",否则框架可能以为设备是挂起状态,后续的 resume 逻辑就乱了。
pm_runtime_mark_last_busy配合 autosuspend 使用,它记录最后一次使用的时间戳。pm_runtime_put_autosuspend会减少引用计数,并且如果计数归零,会延迟一段时间再挂起,而不是立即挂起。
4.2 autosuspend 延迟机制的参数选择
autosuspend 是 runtime pm 里非常实用的一个特性。它解决的是"设备频繁短时间使用"的场景。比如一个传感器每 100ms 采样一次,如果每次采样完立即挂起,下次采样又立即唤醒,挂起唤醒的开销可能比省下的电还多。
延迟时间通过pm_runtime_set_autosuspend_delay设置,单位是毫秒:
pm_runtime_set_autosuspend_delay(dev, 200); pm_runtime_use_autosuspend(dev);200ms 意味着设备空闲 200ms 后才挂起。这个值怎么选?我的经验是看设备的挂起恢复开销和使用频率。
如果挂起恢复很快(比如几微秒),延迟可以设小一点,50ms 到 100ms。如果挂起恢复很慢(比如需要重新配置寄存器、重新锁相环),延迟就要设大,几百毫秒甚至一秒。
还有一个pm_runtime_autosuspend_expiration可以查询延迟是否到期。调试的时候可以用它来确认 autosuspend 是否按预期工作。
4.3 回调函数的实现要点
runtime_suspend和runtime_resume的实现有几个要点。
第一,保存和恢复上下文。挂起前要保存设备寄存器状态,恢复时要写回去。但不是所有寄存器都需要保存,只保存那些会丢失的。具体哪些会丢失,看硬件手册。
第二,处理时钟和电源域。挂起时通常要关时钟、关电源域。恢复时反过来。这里要注意顺序:关的时候先关时钟再关电源,开的时候先开电源再开时钟。顺序错了硬件可能不工作。
第三,返回值处理。回调返回 0 表示成功,负数表示失败。失败时框架会把状态设回 active,并置位runtime_error。如果你返回错误,要确保设备处于可用状态,否则后续操作会出问题。
一个典型的 suspend 回调长这样:
static int my_runtime_suspend(struct device *dev) { struct my_device *mydev = dev_get_drvdata(dev); /* 保存寄存器 */ my_save_registers(mydev); /* 关时钟 */ clk_disable_unprepare(mydev->clk); /* 关电源域 */ regulator_disable(mydev->power); return 0; }resume 回调就是反过来的操作。注意 resume 里如果任何一步失败,要回滚已经做的操作,否则设备状态不一致。
5. 常见问题排查与避坑经验
5.1 设备不挂起的排查思路
设备不挂起是最常见的问题。排查步骤我一般这么走:
先看usage_count是不是零。通过 sysfs 的runtime_usage文件可以看:
cat /sys/devices/.../power/runtime_usage如果不是零,说明有地方 get 了没 put。这时候可以用pm_runtime_get的调用栈来定位,或者用 ftrace 跟踪pm_runtime_get和pm_runtime_put的调用。
如果usage_count是零但还不挂起,看disable_depth:
cat /sys/devices/.../power/disable_depth大于零说明被禁用了。检查是不是有地方调用了pm_runtime_forbid没allow,或者系统 suspend 流程还没结束。
再看runtime_status:
cat /sys/devices/.../power/runtime_status如果是suspended,那其实已经挂起了,你可能看错了设备。如果是active,结合前面的计数和禁用深度继续查。
还有一个容易忽略的点:父设备的child_count。如果子设备还活跃,父设备不会挂起。检查设备树里的父子关系,确认子设备是否都挂起了。
5.2 挂起后无法唤醒的定位
比不挂起更严重的是挂起后醒不过来。这种问题通常出在 resume 回调或者唤醒源配置上。
先确认唤醒源是否使能。很多设备挂起后需要配置一个唤醒中断,否则外部事件来了也唤不醒。这个配置通常在 suspend 回调里做,检查有没有漏。
再看 resume 回调的返回值。如果 resume 失败,设备状态会停在RPM_RESUMING或者回到RPM_SUSPENDED,后续访问就会失败。用 dmesg 看有没有相关错误日志。
还有一种情况是 resume 回调里访问了还没准备好的资源。比如时钟还没开就去读寄存器,结果总线挂死。这种要看 resume 里的操作顺序,确保依赖的资源先恢复。
我踩过的一个坑是:在 suspend 里关了电源域,但 resume 里忘了重新使能,结果寄存器读写全部失败。后来养成了习惯,suspend 和 resume 成对写,写完对照检查一遍。
5.3 中断上下文使用的限制
前面提过pm_runtime_irq_safe,这里展开说。设置这个标志后,runtime pm 的回调可以在中断上下文调用,但代价是回调里不能睡眠。
什么情况需要这个标志?典型的是设备的中断处理里需要访问设备寄存器,而设备可能处于挂起状态。如果不设置 irq_safe,中断里调用pm_runtime_get会触发 resume,resume 可能睡眠,中断上下文睡眠就是灾难。
但设置 irq_safe 后,你的 suspend/resume 回调就不能用可能睡眠的函数了。clk_prepare、regulator_enable、mutex_lock这些都不能用。只能用clk_enable(非 prepare 版本)、spin_lock这些。
我的建议是:能不用 irq_safe 就不用。如果中断里确实需要访问设备,考虑把操作挪到工作队列或者 threaded irq 里。这样回调可以在进程上下文执行,限制少很多。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 设备不挂起 | 引用计数不为零 | 查 runtime_usage,跟踪 get/put |
| 设备不挂起 | 被禁用 | 查 disable_depth |
| 设备不挂起 | 子设备活跃 | 查 child_count 和子设备状态 |
| 挂起后无法唤醒 | 唤醒源未配置 | 检查 suspend 里的唤醒配置 |
| 挂起后无法唤醒 | resume 失败 | 查 dmesg 和 runtime_status |
| 中断里调用卡死 | 回调睡眠 | 检查是否设置 irq_safe |
| 计数变负数 | put 多于 get | 跟踪调用栈,检查错误路径 |
| autosuspend 不生效 | 延迟未设置 | 查 autosuspend_delay 和 use_autosuspend |
6. 调试工具与实战技巧
6.1 用 ftrace 跟踪 runtime pm 调用
ftrace 是排查 runtime pm 问题的利器。内核里有个pm_runtime的 tracepoint,可以跟踪所有 get/put/suspend/resume 事件。
启用方法:
# 挂载 debugfs mount -t debugfs none /sys/kernel/debug # 启用 runtime pm 事件 echo 1 > /sys/kernel/debug/tracing/events/power/enable # 开始跟踪 echo 1 > /sys/kernel/debug/tracing/tracing_on # 查看结果 cat /sys/kernel/debug/tracing/trace输出里能看到每个设备的操作序列,包括调用者。如果发现某个设备频繁 suspend/resume,说明 autosuspend 延迟设小了,或者有地方在频繁 get/put。
我一般会配合trace-cmd工具用,录制一段时间后分析。特别是排查"设备不挂起"的时候,看 trace 里最后一次 put 之后有没有 suspend 事件,一目了然。
6.2 sysfs 接口的实用技巧
每个设备的 runtime pm 状态都在/sys/devices/.../power/下面。除了前面提到的control、runtime_status、runtime_usage、disable_depth,还有几个有用的:
runtime_active_time:设备处于活跃状态的总时间(毫秒)runtime_suspended_time:设备处于挂起状态的总时间runtime_active_kids:活跃子设备数量async:异步 suspend/resume 开关
runtime_active_time和runtime_suspended_time可以用来评估电源管理效果。如果活跃时间远大于挂起时间,说明设备大部分时间都在工作,要么是使用频率高,要么是挂起有问题。
写脚本批量检查所有设备的状态也很实用:
for dev in /sys/devices/*/power/runtime_status; do status=$(cat $dev) if [ "$status" = "active" ]; then echo "$dev: $status" fi done这个脚本能列出所有活跃设备,快速定位哪些设备没挂起。
6.3 系统 suspend 与 runtime pm 的交互
系统 suspend 的时候,runtime pm 会被临时禁用。具体流程是:系统 suspend 开始时,内核遍历所有设备,对活跃设备调用pm_runtime_resume确保它们处于活跃状态,然后调用系统级的 suspend 回调。系统 resume 后,再恢复 runtime pm。
这个交互有个坑:如果你的驱动在系统 suspend 回调里依赖 runtime pm 的状态,可能会出错。因为系统 suspend 期间 runtime pm 是禁用的,pm_runtime_get不会触发 resume。
正确的做法是:系统 suspend/resume 回调里直接操作硬件,不要依赖 runtime pm。runtime pm 的回调只在设备级动态管理时用。
还有一点,系统 suspend 前会调用pm_runtime_disable,这会增加disable_depth。如果你的驱动在 suspend 回调里检查disable_depth,会看到非零值,这是正常的。
7. 性能与功耗的权衡实践
7.1 挂起恢复开销的测量
要优化功耗,先得知道挂起恢复的开销。测量方法是在 suspend 和 resume 回调里打时间戳,然后算差值。
static ktime_t suspend_time; static int my_runtime_suspend(struct device *dev) { suspend_time = ktime_get(); /* ... */ } static int my_runtime_resume(struct device *dev) { ktime_t now = ktime_get(); s64 delta = ktime_to_us(ktime_sub(now, suspend_time)); dev_info(dev, "resume took %lld us\n", delta); /* ... */ }实测下来,简单的设备挂起恢复可能只要几十微秒,复杂的设备(比如需要重新加载固件的)可能要几毫秒甚至几十毫秒。
知道开销后,autosuspend 延迟就可以合理设置。经验公式是:延迟时间 > 挂起恢复开销 × 2。比如开销 1ms,延迟至少设 2ms。但实际还要考虑使用频率,如果设备每秒用一次,延迟设 2ms 意味着大部分时间都在挂起,省电效果明显。
7.2 不同场景下的策略选择
不是所有设备都适合激进的电源管理。我一般分三类:
第一类,使用频繁且挂起开销小的设备,比如 GPIO 控制器、简单的 I2C 设备。这类可以设较小的 autosuspend 延迟,甚至不用 autosuspend,直接引用计数归零就挂起。
第二类,使用不频繁但挂起开销大的设备,比如摄像头、显示屏。这类要设较大的延迟,避免频繁挂起恢复。有些场景下甚至考虑不用 runtime pm,改用系统级 suspend。
第三类,随时可能被唤醒的设备,比如触摸屏、传感器。这类要配置好唤醒源,挂起后能快速响应。autosuspend 延迟要小,保证响应速度。
7.3 功耗数据的采集与分析
评估 runtime pm 效果,最终要看功耗数据。采集方法有几种:
硬件层面,用功耗分析仪测整机电流。这是最准的,但需要设备支持。
软件层面,用runtime_active_time和runtime_suspended_time估算。如果挂起时间占比高,功耗通常就低。
还有内核的powertop工具,可以分析哪些设备在阻止系统进入低功耗状态。虽然它主要针对系统级 suspend,但对 runtime pm 也有参考价值。
我一般会做对比测试:关闭 runtime pm 跑一段时间,记录功耗;开启 runtime pm 再跑,对比差异。差异明显说明配置有效,差异不明显就要查是不是有设备没挂起。
8. 从框架到实战的几点体会
runtime pm 这套机制,刚看代码的时候觉得绕,用熟了会发现设计得很精巧。引用计数解决多方共享,状态机保证转换有序,autosuspend 平衡功耗和性能。每个设计都有它的道理。
我在实际项目里踩过的坑,大部分不是框架本身的问题,而是使用方式不对。引用计数不配对、回调里睡眠、唤醒源没配、autosuspend 延迟拍脑袋定,这些都是人为因素。框架给了工具,怎么用还是看人。
有个经验分享给刚接触的人:先在简单的设备上练手,比如一个 GPIO 或者 I2C 设备,把 get/put、suspend/resume 的流程跑通,用 ftrace 看状态变化。等熟悉了再上复杂的设备。上来就搞摄像头、显示屏这种,很容易被各种细节淹没。
还有一点,runtime pm 的调试信息一定要打开。内核配置里CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG打开后,sysfs 接口更全,日志更详细。生产版本可以关掉省空间,但开发阶段一定要开。
最后说个实际场景:我之前做一个电池供电的设备,待机功耗一直下不去。用 ftrace 查了半天,发现是一个传感器驱动在 probe 里 get 了设备但没 put。改完之后待机功耗直接降了一半。这种问题不看 trace 很难发现,因为代码逻辑看起来没问题,就是漏了一个 put。
runtime pm 不是银弹,它解决的是设备级动态电源管理。系统级功耗优化还要配合 CPU idle、DVFS、系统 suspend 这些机制。但把 runtime pm 用好了,是功耗优化的基础。