文件
phonerail/firmware/HANDOFF.md
T
zhou yang和Claude Opus 5 273c53347e A10:安全巡检的判据(SR-5 / SW-8)
判据全在 t_safety_step() 里、**放锁之后**、直接写引脚(§10.3 原话:不经任何
队列或抽象层):

  SR-5   |实测 − 该路设定值| > 100 mV **连着两圈** → 先断 VBUS 再关 eFuse
  SW-8   I < −50 mA,或 VBUS 期间电压被顶高 > +100 mV → **一圈就断 VBUS**

七条实现约定(详见需求文档 §10.2):

· 「持续 20 ms」数的是圈,不是墙钟毫秒;
· 反灌不设确认窗口,与过压那 20 ms 的不对称是有意的 —— 误断 VBUS 只丢一次
  USB 数据,漏判会把能量泵回 12V 母线连累另外四台(设计文档 §15.2);
· 判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装
  (对应 §10.10 的「盲跑 → 保持供电,禁用该路判据」);
· 比的是该路 DAC 设定值的影子,不是 3900 这个常数(SW-4 要单路微调);
· SR-5 关路关干净(不留「只靠 USB 供着电」的窗口),SW-8 只断 VBUS 不碰 eFuse;
· 断电之前先 t_actuator_abort(),断 VBUS 时只打断 EDL(只有它写 VBUS_EN);
· 动作在前、记录在后,事件走回调出去,A11 在那儿接环形缓冲。

判据一条 I²C 事务都不发(设定值、两根 EN、正在跑的序列全部走影子),
§10.3 那 7.8 % 的预算不变。顺带新增两处影子:rail_efuse_is_on()/rail_vbus_is_on()
与 rail_setpoint_mv(),都**只在写成功时更新**,两个方向都倒向安全的那一侧。

29 个用例;25 条注错逐个跑过,24 条变红。第一版把「持续 20 ms」按墙钟算,
测试一半红一半绿 —— 巡检的采样时刻带亚毫秒残差(一圈占 1.56 ms 总线,
而睡到下一拍按整毫秒算),相邻两次判据的时刻是 21/19 ms 交替,19 ms 那一半
要拖到第三圈 = 40 ms,超验收且间歇性。改成数圈之后一次不飘,
并留了一条跑 20 轮、两种相位都踩得到的回归拦网。

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-07 08:41:53 +08:00

8.1 KiB
原始文件 Blame 文件历史

交接

更新:2026-09-07 完成任务包 A10(安全巡检的判据:SR-5 / SW-8)

一句话现状

t_safety_step() 在放锁之后跑两条判据,越界直接写引脚(不发消息给谁): SR-5「偏离设定 >±100 mV 连着两圈」→ 先断 VBUS 再关 eFuse; SW-8「I < −50 mA 或电压被顶高 >+100 mV」→ 一圈看见就断 VBUS(不碰 eFuse)。 断电之前先 t_actuator_abort()(否则硬重启 4 s 后把电又抬回来), 动作做完才记事件;事件走一个回调出去,A11 在那儿接环形缓冲。 判据只在「该路该有电 + 这一圈的值新鲜 + 过了 60 ms 缓启动窗口」时武装。 13 条 ctest(213 个用例)全绿,idf.py build 通过,25 条注错跑过 24 条变红。

下一步

A11:事件日志(环形缓冲 + 落盘)。验收是「写满 4096 条不丢最新;掉电重启后能读回」。 A10 给它备好的:safety_event_t(§10.7 的单条形状,16 B)与 t_safety_set_event_sink(fn, ctx) —— 只要把入队函数接上去就行,判据那一层不用动。 app_main.c 里现在接的是一个打日志的临时出口(log_safety_event()),换掉它。 ⚠️ 回调是在断电之后、不持任何锁的情况下被调的,入队函数不许阻塞。

本次新增/修改的文件

  • main/t_safety.h / t_safety.c — 两条判据 + 事件(约 200 行,一多半是注释) · 阈值都带出处:T_SAFETY_DEV_MV 100(SR-5)、T_SAFETY_DEV_HOLD_CYCLES 2 与折算值 _HOLD_MS 20、T_SAFETY_REV_UA −50000(设计文档 §15.2 对策 2)、 T_SAFETY_REV_BUDGET_MS 200(SW-8)、T_SAFETY_SETTLE_MS 60(tON+斜坡 17 ms 的 3 倍) · safety_ev_t 四种事件 + safety_event_t(§10.7 的单条形状)+ 事件出口回调 · 每路判据状态 safety_rail_t 放在快照里,t_safety_init() 一并清干净
  • main/rail_cfg.h / .c — 新增 rail_efuse_is_on() / rail_vbus_is_on() (两根 EN 线的影子,只在写成功时更新)
  • main/rail.h / .c — 新增 rail_setpoint_mv() / rail_setpoint_reset() (五路设定值的影子,初值就是 DAC 的 NVM 默认 3900)
  • main/app_main.c — log_safety_event() 临时事件出口;t_safety_set_event_sink() 接在建任务之前
  • test/test_t_safety_rules.c — 29 个用例(新文件;A8 那份 test_t_safety.c 没动)
  • main/phonerail.h — 版本 0.1.0-a10
  • ../需求文档.md §10.2 — 记 A10 落地 + 七条实现约定

验收命令与结果

$ bash host/test.sh
100% tests passed, 0 tests failed out of 13   # +test_t_safety_rules(29),共 213 例

$ .\build_idf.ps1          # PowerShell
phonerail.bin binary size 0x3d650 bytes. ... 0xc29b0 bytes (76%) free.
Project build complete.

# 25 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库):
#   SR-5: 只判过压方向 / 阈值写成 >= / 看见一圈就关 / 回到范围内不清零 /
#          **确认窗口改回按墙钟算 >= 20 ms** / 设定值写死 3900
#   闸门: 盲跑那一路照样判 / 关着的路照样判 / 没有缓启动窗口 / 跳过的那一圈也判
#   关路: 不 abort / 只关 eFuse 不断 VBUS / 顺序反过来 / 事件记在断电之前
#   SW-8: 符号写反 / 不看 VBUS 通没通 / 顺手把 eFuse 也关了 /
#          不打断 EDL / 无条件打断这一路的序列 / 挪到 SR-5 后面
#   其它: 判据跑在放锁之前 / 顺手多读一次寄存器 / 事件里记设定值 / …
#   → 24 条变红。剩下 1 条见下面「抓不到的那一条」。

踩到的坑(只写会再犯的)

  • ⚠️⚠️ 「持续 20 ms」按墙钟算是错的,必须数圈。 第一版写的是 now − 第一次看见 >= 20 ms,测试一半红一半绿,看上去像是测试写飘了。 真因:巡检的采样时刻带亚毫秒残差 —— 一圈自己占 1.56 ms 总线,而 sched_periodic_wait() 是按整毫秒睡的,于是相邻两次判据的时刻是 21 / 19 ms 交替(实测 1, 22, 41, 62, 81, 102, 121, 142 …)。 19 ms 那一次不满足 >= 20,判据要拖到第三圈 = 40 ms,超了 §12.1 的验收, 而且是间歇性的。改成「连着两圈都越界」之后一次都不飘。 凡是「持续 N 个周期」的判据都数圈,别拿墙钟去凑。 回归拦网是 test_the_cut_is_never_late_because_of_sub_millisecond_jitter(), 它跑 20 轮、两种相位都踩得到。
  • ⚠️ 「盲跑不判据」这条,测试的写法决定了它能不能被抓住。 第一版是 「改坏寄存器 → 立刻让器件失联 → 跑 1 秒」,而那时快照里躺着的是正常值, 于是「判据不看 rail_ok」这个错一点痕迹都不留,测试永远绿。 正确写法是先让坏值真的读进快照一次,再让器件失联。 凡是测「陈旧值不许被采信」的,都要先把陈旧值做成一个越界的值。
  • 判据顺序:SW-8 在前,SR-5 在后。 过压且 VBUS 通着时两条都成立, 先撤掉能量来源(VBUS),20 ms 后再由 SR-5 决定要不要连这一路一起关掉。 反过来的话,第一圈就把电关了,日志里只剩一条「过压关路」, 「是 USB 灌回来的」这个信息丢了。
  • t_actuator_abort() 要在断电之前,虽然它最坏会等 595 那把锁 20 ms (ACT_LOCK_TIMEOUT_MS)、让巡检丢一拍。反过来(先断电再 abort)有个真实的洞: abort 阻塞在锁上的那 20 ms 里,优先级更低的 t_actuator 反而能跑, HR_OFF 到点就把 EFUSE_EN 抬回去了。丢一拍认了 —— SR-5 原文写着它 「仅承担记录与告警,不承担实时保护」。
  • 两个影子都只在写成功时更新(rail_efuse_is_on / rail_setpoint_mv)。 方向是有讲究的:关的那次写失败 ⇒ 影子留在 on ⇒ 判据继续武装(还会再关一次); 开的那次写失败 ⇒ 影子留在 off ⇒ 不会对着一路没开成的电报偏差。两边都倒向安全侧。
  • 判据一条 I²C 事务都不许发,所以设定值、两根 EN、正在跑的序列全部走影子。 多读一次就把 §10.3 那 7.8 % 的预算顶穿,A8 的 test_one_cycle_is_exactly_thirteen_transactions() 会当场变红。
  • 抓不到的那一条:「关路之后把 dev_n 清零」注进去仍然全绿 —— 它只在写引脚失败时才有区别(影子留在 on ⇒ 下一圈立刻再关一次, 清零的话要再等两圈),而 sim 里造不出「已配置的输出脚写失败」。 所以那一行干脆没写(不写才是 do-nothing 的那一边),理由留在代码注释里。

这一层的几条约定(写 A11/A12/A15 之前看一眼)

  • A11 只接 t_safety_set_event_sink(),不要动判据。 判据那一层已经保证 「动作在前、记录在后」,入队函数只管入队;阻塞或回头调 t_safety_* 都是错的。 §10.7 里其它几类必记事件(eFuse FAULT / PG 变化 / 过温降载 / 越界命令被拒 / I²C 器件失联)各归各的包,A10 只产生自己这两条判据的四种。
  • A12 的命令处理器改电压时走 rail_set_mv(),别绕过它。 判据比的是那里的影子; 绕过去直接写 DAC 的话,改完那一路会在 20 ms 后被自己的判据关掉。
  • A15 的启动顺序里,五路上电要错开 100 ms(§10.9 第 5 步)—— 这和 60 ms 的缓启动窗口是相容的:每一路的窗口从它自己的 EFUSE_EN↑ 算起。 另外 t_actuator_init() 必须排在巡检任务之前(判据要 abort 它), app_main.c 里已经是这个顺序,别调过来。
  • 改了任何一个阈值就是改了需求,test_thresholds_match_the_documents() 会拦; 先去改 SR-5 / SW-8 / 设计文档 §15.2,再改这里。
  • ⚠️ T_SAFETY_DEV_HOLD_MS 和 _HOLD_CYCLES 是同一件事的两种写法 (毫秒 = (圈数−1) × 巡检周期),代码用圈数、文档用毫秒,测试拿任务表里的周期 把两者钉在一起。巡检周期哪天改了,那条断言会当场对不上 —— 这正是它存在的理由,别去「修」它。