A9:三个执行器状态机 + 互斥
需求 §10.6 的三个序列,每路一份状态机,全部以「收尾」收口:
硬重启 EFUSE_EN↓ → 4 s(§7.4 的 3~5 s 窗口)→ EFUSE_EN↑ → 17 ms
(tON 5.1 + 缓启动斜坡 11.5)→ 等 PG
按键 按下 → 保持 T(100 ms / 2 s / 10 s)→ 松开,组合键一帧发完
EDL EDL_EN=1 → VBUS_EN=1 → 等枚举 → EDL_EN=0(SW-6 的顺序)
一个函数一步,不写 while(1):t_actuator_step() 只看时钟、推状态、返回「下次
最晚多久再来」,任务体在 app_main.c 里还是三行。放电由硬件联锁做(SR-7),
本文件里没有任何放电接口。
四条关键判断:
· 按下去的东西一定要松开 —— 正常结束 / 超时 / 出错 / abort 走同一条收尾路径,
写不进 595 就一直重试,不设放弃上限(放弃 = 电源键永久按住,而遥测正常);
· 等不到 PG 不许再断电(§10.10:PCA9555 不应答不关输出);
· PG 由 t_safety 的快照喂进来,执行器不碰 I²C ⇒ 两把锁永远不会嵌套;
· 不可重入直接拒(HAL_ERR_STATE),被拒的请求一个字节都不发。
23 个用例;21 条注错逐个跑过全部变红。其中一条第一遍没抓住 ——「等 PG 时一觉
睡满 500 ms」:测试自己掌握节奏就测不到 step 返回的睡眠时长,补了一个完全听
step 的任务循环测试。
Co-Authored-By: Claude Opus 5 <[email protected]>
这个提交包含在:
+78
-73
@@ -1,92 +1,97 @@
|
||||
# 交接
|
||||
更新:2026-09-07 完成任务包 **A8**(任务骨架与调度:5 个任务、两把锁)
|
||||
更新:2026-09-07 完成任务包 **A9**(三个执行器状态机 + 互斥)
|
||||
|
||||
## 一句话现状
|
||||
§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` 通过。
|
||||
§10.6 的三个序列成了**每路一份**的状态机,全部以「收尾」收口:
|
||||
硬重启 `EFUSE_EN↓ → 4 s → EFUSE_EN↑ → 17 ms → 等 PG`(等不到只记 `NO_PG`,**电继续开着**)、
|
||||
按键 `按下 → 保持 T → 松开`(组合键一帧发完)、EDL `EDL_EN=1 → VBUS_EN=1 → 等枚举 → EDL_EN=0`。
|
||||
一路上同时只能跑一个,被拒的请求一个字节都不发;**按下去的东西一定会松开**
|
||||
(收尾写不进 595 就一直重试,绝不放弃)。PG 由 `t_safety` 的快照喂进来,
|
||||
执行器自己不碰 I²C ⇒ 两把锁永远不会嵌套。
|
||||
12 条 ctest(184 个用例)全绿,`idf.py build` 通过,**21 条注错逐个跑过全部变红**。
|
||||
|
||||
## 下一步
|
||||
**A9**:三个执行器状态机 + 互斥(需求 §10.6 全节)。
|
||||
验收是「硬重启走完 17 ms 上电时序;按键序列不重入;EDL 顺序固定」。
|
||||
A8 给它备好的:`HAL_LOCK_SR595`(595 那把锁,sim 里能强制检查)、
|
||||
虚拟时钟下的 `sched_*`、`sim_gpio_write_seq()`(A7 留的顺序断言)。
|
||||
⚠️ A9 的状态机同样要写成「一个函数一步」,不要写进 `while(1)` —— 死循环在宿主机上跑不到。
|
||||
**A10**:安全巡检的判据(SR-5 / SW-8)。验收是「注入过压 → 20 ms 内关路并记事件;
|
||||
注入 −60 mA → 断 VBUS」。判据写在 `t_safety_step()` 里、**放锁之后**,
|
||||
直接 `rail_efuse_enable(rail,false)` / `rail_vbus_enable(rail,false)`,
|
||||
不要发消息给 t_actuator(§10.3 原话:不经任何队列或抽象层)。
|
||||
A9 给它备好的:`t_actuator_abort(rail)`(关路之前把那一路正在跑的序列停掉),
|
||||
`safety_snap_t` 里的 `rail_ok[]`(判据先看 `ok` 再看值)。
|
||||
|
||||
## 本次新增/修改的文件
|
||||
- `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 落地 + 五条实现约定
|
||||
- `main/t_actuator.h` / `t_actuator.c` — 三个状态机 + 互斥 + 收尾(约 400 行,一半是注释)
|
||||
· 时间常量都带出处:`ACT_HR_OFF_MS` 4000(§7.4 的 3~5 s 窗口,配 `_MIN_/_MAX_` 两个界)、
|
||||
`ACT_HR_RAMP_MS` 17(tON 5.1 + 斜坡 11.5)、`ACT_HR_PG_TIMEOUT_MS` 500 + `_POLL_MS` 5、
|
||||
`ACT_KEY_MS_TAP/ON/FORCE` 100/2000/10000(SW-5)、`ACT_KEY_MAX_MS` 15000(拦手误)、
|
||||
`ACT_EDL_SHORT_MS` 10 + `ACT_EDL_ENUM_MS` 3000
|
||||
· `act_keys_t` 是**逻辑键位**(PWR/VUP/VDN + FASTBOOT/RECOVERY 两个组合),
|
||||
链上位号由 `sr595_key_bit()` 查表换算,本文件里没有一处算出来的位号
|
||||
- `main/app_main.c` — 巡检快照挪到文件作用域 + `act_pg_from_safety()`;
|
||||
`task_actuator` 任务体(三行);`t_actuator_init()` 在建任务之前
|
||||
- `test/test_t_actuator.c` — 23 个用例
|
||||
- `main/CMakeLists.txt` / `host/CMakeLists.txt` / `main/phonerail.h`(版本 `0.1.0-a9`)
|
||||
- `../需求文档.md` §10.6 — 记 A9 落地 + 六条实现约定
|
||||
|
||||
## 验收命令与结果
|
||||
```
|
||||
$ bash host/test.sh
|
||||
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 %)
|
||||
100% tests passed, 0 tests failed out of 12 # +test_t_actuator(23),共 184 例
|
||||
|
||||
$ .\build_idf.ps1 # PowerShell
|
||||
phonerail.bin binary size 0x3cc40 bytes. ... 0xc33c0 bytes (76%) free.
|
||||
phonerail.bin binary size 0x3d240 bytes. ... 0xc2dc0 bytes (76%) free.
|
||||
Project build complete.
|
||||
|
||||
# 16 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
|
||||
# 节拍:next 从「现在」起算(漂移)/ 用裸比较判先后(回绕)/ 过载后补跑
|
||||
# 任务表:t_log 抬到最高 / 巡检周期缩到 5 ms / thermal 与 actuator 不同级
|
||||
# 巡检:拿锁改成死等 / 一片读失败就中断整圈 / 读失败把值清零 / 忘了放锁 /
|
||||
# 地址改成 0x40+ch / 多读一次 PCA9555 / 占用游标不对齐到「现在」
|
||||
# 记账:漏掉 STOP 位
|
||||
# sim:两把锁允许嵌套 / 假总线上的事务不花时间
|
||||
# → 16 条全部变红。
|
||||
# 21 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
|
||||
# 硬重启:不等那 17 ms / 断电窗口缩到 1 s / 断电后忘了开回来 / 只置状态不断电 /
|
||||
# 等不到 PG 就关路 / PG 只在进入那一刻看一眼 / 等 PG 时一觉睡满 500 ms /
|
||||
# 开始前不松键
|
||||
# 按键: 组合键分两次写 / 位号改成用通道号算 / 不查「正在跑别的序列」/
|
||||
# 时长上限没了 / 按下失败就直接返回不收尾
|
||||
# EDL: 顺序反过来(先插 USB 再短接)/ 收尾时顺手把 VBUS 也断了 / 位号用算的
|
||||
# 收尾: 松键写成「整条链的键位全清掉」/ 写不进去就放弃 / 结束直接回 IDLE /
|
||||
# 换状态后不当场再走一步
|
||||
# abort: 顺手把电开回来
|
||||
# → 21 条全部变红。
|
||||
```
|
||||
|
||||
## 踩到的坑(只写会再犯的)
|
||||
- ⚠️ **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()`,
|
||||
比定一套顺序然后指望后面每个人都记得可靠。
|
||||
- ⚠️ **「测试自己掌握节奏」会把「step 返回的睡眠时长」整个漏掉。** 本文件的
|
||||
`run_ms()` 是按**测试**的节奏叫 `t_actuator_step()` 的,所以「等 PG 时一觉睡满
|
||||
500 ms 超时」那条注错第一遍**没被抓住** —— 状态机的判据全对,只是任务会睡过头。
|
||||
补了一个 `test_the_task_loop_only_sleeps_what_step_asks_for()`:完全听 step 的,
|
||||
它说睡多久就睡多久,PG 在斜坡走完之后才置位,序列必须在一个轮询间隔内收工。
|
||||
**凡是「返回一个时长给调用方去睡」的函数,都要有一个这样完全听它的测试。**
|
||||
- ⚠️ **测完成时刻要在「不 busy」的那一步立刻记,不能等循环退出。** 上面那个测试
|
||||
第一版把总时长量成了 4122 ms(真值 4022):最后一次 `sim_time_advance_ms(w)`
|
||||
已经先把表拨走了 100 ms。多出来的正好是 `ACT_IDLE_WAIT_MS`,看起来像个真信号。
|
||||
- **`t_actuator_step()` 返回的是五路的最小值**,空闲那几路各返回 100 ms ⇒
|
||||
单路要求睡 500 ms 时实际只睡 100 ms。写针对某一路的时序断言时别忘了这个下限。
|
||||
- **「按下失败」也必须走收尾。** 直接返回错误码就等于把一个脏的「按下」影子留下:
|
||||
谁下一次 `sr595_flush()` 谁就替你把键按下去,而没有任何人会来松开它。
|
||||
代价是那一路会短暂 busy 一下(请求仍然返回错误码),值。
|
||||
- **`CLEANUP` 故意不设放弃上限。** 「重试 N 次就算了」是这里最自然的写法,
|
||||
而它的含义正好是「595 出问题时把电源键永久按住」。宁可让这一路一直 busy、
|
||||
把 `cleanup_retries` 报到遥测里,让人看见。
|
||||
- **ESP-IDF 默认 `CONFIG_FREERTOS_HZ=100`(10 ms 一个 tick)**,`pdMS_TO_TICKS(5)`
|
||||
是 **0**,而 `vTaskDelay(0)` 只是让一次 CPU。中优先级任务在收尾重试时会因此空转,
|
||||
把 t_link / t_log 饿死。`app_main.c` 里钳到至少一个 tick。
|
||||
早醒本身没关系:每个状态都是拿**绝对时刻**算的,早来就再返回一个剩余时间。
|
||||
|
||||
## 这一层的几条约定(写 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,是预期的,不是故障。
|
||||
## 这一层的几条约定(写 A10/A12 之前看一眼)
|
||||
- **A10 的判据写在 `t_safety_step()` 里,放锁之后,直接写引脚。** 发消息给
|
||||
t_actuator 是错的(§10.3 原话:不经任何队列或抽象层)。要关的那一路上如果有
|
||||
序列在跑,先 `t_actuator_abort(rail)` 再关 —— abort 只收尾,**不动两根 EN 线**。
|
||||
- **A12 的命令处理器只管语义与判重,动作一律转成这三个请求。** 三个请求都是
|
||||
「立刻返回」的(不阻塞),busy 时返回 `HAL_ERR_STATE`,让上位机自己决定是重试
|
||||
还是先 abort。**SW-1 的「先软后硬」是上位机的策略**(崩溃判定本来就在那一侧,
|
||||
§10.8),A9 只提供机制:`t_actuator_key(rail, ACT_KEYS_PWR, ACT_KEY_MS_FORCE)`
|
||||
是一级,`t_actuator_hard_reset(rail)` 是二级。
|
||||
- ⚠️ **`sr595_init()` 由 A15 负责,而且必须跑在任何按键 / EDL 请求之前。**
|
||||
没 init 时 `sr595_flush()` 返回 `HAL_ERR_STATE`,而收尾**按设计不放弃**,
|
||||
于是那一路会永久 busy。**目前这条路径够不到**:请求只可能来自 A12 的命令处理器,
|
||||
而 A15 的启动顺序排在它前面(init 不成功就 abort)。A15 里要保住这个先后 ——
|
||||
先把链子 init 好,再让 t_link 开始收命令。硬重启不受影响(只碰 GPIO)。
|
||||
代码里没有为这条加「没 init 就拒」的分支:sim 里没有办法把 595 摆回「没 init」,
|
||||
加了就是一段测不到的代码,比让 A15 守住这个顺序更容易出事。
|
||||
- **改了任何一个时间常量就是改了需求**,`test_timings_match_the_documents()` 会拦;
|
||||
先去改 §7.4 / SW-5 / SW-6,再改这里。
|
||||
|
||||
在新工单中引用
屏蔽一个用户