A7:五路配置表 + 表驱动遍历 + 启动自检

六组编号(INA 地址 / DAC 片通道 / 三个按键位 / EDL 位 / 两根 EN 的 GPIO)拼成
一张 const 表 rail_cfg(1..5),没有一列是顺排的。开机 rail_selftest() 把它和
A3~A6 四个驱动的表逐格对账,再查全板引脚重复 / 越界,不过就 abort ——
那四个自检谓词至此全部有了调用点。

判据拎成 rail_selftest_table(t) 单独可调:真表是 const 的,不这么做就没法证明
「真有事的时候查得出来」。测试复制一张表出来逐样改坏,六种坏法都验过。

顺带修一个真 bug:HAL 两侧 GPIO 边界写的是 pin < 48,而 EFUSE_EN2 就在 IO48
上 —— CH2 的 eFuse 既开不了也关不掉。sim 与 ESP 两份都改成 0~48 含。

9 条 ctest(131 个用例)全绿,idf.py build 通过,16 条注错 15 条变红
(唯一没红的那条与原因记在 HANDOFF.md)。

Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
zhou yang
2026-09-07 07:13:04 +08:00
共同撰写人 Claude Opus 5
父节点 eb3df9ff49
当前提交 45de84ed3c
共修改 13 个文件,包含 1249 行新增和 63 行删除
+75 -58
查看文件
@@ -1,76 +1,93 @@
# 交接
更新:2026-09-07 完成任务包 **A6**(PCA9555 驱动)
更新:2026-09-07 完成任务包 **A7**(通道配置表 + 表驱动遍历 + 启动自检)
## 一句话现状
板上唯一的 I/O 扩展器(U23 @ 0x20)**16 个脚全部是输入**:5×FLT# + 5×PGOOD +
BUCK_PG + INA_ALERT + 4 个焊死在地上的备用脚。驱动**根本不提供「配成输出」的接口**,
方向字一律过 `pca9555_dir_safe()`。位表与地址从 PCB 焊盘网络逐脚核回来(与文档一致)。
8 条 ctest(81 个用例)全绿,`idf.py build` 通过。
五路的六组编号(INA 地址 / DAC 片通道 / 三个按键位 / EDL 位 / 两根 EN 的 GPIO)
拼成了**一张 const 表** `rail_cfg(1..5)`,开机自检 `rail_selftest()` 把它和四个驱动
那四张表**逐格对账**,再查一遍全板引脚有没有重复 / 越界,不过就 `abort()`。
A3~A6 留下的四个自检谓词至此全部有了调用点。9 条 ctest(131 个用例)全绿,
`idf.py build` 通过。
## 下一步
**A7**:通道配置表 + 表驱动遍历(需求 §10.4 全节)。四个驱动包(A3/A4/A5/A6)已全部完成,
A7 的活是把 `rail_cfg_t` 那张静态表建起来,并在启动自检里**一起**调上四个自检谓词:
`ina226_addr_table_ok()` / `dac63204_map_ok()` / `sr595_map_ok()` / `pca9555_map_ok()`
(前三个至今没有任何调用点,A7 是它们的归宿)。
**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 上电时序会更需要它)。
## 本次新增/修改的文件
- `components/drv/include/pca9555.h` — 位表、三种极性、接地脚掩码(唯一对外契约)
- `components/drv/pca9555.c` — 驱动本体(≈195 行,只经 `hal_i2c_*`)
- `components/phonerail_hal/sim/sim_pca9555.c` — **按 datasheet 建模的假器件**
(Output 复位 0xFF、输入按位合成、接地脚短路检测、INT# 只被读输入端口清)
- `components/phonerail_hal/sim/hal_i2c_sim.c` + `sim_hal.h` — 假 I²C 加了一个
**窃听钩子**(`SIM_I2C_EV_POST_WRITE` / `PRE_READ`),通用寄存器表模拟不了「配成
输出就对地短路」这种事;以后再有这类器件照这个挂
- `test/test_pca9555.c`(20 个用例)
- 三处 CMakeLists + `sim_hal.c` 的 reset 汇总 + `main/phonerail.h`(版本 `0.1.0-a6`)
- `../需求文档.md` §10.4 — 记 A6 落地 + 四条实现约定
- `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 落地 + 五条实现约定 + 那张六列表
## 验收命令与结果
```
$ bash host/test.sh
100% tests passed, 0 tests failed out of 8 # +test_pca9555(20)
100% tests passed, 0 tests failed out of 9 # +test_rail_cfg(28),总计 131 个用例
$ .\build_idf.ps1 # PowerShell
phonerail.bin binary size 0x32550 bytes. ... 0xcdab0 bytes (80%) free.
Project build complete. # pca9555.c.obj 在(已单独确认)
phonerail.bin binary size 0x34250 bytes. ... 0xcbdb0 bytes (80%) free.
Project build complete. # rail_cfg.c.obj 在(已单独确认)
# 12 个「一定会被写出来的错」逐个注进去跑过,**全部变红**(一次性脚本,没入库;做法见下面第 4、5 条坑):
# FLT#/PGOOD/INA_ALERT/BUCK_PG 四处极性各写反 -> 3~4 条红
# FLT 去读 Port1(端口搞反) -> 只有 test_one_rail_at_a_time 红
# 方向写成 0x7F(P0_7 配成输出) -> 4 条红,含 sim 的短路计数
# 忘了清极性反转 / 写了 Output Port -> 各 1~2 条红
# dir_safe 写成近似判据 dir != 0 -> 1 条红
# 读输入拆成两次事务 -> 1 条红(事务计数)
# 读失败时把调用方缓冲清零 -> 1 条红
# 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 的,没有任何输入能让它该报错。这是已知残留,不是漏做。
```
## 踩到的坑(只写会再犯的)
- ⚠️ **PCA9555 的 Output Port 寄存器复位值是 0xFF。** 所以把一个接地脚配成输出的
那一刻它就输出高电平,中间没有「先写个 0 再说」的余地 —— 这比「配错方向」本身
更急。和 A5 那条「ESP-IDF 一配成输出就开始驱动」是同一类坑,只是这里默认值是**高**。
- **FLT 与 PG 这次确实是顺排的**(`bit = rail - 1`),和 INA226 地址、595 位号那两张表
不一样。所以这一片最容易写出来的错不是算式,而是**把 Port0 / Port1 搞反**:
两个端口的 bit0~4 长得一模一样,**静息态两个端口读回来都是全 1**,
单看一帧完全看不出来。注错实验证实:搞反之后 20 条测试里只有
`test_one_rail_at_a_time` 一条红 —— 那条是专门为它写的,别删。
- **极性反转寄存器要无条件清零,不能赌复位值**:看门狗只复位 ESP32,PCA9555 不掉电。
- **`-Werror` 会替测试「假装」拦住错**:注错实验里「读输入拆成两次事务」第一版是
编译期红(`reg` 变量没人用),根本没跑到断言。补了一版能编过的才算数。
以后做注错实验,**编译期红的那一条一定要重写成能编过的**。
- **注错脚本必须 try/finally 恢复原文**:第一版崩在 `subprocess` 吃不下
`/c/cygwin64/...` 这种 POSIX 路径(Windows Python 要 `C:\cygwin64\...`),
异常直接把注错留在了源文件里。(同批小坑:长测试文件用 Bash heredoc 写会
`unexpected EOF`,改用 Write 工具。)
- ⚠️ **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)`
之后,测试复制一份表出来逐样改坏即可。这条比多写十个断言管用。
## 这个驱动 / 这一层的几条约定(写 A10 之前看一眼)
- **三种极性各只出现一次**(`pca9555_rail_fault` / `_rail_pg` / `_buck_pg` / `_ina_alert`)。
上层一律问「有没有故障」,**不许自己去 & 位** —— 和 `sr595_set_hub_reset()` 里那个
反相、`rail_mv_to_code()` 里那个减法同一个道理:多一处就多一处写反的机会。
- **`IOEXP_INT#`(IO38)是加速路径不是主路径**:PCA9555 要**读走输入端口**才释放 INT,
而且输入变回原值时它也会释放。只靠中断的话,漏掉一次读就再也等不到下一个沿。
所以 `t_safety` 的 20 ms 轮询是无条件的。A6 只留了引脚宏,读它是 A8/A10 的活。
- **这一片不应答不许关输出**(§10.10):只是失去 FAULT / PG 可见性,改用 INA226 的
电压判据兜底并持续告警。驱动的读失败一律只返回错误码,不做任何动作。
- ⚠️ **「全 0」不是安全默认值**:它和「五路真的全炸了」读起来一模一样
(五路全故障 + 五路全不良 + Buck 不良 + 告警)。上拉没焊、总线被拉低都是这个样子,
所以 A10 判故障之前要先确认这一帧是真读回来的,不是失败后的残留缓冲。
## 这一层的几条约定(写 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 的降级矩阵管的是硬件坏了。带着错表跑起来的样子是「命令发给了另一路」,
五台手机全在跑、一个错误码都不报。