功耗子系统的系列写到第九篇,终于轮到 thermal framework 了。前面聊 cpufreq、cpuidle 的时候我反复强调过一句话:功耗和发热是一枚硬币的两面,省电没做好,发热就会找上门;发热没管住,省电策略再漂亮也是空中楼阁。在真实产品里,很多工程师对 thermal 的印象就是"温度高了就降频",但真被问到 thermal framework 怎么组织的、governor 是怎么选的、cdev 是怎么绑上去的,能讲清楚的人其实不多。这篇就从通用架构入手,把 thermal framework 的骨架、关键数据结构、工作流程和调试方法完整梳理一遍。内容面向正在看内核功耗代码、或者做产品热设计需要和内核层打交道的同学,也适合刚接触嵌入式 Linux 想系统了解 thermal 机制的初学者。
1. 整体架构与分层设计思路
1.1 三层模型:zone、cdev 与 governor
Linux thermal framework 的核心设计可以抽象成三个角色:thermal zone(热区)、cooling device(冷却设备)、thermal governor(热管理策略)。
thermal zone 抽象的是一个带温度传感器的"区域",比如 CPU 封装、电池、壳温传感器的覆盖范围。每个 zone 有自己的一组 trip point(温度阈值),比如 60 度提示、80 度降频、95 度关机。cooling device 抽象的是任何能影响发热的手段,最常见的是 cpufreq 调频、cpu idle 调 idle 状态、CPU 热插拔、风扇转速、GPU 降频等等。governor 则是决策层,它根据 zone 上报的温度、trip point 的命中情况,决定要不要触发 cooling device,以及触发到什么档位。
这套三层模型的妙处在于:传感器厂商只需要把温度读上来注册成一个 zone,硬件工程师只需要把风扇或者调频接口封装成 cdev,策略工程师只需要写 governor 逻辑。三者通过内核提供的标准接口对接,互不感知对方内部实现。我在实际项目里见过不少刚接触 thermal 的同事,上来就想着"温度高了我去 call cpufreq 接口把频率降下来",这就是没理解框架的意图。正确做法是注册一个 cpufreq 类型的 cdev,让 governor 来决定调频的时机和幅度。你现在手写代码干预频率,后面换了 governor、改了温控策略,代码就全废了。
1.2 为什么一定要做解耦而不是直接硬编码
可能有人会问:一个 SoC 上就那么几个传感器,几套降频策略,直接写死不好吗?非要用框架绕一圈?我早年也这么想过,直到被现实教育了。
第一,不同产品的 thermal 策略差异非常大。手机希望表面温度优先,优先控制壳温传感器;服务器希望性能优先,宁可风扇狂转也尽量不降 CPU;平板没有风扇,只能靠调频和 idle 来压温度。同一个内核要适配这些完全不同的策略,单纯的硬编码根本没法维护。框架把策略抽出来做成可配置的 governor,产品工程师只需要在 device tree 里调整 trip point 和绑定关系,不用动 kernel 代码。
第二,冷却手段本身是异构的。x86 平台有 ACPI 的 _ACx 被动冷却和 _PSL 主动冷却,ARM 平台一般靠 cpufreq 和 thermal 的绑定,带风扇的平台还要接 PWM 调速。如果每个平台都各自实现一套温度判断和降温逻辑,内核里会出现大量重复代码。thermal framework 把"温度感知"和"降温执行"全部抽象成标准接口,新平台接入的成本大幅降低。我自己在适配一个新 SoC 的时候,最快的路径就是看 vendor 提供的 driver 是注册成 zone 还是 cdev,然后补 device tree 配置,基本两三天能把整条链路跑通。
2. 核心数据结构与注册流程
2.1 thermal_zone_device 里面到底装了什么
先看 zone 侧的核心结构,内核 include/linux/thermal.h 里struct thermal_zone_device的关键字段:
struct thermal_zone_device { char type[THERMAL_NAME_LENGTH]; /* zone 类型名,如 cpu-thermal */ struct device device; /* 标准内核设备模型 */ struct thermal_zone_device_ops *ops; /* 温度读取、trip 操作等回调 */ struct thermal_zone_params *tzp; /* 参数,governor 可能用到 */ struct list_head thermal_instances; /* 该 zone 绑定的 cdev 实例列表 */ struct idr idr; /* trip point 管理 */ int num_trips; /* trip 数量 */ int temperature; /* 当前温度,单位毫摄氏度 */ int target_temperature; /* governor 选择的 target 温度 */ struct thermal_governor *governor; /* 当前生效的 governor */ ... };ops是 zone driver 必须实现的回调集合,典型的有get_temp、set_trip_temp、get_trend、get_trip_type等。其中get_temp是每次轮询都会调用的函数,必须保证低延迟和低开销,我见过有实现里在 get_temp 里直接去 i2c 读外部传感器,每次要几十毫秒,直接把 thermal 轮询线程拖垮了。
num_trips和 trip point 数组描述了这个 zone 的所有温度阈值。这里有个容易忽略的细节:trip 和 cdev 的绑定关系是分层的,一个 zone 可以挂多个 trip,每个 trip 又可以绑定多个 cdev 实例。这个"多对多"关系是理解 thermal 框架的关键,后面单独章节展开。
2.2 cdev 侧的结构与回调
冷却设备侧的核心结构:
struct thermal_cooling_device { int id; char type[THERMAL_NAME_LENGTH]; struct device device; void *devdata; const struct thermal_cooling_device_ops *ops; struct list_head thermal_instances; /* 被哪些 zone 的哪些 trip 引用 */ ... }; struct thermal_cooling_device_ops { int (*get_max_state)(struct thermal_cooling_device *cdev, unsigned long *state); int (*get_cur_state)(struct thermal_cooling_device *cdev, unsigned long *state); int (*set_cur_state)(struct thermal_cooling_device *cdev, unsigned long state); };cdev 的接口非常简洁:你告诉我能档位到几(get_max_state)、现在在几档(get_cur_state)、让我切到某一档(set_cur_state)。至于档位和频率怎么对应,是 cpufreq 一层自己的事。cpufreq_cooling.c 里会在注册时扫描可用的频率点,把从 max_freq 到 min_freq 分成若干个 state,state 0 表示最高频(不降温),state 越大频率越低。
我第一次读 cpufreq_cooling 代码的时候,最惊讶的就是"降温"竟然只是把 state 往上加这么简单。但背后有一套 power model 的估算逻辑(IPA governor 用的),cpufreq_cooling 注册时会填一个em_pd(Energy Model)相关的结构。这块后面讲 power_allocator 的时候还要细说。
2.3 注册路径与顺序
zone 和 cdev 的注册顺序没有严格先后要求,因为 thermal framework 提供了解耦的绑定机制。典型流程是这样的:
/* cdev 侧 */ cdev = thermal_of_cooling_device_register(np, "cpufreq", cpufreq_dev, &cpufreq_cooling_ops); /* zone 侧,一般走 device tree */ tzd = thermal_zone_device_register("cpu-thermal", num_trips, mask, tz_dev, ops, tzp, passive_delay, polling_delay);在 device tree 为主的 ARM 平台,thermal_of_zone_register会把 DT 里的 trip point、cooling map 一次性解析出来,然后自动把 cdev 绑定到对应 trip 上。也就是说,对于大多数工程师来说,你不需要手动调 bind 接口,只要把 DT 写对,内核启动时这个关系网就建好了。
这里要提醒一个坑:zone 注册后 goernor 是默认值(step_wise),如果你想用 power_allocator,要么在 DT 里指定thermal-governor属性,要么在thermal_zone_params里设置governor_name字段。内核 5.x 之后的版本还有一个全局的governor选择机制,会先看 zone 的偏好,再看全局配置。调试的时候如果发现跑的 governor 和预期不符,第一件事先查sysfs里实际生效的是谁,而不是猜代码。
3. trip point、绑定机制与 governor 工作逻辑
3.1 trip point 的设计与状态机
trip point 不只是简单阈值,它还带"类型"。老内核里 trip type 有 4 种:active(主动冷却,对应风扇)、passive(被动冷却,对应降频)、hot(临界高温)、critical(致命温度,直接触发系统关机或者硬件 reset)。
关键点是hot 和 critical 不是给 governor 用的,而是给紧急路径用的。critical 触发了 thermal 会走thermal_emergency_poweroff,直接强制关机;hot 在触发时如果设备没有能力继续降温,同样会走 emergency 路径。这层"保险丝"设计非常重要:governor 正不正常工作是一回事,温度真到危险线能不能兜底是另一回事。我遇到过某平台 vendor 把 critical trip 写进 DT 但没验证,结果跑到高温时内核 panic,问题排查了很久才发现是 thermal 层主动触发关机导致的。
当 zone 温度穿过某个 trip point 时,thermal framework 会调用thermal_zone_set_trips更新轮询区间,同时通知 governor。governor 根据温度趋势(上升/下降)决定 cdev 的档位往哪个方向调。这里有个细节:轮询不是固定间隔扫描所有 trip,而是只关心当前温度附近的那两个 trip。通过set_trips把下一次需要关注的上限下限告诉传感器层,温度没跨越就继续 sleep。这套机制叫 interrupt-driven trip crossing,能大幅降低轮询功耗。
3.2 多对多绑定:thermal_instance 是怎么组织的
前面提到 zone 和 cdev 是多对多的,这个关系在内核里叫thermal_instance对象:
struct thermal_instance { int id; struct thermal_zone_device *tz; struct thermal_cooling_device *cdev; int trip; unsigned long upper, lower; unsigned long target; ... };一个thermal_instance代表"某个 zone 的某个 trip 绑定了某个 cdev"。每次温度穿过 trip,governor 遍历这个 trip 上的所有实例,对每个实例计算 target state。上下限upper和lower可以由 DT 的 cooling-map 配置,也可以在绑定后动态修改。这个设计让同一组传感器温度可以同时驱动 CPU 降频和风扇提速,也允许同一个风扇被多个温度传感器共同控制(比如 CPU 温度和 GPU 温度都绑到同一个风扇 cdev 上,取最高档位生效)。从架构角度看,这种关系没有用树状结构而是用扁平列表,就是为了方便遍历和动态增删,因为运行时用户态可能通过 sysfs 修改绑定关系。
3.3 四种常见 governor 的取舍
Linux 内核里可选的 governor 不少,但实际产品里真正常用的就几种:
step_wise:默认配置,按温度趋势和 trip 状态一步一档地调整。温度上升且超过 trip 就"逐级降温",温度下降则"逐级回暖"。逻辑简单,调起来直观,但响应偏保守。
bang_bang:只有开/关两态,常用于风扇。温度过 trip 开,回到 hysteresis 区间关。实现最简单,缺点是控制曲线粗糙,风扇频繁启停。
power_allocator:基于功耗模型的分配器,也叫 IPA。它把目标温度看成"预算",用 PID 控制器的思路动态计算允许功耗,然后把功耗预算按 EM(Energy Model)分配给各个 cooling device。控制效果平滑,适合对温控要求高的移动设备。
user_space:内核不做决策,只上报温度,用户在用户态用 thermal daemon 决定怎么处理。桌面 Linux 上比较常见,但嵌入式产品很少用,因为多了用户态转发延迟。
我个人的建议是:能上 IPA 就上 IPA,省事而且效果好;但前提是 EM 信息得填对。IPA 算不准,十个里面有八个是 EM 的 dynamic power coefficient 给的不对。
# 查看当前各 zone 使用哪个 governor cat /sys/class/thermal/thermal_zone*/mode cat /sys/class/thermal/thermal_zone*/policy注意policy文件就是 governor 的名字,你可以 echo 切换(比如echo power_allocator > policy),但前提是内核编译进了 power_allocator governor。
4. Device Tree 配置与绑定关系实例
4.1 一份最小可用的 DT thermal 节点
ARM64 平台上 thermal 配置几乎全在 DT 里。下面是一份典型的单 CPU 热区配置:
cpu_thermal: cpu-thermal { polling-delay-passive = <250>; polling-delay = <1000>; thermal-sensors = <&tsens0 0>; trips { cpu_alert0: cpu-alert0 { temperature = <65000>; hysteresis = <2000>; type = "passive"; }; cpu_alert1: cpu-alert1 { temperature = <85000>; hysteresis = <2000>; type = "passive"; }; cpu_crit: cpu-crit { temperature = <105000>; hysteresis = <2000>; type = "critical"; }; }; cooling-maps { map0 { trip = <&cpu_alert0>; cooling-device = <&cpu0 THERMAL_NO_LIMIT THERMAL_NO_LIMIT>; }; map1 { trip = <&cpu_alert1>; cooling-device = <&cpu0 THERMAL_NO_LIMIT THERMAL_NO_LIMIT>; }; }; };polling-delay-passive是被动冷却阶段(至少一个 passive trip 被触发)的轮询间隔,polling-delay是正常状态下的轮询间隔。这里单位都是毫秒,数值需要权衡监测精度和功耗:太快会让 thermal 轮询线程空转,太慢会在快速升温场景下反应迟钝。我经验值是被动阶段 100-250ms、正常阶段 1000ms 比较稳妥。
THERMAL_NO_LIMIT表示 cdev 上下限不限制,实际使用中也可以写具体档位,比如<&cpu0 0 4>表示在这个 trip 下 cdev 只能在 0 到 4 档之间调。产品上常见做法是低温度 trip 限制低档位,高温度 trip 逐步放开限制,让降温梯度更平滑。
4.2 binding 关系在 DT 里怎么生效
从这份 DT 可以看到,cooling-map 是绑定关系的声明处。map0 表示 cpu_alert0 触发了,就调 cpu0 这个 cdev;map1 表示 cpu_alert1 触发了,也调 cpu0。同一 cdev 出现在多个 map 中太常见了——多个 trip 控制一个 cdev,这在 step_wise 下就是"温度越高档位越高"的阶梯策略。
zones 和 cdevs 都注册完之后,内核里thermal_of_build_thermal_zone会遍历 DT 的 cooling-maps,为每个 trip 和 cdev pair 创建 thermal_instance。to cool down what with which device at what trip——这就是完整的关系三元组。我在给新人讲 thermal 的时候总说:DT 里的 thermal 节点不是在配温度表,而是在搭一张关系网。这张网决定了整个系统的温控行为。
4.3 多 sensor 多 zone 的联动设计
实际 SoC 往往不止一个热区。手机里有 CPU、GPU、电池、充放电 IC、壳温等多个 zone,它们之间还有约束关系。比如壳温 zone 触发后不止要降频,还要限制充电电流——这时可以把 PWM charger 注册成一个 cdev,绑定到壳温 trip 上。
还有一种常见的联动是 CPU 和 GPU 共享一个散热预算。这种情况下两个 zone 绑定同一个风扇 cdev,内核取的是所有实例里请求的最高档位。这个"取 max"的逻辑在thermal_cdev_update函数里,它遍历整个 cdev 相关的 instance 列表找 target 最大的一个。所以即使两个 zone 温度不同步,风扇也总是以最激进的需求运转,优先保证最热的那块区域不过温。
5. sysfs 接口与用户态调试方法
5.1 /sys/class/thermal 全景
内核启动后,thermal 框架会在 /sys/class/thermal 下暴露两类节点:
/sys/class/thermal/ ├── thermal_zone0/ # 第一个热区 │ ├── temp # 当前温度(毫摄氏度) │ ├── policy # 当前 governor 名称 │ ├── mode # enabled/disabled │ ├── trip_point_0_temp # 第 0 个 trip 的阈值 │ ├── trip_point_0_type # 第 0 个 trip 的类型 │ └── ... ├── thermal_zone1/ ├── cooling_device0/ # 第一个冷却设备 │ ├── max_state # 最大档位 │ ├── cur_state # 当前档位 │ └── type # cdev 类型,如 cpufreq └── cooling_device1/这套 sysfs 接口非常方便做快速验证。我在实验室里调的流程一般是:先cat温度和 policy 确认链路通了,再手动改 cur_state 确认 cdev 能动,最后回到 DT 调整 trip 参数做整机验证。
5.2 常用调试动作
# 查看温度和档位 cat /sys/class/thermal/thermal_zone0/temp cat /sys/class/thermal/cooling_device0/cur_state # 手动强制切 cdev 档位(模拟 governor 决策) echo 5 > /sys/class/thermal/cooling_device0/cur_state # 切换 governor echo power_allocator > /sys/class/thermal/thermal_zone0/policy # 禁用/启用某个 zone echo disabled > /sys/class/thermal/thermal_zone0/mode注意:手动写 cur_state 只是测试 cdev 本身是否正常,不会反映真实策略效果。真正看 governor 行为要盯thermal_zone0/temp和cooling_device0/cur_state的变化曲线。我常用watch -n 1两个终端同时刷,一个盯温度一个盯档位,升温时档位跟得紧不紧、降温时回落得及时不及时,一眼就看出来。
5.3 tracing 与日志
内核对 thermal 也有 tracepoint 支持。CONFIG_THERMAL_TRACE使能后可以抓:
# 抓 thermal 温度变化和 trip 触发事件 trace-cmd record -e thermal_temperature -e thermal_zone_triptrace 出来的信息比 dmesg 精确得多,能看到每次温度轮询、每个 trip 的命中时间点,非常适合排查"温度都到 90 度了为何没触发降频"这类问题。
6. 常见问题与排查实录
6.1 温度读不到或者读到 0
遇到这种问题先不要怀疑 zone driver,先确认传感器子系统是不是正常。检查 dmesg 里有没有传感器初始化失败的日志;再用i2cdetect之类工具确认 sensor 芯片在线。实际案例中,GPIO 中断没配好导致温度不更新、I2C 时钟频率太低读超时的情况非常多见。还有一点容易踩:get_temp的返回值单位是毫摄氏度,而 DT 里的 temperature 单位是摄氏度。有的传感器驱动天然返回整数摄氏度,直接注册进去会导致温度显示放大 1000 倍。判断方法很简单:cat /sys/class/thermal/thermal_zone0/temp看数值量级,正常应该是 30000-100000(毫摄氏度),如果看到 30 或 31,基本就是单元转换的问题。
6.2 governor 不切换或者策略不生效
最常见原因是从没检查过实际生效的 governor。可能你 DT 里写了thermal-governor = "power_allocator",但内核没开CONFIG_THERMAL_GOV_POWER_ALLOCATOR,于是回退到 step_wise。另一个高频问题是因为被动轮询没开。polling-delay-passive配置成 0 的话,即使温度越过了 passive trip,框架也可能不进入被动轮询模式。这就导致温度一直涨但 cdev 档位不动。排查时先确认对应的 trip 是否被命中,再看cur_state有没有变化;如果 trip 命中了但 cdev 没动,去查binding信息。
6.3 降温响应太慢或者太激进
这个基本属于调参问题,但参数不只是 trip 温度。step_wise 下"激进"通常是因为 cdev 最大档位对应频率下降幅度太大,可以考虑在 DT 的 cooling-device 节点里设置更小范围的上限;"太慢"则往往是轮询间隔过大,或者 trip 的 hysteresis 太大导致温度回落要等很久才释放档位。如果用了 IPA,先检查power_allocator的sustainable_power是否和实测底功耗匹配,这个值的偏差会直接导致 PID 输出整体偏移。简易标定方法:在待机稳定状态下读一次整机功耗,通常可以作为 sustainable_power 的参考量。
6.4 重启后配置"丢失"
有用户反映 sysfs 里改好的 policy 或 cur_state,重启后恢复默认。这不是 bug,thermal 的策略配置本来就是运行时属性,固化配置应该走 DT 或内核配置参数。这条提醒主要是为了让刚接触的同学理解 sysfs 的定位:sysfs 是调试接口和运行时控制接口,不是持久化配置仓库。你该修改的是 DT 里的默认值,而不是保存 sysfs 的 echo 命令到开机脚本里"骗自己"。
7. 对 thermal framework 的一点实战心得
最后分享几个我这些年踩过坑后的体会。第一个体会是:thermal framework 的代码阅读路径一定从 sysfs 和 device tree 开始,不要一上来就扎进 governor 源码。先通过 sysfs 确认链路通不通、温度准不准、cdev 动没动,再倒回去读源码,你会发现自己对thermal_zone_device_update、thermal_zone_set_trips这些函数理解特别快。第二个体会是:绑定关系是整个框架的灵魂。理解了 thermal_instance 和 cooling-map 的关系,你就理解了 thermal framework 90% 的宏观逻辑。第三个体会是:真正产品级的热管理,永远不是内核单层能搞定的。内核 thermal framework 提供的是机制,传感器布板位置、导热路径、策略参数这些属于系统和硬件全局的决策,需要热设计工程师、驱动工程师和内核工程师一起把参数调出来。框架做得再好,传感器布在错误的位置,策略也是瞎忙活。
这套 thermal 架构从 3.x 内核演化到现在,核心抽象一直没变,但细节越来越丰富。如果后面有时间,我打算单独写一篇 power_allocator 的源码级拆解,把 PID 参数怎么算、EM 怎么建模讲透,那部分内容单独撑一篇都不过分。对 IPA 感兴趣的同学可以先去读drivers/thermal/gov_power_allocator.c,配合include/linux/energy_model.h一起看,比任何二手转述都来得直接。