A8:任务骨架与调度(5 个任务、两把锁)

需求 §10.3 落地。四件事:

1. 五任务表(tasks.c):周期与优先级写在一张 const 表里,开机自检查的是
   相对顺序(safety 唯一最高、log 唯一最低、thermal 与 actuator 同级),
   外加「巡检周期必须长于 INA226 一帧转换 8.8 ms」。不过就 abort。

2. 节拍器(sched.c):记下一次该醒的绝对时刻,回绕安全(int32_t 差判先后)。
   过载时跳过错过的整周期、保住相位,不补跑。

3. 两把锁(hal_lock):I²C 与 74HC595,禁止嵌套持有 —— 这块板上两者没有交集,
   「拿着 A 去拿 B」永远是写错了。ESP 侧用 mutex 取优先级继承。

4. t_safety 的一个周期:拿锁 → 6 片 INA226 的 V+I + PCA9555 两端口 → 放锁。
   不死等锁(5 ms 超时就跳过这圈),一片读失败不中断整圈、保留上一圈的值。

I²C 占用由 HAL 按线上真实字节数记账(两个目标同一份代码),静态预算与实测
必须相等:一次寄存器读 48 位,一圈 13 次事务 = 1560 µs / 20 ms = 7.8 %,
与 §10.3 手算的「约 1.5 ms / 约 8 %」相符(本节的 46 位没数 STOP 和重复 START)。

sim 侧配套两样:假 I²C 事务真的把虚拟时钟花掉(否则时序测试是空话),
以及可强制的加锁纪律检查(配一条反向测试,防止检查本身失灵)。

11 条 ctest(161 例)全绿,idf.py build 通过;16 个注错实验全部变红。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 07:35:39 +08:00
共同撰写人 Claude Opus 5
父节点 45de84ed3c
当前提交 ec31891429
共修改 26 个文件,包含 3340 行新增和 1733 行删除
+73 -74
查看文件
@@ -1,93 +1,92 @@
# 交接
更新:2026-09-07 完成任务包 **A7**(通道配置表 + 表驱动遍历 + 启动自检)
更新:2026-09-07 完成任务包 **A8**(任务骨架与调度:5 个任务、两把锁)
## 一句话现状
五路的六组编号(INA 地址 / DAC 片通道 / 三个按键位 / EDL 位 / 两根 EN 的 GPIO)
拼成了**一张 const 表** `rail_cfg(1..5)`,开机自检 `rail_selftest()` 把它和四个驱动
那四张表**逐格对账**,再查一遍全板引脚有没有重复 / 越界,不过就 `abort()`。
A3~A6 留下的四个自检谓词至此全部有了调用点。9 条 ctest(131 个用例)全绿,
`idf.py build` 通过。
§10.3 的五个任务成了一张 const 表(周期 + 优先级,开机自检不过就 abort),
周期任务走**绝对时刻**的节拍器(不漂、回绕安全、过载跳过不补跑),
两把互斥锁(I²C / 595)**禁止嵌套持有**,`t_safety` 的一圈已经能跑:
拿锁 → 6 片 INA226 的 V+I + PCA9555 两个输入端口 → 放锁 → 记账。
I²C 占用由 HAL 按线上真实字节数算出来:**1560 µs / 20 ms = 7.8 %**,
与 §10.3 手算的「约 1.5 ms / 约 8 %」相符。11 条 ctest(161 个用例)全绿,`idf.py build` 通过。
## 下一步
**A8**:任务骨架与调度(5 个任务、两把锁,需求 §10.3 全节)。
验收是「sim 时钟下 `t_safety` 周期 20 ms ±1 ms;I²C 占用打印出来与 §10.3 的 8 % 对得上」。
A7 给它备好的东西:`rail_cfg()` 表驱动遍历、`board_pins.h` 的 SDA/SCL 引脚、
`sim_gpio_write_seq()`(断言顺序用,A9 的 17 ms 上电时序会更需要它)。
**A9**:三个执行器状态机 + 互斥(需求 §10.6 全节)。
验收是「硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。
A8 给它备好的:`HAL_LOCK_SR595`(595 那把锁,sim 里能强制检查)、
虚拟时钟下的 `sched_*`、`sim_gpio_write_seq()`(A7 留的顺序断言)。
⚠️ A9 的状态机同样要写成「一个函数一步」,不要写进 `while(1)` —— 死循环在宿主机上跑不到。
## 本次新增/修改的文件
- `main/rail_cfg.h` / `main/rail_cfg.c` — 那张表、启动自检、两根 EN 线(≈250 行)
- `main/board_pins.h` — **全板引脚表**(§8.1 里「全板独一份」的那些脚:I²C、风扇、
NTC、USB、UP_VBUS_DET,加上 strapping / 不存在 / R8 版 PSRAM 三张禁用清单)。
五路自己的 `EFUSE_ENn` / `VBUS_ENn` **不在这里**,它们是「每路一个」,在五路表里
- `main/app_main.c` — 开机跑 `rail_selftest()`,不过就 abort;然后 `rail_en_init()`
- `test/test_rail_cfg.c`(28 个用例)
- `components/phonerail_hal/sim/sim_hal.h` + `sim/hal_gpio_sim.c` — 新增
`sim_gpio_write_seq()`(全局写序号,断言「先断 VBUS 再关 eFuse」这类顺序用);
**`SIM_GPIO_PINS` 48 → 49**
- `components/phonerail_hal/esp/hal_gpio_esp.c` — **`PIN_MAX` 48 → 49**
- 两处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a7`)
- `../需求文档.md` §10.4 — 记 A7 落地 + 五条实现约定 + 那张六列表
- `main/tasks.h` / `tasks.c` — 五任务表(名字/周期/优先级/栈)+ `task_table_ok()`
- `main/sched.h` / `sched.c` — 绝对时刻节拍器(≈60 行),过载计数与最迟到时长
- `main/t_safety.h` / `t_safety.c` — 巡检的**一个周期** + 静态预算 + 一行人话报告
- `components/phonerail_hal/include/hal_lock.h` + `esp/hal_lock_esp.c` + `sim/hal_lock_sim.c`
— 两把锁(HAL 的第五样东西,理由写在 hal_lock.h 开头)
- `components/phonerail_hal/include/hal_i2c_acct.h` + `hal_i2c_acct.c`
— I²C 占用核算,**两个目标编同一份**,所以宿主机跑出来的数就是上板会打印的数
- `sim/hal_i2c_sim.c` — 每次事务记账 + **把虚拟时钟真的花掉** + 查加锁纪律
- `sim/sim_sr595.c` / `sim/sim_hal.c` / `sim/sim_hal.h` — 595 那四根线也查加锁纪律;
新增 `sim_lock_*()`(扮演第二个任务、强制加锁纪律、违规计数)与 `sim_i2c_set_bus_time()`
- `esp/hal_i2c_esp.c` — 四个事务入口各加一行记账
- `components/drv/include/ina226.h` — 新增 `INA226_CONV_US`(8.8 ms,给任务表自检对账)
- `main/app_main.c` — 建五个任务;`selftest_or_die()` 多查一项任务表
- `test/test_sched.c`(17 个用例)/ `test/test_t_safety.c`(13 个用例)
- 三处 CMakeLists + `main/phonerail.h`(版本 `0.1.0-a8`)
- `../需求文档.md` §10.3 — 记 A8 落地 + 五条实现约定
## 验收命令与结果
```
$ bash host/test.sh
100% tests passed, 0 tests failed out of 9 # +test_rail_cfg(28),总计 131 个用例
100% tests passed, 0 tests failed out of 11 # +test_sched(17) +test_t_safety(13),共 161 例
$ ./host/build/test_t_safety.exe
t_safety: cycles=1 skipped=0 i2c_err=0 bus=1560 us/20 ms load=7.8%(§10.3 手算:约 1.5 ms / 约 8 %)
$ .\build_idf.ps1 # PowerShell
phonerail.bin binary size 0x34250 bytes. ... 0xcbdb0 bytes (80%) free.
Project build complete. # rail_cfg.c.obj 在(已单独确认)
phonerail.bin binary size 0x3cc40 bytes. ... 0xc33c0 bytes (76%) free.
Project build complete.
# 16 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
# INA 地址改回 0x40+ch / CH3 的 DAC 挪回 U6 / CH4↔CH5 按键位对调
# EDL 位改回 16+ch / PG 位跟着 FLT 加偏移
# EFUSE_EN2 撞上 UP_VBUS_DET / EFUSE_EN1 落到 strapping / CH1 两根 EN 写反
# EN 极性写反 / all_off 先关 eFuse / all_off 第一个错就 return
# en_init 不写那句明文的 0 / 表与驱动对账被删 / 引脚查重被删
# GPIO 上限退回 48
# → 以上 15 条全部变红。
#
# ⚠️ 唯一没能变红的一条:**把 `rail_selftest()` 整个改成 `return 0;`**。
# 判据本身(`rail_selftest_table()`)已经被测得很死,但「rail_selftest 有没有
# 真的去调它」这一行接线,在一切正常的前提下测试**原理上分不出来**
# —— 真表是 const 的,没有任何输入能让它该报错。这是已知残留,不是漏做。
# 节拍:next 从「现在」起算(漂移)/ 用裸比较判先后(回绕)/ 过载后补跑
# 任务表:t_log 抬到最高 / 巡检周期缩到 5 ms / thermal 与 actuator 不同级
# 巡检:拿锁改成死等 / 一片读失败就中断整圈 / 读失败把值清零 / 忘了放锁 /
# 地址改成 0x40+ch / 多读一次 PCA9555 / 占用游标不对齐到「现在」
# 记账:漏掉 STOP 位
# sim:两把锁允许嵌套 / 假总线上的事务不花时间
# → 16 条全部变红。
```
## 踩到的坑(只写会再犯的)
- ⚠️ **HAL 两侧的 GPIO 边界原来都是 `pin < 48`,而 `EFUSE_EN2` 在 IO48 上。**
ESP32-S3 的编号是 **0~48(含 48)**,写成「个数 48」正好把它关在门外,
现象是 CH2 的 eFuse 既开不了也**关不掉**(`hal_gpio_config()` 直接 HAL_ERR_ARG)。
已改成 49,`test_io48_is_a_usable_pin` 是回归拦网。
顺带记住:IO22~IO25 在 S3 上**根本不存在**、IO26~IO32 是模组内部 flash/PSRAM,
写进配置表 `gpio_config()` 既不报错也不干活 —— 自检把这些号也当越界。
- ⚠️ **「不是顺排」要看整列,逐格撞算式会撞空。** 第一版测试是「拿几个常见错算式
逐路比,断言错了几路」。写完逐格核对才发现:CH4 的按键位正好就是 9/10/11、
CH1 的 EDL 位正好是 16、CH3 的两根 EN 正好落在猜的算式上 —— 三处「错了 5 路」
实际都只有 4 路。**猜错算式的测试是句空话**,而且这种空话看上去和真断言一样。
改成断言整列**不是等差数列**(`is_arithmetic()`),任何 `起点+步长×ch` 一次拦掉。
- **注错实验里把源文件 mtime 推到未来**(`os.utime(path, now+5)`):注错与恢复
挤在同一秒里,make 有可能认为 `.o` 还是新的、跳过重编,于是跑的是**上一轮**的
二进制 —— 那样「注了错还是绿」是假绿,看起来却像「这条测试没覆盖到」。
⚠️ 本轮**没有观察到它真的发生**(三条绿是真绿,原因见上面那段),
这条是预防性的;但假绿这种错一旦发生就会被当成结论写进文档,代价不对称。
(A6 那两条纪律仍然有效:try/finally 恢复原文;编译期红的一条要重写成能编过的。)
- **判据要能脱离真表被调用**。真表是 const 的,测试改不动,所以「查得出问题吗」
这件事本来是测不了的 —— 把判据拎成 `rail_selftest_table(const rail_cfg_t *t)`
之后,测试复制一份表出来逐样改坏即可。这条比多写十个断言管用。
- ⚠️ **sim 里「干活不花时间」会让时序测试变成一句空话。** 巡检在 sim 中耗时为零时,
「周期 20 ms」测的只是「delay 参数传对了没」,任何实现都能踩准。
所以假 I²C 现在**真的把虚拟时钟花掉**(按位周期折算),
而且测试第一句就断言「这一圈确实花掉了 `t_safety_budget_us()`」——
这条断言本身就是防着有人把 `sim_i2c_set_bus_time` 关掉。
- ⚠️ **注错实验里 `old in src` 会命中你没想到的那一行。** 这次
`" s_bus_time = true;"` 先匹配到了声明行(`static bool s_bus_time = true;`
里正好含这段),改的是初值、而它每次 reset 又被覆盖回来 —— 于是那条注错「绿了」,
看上去像「测试没覆盖到」。**注错的锚点要连着上一行一起写**,
跑完还要确认它真的改到了那一处。
- **周期任务的相位要用绝对时刻,而且比较必须走 `int32_t` 差。**
裸写 `now >= next` 在 49.7 天回绕那一刻会把「还早」判成「迟到了 49 天」,
然后一口气空转两百万个周期。农场是长稳跑的,这一定会发生。
- **加锁纪律要能被强制检查,否则「都加了锁」是靠人读代码保证的。**
sim 侧 `sim_lock_require()` 打开后,动 I²C / 595 却没持锁就记一次违规;
配套必须有一条**反向测试**(不拿锁直接读一片,违规必须 +1),
不然检查失灵时那些「违规为 0」的断言会永远绿。
- **两把锁不定加锁顺序,直接禁止嵌套。** 这块板上 595 是纯 GPIO、I²C 是纯总线,
没有任何一处需要同时持有;把「不可能」编码进 `hal_lock_take()`,
比定一套顺序然后指望后面每个人都记得可靠。
## 这一层的几条约定(写 A8/A9/A10 之前看一眼)
- **`rail_cfg()` 是唯一入口。** 任何地方都不许再出现 `0x40 + rail`、`3 * (rail-1)`
这类由通道号算出来的编号。六列里没有一列是顺排的,而写错了**板子照样正常工作**,
只是「重启 CH1」重启了 CH4。
- ⚠️ **两列 GPIO 没有第二张表能对账**(INA/DAC/595/PCA 那四列都有驱动那边兜着)。
所以自检**查不出**「`EFUSE_EN1` 和 `VBUS_EN1` 写反」—— 两个都是合法空闲脚、
不重复、不越界。唯一的拦网是 `test_en_pins_match_the_pcb_netlist` 里钉死的十个值
(出处 `.claude/pcb1-parts.json` 的 U20 焊盘网络)和 B1 上板实测。
- **`rail_all_off()` 是复位 / 收尾路径,不是故障快路径。** A10 过压要断的是出事
那一路,直接调 `rail_efuse_enable(rail, false)`,别等着遍历五路。
- **开机顺序**:`rail_selftest()` → `rail_en_init()`(十个脚配成输出并拉低)→
才轮到 `rail_volt_init()` 等器件。DAC 一上电输出就有电压,EN 这时必须已经确定是低的。
- **高有效只出现一次**(`en_write()`)。和 `sr595_set_hub_reset()` 的反相、
`rail_mv_to_code()` 的减法同一条规矩。
- **自检不过就 abort,不降级。** 它查的全是编译进固件的常量表,不过 = 固件写错了,
§10.10 的降级矩阵管的是硬件坏了。带着错表跑起来的样子是「命令发给了另一路」,
五台手机全在跑、一个错误码都不报。
## 这一层的几条约定(写 A9/A10 之前看一眼)
- **任务体里不许有判据和时序。** 写进 `while(1)` 的东西宿主机一行也跑不到 ——
`app_main.c` 里的五个任务体各自只有三行(拿节拍 / 调一个函数 / 睡到下一拍),
真正的活在 `main/` 的其它 .c 里。A9 的状态机照这个形状写。
- **A10 的判据写在 `t_safety_step()` 里,而且要在放锁之后。**
§10.3 原话:越界直接操作 `EFUSE_EN` / `VBUS_EN`,**不经任何队列或抽象层**。
发消息给 t_actuator 是错的;拿着总线锁去写引脚也是错的(那两根线和 I²C 无关)。
- **判据先看 `ok` 再看值。** 读失败时 `snap.rail[]` 里留的是上一圈的值。
- **动了巡检里读的东西,就要同步改 `t_safety_budget_us()` 的那一行**
(测试会拦:静态预算与实测必须相等)。
- **器件初始化还没做**(INA226 的 Config/Calibration、DAC、PCA9555 方向、595 安全值),
那是 A15。在那之前上板跑起来电流恒读 0,是预期的,不是故障。