ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式Linux功耗管理:PM QoS框架原理与调试实战

嵌入式Linux功耗管理:PM QoS框架原理与调试实战

1. 功耗管理的隐形战场:为什么需要PM QoS

做嵌入式Linux或者移动端内核开发的朋友,大概率都遇到过这样的场景:系统整体功耗测试超标,用功耗仪一测,发现某个模块在系统空闲时依然不肯进入低功耗状态。你翻遍驱动代码,发现它明明已经调用了运行时电源管理接口,引用计数也归零了,可硬件时钟就是不停。这种"看起来该省电的地方没省电"的问题,十有八九是PM QoS在背后"捣鬼"。

PM QoS,全称Power Management Quality of Service,翻译过来叫电源管理服务质量。这个名字听起来有点绕,你可以把它理解成内核里的一套"功耗需求投票系统"。系统里各个设备、各个驱动、甚至用户空间进程,都可以通过这套框架向内核表达自己对延迟、吞吐、功耗的底线要求。内核汇总所有投票,取一个最严格的结果,然后据此决定CPU能睡多深、总线能降多低、外设能关多彻底。

这套机制解决的核心矛盾是:省电和性能之间天然对立。CPU进入深度休眠可以大幅省电,但唤醒延迟会从微秒级飙升到毫秒级;某个音频设备为了保持低延迟播放,要求系统不能进入某些低功耗状态;而一个后台下载任务又希望系统尽量省电。如果没有一套统一的协商机制,每个子系统各自为政,要么过度保守导致功耗下不来,要么过度激进导致功能异常。PM QoS就是那个居中调度的"裁判"。

这篇文章面向的是已经有一定Linux内核基础、正在做功耗优化或者驱动开发的工程师。我会把PM QoS framework的框架结构、核心数据结构、注册与更新流程、以及实际调试中容易踩的坑,从头到尾梳理一遍。内容基于内核源码中drivers/base/power/qos.c、include/linux/pm_qos.h以及相关的sysfs接口实现,结合我在实际项目中的调试经验展开。

提示:PM QoS和Runtime PM是两个不同层面的东西。Runtime PM管的是"设备当前要不要上电",PM QoS管的是"系统/设备允许达到多低的功耗状态"。两者配合使用,但不要混为一谈。

2. PM QoS框架的整体架构与两类约束

2.1 从全局约束到per-device约束的分层设计

PM QoS框架在设计上分成了两个大类:全局约束(system-wide constraints)和设备级约束(per-device constraints)。这个划分不是拍脑袋决定的,而是源于功耗管理本身的层次性。

全局约束影响的是整个SoC级别的低功耗状态,比如CPU idle的C-state深度、CPU频率的下限、DMA延迟容忍度等。这类约束通常由cpuidle governor、cpufreq governor以及一些对全局延迟敏感的外设驱动来设置。设备级约束则只影响某一个特定设备的电源状态,比如某个PCIe设备要求它的父总线不能进入L1低功耗态。

从代码组织上看,全局约束的API定义在include/linux/pm_qos.h中,核心实现位于drivers/base/power/qos.c。设备级约束则通过dev_pm_qos_*系列函数操作,每个device结构体里都内嵌了一个dev_pm_qos对象来管理该设备的QoS请求。

这种分层带来的好处是隔离性:一个USB网卡驱动设置自己的延迟容忍度,不会莫名其妙地把整个CPU的C-state限制住。但反过来说,如果驱动开发者搞混了两类接口,就会出现"我只想限制自己设备,结果把整个系统功耗拖垮"的悲剧。

2.2 全局QoS的四种约束类型

全局约束目前内核里主要维护了四类,每一类对应一个独立的plist(优先级链表)来管理请求:

约束类型枚举值作用对象典型设置者
CPU DMA延迟PM_QOS_CPU_DMA_LATENCYCPU进入idle的延迟容忍音频、网络驱动
网络吞吐PM_QOS_NETWORK_THROUGHPUT网络子系统的吞吐下限网络协议栈
内存带宽PM_QOS_MEMORY_BANDWIDTH内存带宽需求显示、视频驱动
预留/自定义PM_QOS_RESERVED框架扩展位平台自定义

每一类约束在内核里都是一个struct plist_head,里面挂着一堆struct pm_qos_request节点。每个节点有一个node.prio字段,这个字段就是请求的值。内核在需要读取当前有效约束时,直接取plist头节点的prio——因为plist是按prio排序的,头节点就是最严格的那个请求。

这里有个设计细节值得注意:不同约束类型的"严格"方向是不一样的。对于CPU DMA延迟,值越小越严格(0表示完全不能有延迟,即禁止CPU进入任何idle状态);对于网络吞吐,值越大越严格。所以plist的排序方向在不同约束类型下是相反的,内核通过pm_qos_add_request时传入的target_value和比较逻辑来区分。

2.3 per-device QoS的请求聚合逻辑

设备级QoS稍微复杂一点。每个设备可以设置多种类型的QoS请求,包括:

  • RESUME_LATENCY:设备恢复(resume)能容忍的最大延迟
  • LATENCY_TOLERANCE:设备运行时的延迟容忍度
  • NO_POWER_OFF:禁止设备断电的标志
  • FLAGS:其他标志位

这些请求不是简单取极值就完事。内核在dev_pm_qos_constraints结构里为每种类型维护了一个约束链表,同时缓存了一个effective_constraint指针指向当前生效的约束。当有新的请求加入或移除时,会触发__dev_pm_qos_update_constraints重新计算有效值,并通过通知链(notifier chain)告知关心这个设备QoS变化的驱动。

通知链是设备级QoS的一个关键机制。比如某个设备的父总线驱动注册了notifier,当子设备的延迟容忍度变化时,父总线可以据此调整自己的电源状态。这种级联反应在PCIe、USB等层次化总线里非常常见。

3. 核心数据结构拆解:plist、request与constraints

3.1 pm_qos_request:一次投票的载体

先看全局QoS的请求结构,定义在include/linux/pm_qos.h:

struct pm_qos_request { struct plist_node node; int pm_qos_class; struct delayed_work work; /* 用于延迟更新场景 */ };

node是挂到plist上的节点,node.prio就是请求的值。pm_qos_class标明这个请求属于哪一类约束。work这个字段是为pm_qos_update_request_timeout准备的——有些场景下驱动希望设置一个临时约束,超时后自动恢复,这个delayed_work就是干这个的。

初始化一个request有两种方式:静态定义用DEFINE_PM_QOS_REQUEST,动态分配用pm_qos_add_request。我个人的经验是,如果request的生命周期和驱动模块一致,用静态定义更省事,也避免了在原子上下文里分配内存的风险。

3.2 plist的排序玄机

plist是"priority sorted list"的缩写,内核里专门为需要按优先级排序的场景实现的一种链表。它的核心特性是:插入时按prio排序,取头节点就是最高优先级的元素。

对于PM_QOS_CPU_DMA_LATENCY这类"值越小越严格"的约束,内核在插入时会把实际值直接作为prio,plist默认是升序排列,所以头节点就是最小值,也就是最严格的约束。而对于"值越大越严格"的约束,内核在插入时会做一个转换,保证头节点始终是最严格的那个。

这个转换逻辑在pm_qos_add_request里通过plist_node_init和后续的plist_add完成。如果你在调试时发现取到的有效值和预期相反,先检查一下约束类型的严格方向定义,十有八九是这里理解反了。

3.3 dev_pm_qos与constraints的关联

设备级QoS的数据结构层次更深一层:

struct dev_pm_qos { struct dev_pm_qos_constraints resume_latency; struct dev_pm_qos_constraints latency_tolerance; struct dev_pm_qos_flags flags; struct pm_qos_request *pq_req; /* 用于向全局QoS传播 */ }; struct dev_pm_qos_constraints { struct plist_head list; s32 default_value; s32 no_constraint_value; enum pm_qos_type type; struct blocking_notifier_head *notifiers; struct plist_node *effective_constraint; struct pm_qos_request *pq_req; };

effective_constraint是个缓存指针,指向当前链表里最严格的那个约束节点。每次链表变动后重新计算,避免每次都遍历。no_constraint_value表示"没有约束"时的默认值,比如对于resume_latency,这个值通常是PM_QOS_RESUME_LATENCY_NO_CONSTRAINT,一个很大的数。

pq_req这个字段是连接设备级和全局QoS的桥梁。当设备级约束需要传播到全局时(比如某个设备的延迟容忍度要求CPU不能睡太深),内核会通过这个request向全局QoS投票。

4. 注册、更新与通知:QoS请求的完整生命周期

4.1 全局QoS请求的添加与更新

添加一个全局QoS请求的标准流程:

struct pm_qos_request my_req; pm_qos_add_request(&my_req, PM_QOS_CPU_DMA_LATENCY, 50);

这行代码的意思是:向CPU DMA延迟约束投票,要求延迟不超过50微秒。如果系统里已经有更严格的请求(比如某个音频驱动投了20),那么有效值仍然是20;如果这是最严格的,有效值就变成50。

更新请求用pm_qos_update_request:

pm_qos_update_request(&my_req, 100);

移除请求用pm_qos_remove_request。这里有个坑:移除请求后,该请求对有效值的影响立即消失,但不一定触发通知。如果移除后有效值没变(因为还有更严格的请求在),通知链不会触发。只有有效值真正变化时,才会调用blocking_notifier_call_chain通知所有注册者。

4.2 设备级QoS的添加流程

设备级QoS的API以dev_pm_qos_开头:

dev_pm_qos_add_request(dev, req, DEV_PM_QOS_RESUME_LATENCY, 1000);

这个调用会做几件事:首先检查设备是否已经初始化了QoS支持(dev->power.qos是否为NULL),如果没有则先分配;然后把请求挂到对应类型的约束链表上;接着重新计算有效约束;最后如果有效值变化,触发notifier。

设备级QoS的一个特殊之处是默认值处理。每个设备在初始化QoS时,会从父设备继承默认的resume_latency和latency_tolerance。这个继承逻辑在dev_pm_qos_constraints_init里实现。如果父设备没有设置,就用全局默认值。这个继承链在设备树层次较深时特别重要,一个设置不当的默认值可能影响一整棵子树。

4.3 通知链的注册与回调时机

通知链是QoS框架的"神经系统"。注册一个notifier:

static int my_notifier_call(struct notifier_block *nb, unsigned long val, void *ptr) { /* val是新的有效值,ptr是dev_pm_qos_constraints指针 */ return NOTIFY_OK; } static struct notifier_block my_nb = { .notifier_call = my_notifier_call, }; dev_pm_qos_add_notifier(dev, &my_nb, DEV_PM_QOS_RESUME_LATENCY);

回调时机是在有效约束值发生变化之后。注意,notifier是在持有dev_pm_qos_mtx的情况下调用的,所以回调函数里不能睡眠,也不能再调用会获取同一把锁的QoS API,否则死锁。

我在实际项目中遇到过因为notifier回调里调用了dev_pm_qos_read_value导致死锁的案例。那个函数内部会尝试获取同一把mutex,结果就是系统在suspend流程里卡死。后来改成在回调里只做标记,用工作队列异步处理才解决。

5. sysfs与debugfs接口:用户空间的观测窗口

5.1 /sys/devices/.../power/下的QoS节点

每个设备在sysfs里都有对应的QoS属性文件,位于/sys/devices/.../power/目录下:

  • pm_qos_resume_latency_us:读写resume延迟约束
  • pm_qos_latency_tolerance_us:读写运行时延迟容忍度
  • pm_qos_no_power_off:读写禁止断电标志

读这些文件会返回当前的有效约束值,写则相当于从用户空间添加一个请求。这个接口在调试时非常有用——你可以直接echo一个值进去,观察系统功耗变化,快速定位是哪个约束在阻止低功耗。

比如怀疑CPU不肯进C-state,可以:

cat /sys/devices/system/cpu/cpu0/power/pm_qos_resume_latency_us

如果返回0,说明有请求要求CPU完全不能有resume延迟,那CPU自然进不了深度idle。

5.2 debugfs中的QoS状态导出

debugfs提供了更详细的视图,通常在/sys/kernel/debug/pm_qos/或者/sys/kernel/debug/devices/.../下。不同内核版本路径可能不同,但核心信息一致:列出每个约束类型下所有活跃的请求,包括请求的值、请求者的名称(如果注册时提供了)。

这个视图在排查"谁在阻止低功耗"时是杀手锏。我曾经遇到一个案例,系统待机功耗比预期高30mA,用debugfs一看,发现是一个已经卸载的驱动模块留下的僵尸请求没清理。因为request结构是静态分配的,模块卸载时没有调用pm_qos_remove_request,导致plist里一直挂着一个无效节点。

注意:静态定义的pm_qos_request如果所在模块被卸载,必须在模块退出函数里显式移除请求,否则就是内存泄漏加逻辑错误。

6. 实战调试:几个典型的PM QoS踩坑案例

6.1 音频驱动导致的CPU无法深睡

这是最经典的PM QoS问题。某项目音频播放时功耗正常,但暂停后功耗居高不下。排查发现音频驱动在暂停时没有移除PM_QOS_CPU_DMA_LATENCY请求,仍然投着0值,导致CPU被禁止进入任何idle状态。

修复方法是在音频流的pause回调里调用pm_qos_remove_request,在resume回调里重新添加。但这里有个细节:不要在原子上下文里做remove操作,因为remove可能触发notifier,而notifier回调可能睡眠。如果pause是在中断上下文触发的,需要用工作队列延迟处理。

6.2 设备级QoS的默认值继承陷阱

设备树里某个I2C设备没有显式设置resume_latency,它从父I2C控制器继承了默认值。而父控制器的默认值又是从平台设备继承的,最终继承了一个很小的值。结果这个I2C设备在运行时一直要求系统保持低延迟,CPU无法进入深度C-state。

这个问题的隐蔽性在于:设备驱动本身没有设置任何QoS请求,但继承链把约束带进来了。排查时要在debugfs里逐级查看父设备的QoS值,才能找到源头。修复方法是在设备probe时显式设置一个合理的默认值,覆盖继承值。

6.3 notifier回调中的死锁

前面提过的死锁问题值得再展开。dev_pm_qos_add_notifier注册的回调在dev_pm_qos_mtx保护下调用。如果回调里调用了以下任一函数,就会死锁:

  • dev_pm_qos_read_value
  • dev_pm_qos_update_request
  • dev_pm_qos_remove_request
  • dev_pm_qos_add_request

正确的做法是在回调里只记录状态变化,把实际处理放到工作队列或者线程化中断里。如果确实需要在回调里读取值,可以用dev_pm_qos_read_value的无锁版本(如果内核版本支持),或者提前把值缓存起来。

6.4 全局QoS与cpuidle governor的交互

PM_QOS_CPU_DMA_LATENCY的约束最终会被cpuidle governor读取,用来过滤可用的C-state。约束值越小,可用的C-state越浅。如果约束值是0,所有非POLL的C-state都被禁用。

这里有个容易忽略的点:约束值不是直接和C-state的exit_latency比较,而是有一个转换。内核在cpuidle_governor里会把QoS约束值加上一个安全余量,再和每个C-state的exit_latency比较。所以设置约束值为10微秒,实际可能只允许exit_latency小于某个更小值的C-state。具体余量因平台而异,调试时要以实测为准。

7. 从框架到实践:PM QoS的使用建议

7.1 什么时候该用全局QoS,什么时候用设备级

判断标准很简单:约束的影响范围。如果约束只影响某一个设备的电源状态,用设备级;如果约束需要影响CPU idle、cpufreq等全局行为,用全局QoS。

但实际项目中经常有交叉。比如一个USB摄像头驱动,它既需要设置自己的resume_latency(设备级),又因为要保证视频流不丢帧,需要向CPU DMA延迟投票(全局级)。这时候两个都要用,但要注意全局请求的生命周期管理——视频流停止时必须移除,否则就是前面说的功耗问题。

7.2 请求值的选取原则

设置QoS请求值时,不要拍脑袋填一个"看起来安全"的数。正确的做法是:

  1. 先测量设备在无约束情况下的实际延迟需求
  2. 加上20%-50%的余量作为请求值
  3. 在目标平台上实测功耗和功能,根据结果微调

比如音频驱动,实测DMA传输的最坏延迟是30微秒,那请求值可以设50微秒。设0是过度保守,设100又可能在某些负载下丢帧。

7.3 调试工具链的搭建

一套顺手的调试工具能省很多时间。我通常会在目标板上准备这些:

  • 一个脚本定期dump/sys/kernel/debug/pm_qos/下的所有约束
  • 一个脚本监控/sys/devices/system/cpu/cpu*/cpuidle/下的state使用统计
  • 功耗仪的数据和QoS约束变化做时间对齐

有了这些,当功耗异常时,可以快速定位是哪个约束在哪个时间点发生了变化,进而找到对应的驱动。

7.4 内核版本差异的注意事项

PM QoS框架在不同内核版本间有演进。比如早期版本里dev_pm_qos_add_request的签名和现在不同,PM_QOS_*枚举值也有增减。移植驱动时,先确认目标内核版本的include/linux/pm_qos.h,不要直接照搬旧代码。

另外,CONFIG_PM_QOS相关的配置项也要确认打开。有些精简内核配置里会关掉PM QoS,这时候所有QoS API都变成空操作,调试时会很困惑——明明代码调了API,功耗却没变化。

我个人在实际操作中的体会是,PM QoS框架本身不复杂,复杂的是系统里各个驱动对它的使用方式。把框架的注册、更新、通知三条主线理清楚,再结合debugfs的观测,大部分功耗问题都能定位到具体的请求源头。真正难的是判断某个请求是否合理——这需要对具体硬件和业务场景有深入理解,没有通用答案。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进