固件:R2 迁移 R-0 ~ R-6(底板 + 5 片同构刀片)
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]>
这个提交包含在:
+53
-109
@@ -1,125 +1,69 @@
|
||||
# 交接
|
||||
更新:2026-09-07 完成任务包 **A11**(事件日志:环形缓冲 + 落盘)
|
||||
更新:2026-09-16 完成 **R-0 ~ R-6**(整个 R2 迁移,计划 v2.0 §6.2)
|
||||
|
||||
## 一句话现状
|
||||
事件从 `evlog_put*()` 进一个 256 条的 RAM 环(临界区里两条指令,**不阻塞、不碰 flash**),
|
||||
由最低优先级的 `t_log` 攒够 16 条或最老那条等满 1 s 时批量写进 **`evlog` 分区**
|
||||
(17 个扇区 × 4 KB,**4335 条**)。序号 = 记录在 flash 里的位置(`epoch × 255 + 槽号`),
|
||||
所以掉电重启后接着数、`evlog_read(起始序号)` 直接给 A12 的 `events` 命令用。
|
||||
A10 的事件出口已经接上(`app_main.c` 里 `evlog_put_safety`),**判据那一层一个字没动**。
|
||||
14 条 ctest(240 个用例)全绿,`idf.py build` 通过,**23 条注错跑过 21 条变红**。
|
||||
固件已经整体从 R1(单板 / 六片不同地址 INA226 / 74HC595 键链 / `VBUS_EN` 是 GPIO)
|
||||
迁到 **R2**(底板 + 5 片同构刀片 / 全是 0x40 与 0x20 / TCA9548A 分通道 /
|
||||
`VBUS_EN` 在刀片 PCA9555 的 P0.4 上)。**19 个 ctest、351 个用例全绿**,`idf.py build` 通过。
|
||||
器件层与拓扑层全部换掉,**判据阈值与时间常量一个没动**(它们来自电路计算,R2 没改电路)。
|
||||
|
||||
## 下一步
|
||||
**A12**:命令处理器(语义 + 序号判重 + 限幅)。验收是「同序号发两遍只执行一次;
|
||||
越界命令被拒且记事件」。A11 给它备好的:
|
||||
- `evlog_read(from_seq, out, max)` 就是 `events 起始序号` 那条命令的实现
|
||||
—— ⚠️ **先 `evlog_flush(0)` 再读**,没落盘的事件还没有序号(见下面「几条约定」)。
|
||||
- 「越界命令被拒」记 `LOG_EV_CMD_REJECT`(号已经占住,`arg` 放命令号)。
|
||||
- `evlog_stats()` 里的 `oldest_seq` / `next_seq` 是上位机的游标两端,
|
||||
`dropped` / `io_errors` 该进 `status` 的回复里。
|
||||
**A12**:命令处理器(语义 + 序号判重 + 限幅)。R2 多一条验收:
|
||||
**空槽(`PRSNT#` 为高)的命令一律拒**(`slot_present()` 已备好)。
|
||||
另外 A15 开工前先把启动序列从 `app_main.c` 拆成宿主机可编的函数 —— 见下面「还没有拦网的」。
|
||||
|
||||
## 本次新增/修改的文件
|
||||
- `main/evlog.h` / `evlog.c` — 环形缓冲 + 落盘 + 按序号读回(约 500 行,一半是注释)
|
||||
· `log_ev_t` 十四种事件(**数字就是 flash 格式**,只许往末尾加)
|
||||
· `evlog_rec_t` 16 B(§10.7 的「单条」)+ 每条一个 **CRC-8**
|
||||
· `evlog_init/put/put_safety/due/flush/read/stats/capacity`
|
||||
- `components/phonerail_hal/include/hal_store.h` + `sim/hal_store_sim.c` +
|
||||
`esp/hal_store_esp.c` — **HAL 的第六样**:掉电不丢的一块字节区(擦扇区 / 写字节 / 读)。
|
||||
sim 那份按 **NOR 的规矩**来:写是按位与、擦是整扇区、可以被「掉电」切成半条。
|
||||
- `components/phonerail_hal/include/hal_lock.h` + 两份实现 — 新增
|
||||
`hal_crit_enter/exit()`。**这不是第三把锁**,是关中断的临界区(ESP 侧是 portMUX,
|
||||
双核上两个核同时入队是真会发生的)。sim 侧会数「在临界区里干了会阻塞的事」的次数。
|
||||
- `sim/sim_hal.{h,c}` — 新增 **`sim_hal_reboot()`**(掉电重启:除 flash 内容外全清)
|
||||
与假 flash 的控制面(`sim_store_tear_next_write()` 等)
|
||||
- `main/app_main.c` — `evlog_init()`(失败不 abort)、事件出口接 `evlog_put_safety`、
|
||||
`task_log` 填上(100 ms 醒一次、`evlog_due()` 说要写才写、丢过事件就告警)
|
||||
- `partitions.csv`(新)+ `sdkconfig.defaults` — 自定义分区表,多一块 `evlog` 68 KB
|
||||
- `test/test_evlog.c` — 27 个用例(新文件)
|
||||
- `main/phonerail.h` — 版本 `0.1.0-a11`
|
||||
- `../需求文档.md` §10.7 — 记 A11 落地 + 八条实现约定
|
||||
- `main/i2cbus.{h,c}` **新** — mux 原子事务:拿锁 → 选通道 → 跑回调 → 放锁;通道影子 + 失败作废
|
||||
- `main/slot.{h,c}` **新** — 一片刀片的 I/O 门面:路号 → mux 通道 → 影子下标只在这里换算一次
|
||||
- `components/drv/tca9548a.{h,c}` / `ch224a.{h,c}` **新** — I²C 八路多路器 / PD 受电(只读)
|
||||
- `main/t_thermal.{h,c}` **新** — 风扇闭环(一路 PWM 两只风扇 + `FAN_EN` 总开关 + 两路 TACH)
|
||||
- `main/board_pins.h` — 整表重写(R2 的 GPIO 与 R1 **一个都不一样**)
|
||||
- `components/drv/pca9555.{h,c}` — 重写:P0.0~4 是输出、五实例影子、初始化顺序是安全本身
|
||||
- `components/drv/ina226.{h,c}` — 五路同址 0x40 + 输入侧 0x43
|
||||
- `main/rail_cfg.{h,c}` — 换列:+`mux_ch` +`prsnt_n`,−`vbus_en`;新增 `RAIL_SELFTEST_MUX`
|
||||
- `main/t_safety.{h,c}` — 一圈改成「五次 `xact` + 主干一次」;+整圈锁预算;+mux 失联/在位两条判据
|
||||
- `main/t_actuator.{h,c}` — 595 链 → PCA9555 Port0;本文件现在**要拿 I²C 锁**
|
||||
- `main/app_main.c` — R2 启动顺序(含 `HUB_RST` 只脉冲一次、`ina226_set_reversed(0x43,true)`)
|
||||
- `main/evlog.h` — 末尾追加 `LOG_EV_MUX_LOST=14 / SLOT_EMPTY=15 / PD_LIMIT=16`
|
||||
- sim:假 TCA9548A(关着的支路一律 NAK)、五实例假 PCA9555、假风扇
|
||||
- `host/qtest.sh` **新** — 单个测试的快编快跑,迁移期并行开工用(DoD 仍认 `host/test.sh`)
|
||||
- `components/drv/include/sr595.h` — 顶部加「⛔ R2 已退役」横幅,代码与测试保留不动
|
||||
|
||||
## 验收命令与结果
|
||||
```
|
||||
$ bash host/test.sh
|
||||
100% tests passed, 0 tests failed out of 14 # +test_evlog(27),共 240 例
|
||||
100% tests passed, 0 tests failed out of 19 # 351 个用例
|
||||
|
||||
$ .\build_idf.ps1 # PowerShell
|
||||
phonerail.bin binary size 0x3ec30 bytes. ... 0xc13d0 bytes (75%) free.
|
||||
Project build complete.
|
||||
|
||||
$ python .../gen_esp32part.py build/partition_table/partition-table.bin
|
||||
evlog,data,undefined,0x110000,68K, # 17 个扇区
|
||||
|
||||
# 23 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
|
||||
# 环形: 满了丢最新而不是盖最旧 / 丢了不补 LOST / 补记 LOST 之后不清零 /
|
||||
# 攒批计时从最新一条算起
|
||||
# 校验: 退回异或和 / 记录从扇区头那一槽开始写 / 假 flash 写成覆盖而不是按位与
|
||||
# 落盘: 换扇区时不擦 / 绕圈后不更新 oldest_seq / 写失败后不放弃这个扇区 /
|
||||
# 落盘时不补记 LOST / flush 不守预算 / 攒批没有超时兜底 / 攒够一批也不写
|
||||
# 重启: 每次上电都重新格式化 / 扫描取 epoch 最小的扇区 / 扫描停在上一条
|
||||
# 读回: 读到洞就跳过 / 读不 clamp 到 oldest_seq
|
||||
# 其它: 事件时间戳改成记账时刻 / 映射写成加个偏移
|
||||
# → 21 条变红。剩下 2 条见下面「抓不到的那两条」。
|
||||
$ .\build_idf.ps1
|
||||
Project build complete. # phonerail.bin 0x3fa90,分区剩 75%
|
||||
```
|
||||
|
||||
## 踩到的坑(只写会再犯的)
|
||||
- ⚠️⚠️ **校验和用异或是错的,必须用 CRC。** 掉电把一次写切在第 8 个字节上时,
|
||||
后 8 个字节留在 0xFF —— 而 **8 个 0xFF 的异或恰好是 0**。于是一条
|
||||
`bus_mv`/`current_ua` 都是 0 的记录(`LOG_EV_BOOT` 就长这样),
|
||||
**半条的异或和与整条一模一样**,扫描把半条当成一条真事件,接着往后写。
|
||||
这条错在正常运行里一点痕迹都没有,只在掉电之后出现。
|
||||
拦网是 `test_a_torn_write_with_zero_payload_is_still_rejected()`。
|
||||
- ⚠️ **「掉电重启」的测试不能用 `sim_hal_reset()`**:那是「换了一块新板子」,
|
||||
flash 一起擦掉,于是测的是「刚擦干净的 flash 读不出东西」—— 永远绿。
|
||||
为此专门加了 `sim_hal_reboot()`(CPU 侧全清、flash 一个字节不动)。
|
||||
凡是测「掉电后还在不在」的,先问一句:这个假实现里,什么东西该活下来。
|
||||
- ⚠️ **「读到洞就跳过」这个错被 `static` 数组的零初始化掩盖过一次。**
|
||||
`evlog_read()` 遇到坏槽要放一条空记录**占住位置**,跳过的话上位机游标就对不上;
|
||||
但测试用的 `static evlog_rec_t buf[]` 本来就是全 0,而「空记录」也是全 0,
|
||||
于是注错之后测试照样绿。**读之前先把缓冲填成非零**(现在填 0xAA)。
|
||||
- **攒批的计时起点是环里最老那一条,不是最新那一条。** 取最新的话,事件慢慢地
|
||||
一条一条来(真板上就是这个样子)时窗口每次被重置,**永远不 due** ——
|
||||
现象是「日志一直不落盘」,而每一条断言都是绿的。
|
||||
拦网是那条「一条 + 600 ms + 一条 + 600 ms」的用例。
|
||||
- **扫描要取 epoch 最大的扇区**,而这件事在「只写过一个扇区」的用例里
|
||||
一点区别都没有(min == max)。测重启必须先写**超过 255 条**跨过扇区边界,
|
||||
否则整段绕圈代码一行都没跑到。
|
||||
- **NOR 的写是按位与,不是赋值。** 假 flash 写成 `=` 的话,「忘了先擦」
|
||||
这个错在宿主机上一次也复现不出来,而真板上的现象是「记录读出来是两条的与」。
|
||||
- **`t_safety` 的事件出口是在断电之后、不持任何锁时被调的**,所以入队里
|
||||
`ESP_LOGW` 那一行必须排在 `evlog_put_safety()` **后面** —— 抢串口是会等的。
|
||||
- **抓不到的那两条**(注进去仍然全绿,理由留在代码注释里):
|
||||
① `rec_valid()` 里的「全 0xFF 直接判空」:当前这个 CRC 的初值/多项式下,
|
||||
擦干净的槽本来就校验不过,那一行是冗余的一道保险 —— 它只在**有人改了
|
||||
CRC 的多项式或初值**时才有区别,而那种改动没有任何测试拦得住。
|
||||
② `hdr_valid()` 里的 `epoch % 扇区数 == 扇区号`:它保的是「扇区号由 epoch
|
||||
算出来」这条被后面所有代码假定的不变量,但要造出反例得让分区几何
|
||||
在两次上电之间变掉,而 sim 的几何是编译期常量。
|
||||
两条都留着 —— 它们保的是别人改代码时的假设,不是当前这一版的行为。
|
||||
- ⛔⛔ **PCA9555 的初始化顺序就是安全本身。** Output Port 复位值是 **0xFF**,
|
||||
所以必须 `OUTPUT=0 → POLINV=0 → CONFIG1 → CONFIG0` ——**方向最后改**。
|
||||
反过来的那一刻 P0.0~P0.4 一起输出 1 = 三个键全按下 + EDL 短接 + VBUS 上电。
|
||||
注错实测:把顺序调过来,33 条用例里**只有 1 条变红**,终态完全正确 ——
|
||||
这就是为什么它在真板上查不出来。拦网是 `sim_pca9555_unsafe_enable()`。
|
||||
- ⛔ **`check_mux()` 的两段式不能拿「复位计数器和自己的快照比」做状态**:
|
||||
复位之后把后者同步成前者,两个数永远相等 ⇒ 每圈都走「先复位」分支,
|
||||
`MUX_RST#` 每 20 ms 脉冲一次而 `MUX_LOST` 一次也不出。状态要单独存(`mux_step`)。
|
||||
- ⛔ **R2 一圈要拿六次锁**(R1 只拿一次)。每次 5 ms 超时看着没问题,
|
||||
六次加起来 **30 ms > 20 ms 周期** ⇒ 总线繁忙时巡检成片丢拍,而每次 `xact` 都按时返回了。
|
||||
现在有 `T_SAFETY_LOCK_BUDGET_MS`(10 ms)这条整圈的闸。
|
||||
- ⚠️ **「断 VBUS 在锁内、关 eFuse 在锁外」这条,光断言「写的时候持着锁、通道对」拦不住**:
|
||||
把它挪到锁外之后 `slot_vbus_enable()` 自己也会拿锁、也会选对通道,两条断言照样过。
|
||||
真正的硬凭据是**「这一写发生在本圈第几次拿锁里」**(对 = 第 rail 次,错 = 第 7 次)。
|
||||
- ⚠️ **`slot_present()` 读的是缓存**,`t_safety` 每圈要先 `slot_prsnt_poll()`。
|
||||
不 poll 的现象是「拔掉刀片永远判不出来」,而快照里那五个 bool 看着完全正常。
|
||||
- ⚠️ 假总线上 `hal_i2c_write_read()` 会让 `n_write` 和 `n_read` **各 +1**,
|
||||
所以「巡检有没有写刀片」不能看 `n_write`,要看最后一次写的寄存器号。
|
||||
- ⚠️ `i2cbus_init()` **必定留下一次 `MUX_RST#` 低脉冲**(先 config(OUTPUT) 再写 1),
|
||||
测试里所有复位断言都要走增量。
|
||||
|
||||
## 这一层的几条约定(写 A12/A15/A16 之前看一眼)
|
||||
- **要读最新的事件,先 `evlog_flush(0)`。** 序号是 flash 里的位置算出来的,
|
||||
所以还在 RAM 里的事件**还没有序号**,`evlog_read()` 看不见它们。
|
||||
这不是疏漏:入队时发号会在缓冲溢出时留下永远补不上的空号。
|
||||
- **产生事件的人只调 `evlog_put()`,不碰 flash、不调 `evlog_flush()`。**
|
||||
`t_log` 是唯一碰 flash 的任务。在 t_safety / t_actuator 里 flush,
|
||||
等于把一次几毫秒的 flash 写事务塞进 20 ms 的节拍里。
|
||||
- **新事件类型只能往 `log_ev_t` 末尾加。** 中间插一个,flash 里跑了半年的
|
||||
历史记录会整体挪位,**而且读出来仍然是合法记录**,没有一处会报错。
|
||||
`test_flash_format_is_frozen()` 把每一个数字都钉死了 —— 那条用例变红时,
|
||||
要改的是你的枚举,不是那条用例。
|
||||
- **§10.7 的必记事件里还有六类没人产生**,号已经占好:`LOG_EV_EFUSE_FAULT` /
|
||||
`PG_CHANGE` / `I2C_LOST`(A15 的降级矩阵)、`THERMAL_DERATE` / `FAN_STALL`(A16)、
|
||||
`REBOOT` / `CMD_REJECT`(A9 的序列 + A12 的命令)。填的时候顺手把
|
||||
`evlog_ev_str()` 的名字和 `test_every_event_type_has_a_name()` 一起看一眼。
|
||||
- **改了 `partitions.csv` 里 `evlog` 的大小,要跟着改 `sim_hal.h` 的
|
||||
`SIM_STORE_SECTORS`。** 两边不一致时宿主机测试仍然全绿,板子上却是另一个容量。
|
||||
`test_capacity_meets_the_requirement()` 把它们钉在一起了,但它钉的是 sim 那一侧
|
||||
—— csv 那一侧只有人眼。
|
||||
- **`hal_crit_enter/exit()` 不是第三把锁**(§10.3 的共享资源只有两个)。
|
||||
它是关中断的临界区:不阻塞、没有持有者、不参与「不许嵌套两把锁」那条规则。
|
||||
里面**只许改内存** —— sim 的 `sim_crit_blocking()` 会数「在临界区里等锁或睡」
|
||||
的次数,`test_evlog.c` 的 `tearDown()` 每个用例都查一次,必须恒为 0。
|
||||
- **`evlog_init()` 失败不许 abort。** 日志是排查手段,不是安全链的一环(§10.2):
|
||||
没有分区时事件照进 RAM,只是落不了盘,五路照供电。这与 `selftest_or_die()`
|
||||
的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。
|
||||
## 还没有拦网的(不是遗漏,是已知边界)
|
||||
- **启动序列一行也测不到**:`app_main.c` 只进 IDF 目标。像 `ina226_set_reversed(0x43,true)`
|
||||
这种「漏了就把正常供电判成反灌」的单行,现在唯一的拦网是人眼。A15 先拆函数。
|
||||
- **mux 通道整体循环错位**(0→1→2→3→4→0)自检全过:五路互不重复、都不越界、都不等于 5。
|
||||
现象是「每一路读到的都是隔壁那台手机」。唯一判据是上板 **B1c 五槽逐一单插**。
|
||||
- **选通道「写下去不报错但没生效」**:假器件写了就生效,宿主机造不出来。见计划 §11。
|
||||
- **真并发**:sim 单线程,`i2cbus_xact` 把「拿锁 + 选通道」绑成原子的价值只能间接证明。
|
||||
|
||||
在新工单中引用
屏蔽一个用户