ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux runtime pm 深度解析:从引用计数到待机功耗优化实战

Linux runtime pm 深度解析:从引用计数到待机功耗优化实战 1. 从一次待机耗电异常说起runtime pm 到底在管什么前阵子帮朋友排查一个嵌入式设备的待机功耗问题现象很典型系统进入 suspend 之后整机静态电流比预期高了将近 8mA电池续航直接缩水三分之一。用万用表逐路量下去最后锁定在一颗 I2C 触摸控制器上——系统都睡了它还在正常供电、正常时钟压根没进入低功耗状态。问题根因不复杂驱动里只实现了系统级的 suspend/resume 回调没有把 runtime pm 这套机制接进去导致设备在系统挂起之前从来没有被单独关过。这件事让我意识到很多人对 Linux 功耗子系统的理解停留在“suspend 就是整机睡觉”这个层面而 runtime pmRuntime Power Management运行时电源管理恰恰是那个容易被忽略、却直接决定待机功耗的关键角色。它管的是设备在系统正常运行期间按需独立地上电、下电、进入低功耗状态跟系统级的 suspend/resume 是两套并行又互相配合的机制。这篇内容我打算把 runtime pm 从概念到代码、从原理到实操完整梳理一遍。适合三类人看一是做嵌入式 Linux 驱动开发、需要给外设加电源管理的工程师二是做功耗优化、被待机电流折磨过的系统工程师三是对内核 PM Core 感兴趣、想搞明白struct device里那一堆 pm 字段到底干嘛用的学习者。我会尽量把每个设计决策背后的“为什么”讲清楚而不是只丢一堆 API 让你背。先给个全局印象runtime pm 的核心思想是引用计数 状态机。每个设备有一个使用计数usage count当计数归零且满足条件时设备可以进入低功耗当有人要用设备时计数加一设备被唤醒。听起来简单但内核为了让它既高效又安全围绕struct device设计了一整套回调、标志位和锁机制这才是真正需要吃透的地方。2. runtime pm 的整体设计与核心思路拆解2.1 它和系统级 suspend 到底什么关系很多人第一次接触会混淆这两个概念我用一个生活化的类比说清楚。系统级 suspend 像是整栋楼晚上统一拉闸断电所有住户一起睡runtime pm 则像是每户人家自己控制空调和灯谁不用谁关跟楼里其他住户没关系。两者是正交的一栋楼可以白天各户自己省电runtime pm晚上再统一拉闸system suspend。从内核实现角度看这两套机制共享同一批回调的“语义”但触发路径完全不同。系统级 suspend 走的是dpm_suspend那条线按设备树顺序遍历所有设备runtime pm 走的是每个设备自己的dev-power状态机由驱动主动调用pm_runtime_get/put来驱动。关键点在于系统级 suspend 在挂起设备之前会先把已经 runtime suspended 的设备跳过因为人家已经睡了不用再睡一次。这个配合逻辑在pm_runtime_force_suspend之类的辅助函数里体现得很明显。理解这层关系很重要因为它决定了你写驱动时的策略如果一个设备支持 runtime pm那么系统级 suspend 回调里往往不需要重复做下电动作内核会帮你协调。反过来如果你只写了系统级回调没写 runtime pm那设备在系统运行期间就永远是全功率待机功耗自然下不来。2.2 引用计数为什么是这套机制的基石runtime pm 用引用计数而不是简单的布尔开关这个选择背后有充分的理由。设想一个 I2C 控制器它下面挂了触摸屏、加速度计、EEPROM 三个设备。如果触摸屏正在用总线控制器就不能睡如果三个设备都不用控制器才能睡。这种“多个使用者共享一个资源”的场景布尔开关根本表达不了必须用计数。内核里对应的就是dev-power.usage_count通过pm_runtime_get()加一、pm_runtime_put()减一。当计数减到零内核会尝试让设备进入 idle。这里有个容易踩的坑pm_runtime_get()是同步的它会阻塞直到设备真正被唤醒resume 完成而pm_runtime_get_noresume()只加计数不唤醒适合在中断上下文或者你确定设备已经醒着的时候用。选错了轻则性能抖动重则死锁。计数还有一个隐藏语义它同时充当了“防止设备被自动挂起”的锁。只要计数大于零runtime pm 的自动挂起就不会碰这个设备。所以驱动里常见的一个模式是在probe阶段先pm_runtime_get_noresume把计数顶住初始化完硬件、注册好中断之后再pm_runtime_put放开让设备可以自动进入低功耗。这个顺序不能反否则初始化过程中设备可能被挂起寄存器访问直接失败。2.3 状态机与几个关键标志位struct device里的struct dev_pm_info power是整个 runtime pm 的状态中枢里面几个字段必须认识字段含义典型用途usage_count使用计数引用计数决定能否挂起disable_depth禁用深度大于 0 时 runtime pm 完全失效runtime_status运行时状态RPM_ACTIVE / RPM_SUSPENDED / RPM_SUSPENDING / RPM_RESUMINGruntime_error错误标志记录上次 resume/suspend 是否失败runtime_auto自动挂起开关控制是否允许自动 idleignore_children忽略子设备父设备是否因子的活跃而保持活跃child_count活跃子设备数用于父子联动runtime_status这个状态机是理解一切的关键。设备初始是RPM_ACTIVE当计数归零且允许自动挂起时进入RPM_SUSPENDING回调执行成功后变RPM_SUSPENDED。有人get时从RPM_SUSPENDED进入RPM_RESUMING回调成功后回RPM_ACTIVE。中间态的存在是为了处理并发——两个 CPU 同时想 resume 同一个设备时第二个会发现状态已经是RPM_RESUMING就等待而不是重复调用回调。disable_depth是个很巧妙的设计。它是个计数器而不是布尔值意味着可以嵌套禁用。比如父设备在挂起自己的过程中需要临时禁止子设备的 runtime pm 活动就pm_runtime_disable一下用完再enable。嵌套计数保证了多层调用不会互相干扰。这个字段大于零时所有pm_runtime_get/put对状态机的影响都被屏蔽但计数本身还在变等 enable 之后一次性结算。3. 核心细节解析与实操要点3.1 回调函数集驱动要填哪些坑runtime pm 的回调定义在struct dev_pm_ops里跟 runtime 相关的主要是三个runtime_suspend、runtime_resume、runtime_idle。前两个是必须的第三个可选。runtime_suspend的职责是把设备置于低功耗状态通常是关时钟、关电源域、保存必要的寄存器上下文。注意它运行在进程上下文因为pm_runtime_put可能睡眠所以可以调用可能睡眠的函数比如 I2C 传输、msleep。但有个铁律这个回调返回 0 才算成功返回负数会被记录到runtime_error并且设备状态回滚到 ACTIVE。runtime_resume做相反的事恢复时钟、上电、还原寄存器。它同样在进程上下文运行。这里有个细节很多人忽略resume 回调里访问硬件之前要确保时钟和电源已经就绪而这些往往依赖父设备比如时钟控制器、电源管理 IC已经处于 active。内核通过父子设备的 runtime pm 联动来保证这一点前提是你在设备树里正确描述了power-domains和时钟依赖。runtime_idle比较特殊它在计数归零、准备挂起之前被调用给你一个“要不要现在挂起”的决策机会。默认行为是直接调用runtime_suspend。有些驱动会在这里做延迟挂起比如等一个超时看有没有新请求进来避免频繁上下电。但大多数场景直接用默认就行不要过度设计。3.2 自动挂起与手动控制的取舍runtime pm 有两种使用模式自动挂起autosuspend和手动控制。自动挂起是主流做法。驱动在初始化完成后调用pm_runtime_allow()或pm_runtime_set_autosuspend_delay()配合pm_runtime_use_autosuspend()之后只要计数归零内核就会在延迟时间后自动挂起设备。这个延迟很关键——比如一个触摸屏用户手指抬起后可能马上又按下如果立刻挂起再唤醒开销比省下的电还大。设置一个 100~500ms 的延迟能把连续操作合并成一次上下电。手动控制则是驱动自己在合适的时机显式调用pm_runtime_put_sync()强制挂起。适合那些生命周期明确、不需要自动决策的设备比如某些一次性使用的传感器。我个人的经验是能用自动挂起就用自动挂起手动控制容易在异常路径上漏掉put导致设备永远不睡。自动挂起配合合理的延迟既省心又省电。只有在自动挂起的延迟策略无法满足实时性要求时才考虑手动。3.3 父子设备的联动机制这是 runtime pm 里最精妙也最容易出错的部分。内核默认行为是父设备只有在所有子设备都 suspended 之后自己才能 suspended。这个逻辑通过child_count实现——每当一个子设备变 active父设备的child_count加一子设备 suspended 时减一。父设备的计数归零判断里会检查child_count是否也为零。这个机制保证了电源域的正确性如果子设备还在工作父设备比如它所在的电源域就不能断电。但有时候这个默认行为不是你想要的比如一个 USB 控制器下面挂着多个端口控制器本身可以在端口空闲时进入低功耗而不必等所有端口设备都睡。这时候就要设置ignore_children标志告诉内核“别管我的子设备我自己决定”。设置ignore_children要非常谨慎因为一旦设错可能导致子设备还在访问硬件时父设备已经断电直接触发总线错误或者更隐蔽的数据损坏。我的建议是除非你非常清楚父子之间的电源依赖关系否则保持默认。真需要优化先在runtime_idle里做文章而不是动ignore_children。4. 实操过程与核心环节实现4.1 一个最小可用的 runtime pm 驱动骨架下面这段代码是我从一个真实 I2C 传感器驱动里抽出来的骨架去掉了业务逻辑保留了 runtime pm 的完整接入方式。你可以直接照着改。#include linux/pm_runtime.h #include linux/pm_clock.h struct my_sensor { struct device *dev; struct i2c_client *client; struct regulator *vdd; struct clk *clk; bool powered; }; static int my_sensor_runtime_suspend(struct device *dev) { struct my_sensor *sensor dev_get_drvdata(dev); /* 保存关键寄存器上下文 */ my_sensor_save_regs(sensor); /* 关时钟注意顺序先关时钟再断电源 */ clk_disable_unprepare(sensor-clk); /* 断电 */ if (sensor-vdd) { regulator_disable(sensor-vdd); } sensor-powered false; dev_dbg(dev, runtime suspended\n); return 0; } static int my_sensor_runtime_resume(struct device *dev) { struct my_sensor *sensor dev_get_drvdata(dev); int ret; /* 上电顺序与 suspend 相反 */ if (sensor-vdd) { ret regulator_enable(sensor-vdd); if (ret) return ret; } ret clk_prepare_enable(sensor-clk); if (ret) { if (sensor-vdd) regulator_disable(sensor-vdd); return ret; } /* 恢复寄存器上下文 */ my_sensor_restore_regs(sensor); sensor-powered true; dev_dbg(dev, runtime resumed\n); return 0; } static const struct dev_pm_ops my_sensor_pm_ops { SET_RUNTIME_PM_OPS(my_sensor_runtime_suspend, my_sensor_runtime_resume, NULL) }; static int my_sensor_probe(struct i2c_client *client) { struct my_sensor *sensor; int ret; sensor devm_kzalloc(client-dev, sizeof(*sensor), GFP_KERNEL); if (!sensor) return -ENOMEM; sensor-dev client-dev; sensor-client client; i2c_set_clientdata(client, sensor); sensor-vdd devm_regulator_get(client-dev, vdd); sensor-clk devm_clk_get(client-dev, sclk); /* 关键先顶住计数防止初始化期间被挂起 */ pm_runtime_get_noresume(client-dev); pm_runtime_set_active(client-dev); pm_runtime_enable(client-dev); /* 硬件初始化此时设备保证是 active 的 */ ret my_sensor_hw_init(sensor); if (ret) goto err_pm; /* 配置自动挂起延迟 200ms */ pm_runtime_set_autosuspend_delay(client-dev, 200); pm_runtime_use_autosuspend(client-dev); /* 放开计数允许自动挂起 */ pm_runtime_put(client-dev); return 0; err_pm: pm_runtime_disable(client-dev); pm_runtime_put_noidle(client-dev); return ret; }这段代码里有几个点值得展开说。pm_runtime_set_active在pm_runtime_enable之前调用是为了告诉内核“设备当前实际是开着的”避免内核误以为它已经 suspended 而跳过第一次 resume。这个顺序错了第一次get时可能不会触发 resume 回调硬件状态和内核状态就对不上了。pm_runtime_get_noresume和pm_runtime_put的配对是初始化阶段的经典模式。get_noresume只加计数不触发 resume因为设备本来就是 active 的初始化完成后put减计数如果此时没有其他使用者设备就会在 200ms 后自动挂起。4.2 在数据传输路径上正确使用 get/put设备初始化好了只是第一步真正决定功耗的是每次数据传输前后是否正确 get/put。以 I2C 读传感器数据为例static int my_sensor_read_data(struct my_sensor *sensor, u8 *buf, int len) { int ret; /* 唤醒设备同步等待 resume 完成 */ ret pm_runtime_get_sync(sensor-dev); if (ret 0) { pm_runtime_put_noidle(sensor-dev); return ret; } /* 此时设备保证 active可以安全访问 */ ret i2c_smbus_read_i2c_block_data(sensor-client, MY_SENSOR_DATA_REG, len, buf); /* 用完释放配合 autosuspend 延迟挂起 */ pm_runtime_mark_last_busy(sensor-dev); pm_runtime_put_autosuspend(sensor-dev); return ret; }这里pm_runtime_get_sync的返回值必须检查。如果 resume 失败比如电源芯片没响应它会返回负数此时不能继续访问硬件而且必须用pm_runtime_put_noidle把刚才加上的计数减回去否则计数泄漏设备永远不睡。这个错误路径是新手最容易漏的地方。pm_runtime_mark_last_busy配合pm_runtime_put_autosuspend是自动挂起模式的标准收尾。mark_last_busy更新最后活跃时间戳put_autosuspend减计数并启动延迟计时。如果这期间又有新的get计时重置设备保持 active。这套组合能把高频小数据传输的上下电次数降到最低。4.3 参数计算autosuspend 延迟怎么定延迟时间不是拍脑袋定的我一般按这个思路算。假设设备单次上下电开销是 T_onoff包括 resume 回调耗时 硬件稳定时间平均请求间隔是 T_interval。如果 T_interval T_onoff那么每次请求都上下电是净亏的应该把延迟设得比 T_interval 大让设备保持 active。具体到数字一个传感器 resume 需要 2ms时钟稳定 寄存器恢复用户操作间隔大约 50ms。那么延迟设 100ms 比较合适——既能合并连续操作又不会让设备在真正空闲时还赖着不睡。如果延迟设成 10ms每次操作都要上下电2ms 的开销占比太高设成 1000ms空闲时白白多耗 900ms 的电。实测方法也简单在runtime_suspend和runtime_resume里打时间戳跑一段真实负载统计上下电次数和总耗时。然后调整延迟看哪个值让“总功耗 静态功耗 上下电开销”最小。这个优化没有万能公式必须结合具体硬件和负载特征。5. 常见问题与排查技巧实录5.1 设备永远不睡计数泄漏排查这是最高频的问题。现象是cat /sys/kernel/debug/pm_genpd/*/state或者/sys/devices/.../power/runtime_status一直显示active设备从不进入 suspended。排查第一步看usage_count。在 debugfs 里读/sys/kernel/debug/pm_runtime/...或者直接看runtime_status和runtime_usage。如果 usage_count 大于零说明有地方get了没put。常见泄漏点错误路径上get_sync失败后没调put_noidle中断处理里get了但设备移除时没清理多个代码路径共享一个get但put只在其中一条路径上我一般用pm_runtime_get的调用点做二分排查在可疑的get后面加打印看哪个计数加完就再也没减过。内核的CONFIG_PM_ADVANCED_DEBUG打开后/sys/devices/.../power/runtime_usage能直接读到计数非常方便。5.2 resume 失败导致设备卡死runtime_resume返回负数时设备状态会停在RPM_RESUMING或者回滚后续所有get_sync都会失败。如果驱动没处理这个错误上层可能一直重试表现为系统卡顿或者某个功能完全不可用。根因通常是 resume 回调里访问了还没就绪的资源。比如时钟还没使能就去读寄存器I2C 控制器还没 resume 就发起传输。解决办法是检查设备树里的依赖描述是否完整确保power-domains、clocks、regulators都正确声明。内核会按依赖顺序 resume 父设备但前提是你告诉它谁是谁的父。还有一个隐蔽情况resume 回调里调用了会睡眠的函数但当前上下文不允许睡眠。虽然 runtime pm 回调通常在进程上下文但如果你的get是从原子上下文比如自旋锁保护的代码发起的get_sync会直接返回-EAGAIN。这时候要么改用get_noresume加手动唤醒要么重构代码避免在原子上下文操作。5.3 父子设备联动引发的死锁父子联动偶尔会导致死锁父设备在等子设备 suspended子设备在等父设备提供资源才能 suspended。典型场景是子设备的runtime_suspend里需要访问父设备的总线但父设备已经因为child_count归零而开始挂起自己总线时钟被关了。避免这个问题的原则是子设备的 suspend 回调不应该依赖父设备处于 active。如果确实需要就在子设备 suspend 之前先确保父设备被get住。或者反过来给父设备设ignore_children让它不因子设备而保持 active但这样又回到前面说的风险。我的经验是遇到这类死锁先打开CONFIG_PM_DEBUG和CONFIG_PM_TRACE用pm_print_active_wakeup_sources之类的工具看谁挡住了谁。多数情况下问题出在设备树的依赖描述不准确而不是代码逻辑本身。5.4 常见问题速查表现象可能原因排查手段解决方向设备永不 suspendedusage_count 泄漏读 runtime_usage找未配对的 getresume 返回错误资源未就绪看 dmesg 报错补全设备树依赖系统 suspend 后功耗仍高驱动未接 runtime pm检查 pm_ops补 runtime 回调频繁上下电autosuspend 延迟过短统计上下电次数调大延迟父子设备死锁依赖关系错误PM_TRACE调整 ignore_childrenget_sync 返回 -EAGAIN原子上下文调用看调用栈改用 noresume6. 几个我踩过的坑和实操心得第一个坑是关于pm_runtime_enable的时机。我早期写驱动时习惯在probe最开始就 enable结果硬件还没初始化自动挂起就触发了runtime_suspend回调里访问未初始化的时钟指针直接 oops。正确做法是先把硬件和资源准备好set_active之后再 enable。这个顺序在内核文档里其实有写但很容易被忽略。第二个心得是关于调试手段。/sys/kernel/debug/pm_runtime/下面有个runtime_status汇总但更实用的是每个设备目录下的power/runtime_status、power/runtime_usage、power/control。control文件可以手动写on或auto临时强制设备保持 active 或者允许自动挂起排查问题时特别方便。我经常用echo on power/control来排除自动挂起干扰确认是挂起逻辑的问题还是别的。第三个经验是不要过度追求“设备一空闲就睡”。有些设备上下电开销很大比如需要重新加载固件的 DSP频繁挂起反而更费电。这种设备适合用较长的 autosuspend 延迟或者在runtime_idle里做更聪明的决策。内核给了你runtime_idle这个钩子就是让你根据实际情况定制策略的别浪费。最后一个提醒runtime pm 的正确性高度依赖设备树的准确性。power-domains、clocks、regulators、interconnects这些描述如果漏了或者写错了runtime pm 要么不工作要么工作得不安全。我现在的习惯是写完驱动先dtc反编译检查一遍设备树确认依赖关系跟硬件手册对得上再开始调功耗。这一步花十分钟能省掉后面几小时的诡异 bug 排查。
返回列表