做内核功耗/性能调优的朋友,几乎都遇到过这种尴尬场景:系统明明处于空闲状态,某个外设却在报“中断响应超时、数据丢了”一类的问题。反过来,音频、显示、USB这些模块对延迟和带宽又有硬需求,而 cpuidle、cpufreq 又总想把系统往低功耗方向推。这两边的矛盾怎么协调?Linux 从早期就给了个答案:PM QoS framework,一套把“需求侧约束”传遍整个电源管理决策链的机制。这一篇就专门把这个框架拆开来看,从数据结构、聚合逻辑到实际用法和调试技巧都过一遍。
很多同学管 PM QoS 叫“很薄的一层”,这话对也不对。说它薄,是因为它本身不负责具体行为,只维护“约束值”;说它不对,是因为这层薄薄的协调机制挂在 cpuidle、cpufreq、设备 runtime PM、调度器等多个子系统的关键路径上,一旦用错,系统要么频繁进深睡导致延迟爆炸,要么一直醒着功耗直线上升。这篇文章适合正在做 Linux 内核电源管理、音频低延迟、车载系统功耗优化的工程师参考,也适合内核初学者把这套框架当成理解“子系统间如何优雅通信”的范本。
1. PM QoS 框架到底解决什么问题
1.1 把“需求”和“决策”解耦
先想一个具体场景:手机在播放音乐时,音频线程要求中断响应延迟不能超过 100us;此时系统如果没有限制,cpuidle 可能会切入 C6/C7 这种深睡状态,CPU 要几十到几百微秒才能醒来,音频数据就出断流了。反过来,如果一直锁住 CPU 不睡,功耗又会明显上涨。
PM QoS 的想法就是:不要在每一个驱动里直接去 hack cpuidle 或者 cpufreq,而是让驱动声明“我需要多少延迟/带宽”,系统统一汇总这些需求,然后 cpuidle、cpufreq 在决策时都参考这个汇总值。这样一来,“需求侧”和“决策侧”彻底解耦:音频驱动只管提需求,不管 CPU 到底怎么睡;cpuidle 也不用关心谁是调用方,只看一个数字就行。
这就是整个框架的核心设计思路,也是它虽然代码量不大、却值得单独拉出来梳理的原因。
1.2 三类约束对象与含义
PM QoS 在全局层面维护的主要有三类约束,看内核里的pm_qos_class定义最容易理解:
| QoS 类型 | 约束含义 | 典型使用场景 |
|---|---|---|
| CPU_DMA_LATENCY | CPU 响应中断/DMA 的最大延迟容忍值 | 音频、串口、DMA 搬运、车载系统 |
| NETWORK_LATENCY | 网络收发路径的延迟上限 | 网络设备、实时音视频推流 |
| NETWORK_THROUGHPUT | 网络吞吐需求下限 | 高吞吐传输场景 |
其中最常打交道的就是PM_QOS_CPU_DMA_LATENCY,因为音频、外设这类模块对 CPU 续航状态最敏感。比如在 Android 上,AudioFlinger或者 HAL 层通过写/dev/cpu_dma_latency来锁住 CPU 延迟,保证低延迟播放;VHAL、蓝牙、modem 相关驱动也都有类似操作。
另外还有一类标志型约束,比如PM_QOS_FLAG_NO_POWER_OFF,用于阻止设备在某个状态下被断电。这类约束虽然不涉及数值,但挂在同一个框架里管理,原理大同小异。
2. 核心链路:请求、聚合与通知
2.1 一次请求的生命周期
PM QoS 约束从发起到生效,大体走这么四步:
- 驱动/模块创建一个
pm_qos_request对象; - 调用
pm_qos_add_request()把约束值加入对应约束类; - 系统内部重新聚合目标值,并通知注册过的客户端;
- 需求变化时调用
pm_qos_update_request()更新,用完后pm_qos_remove_request()移出。
内核侧的使用形态非常朴素,一个典型的添加请求代码长这样:
static struct pm_qos_request audio_qos_req; static void audio_qos_activate(void) { pm_qos_add_request(&audio_qos_req, PM_QOS_CPU_DMA_LATENCY, 100); } static void audio_qos_deactivate(void) { pm_qos_remove_request(&audio_qos_req); }如果后续要动态调整,就调用:
pm_qos_update_request(&audio_qos_req, 500);这套 API 在 2.6.x 时代就有了,后续版本虽然内部实现改过几轮,但使用模型一直是“ add 一个请求 → update 数值 → remove 请求”的套路。很多刚接触内核的读者会觉得这无非是“往链表里塞个节点”,但其实关键在于第二步:系统是怎么把多个请求聚合成一个目标值的。
2.2 数据结构里的两件套:plist + notifier
PM QoS 内部最核心的结构是struct pm_qos_constraints,它管理三样东西:请求链表、目标值、通知链。
请求链表用的不是普通 list,而是优先队列。原因很简单:系统每次聚合约束时都要快速拿到“最严格”的请求,如果用普通链表,每次都要 O(n) 扫一遍;用 plist 之后,插入时按数值排好序,取最值就是 O(1)。
通知链用的是 blocking notifier,一旦目标值发生变化,系统要把新目标值广播给所有关心它的模块。比如cpuidle关心 CPU 延迟约束,它注册一个回调函数,当目标值变小(更严格)或者变大(更宽松)时,就重新计算可选的 C-state。
一个简化版的pm_qos_constraints结构可以理解为:
struct pm_qos_constraints { struct plist_head list; /* 所有请求按值排序 */ s32 target_value; /* 聚合后的目标值 */ s32 default_value; /* 无请求时的默认值 */ enum pm_qos_type type; /* PM_QOS_MIN / PM_QOS_MAX */ struct blocking_notifier_head notifiers; };2.3 聚合算法:为什么一定取“最严格”
这是理解 PM QoS 的关键。假设有三个进程请求了三个不同的 CPU 延迟约束:
- 进程 A:2000us
- 进程 B:100us
- 进程 C:500us
聚合结果是多少?是 100us。
理由非常直觉:系统必须满足所有请求中最苛刻的那一个。因为延迟约束是上界,只要能满足最严格的 100us,那 2000us 和 500us 的要求自然都能满足;但如果取 500us,进程 B 就先炸了。所以延迟类约束按“最小值”聚合,也就是 plist 里排最前面的值。
在通知其它模块时,这个target_value就是它们唯一参考的数字。cpuidle看到它,就知道不能选择“唤醒时间超过这个值”的 C-state。
这里有个容易混淆的点:type字段。延迟约束是“越小越严格”,所以 type 是PM_QOS_MIN;而网络吞吐这类“必须不小于某个值”的约束,type 才是PM_QOS_MAX。设计上为了统一处理,框架内部会按 type 决定取列表头部还是尾部,但业务层你不用关心排序方向,只要理解最终值一定是最不利、最严格的那一个。
3. 内核各模块怎么配合 PM QoS
3.1 cpuidle 的 C-state 选择
cpuidle子系统是 PM QoS 最大的消耗者。在menugovernor 选择 C-state 时,会读取当前的 CPU 延迟约束,把它作为硬条件过滤掉唤醒时间超标的浅睡状态。
举个例子。当前 CPU 延迟约束是 50us,menu governor 扫描可用的 C-state:
| C-state | 退出延迟 | 是否可用 |
|---|---|---|
| C1 | 1us | 可用 |
| C2 | 30us | 可用 |
| C3 | 150us | 不可用 |
| C6 | 500us | 不可用 |
于是 CPU 最多只能进 C2,哪怕 C6 能省更多功耗。没有 PM QoS 时,驱动只能通过acpi_idle或直接禁 C-state 的方式硬设,既笨又不灵活。
实际项目里常见的坑是:某个驱动长期持有一个过严的延迟约束忘了释放,比如一直锁着 10us,那 CPU 基本就只能停留在 C1/C2,待机功耗高出几倍。这类问题不是“驱动功能坏了”,而是“QoS 约束脏了”。
3.2 cpufreq 和调度器为何也要看它
PM QoS 不只是 cpuidle 在用。cpufreq在某些策略下也会参考 QoS 约束,特别是涉及到最高频率限制、boost 开关、以及特定调度负载需求的场景。比如系统要求网络低延迟,调度器为了保证 CPU 不因频率爬升太慢而丢包,可能就不允许降到最低频率。
另外在较新的内核里,调度器的唤醒逻辑也会关注设备级 QoS。schedutil调度器根据当前负载决定频率时,如果系统处于“低延迟约束”状态,它会更积极地拉升频率。这其实体现了 CPU 频率与响应延迟之间“上游需求传导到下游参数”的关系。
这里多说一句:PM QoS 并不是直接告诉cpufreq必须跑多快,而是提供一个约束基线。频率决策仍然由 governor 根据负载完成,只是这个基线会影响 governor 的预算。理解这个边界很重要,否则排查功耗问题时容易把两个子系统混为一谈。
3.3 设备级 PM QoS:dev_pm_qos
除了全局的三类约束,PM QoS 还有一套设备级机制,叫dev_pm_qos。它解决的场景更细:某个设备在 runtime PM 过程中,什么时候允许进入低功耗、什么时候必须保持可快速唤醒。
设备级 QoS 的请求类型主要有:
| 请求类型 | 作用 |
|---|---|
| DEV_PM_QOS_RESUME_LATENCY | 设备恢复运行的最大延迟 |
| DEV_PM_QOS_LATENCY_TOLERANCE | 设备可容忍的延迟上限 |
| DEV_PM_QOS_FLAGS | 控制设备电源状态标志 |
最常见的用法是DEV_PM_QOS_RESUME_LATENCY。比如一个磁盘控制器,如果它挂起后要 200ms 才能恢复,但上层文件系统要求 50ms 内必须能继续 I/O,那系统就不能让该设备进入深度挂起。驱动可以这样请求:
struct dev_pm_qos_request resume_qos; dev_pm_qos_add_request(&pdev->dev, &resume_qos, DEV_PM_QOS_RESUME_LATENCY, 50);设备级 QoS 与全局 QoS 的差别在于作用对象:全局 QoS 影响 CPU/网络的总体行为,设备级 QoS 只作用于单个设备。但两者共享同一套“请求-聚合-通知”模型,理解了全局机制,设备级机制也就没什么新大陆了。
4. 实操:驱动与用户态怎么用
4.1 内核驱动使用示例
实际写驱动时,PM QoS 三个 API 基本就够用:pm_qos_add_request、pm_qos_update_request、pm_qos_remove_request。
以音频 DMA 驱动为例,典型流程是:
- 打开音频流时,添加一个 100us 的 CPU 延迟约束;
- 播放中根据音频数据量动态调整约束值;
- 关闭音频流时,移除约束。
代码样式如下:
#include <linux/pm_qos.h> static struct pm_qos_request audio_latency_req; void audio_stream_start(void) { pm_qos_add_request(&audio_latency_req, PM_QOS_CPU_DMA_LATENCY, 100); } void audio_stream_update(u32 latency_us) { pm_qos_update_request(&audio_latency_req, latency_us); } void audio_stream_stop(void) { pm_qos_remove_request(&audio_latency_req); }这里有一个必须强调的点:pm_qos_add_request不是为了“主动加速”,而是为了“承诺延迟上限”。它只影响低功耗策略,并不会直接压榨 CPU 性能。如果驱动把它误解成“我要高性能”,就会在不需要低延迟的场景里误锁约束,造成无谓功耗。
4.2 用户态节点与 sysfs
内核态之外,PM QoS 也给用户空间开放了访问入口,最常见的就是/dev/cpu_dma_latency。
传统用法是打开这个节点,直接写入一个延迟值:
int fd = open("/dev/cpu_dma_latency", O_WRONLY); int32_t latency = 100; /* 微秒 */ write(fd, &latency, sizeof(latency)); /* 保持 fd 打开,约束就持续生效 */注意关键点:只要 fd 不关闭,约束就存在;一旦进程退出或关闭 fd,这个约束立刻消失。很多人写过类似代码却没意识到 fd 的生命周期和 QoS 约束绑定,导致“为什么我的进程退出了,系统还保持低延迟”,或者反过来“为什么我明明写了值,却失效了”。
设备级 QoS 在 sysfs 里也有暴露,路径一般是:
/sys/devices/.../power/pm_qos_resume_latency_us /sys/devices/.../power/pm_qos_latency_tolerance_us直接往这些文件里写值,等同于从用户态添加一个设备级约束。这在调试设备电源状态时非常实用:不用改驱动,快速验证设备能不能在指定延迟内恢复。
4.3 典型场景组合:低延迟音频 + 深睡策略
真实项目里往往不是单一约束,而是多个约束叠加。以车载系统为例:
- 音频路径请求
CPU_DMA_LATENCY = 80us,保证音频数据不欠载; - 显示屏刷新的某个 DMA 请求
CPU_DMA_LATENCY = 200us; - 后台下载任务请求
NETWORK_THROUGHPUT = 200Mbps。
最终 CPU 延迟约束取 80us,因为音频最苛刻,其它模块都跟着受益;网络吞吐则单独聚合。整体状态就是:CPU 可以浅睡但不能深睡,网络路径不能进入节能模式,其余模块可以正常挂起。
这种“浅睡 + 其它模块照常入睡”的状态,正是 PM QoS 追求的效果:需求侧互不干扰,系统整体功耗又不会因为某一模块的特殊需求而全盘失控。
5. 路上踩过的坑与排查技巧
5.1 约束“凭空消失”是为什么
很多人排查功耗时遇到过:自己明明在用户态写入了/dev/cpu_dma_latency,但 cpuidle 的 C-state 还是选得很深。第一个动作应该是确认写入进程还活着、fd 还开着。因为用户态 QoS 请求是跟随 fd 生命周期的,进程崩溃或被 kill 后,请求自动销毁,系统恢复正常策略。
反过来,谁持有了过严约束、导致 CPU 无法深睡,排查方法是一样的:查哪个进程打开着/dev/cpu_dma_latency,或者在内核侧注册了未释放的pm_qos_request。
5.2 把约束语义搞反
前面说过,延迟类是取“最小值”聚合,而网络吞吐这类取“最大值”。实际项目里见过有人把 CPU 延迟请求写成很大值,比如 5000us,以为“数字越大越好”,结果系统只给一个非常宽松的响应保障,音频还是断流。
正确理解:对于延迟约束,写入值越小,系统越“精神”;写入值越大,系统越“放任”。音频低延迟要写小值,比如 50~200us;如果想恢复默认行为,写一个远大于默认的值,比如 2000us 以上,大多数系统就会表现得很“亲民”。
5.3 排查与观测手段
PM QoS 的调试要看两个层面:全局约束和单个设备约束。
内核侧可以通过trace-cmd或perf trace观察pm_qos_update_target、pm_qos_add_request的调用流程。比如想知道哪个驱动在动态改 QoS,直接 tracepm_qos_update_request相关函数,再按调用栈定位模块。
设备级约束可以看 sysfs 下的power相关目录,也可以在内核里打开CONFIG_PM_DEBUG和CONFIG_PM_ADVANCED_DEBUG,通过 debugfs 观察设备电源状态与 QoS 参数。实测下来,最常用的手段其实是动态打印:在目标驱动的runtime_suspend和runtime_resume加上dev_dbg(),配合 QoS 值变化,很快能推断出是谁在限制设备挂起。
5.4 一个容易漏掉的细节:notifier 回调里别睡
pm_qos_add_request导致约束变化时,会同步回调所有注册的 notifier。这些回调里的逻辑一定要短平快,不能做耗时操作,更不能睡眠。原因很简单:这个函数可能运行在中断上下文或者非常苛刻的原子上下文里。如果回调里用了 mutex、睡眠、IO 操作,轻则系统死锁,重则直接崩溃。实际写代码时,如果回调逻辑较复杂,建议把工作丢进schedule_work或者timer延后处理。
5.5 从功耗角度反推 QoS 是否“脏”
最后分享一个实用技巧。在项目调试阶段,我都会给测试镜像里放一个小工具:统计一段时间内全局 CPU 延迟约束的最大值、最小值和持续时长。如果发现最小值长期存在,就该去查谁持有这个约束。很多功耗问题,追根溯源都是“某个模块升级后忘了释放 PM QoS 请求”,而不是 cpuidle 或 cpufreq 本身的算法出了问题。
我个人的习惯是:在关键驱动里为 QoS 请求增加 trace 或 debugfs 导出,记录每次 add/update/remove 的调用点。成本很低,但排查线上问题时能少熬好几个夜。PM QoS 这个框架说简单也简单,说重要是真重要,它把“省多少电”和“响应多快”的博弈压缩成了几个简洁的数值,理解它之后,你再去看 cpuidle 和 cpufreq 的代码,很多看似奇怪的行为都能顺理成章地解释了。