2026-09-16 完成、当时未提交,这里补提交(原样,不含 09-27 上板的修改)。 - 器件层 / 拓扑层整体从 R1 换到 R2:TCA9548A 分通道、五路同址 INA226 0x40 + 输入侧 0x43、 刀片 PCA9555(VBUS_EN 挪到 P0.4)、CH224A 只读驱动、风扇闭环 t_thermal - 新增 i2cbus(mux 原子事务)、slot(刀片 I/O 门面)、host/qtest.sh - 74HC595 链退役(代码与测试保留) - 判据阈值与时间常量未动;19 个 ctest 全绿,idf.py build 通过 Co-Authored-By: Claude Opus 5.5 <[email protected]>
244 行
15 KiB
C
244 行
15 KiB
C
/* `t_thermal` —— 1 s 热管理任务的**一个周期**(需求 §10.3 第二行、计划 A16 / R-6)。
|
||
|
||
一个周期干四件事:读两路温度 → 算风扇占空比 → 结算 TACH 采样窗口 → 记事件。
|
||
和 `t_safety` 一样写成「一个周期是一个函数」而不是 `while(1)`:死循环在宿主机上
|
||
一行也跑不到,而这一包的全部价值就是下面那几条判据。
|
||
|
||
---- R2 的风扇硬件(**已按板上 PCB 网络逐脚核实**,设计文档 §22.6 FAN-R2)----
|
||
|
||
+12V ──Q7(P-MOS)── FAN_12V_SW ── J26.2 / J27.2(两只风扇的 12V)
|
||
Q7.G ←─ Q8(N-MOS) ←─ R748 ←─ FAN_EN(IO48)
|
||
FAN_PWM(IO47) ─R733─ Q6.G,Q6.D = FAN_PWM_L → J26.4 / J27.4(**一路喂两只**)
|
||
J26.3 / J27.3 ─→ Q9 / Q10 栅极,漏极上拉到 +3V3 → FAN_TACH_IN(IO21) / FAN_TACH_OUT(IO14)
|
||
|
||
由此来的**三件固件必须知道的事**,每一件写反了都不会报错:
|
||
|
||
1. **`FAN_EN` 复位态 = 风扇不转。** R749 把 Q8 栅极拉到地、R746 把 Q7 栅极拉回源极,
|
||
所以 MCU 高阻(复位、下载、跑飞)时 12V 是断的 —— 这是 SR-6「复位安全」要的,
|
||
代价是**固件必须主动把它拉高**。忘了拉高的现象是「一切正常,就是不散热」:
|
||
温度闭环照常算占空比、PWM 照常输出、TACH 读不到脉冲被启动宽限盖住,
|
||
功能测试全绿,只有在机器满载跑了半小时之后才以「手机降频」的形式暴露。
|
||
|
||
2. **PWM 是反相的。** Q6 是低边 N-MOS,栅极高 ⇒ 把风扇的 PWM 线**拉低**
|
||
⇒ `D_gpio = 1 − D_fan`。本模块对外一律用**风扇侧**占空比(`duty`),
|
||
只在写 `hal_pwm_set_permille()` 的那一行做反相(`gpio_duty`)。
|
||
写反了的现象是「越热转得越慢」—— 而遥测里占空比在跟着温度往上走,完全正常。
|
||
|
||
3. **TACH 是反相的。** 开集三极管 Q9/Q10 各接一路,风扇 TACH 拉低 ⇒ GPIO 读到高。
|
||
**数边沿没问题,任何「按电平判它转没转」的写法都是反的。** 所以本模块只吃
|
||
「累计边沿数」,一次电平也不读 —— 见 `therm_tach_fn`。
|
||
|
||
---- 温度从哪来:**没有 ADC 的 HAL,也不打算加**(R-6 的一个决定)----------
|
||
|
||
R2 把载板整个删了,LM75 跟着没了,温度只剩两路 NTC(IO4/IO5,ADC1)。
|
||
本项目的 HAL 只有 gpio / i2c / pwm / time / store / lock 六样,**没有 ADC**。
|
||
这里**不加第七样**,理由两条:
|
||
· ESP32-S3 的 ADC 校准(`esp_adc_cal` 曲线拟合 + eFuse 里的校准值)在没有板子
|
||
的时候一行也验不了,加进 HAL 只会得到一个「sim 里永远对、板上还没试过」的接口;
|
||
· HAL 每多一个概念,后面每一次会话都要多读一遍。
|
||
所以温度**由外部喂进来**(`therm_temp_fn`),和 `t_actuator` 的 PG 回调同一个形状。
|
||
ADC 采样 + NTC 查表留到上板那一包(Track B),那时它是一个几十行的
|
||
`ntc.c`,接进这个回调即可,本模块一个字不用改。TACH 计数器同理(板上是 PCNT)。
|
||
|
||
---- 采样窗口:这一包最容易写错的地方(需求 TR-6)------------------------
|
||
|
||
低边调速的三线风扇在 PWM 关断期间**根本没有回流**,TACH 那一路是死的;
|
||
于是「这个 1 s 窗口里没数到脉冲 ⇒ 停转」在占空比低的时候会**稳定误报**。
|
||
(FAN-R2 换成四线风扇之后 12V 是常供的、TACH 连续,但判据不能建立在
|
||
「买到的一定是四线风扇」上 —— 而且四线风扇的脉冲只会**更多**,不会更少。)
|
||
|
||
本模块选的办法是 **③「窗口长度与占空比挂钩」**:判据不看墙钟走了多久,
|
||
而是看这个窗口里**攒够了多少导通时间**(`T_THERMAL_TACH_ON_MS`)。
|
||
导通时间是脉冲真正可能出现的时间,所以「窗口里该有多少个脉冲」与占空比无关,
|
||
门限就能是一个常数。占空比 100 % 时窗口 3 s,30 % 时自动拉长到 10 s。
|
||
|
||
没选另外两个的理由:
|
||
· ①「窗口跨足够多个 PWM 周期」解决不了问题 —— 25 kHz 下 1 s 已经跨了 25000 个
|
||
周期,照样会误报。误报的根源不是周期数少,是**期望脉冲数随占空比变**。
|
||
· ②「只在导通期计数」要求固件跟着 PWM 相位走。25 kHz、一个周期 40 µs,
|
||
软件跟不上;真做只能上硬件(PCNT 的 control 脚接 PWM),那是板上那一包的事,
|
||
而且它解决的是同一个问题的硬件版本 —— 本模块的判据在那种硬件下照样成立。
|
||
|
||
⚠️ 与之配套的是**占空比下限** `T_THERMAL_DUTY_MIN`:占空比真掉到 0,
|
||
导通时间永远攒不满,这条判据就变成了「永远不判」。所以闭环的下限不是 0;
|
||
真要停风扇只能撤 `FAN_EN`(§22.6:0 % PWM 时四线风扇仍转 3300 RPM)。
|
||
|
||
---- 事件:入队,不落盘 --------------------------------------------------
|
||
`evlog_put()` 是几条指令的入队,任何任务都能调;`evlog_flush()` 是唯一碰 flash
|
||
的地方,只有 `t_log` 调(`evlog.h` 文件头)。本模块一次也不碰 flash。 */
|
||
#ifndef T_THERMAL_H
|
||
#define T_THERMAL_H
|
||
|
||
#include <stdbool.h>
|
||
#include <stdint.h>
|
||
|
||
#include "hal_err.h"
|
||
|
||
#ifdef __cplusplus
|
||
extern "C" {
|
||
#endif
|
||
|
||
/* ---- 硬件常数(有出处的那些)-------------------------------------------- */
|
||
|
||
/* 风扇 PWM 走 `hal_pwm` 的 0 号通道 —— 全板只有这一路 PWM(`hal_pwm.h` 文件头)。 */
|
||
#define T_THERMAL_PWM_CH 0
|
||
|
||
/* 25 kHz:四线风扇 PWM 的通行规格(21~28 kHz),也是 `hal_pwm_esp.c` 与
|
||
`test_hal_sim.c` 里已经在用的数。落进音频带(<20 kHz)的现象是**啸叫**。 */
|
||
#define T_THERMAL_PWM_HZ 25000
|
||
|
||
/* ⚠️ Q6 反相(设计文档 §22.6「栅极高 = 风扇 PWM 线低」,已按板上网表核实:
|
||
`R733: FAN_PWM → Q6.G`、`Q6.D = FAN_PWM_L → J26.4/J27.4`)。 */
|
||
#define T_THERMAL_PWM_INVERTED 1
|
||
|
||
/* 每转几个 TACH 脉冲。⚠️ **待上板实测**:§22.6 原话「2PPR 也来自系列波形示例」,
|
||
而 2026-09-13 换成 60×60×10 薄风扇之后连 MPN 都还没定。
|
||
它只影响报出去的 RPM 数值,**不影响停转判据**(那条只看「有没有脉冲」)。 */
|
||
#define T_THERMAL_TACH_PPR 2
|
||
|
||
/* ---- 闭环阈值 ------------------------------------------------------------
|
||
⚠️ 下面四个**全部是「待上板实测后填」**,不是算出来的:
|
||
R2 把测温点从「进风侧空气」挪到了「两排手机之间的排母走廊」,设计文档明说
|
||
「风扇闭环(§22.6)的阈值要按新位置重新定 —— 这是挪件的代价,不是 bug」,
|
||
而新位置的温升曲线只有上板才有。这里给的是**形状**(分段线性 + 迟滞),
|
||
数值先按常识填、单列在这里,上板实测之后只改这四行。 */
|
||
|
||
/* 低于它就按下限转(安静优先)。 */
|
||
#define T_THERMAL_TEMP_LO_MC 40000 /* 40.000 °C —— 待实测 */
|
||
/* 高于它满速。 */
|
||
#define T_THERMAL_TEMP_HI_MC 65000 /* 65.000 °C —— 待实测 */
|
||
/* 过温降载门限(FR-13「超阈值先降流再关路」)。本模块只**报**,不动电源 ——
|
||
真降载要按整板功率优先级关路,那是限功率那一包的事(计划 §5.9)。 */
|
||
#define T_THERMAL_DERATE_MC 80000 /* 80.000 °C —— 待实测 */
|
||
/* 降载解除门限。⚠️ 必须**低于**上面那个:没有迟滞的现象是温度在门限上抖,
|
||
每秒记一条降载事件,把 256 条的 RAM 环形缓冲在 4 分钟内刷光(`evlog.h`),
|
||
真正有用的过压/反灌事件被挤掉 —— 而那时日志里看起来「记录得非常完整」。 */
|
||
#define T_THERMAL_DERATE_CLEAR_MC 70000 /* 70.000 °C —— 待实测 */
|
||
|
||
/* 占空比下限(千分比)。风扇侧口径。见文件头「与之配套的是占空比下限」。
|
||
⚠️ 待上板实测:低了 TACH 窗口拉得太长(判停转变慢),高了吵。 */
|
||
#define T_THERMAL_DUTY_MIN 300
|
||
|
||
/* ---- TACH 采样窗口 ------------------------------------------------------ */
|
||
|
||
/* 一个判据窗口要攒够这么多**导通时间**(不是墙钟时间)。见文件头。
|
||
3 s:占空比 100 % 时 3 s 判一次,30 % 下限时 10 s 判一次 —— 风扇停了之后
|
||
最坏 10 s 才报出来,而机箱的热时间常数是分钟级,够用。 */
|
||
#define T_THERMAL_TACH_ON_MS 3000
|
||
|
||
/* 窗口里少于这么多个边沿 ⇒ 判它停了。
|
||
余量巨大是有意的:§22.6 给的最慢转速是 0 % PWM 下的 3300 RPM,
|
||
3300 RPM × 2 PPR × 3 s 导通 = **330 个脉冲**,门限 4 是它的 1/80。
|
||
这条判据要答的是「有没有在转」,不是「转得够不够快」—— 转速不足是另一回事
|
||
(风扇老化/积灰),那要等 §22.6 把额定转速定下来才判得了。 */
|
||
#define T_THERMAL_TACH_MIN_EDGES 4
|
||
|
||
/* 窗口墙钟长度的**上界**。窗口按导通时间结算,所以它的实际长度是
|
||
`T_THERMAL_TACH_ON_MS × 1000 / duty`,最长出现在占空比压到下限的时候
|
||
(3 s ÷ 30 % = 10 s)。这个 30 s 是允许它拉到多长的红线:
|
||
拉过头 = 风扇停了很久才报出来。
|
||
|
||
⚠️ 这条不是运行期判断,是 `t_thermal.c` 里的**编译期断言**。
|
||
把占空比下限改小(比如将来限功率想让风扇让电)会同时拉长这个窗口,
|
||
改到 0 的时候停转判据就**静默失效**了 —— 不报错、不告警、再也不报停转。
|
||
写成运行期兜底反而更糟:那段代码在下限 >0 时永远走不到,测不了也就不可信。 */
|
||
#define T_THERMAL_TACH_MAX_MS 30000
|
||
|
||
/* `FAN_EN` 拉高之后的启动宽限:这段时间内不判停转,窗口一直重置。
|
||
设计文档 §22.6「使能后给启动宽限,关电后屏蔽停转告警」。
|
||
没有这个窗口的现象是**每次上电必报一条风扇停转**,然后大家就开始无视这个事件。
|
||
|
||
⚠️ 数值**待实测**(薄风扇的起转时间厂家没给),但它有一个下界,不能随便往小写:
|
||
**必须长于「播种一圈 + 一个完整判据窗口」**,也就是
|
||
`period_ms + T_THERMAL_TACH_ON_MS`(1 s 节拍下 = 4 s),否则第一个窗口在宽限
|
||
到期之前根本结算不了,这条宽限就是一段**永远走不到的死代码** ——
|
||
写成 3 s 时把整个宽限判断删掉,测试一条都不会红(本包实测过)。取 5 s。 */
|
||
#define T_THERMAL_SPINUP_MS 5000
|
||
|
||
/* ---- 外部喂进来的两样东西 ------------------------------------------------ */
|
||
|
||
#define T_THERMAL_NTC_COUNT 2 /* IO4 = RT601、IO5 = RT602 */
|
||
#define T_THERMAL_FAN_COUNT 2 /* 0 = 进风 J26(TACH → IO21)、1 = 排风 J27(IO14) */
|
||
|
||
typedef enum {
|
||
THERM_FAN_IN = 0, /* J26 进风,TACH 走 `BOARD_PIN_FAN_TACH_IN` */
|
||
THERM_FAN_OUT = 1, /* J27 排风,TACH 走 `BOARD_PIN_FAN_TACH_OUT` */
|
||
} therm_fan_t;
|
||
|
||
/* 温度取值回调,单位**毫摄氏度**(整数,不引浮点)。
|
||
返回 false = 这一刻读不到(ADC 还没起来 / NTC 开路)。
|
||
⚠️ 两路**都**读不到时本模块把风扇开到 100 %,不是维持原样 ——
|
||
需求 §10.10 对「载板温度读不到」给的处置就是「风扇转到 100 % 并告警,
|
||
不要按『读不到温度 = 不热』处理」。 */
|
||
typedef bool (*therm_temp_fn)(void *ctx, int idx, int32_t *milli_c);
|
||
|
||
/* TACH 取值回调:返回**累计**边沿数(自己回绕,uint32 相减是回绕安全的)。
|
||
板上这个数来自 PCNT / GPIO 中断,宿主机上来自假风扇。
|
||
返回 false = 计数器这一刻读不到(那就跳过这一路本圈的累加,不判停转)。
|
||
|
||
⚠️ 这里收的是**计数**不是**电平**:接收电路反相(Q9/Q10),
|
||
「电平高 = 在转」和「电平低 = 在转」都是错的,而且两种写法都有一半的时间蒙对。 */
|
||
typedef bool (*therm_tach_fn)(void *ctx, int idx, uint32_t *edges);
|
||
|
||
/* ---- 快照 ---------------------------------------------------------------- */
|
||
|
||
typedef struct {
|
||
/* 本窗口的三个累加量。窗口在「导通时间攒满」或「墙钟超时」时结算并清零。 */
|
||
uint32_t win_edges; /* 数到的边沿 */
|
||
uint32_t win_on_ms; /* **导通**时间:判据用的就是它 */
|
||
uint32_t win_ms; /* 墙钟时长:只用来算 RPM 和兜底超时 */
|
||
|
||
uint32_t last_edges; /* 上一圈读回来的累计值 */
|
||
bool have_last; /* 第一圈只播种,不作差(不然第一圈会白捡一大堆边沿) */
|
||
|
||
uint32_t rpm; /* 上一个**结算完**的窗口算出来的转速 */
|
||
bool rpm_valid; /* 还没结算过任何窗口时为 false —— 0 RPM 与「还不知道」不是一回事 */
|
||
bool stalled; /* 当前判定 */
|
||
uint32_t stalls; /* 判出过几次(只数上升沿,和事件条数一致) */
|
||
uint32_t windows; /* 结算过几个窗口 */
|
||
} therm_tach_state_t;
|
||
|
||
typedef struct {
|
||
int32_t temp_mc[T_THERMAL_NTC_COUNT];
|
||
bool temp_ok[T_THERMAL_NTC_COUNT];
|
||
int32_t temp_max_mc; /* 两路里**高的那一个**:一点凉不代表另一点不烫 */
|
||
bool temp_valid; /* 至少有一路读到了 */
|
||
|
||
uint16_t duty; /* **风扇侧**占空比,千分比。对外一律用这个口径 */
|
||
uint16_t gpio_duty; /* 真写进 hal_pwm 的值(Q6 反相 ⇒ 1000 − duty) */
|
||
|
||
bool fan_en; /* `FAN_EN` 的命令值 */
|
||
uint32_t en_ms; /* 拉高的时刻,启动宽限从这里算 */
|
||
|
||
bool derate; /* 正在过温降载 */
|
||
uint32_t derates; /* 进入过几次(只数上升沿) */
|
||
|
||
therm_tach_state_t fan[T_THERMAL_FAN_COUNT];
|
||
|
||
uint32_t cycles; /* 跑过几圈 */
|
||
uint32_t last_ms; /* 上一圈的时刻,dt 从这里算 —— 不假设「一圈正好 1 s」 */
|
||
uint32_t pwm_errors; /* 写 PWM 失败次数(板上不该发生,发生了要报出去) */
|
||
uint32_t temp_errors; /* 两路温度**全部**读不到的圈数 */
|
||
} thermal_snap_t;
|
||
|
||
/* 把快照清干净 + 起硬件:
|
||
① `FAN_EN` 配成输出并**先拉低**、② 配 PWM 并写满速、③ 再把 `FAN_EN` 拉高。
|
||
顺序来自设计文档 §22.6「先 FAN_EN=0、配好 PWM 与两个计数器,再使能供电」。
|
||
`temp` / `tach` 传 NULL 就是「永远读不到」(温度读不到 ⇒ 满速,见上)。 */
|
||
hal_err_t t_thermal_init(thermal_snap_t *s, therm_temp_fn temp, therm_tach_fn tach, void *ctx);
|
||
|
||
/* 一个周期。周期取 `task_desc(TASK_THERMAL)->period_ms`(1 s,`tasks.c` 里定死),
|
||
但本函数**不假设**自己是按时被调的:dt 一律用 `hal_time_elapsed_ms()` 实测,
|
||
所以任务被挤晚了只会让窗口跟着变长,不会把导通时间算错。 */
|
||
hal_err_t t_thermal_step(thermal_snap_t *s);
|
||
|
||
/* 温度 → 风扇侧占空比。分段线性,纯函数(不碰硬件、不记事件),单独可测。
|
||
`valid == false` ⇒ 满速。单调**不降**是这条曲线唯一不能破的性质。 */
|
||
uint16_t t_thermal_duty_for(int32_t milli_c, bool valid);
|
||
|
||
#ifdef __cplusplus
|
||
}
|
||
#endif
|
||
|
||
#endif /* T_THERMAL_H */
|