嵌入式Linux功耗管理:PM QoS约束机制与驱动实践
1. 功耗管理的隐形裁判PM QoS 到底在管什么做过嵌入式 Linux 功耗优化的朋友大概率遇到过这种场景系统待机功耗死活降不下去查了半天 CPU 频率、外设时钟、电源域最后发现是某个驱动在初始化时悄悄拉高了一个约束让整条低功耗路径根本走不通。这个“悄悄拉高约束”的机制十有八九就是 PM QoS。PM QoS全称 Power Management Quality of Service直译过来是“电源管理服务质量”。这个名字听起来有点抽象你可以把它理解成一套功耗与性能之间的投票系统。系统里每个关心功耗或延迟的模块——CPU 调频器、网卡驱动、音频子系统、显示控制器——都可以通过 PM QoS 投出自己的一票告诉内核“我至少需要多低的延迟”或者“我最多能接受多大的功耗”。内核汇总所有投票取一个能满足所有人的折中值再据此决定 CPU 该跑多快、哪些电源域该开、哪些该关。它解决的核心问题是功耗和性能天生矛盾而不同模块的需求又互相冲突。比如音频播放要求 CPU 唤醒延迟极低否则会爆音而系统空闲时又希望 CPU 尽量睡死省电。没有 PM QoS 之前驱动之间只能靠私有接口互相“打招呼”耦合严重、容易打架。PM QoS 把这套协商机制标准化让每个模块只管投自己的票汇总和裁决交给框架。这篇文章适合三类人看一是正在做嵌入式 Linux 功耗优化的工程师二是需要给驱动加功耗约束的 BSP 开发者三是想搞懂内核功耗子系统整体架构的学习者。我会把 PM QoS 的框架结构、核心数据结构、API 用法、约束聚合逻辑以及实际调试中踩过的坑一条条拆开讲清楚。读完你至少能做到看懂驱动里那些pm_qos_add_request到底在干什么能自己给模块加约束能在功耗异常时顺着 PM QoS 这条线排查下去。2. 框架整体设计两类约束、三层结构、一套聚合逻辑2.1 为什么是“两类约束”而不是一套通用接口PM QoS 在设计上做了一个很关键的切分把约束分成全局约束和每设备约束两类。这个切分不是拍脑袋定的背后有很实际的工程考量。全局约束针对的是整个 SoC 层面的资源典型代表就是 CPU DMA 延迟PM_QOS_CPU_DMA_LATENCY。为什么是 DMA 延迟因为 CPU 从 idle 状态被唤醒的延迟直接决定了 DMA 传输会不会丢数据。如果 CPU 睡得太深唤醒要几百微秒而 DMA 缓冲区又很小数据就来不及搬走直接溢出。所以任何对 DMA 延迟敏感的模块都可以往这个全局约束上投票。每设备约束则是针对某个具体设备的比如某个网卡要求“我工作时 CPU 不能低于某个频率”或者某个显示控制器要求“我刷新时内存带宽不能低于多少”。这类约束只对指定设备生效不会影响全局。提示全局约束和每设备约束在代码里走的是两套不同的 API 和数据结构别混用。全局约束用pm_qos_add_request系列每设备约束用dev_pm_qos_add_request系列前缀dev_就是区分标志。2.2 三层结构请求层、聚合层、通知层从代码组织上看PM QoS 可以拆成三层。请求层是驱动直接打交道的部分提供add_request、update_request、remove_request这些 API。驱动通过pm_qos_request或dev_pm_qos_request结构体来登记自己的需求。这一层的设计要点是请求可以随时增删改框架必须能正确处理生命周期尤其是驱动卸载时忘记 remove 的情况。聚合层是框架的核心负责把所有请求汇总成一个当前有效值。全局约束的聚合相对简单因为约束类型有限每种类型维护一个目标值即可。每设备约束的聚合就复杂一些因为一个设备可能同时有多个约束频率、延迟、带宽而且约束之间还有优先级和类型之分。通知层负责在聚合值发生变化时通知所有关心这个变化的模块。比如 CPU 调频器会注册一个 notifier当 CPU DMA 延迟约束变化时它收到通知后重新计算目标频率。通知层用的是内核标准的 notifier 机制同步调用所以 notifier 回调里不能做耗时操作。2.3 约束聚合逻辑取最严格的那个聚合逻辑说起来简单对于“上限型”约束取所有请求里的最小值对于“下限型”约束取所有请求里的最大值。因为约束的本质是“不能超过”或“不能低于”要满足所有人就得取最严格的那个。举个例子三个驱动分别要求 CPU DMA 延迟不超过 100us、50us、200us那聚合结果就是 50us因为只有 50us 能满足那个要求最苛刻的驱动。反过来如果三个驱动分别要求 CPU 频率不低于 800MHz、1GHz、600MHz聚合结果就是 1GHz。这个逻辑听起来理所当然但实际实现时要处理几个细节请求的优先级、默认值、以及“无请求”时的行为。全局约束在没有请求时通常返回一个默认值比如PM_QOS_DEFAULT_VALUE表示“没有特殊要求”。每设备约束在没有请求时则返回“无约束”让设备可以自由进入低功耗状态。3. 核心数据结构与 API从 request 到 notifier 的完整链路3.1 全局约束的数据结构长什么样全局约束的核心结构是struct pm_qos_constraints它描述了一种约束类型的所有信息。关键字段包括list挂载所有请求的链表头target_value当前聚合后的目标值default_value没有请求时的默认值type约束类型是上限型PM_QOS_MAX还是下限型PM_QOS_MINnotifiers通知链约束值变化时触发每种全局约束类型比如 CPU DMA 延迟在初始化时会创建一个pm_qos_constraints实例并注册到全局数组pm_qos_array里。驱动通过约束类型 ID 来找到对应的实例。单个请求用struct pm_qos_request表示里面最关键的是node挂到链表上的节点和pm_qos_class约束类型 ID。驱动调用pm_qos_add_request时框架会分配一个请求结构把请求值填进去然后挂到对应约束的链表上并重新计算聚合值。3.2 每设备约束的额外复杂度每设备约束的结构是struct dev_pm_qos它挂在struct device上。一个设备可以有多个约束所以dev_pm_qos里包含了一个约束数组每个元素对应一种约束类型频率、延迟、带宽等。每设备约束比全局约束多了一个概念约束的优先级和类型。比如频率约束可以是“最小值”也可以是“最大值”框架需要区分。另外每设备约束还支持“标志位”比如PM_QOS_FLAG_NO_POWER_OFF表示“这个设备不允许断电”PM_QOS_FLAG_REMOTE_WAKEUP表示“这个设备可以远程唤醒”。驱动给设备加约束的典型流程是struct dev_pm_qos_request req; int ret; ret dev_pm_qos_add_request(dev, req, DEV_PM_QOS_RESUME_LATENCY, 100); if (ret 0) { /* 处理错误 */ }这里DEV_PM_QOS_RESUME_LATENCY是约束类型100是约束值单位通常是微秒。加完之后设备的 resume latency 约束就生效了电源管理核心在决定设备能否进入低功耗状态时会参考这个值。3.3 notifier 机制约束变化后谁来干活约束值变化后框架需要通知相关模块。全局约束用的是blocking_notifier_head每设备约束用的是bus_notifier或设备自身的 notifier。注册 notifier 的典型代码是static int my_notifier_call(struct notifier_block *nb, unsigned long val, void *v) { /* val 是新的约束值v 是约束类型相关数据 */ /* 根据新值调整硬件状态 */ return NOTIFY_OK; } static struct notifier_block my_nb { .notifier_call my_notifier_call, }; pm_qos_add_notifier(PM_QOS_CPU_DMA_LATENCY, my_nb);注意notifier 回调是在持有锁的上下文里同步调用的绝对不能在里面睡眠或做耗时操作。我见过有人在 notifier 里直接调用msleep结果系统直接卡死。正确做法是在回调里标记一个 work让工作队列去处理实际硬件操作。4. 实操给一个虚拟驱动加上 PM QoS 约束4.1 场景设定与约束值计算假设我们有一个虚拟的音频驱动它通过 DMA 搬运音频数据。音频缓冲区大小是 4KB采样率 48kHz位深 16bit双声道。我们来算一下它对 CPU DMA 延迟的要求。每秒钟的数据量是 48000 × 2 × 2 192000 字节。4KB 缓冲区能撑的时间是 4096 / 192000 ≈ 21.3ms。也就是说CPU 必须在 21.3ms 内醒来把数据搬走否则就会欠载。考虑到安全余量我们要求 CPU 唤醒延迟不超过 10ms也就是 10000us。这个值就是我们要往PM_QOS_CPU_DMA_LATENCY上投的票。注意单位是微秒别写成毫秒否则约束会松 1000 倍等于没加。4.2 完整驱动代码骨架#include linux/pm_qos.h #include linux/module.h #include linux/platform_device.h struct my_audio_dev { struct device *dev; struct pm_qos_request qos_req; bool qos_active; }; static int my_audio_probe(struct platform_device *pdev) { struct my_audio_dev *adev; int ret; adev devm_kzalloc(pdev-dev, sizeof(*adev), GFP_KERNEL); if (!adev) return -ENOMEM; adev-dev pdev-dev; platform_set_drvdata(pdev, adev); /* 初始化时先不加约束等真正开始播放再加 */ adev-qos_active false; return 0; } static int my_audio_start_playback(struct my_audio_dev *adev) { if (adev-qos_active) return 0; /* 加上 CPU DMA 延迟约束不超过 10000us */ pm_qos_add_request(adev-qos_req, PM_QOS_CPU_DMA_LATENCY, 10000); adev-qos_active true; /* 启动 DMA 传输 */ /* ... */ return 0; } static int my_audio_stop_playback(struct my_audio_dev *adev) { if (!adev-qos_active) return 0; /* 停止 DMA 传输 */ /* ... */ /* 移除约束让系统可以进入更深度的低功耗状态 */ pm_qos_remove_request(adev-qos_req); adev-qos_active false; return 0; } static int my_audio_remove(struct platform_device *pdev) { struct my_audio_dev *adev platform_get_drvdata(pdev); /* 驱动卸载时务必清理约束 */ if (adev-qos_active) pm_qos_remove_request(adev-qos_req); return 0; }这段代码的关键点在于约束只在播放期间存在播放结束立刻移除。很多驱动偷懒在 probe 里加约束remove 里才移除结果设备即使空闲也拖着系统不让睡功耗白白浪费。4.3 验证约束是否生效加完约束后怎么确认它真的起作用了最直接的方法是看 debugfscat /sys/kernel/debug/pm_qos/pm_qos_summary这个文件会列出所有全局约束的当前聚合值、默认值、以及每个请求的值。如果你看到PM_QOS_CPU_DMA_LATENCY的 target value 变成了 10000说明约束生效了。另一个方法是看 CPU idle 状态统计cat /sys/devices/system/cpu/cpu0/cpuidle/state*/usage加约束前后深度 idle 状态的使用次数应该会明显减少因为 CPU 不敢睡太深了。5. 常见问题与排查技巧实录5.1 约束加了但功耗没变化这是最常见的问题。原因通常有三种一是约束类型选错了比如该用PM_QOS_CPU_DMA_LATENCY却用了别的二是约束值单位搞错了把微秒当毫秒三是约束根本没生效因为对应的调频器或 idle 驱动没有注册 notifier。排查顺序建议是先看 debugfs 里的 target value 有没有变没变说明请求没挂上变了但功耗没变说明没有模块响应这个约束去检查 CPUFreq governor 和 cpuidle driver 是否注册了对应的 notifier。5.2 驱动卸载后约束残留如果驱动卸载时忘记pm_qos_remove_request约束会一直挂在链表上导致系统永远无法进入低功耗状态。更麻烦的是如果驱动模块被卸载后重新加载旧约束的指针可能已经失效框架访问时会 oops。提示用devm_pm_qos_add_request可以自动管理生命周期设备销毁时框架自动移除约束。但注意这个 API 在较老的内核版本里可能不存在用之前先确认内核版本。5.3 多个约束互相打架当多个驱动同时加约束时聚合逻辑会取最严格的值。这本身没问题但如果两个驱动的约束值差距很大可能导致系统频繁在高低功耗状态之间切换反而增加功耗。这种情况需要从系统层面协调比如让约束值接近的驱动共用同一个约束或者用每设备约束代替全局约束减少互相干扰。5.4 常见问题速查表现象可能原因排查方法约束值不生效请求未挂载查 debugfs target value功耗无变化无模块响应检查 notifier 注册情况系统无法休眠约束残留检查驱动 remove 路径约束值异常单位错误确认微秒/毫秒系统卡死notifier 里睡眠检查回调实现频繁状态切换约束冲突分析各请求值分布5.5 独家避坑经验我在实际项目里踩过最坑的一次是给一个 USB 控制器加 resume latency 约束值设成了 0。本意是“要求立即唤醒”结果框架把 0 解释成“无约束”约束完全没生效。后来查代码才发现dev_pm_qos_add_request对 0 值有特殊处理0 表示清除约束。正确的做法是设一个很小的非零值比如 1us。另一个坑是 notifier 的注册顺序。如果调频器在 PM QoS 框架初始化之前就注册了 notifier可能收不到早期的约束变化。解决办法是在调频器初始化时主动读一次当前约束值而不是只依赖 notifier。6. 从框架到实战PM QoS 在典型场景中的落地方式6.1 CPU 调频场景约束如何影响频率选择CPUFreq 子系统是 PM QoS 最大的消费者之一。以schedutilgovernor 为例它在计算目标频率时会同时考虑 CPU 利用率、以及 PM QoS 的 CPU DMA 延迟约束。如果约束要求延迟不超过某个值governor 会确保频率不低于某个下限即使当前利用率很低。这个联动关系是通过 notifier 实现的PM QoS 约束变化时通知 CPUFreq 重新评估频率。具体代码路径是pm_qos_update_target→blocking_notifier_call_chain→cpufreq_policy_notifier→cpufreq_update_policy。理解这条链路对调试“为什么 CPU 频率降不下去”这类问题非常关键。6.2 设备运行时电源管理场景每设备约束的用法设备的 runtime PM 和 PM QoS 是配合使用的。设备在runtime_suspend之前会检查自己的 PM QoS 约束是否允许进入低功耗状态。如果约束要求“不允许断电”runtime PM 就会跳过 suspend。典型用法是网卡驱动在数据传输期间网卡会加一个PM_QOS_FLAG_NO_POWER_OFF约束防止自己在传输中途被断电。传输结束后移除约束允许 runtime PM 把网卡挂起。6.3 系统级低功耗场景全局约束的聚合效应系统进入 suspend 之前PM QoS 框架会检查所有全局约束。如果任何约束要求延迟低于某个阈值系统可能无法进入最深的 suspend 状态。这就是为什么有时候明明所有驱动都空闲了系统还是只能进s2idle而进不了deep。排查这类问题时/sys/kernel/debug/pm_qos/pm_qos_summary是第一手资料。它会告诉你哪个约束在阻止深度休眠以及是哪个请求贡献了最严格的值。顺着请求的 owner 信息就能找到对应的驱动。6.4 约束值的动态调整策略约束值不是一成不变的。比如音频驱动在播放高码率音频时可能需要更低的延迟播放低码率时可以放宽。这时候可以用pm_qos_update_request动态调整pm_qos_update_request(adev-qos_req, new_latency);这个 API 会重新计算聚合值如果新值比旧值宽松系统可能立刻进入更低的功耗状态。动态调整的关键是及时性该收紧时立刻收紧该放宽时立刻放宽不要拖到下一个周期。7. 调试工具与内核配置要点7.1 必须打开的配置项要让 PM QoS 完整工作内核配置里这几项必须打开CONFIG_PM电源管理核心CONFIG_PM_QOSPM QoS 框架本身CONFIG_CPU_IDLECPU idle 驱动CONFIG_CPU_FREQCPU 调频CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUGdebugfs 支持如果CONFIG_PM_QOS没开所有pm_qos_*API 会变成空函数编译能过但运行时什么都不做。这是新手最容易踩的坑之一。7.2 debugfs 里的关键文件PM QoS 在 debugfs 里暴露了几个文件位置在/sys/kernel/debug/pm_qos/pm_qos_summary所有全局约束的汇总信息pm_qos_latency_tolerance延迟容忍度相关每设备约束可以通过/sys/kernel/debug/devices/下的设备目录查看pm_qos_summary的输出格式大致是PM_QOS_CPU_DMA_LATENCY: target10000 default2000000000 requests: 10000 (owner: my_audio)看到owner字段就能定位到具体驱动非常方便。7.3 ftrace 跟踪约束变化如果 debugfs 不够用可以用 ftrace 跟踪 PM QoS 的函数调用echo function /sys/kernel/debug/tracing/current_tracer echo pm_qos_update_target /sys/kernel/debug/tracing/set_ftrace_filter echo 1 /sys/kernel/debug/tracing/tracing_on这样每次约束变化都会记录调用栈能清楚看到是谁在什么时候改了约束。8. 几个容易混淆的概念澄清8.1 PM QoS 和 runtime PM 的区别这两个经常被搞混。简单说runtime PM 管的是“设备要不要开”PM QoS 管的是“设备开了之后性能不能低于某个线”。runtime PM 决定设备进入 suspend 还是 activePM QoS 决定 active 状态下系统要提供多少性能。两者配合使用但职责完全不同。8.2 全局约束和每设备约束的选择什么时候用全局约束什么时候用每设备约束我的经验是如果约束影响的是整个 SoC 的公共资源CPU、内存带宽、总线用全局约束如果只影响单个设备的行为用每设备约束。全局约束影响面大加之前要慎重每设备约束影响面小可以放心用。8.3 约束值和实际性能的关系约束值不是实际性能值而是“最低要求”。比如 CPU DMA 延迟约束设成 10000us不代表实际延迟就是 10000us而是说系统会保证延迟不超过这个值。实际延迟可能远低于这个值取决于当前系统状态。理解这一点对设定合理的约束值很重要——不要设得过紧否则会无谓地限制低功耗状态。9. 我在实际项目中的几点体会做功耗优化这些年PM QoS 给我的最大感受是它是一把双刃剑。用好了能让系统在性能和功耗之间找到精确的平衡点用不好一个残留的约束就能让整个功耗优化前功尽弃。我现在的习惯是每加一个 PM QoS 约束都要在代码注释里写清楚三件事为什么需要这个约束、约束值是怎么算出来的、什么条件下应该移除。这三条写清楚了后面维护的人就不会乱改也不会忘记清理。另外约束值的设定一定要留余量。我见过有人把 DMA 延迟约束设成刚好等于缓冲区撑满的时间结果系统稍微一忙就欠载。留 2 到 3 倍余量是比较稳妥的做法既能保证功能又不会过度限制低功耗。最后分享一个排查小技巧如果怀疑某个约束导致功耗异常可以临时把约束值改成一个很大的数比如INT_MAX看功耗是否恢复。如果恢复了说明就是这个约束的问题再顺着 owner 信息去找具体驱动。这个方法简单粗暴但在紧急排查时非常有效。