From 18d3d69856f3afa682a4db4db73274e43028ddf1 Mon Sep 17 00:00:00 2001 From: zhou yang Date: Mon, 7 Sep 2026 09:18:30 +0800 Subject: [PATCH] =?UTF-8?q?A11=EF=BC=9A=E4=BA=8B=E4=BB=B6=E6=97=A5?= =?UTF-8?q?=E5=BF=97=EF=BC=88=E7=8E=AF=E5=BD=A2=E7=BC=93=E5=86=B2=20+=20?= =?UTF-8?q?=E8=90=BD=E7=9B=98=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事件从 evlog_put*() 进一个 256 条的 RAM 环(临界区里两条指令,**不阻塞、 不碰 flash**),由最低优先级的 t_log 攒够 16 条或最老那条等满 1 s 时批量写进 新的 `evlog` 分区(17 扇区 × 4 KB = **4335 条**)。A10 的事件出口原样接上, 判据那一层一个字没动。 八条实现约定(详见需求文档 §10.7): · 落盘不用 NVS 也不用 SPIFFS,是一块裸 data 分区 —— NVS 的页式 GC 会把 「写一条事件」变成一次不可预测的搬移。HAL 因此多出第六样 hal_store; · 17 个扇区不是 16:每扇区要拿一个 16 B 的槽当扇区头,4 KB 只装 255 条, 16 扇区 = 4080,差 16 条不到 §10.7 要求的 4096; · 序号 = flash 里的位置(epoch × 255 + 槽号),不是自增计数器 —— 重启接着数 不用存计数器;代价是没落盘的事件还没有序号,A12 读之前要先 flush; · 入队走关中断的临界区,**不是第三把锁**:入队方是最高优先级的 t_safety, 停在一把 t_log 持着的锁上等于让最低优先级的任务决定巡检节拍; · 丢事件可以,悄悄丢不行 —— 缓冲满了盖最旧的,但补一条 LOG_EV_LOST 记着丢了几条; · 每条记录带 CRC-8(见下),写失败就放弃该扇区剩余部分,被放弃的那段在序号 空间里是个洞,读回时给一条空记录占位(跳过的话上位机游标会卡住); · 每次上电记一条 LOG_EV_BOOT:时间戳上电从 0 计,没有它两次上电的记录会穿插。 事件类型的数字是 flash 格式的一部分,只许往末尾加;safety_ev_t → log_ev_t 是显式 switch 不是加偏移。§10.7 里还没人产生的六类号已占住(A12/A15/A16 去填)。 27 个用例;23 条注错逐个跑过,21 条变红。第一版校验和用异或,漏掉的正是最常见 的那条:payload 全 0 的记录被掉电切在第 8 个字节上时,后 8 个字节留在 0xFF, 而 8 个 0xFF 的异或恰好是 0 —— 半条的校验和与整条一模一样,扫描把半条当成 一条真事件。换成 CRC-8 之后拦住。另外两个只在这里才踩得到的坑:「掉电重启」 不能用 sim_hal_reset()(那是换新板子,flash 一起擦,测试永远绿),为此加了 sim_hal_reboot();「读到洞就跳过」这个错被测试里 static 数组的零初始化掩盖过 一次,缓冲要先填成非零。 Co-Authored-By: Claude Opus 5 --- firmware/HANDOFF.md | 196 +++--- .../components/phonerail_hal/CMakeLists.txt | 3 +- .../phonerail_hal/esp/hal_lock_esp.c | 13 + .../phonerail_hal/esp/hal_store_esp.c | 83 +++ .../phonerail_hal/include/hal_lock.h | 21 + .../phonerail_hal/include/hal_store.h | 59 ++ .../phonerail_hal/sim/hal_lock_sim.c | 34 + .../phonerail_hal/sim/hal_store_sim.c | 153 +++++ .../phonerail_hal/sim/hal_time_sim.c | 1 + .../components/phonerail_hal/sim/sim_hal.c | 25 +- .../components/phonerail_hal/sim/sim_hal.h | 39 +- firmware/host/CMakeLists.txt | 2 + firmware/main/CMakeLists.txt | 1 + firmware/main/app_main.c | 48 +- firmware/main/evlog.c | 570 +++++++++++++++ firmware/main/evlog.h | 160 +++++ firmware/main/phonerail.h | 2 +- firmware/partitions.csv | 18 + firmware/sdkconfig.defaults | 4 + firmware/test/test_evlog.c | 650 ++++++++++++++++++ 需求文档.md | 46 ++ 21 files changed, 2025 insertions(+), 103 deletions(-) create mode 100644 firmware/components/phonerail_hal/esp/hal_store_esp.c create mode 100644 firmware/components/phonerail_hal/include/hal_store.h create mode 100644 firmware/components/phonerail_hal/sim/hal_store_sim.c create mode 100644 firmware/main/evlog.c create mode 100644 firmware/main/evlog.h create mode 100644 firmware/partitions.csv create mode 100644 firmware/test/test_evlog.c diff --git a/firmware/HANDOFF.md b/firmware/HANDOFF.md index 0b85cb5..eb805e1 100644 --- a/firmware/HANDOFF.md +++ b/firmware/HANDOFF.md @@ -1,109 +1,125 @@ # 交接 -更新:2026-09-07 完成任务包 **A10**(安全巡检的判据:SR-5 / SW-8) +更新:2026-09-07 完成任务包 **A11**(事件日志:环形缓冲 + 落盘) ## 一句话现状 -`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 条变红**。 +事件从 `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 条变红**。 ## 下一步 -**A11**:事件日志(环形缓冲 + 落盘)。验收是「写满 4096 条不丢最新;掉电重启后能读回」。 -A10 给它备好的:`safety_event_t`(§10.7 的单条形状,16 B)与 -`t_safety_set_event_sink(fn, ctx)` —— **只要把入队函数接上去就行,判据那一层不用动**。 -`app_main.c` 里现在接的是一个打日志的临时出口(`log_safety_event()`),换掉它。 -⚠️ 回调是在断电之后、不持任何锁的情况下被调的,入队函数不许阻塞。 +**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` 的回复里。 ## 本次新增/修改的文件 -- `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 落地 + 七条实现约定 +- `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 落地 + 八条实现约定 ## 验收命令与结果 ``` $ bash host/test.sh -100% tests passed, 0 tests failed out of 13 # +test_t_safety_rules(29),共 213 例 +100% tests passed, 0 tests failed out of 14 # +test_evlog(27),共 240 例 $ .\build_idf.ps1 # PowerShell -phonerail.bin binary size 0x3d650 bytes. ... 0xc29b0 bytes (76%) free. +phonerail.bin binary size 0x3ec30 bytes. ... 0xc13d0 bytes (75%) free. Project build complete. -# 25 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库): -# SR-5: 只判过压方向 / 阈值写成 >= / 看见一圈就关 / 回到范围内不清零 / -# **确认窗口改回按墙钟算 >= 20 ms** / 设定值写死 3900 -# 闸门: 盲跑那一路照样判 / 关着的路照样判 / 没有缓启动窗口 / 跳过的那一圈也判 -# 关路: 不 abort / 只关 eFuse 不断 VBUS / 顺序反过来 / 事件记在断电之前 -# SW-8: 符号写反 / 不看 VBUS 通没通 / 顺手把 eFuse 也关了 / -# 不打断 EDL / 无条件打断这一路的序列 / 挪到 SR-5 后面 -# 其它: 判据跑在放锁之前 / 顺手多读一次寄存器 / 事件里记设定值 / … -# → 24 条变红。剩下 1 条见下面「抓不到的那一条」。 +$ 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 条见下面「抓不到的那两条」。 ``` ## 踩到的坑(只写会再犯的) -- ⚠️⚠️ **「持续 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 的那一边),理由留在代码注释里。 +- ⚠️⚠️ **校验和用异或是错的,必须用 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 的几何是编译期常量。 + 两条都留着 —— 它们保的是别人改代码时的假设,不是当前这一版的行为。 -## 这一层的几条约定(写 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) × 巡检周期),代码用圈数、文档用毫秒,测试拿任务表里的周期 - 把两者钉在一起。巡检周期哪天改了,那条断言会当场对不上 —— - 这正是它存在的理由,别去「修」它。 +## 这一层的几条约定(写 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()` + 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。 diff --git a/firmware/components/phonerail_hal/CMakeLists.txt b/firmware/components/phonerail_hal/CMakeLists.txt index 1aa499e..319b433 100644 --- a/firmware/components/phonerail_hal/CMakeLists.txt +++ b/firmware/components/phonerail_hal/CMakeLists.txt @@ -10,5 +10,6 @@ idf_component_register( "esp/hal_gpio_esp.c" "esp/hal_pwm_esp.c" "esp/hal_time_esp.c" + "esp/hal_store_esp.c" INCLUDE_DIRS "include" - REQUIRES esp_driver_i2c esp_driver_gpio esp_driver_ledc esp_timer freertos) + REQUIRES esp_driver_i2c esp_driver_gpio esp_driver_ledc esp_timer esp_partition freertos) diff --git a/firmware/components/phonerail_hal/esp/hal_lock_esp.c b/firmware/components/phonerail_hal/esp/hal_lock_esp.c index b539e49..870180a 100644 --- a/firmware/components/phonerail_hal/esp/hal_lock_esp.c +++ b/firmware/components/phonerail_hal/esp/hal_lock_esp.c @@ -63,3 +63,16 @@ uint32_t hal_lock_timeouts(hal_lock_id_t id) { return (id >= 0 && id < HAL_LOCK_COUNT) ? s_timeouts[id] : 0; } + +/* ---- 临界区(不是第三把锁,见 hal_lock.h) ------------------------------- + `portMUX_TYPE` + `portENTER_CRITICAL()`:单核上是关中断,多核上再加一个 + 自旋锁。ESP32-S3 是双核,两个核上的任务同时入队是真会发生的,所以这里 + 必须是 portMUX 而不是 `portDISABLE_INTERRUPTS()`。 + + ⚠️ 关中断的时间要短到能一眼数完 —— evlog 的入队里只有一次 memcpy 和两个 + 下标自增。往里面加任何一行「顺手」的活(取时间戳、算校验、写 flash) + 之前,先想清楚它会不会把 20 ms 的巡检节拍抖出去。 */ +static portMUX_TYPE s_crit = portMUX_INITIALIZER_UNLOCKED; + +void hal_crit_enter(void) { portENTER_CRITICAL(&s_crit); } +void hal_crit_exit(void) { portEXIT_CRITICAL(&s_crit); } diff --git a/firmware/components/phonerail_hal/esp/hal_store_esp.c b/firmware/components/phonerail_hal/esp/hal_store_esp.c new file mode 100644 index 0000000..f83bb2f --- /dev/null +++ b/firmware/components/phonerail_hal/esp/hal_store_esp.c @@ -0,0 +1,83 @@ +/* 掉电不丢区的 ESP 实现:分区表里那块名叫 `evlog` 的 data 分区(见 partitions.csv)。 + + ⚠️ **不用 NVS,也不用 SPIFFS。** §10.7 要的是一条环形的、按序号增量拉取的 + 记录流,而 NVS 是键值表(4096 条记录 = 4096 个键,它的页式 GC 会把 + 「写一条事件」变成一次不可预测的搬移),SPIFFS 是文件系统(掉电一致性 + 自己就是一整章)。evlog 自己已经是环形缓冲,它要的只是「擦一扇区 / 写一段字节」。 + + 分区找不到时返回 HAL_ERR_STATE 而不是 abort:这一条**不该**把板子挡在启动之外。 + §10.7 的日志是排查手段,不是安全链的一环;丢了日志要告警,但五路照供电。 + (对比 `rail_selftest()`:那张表错了会把命令发到另一路上去,所以那里是 abort。) */ +#include + +#include "esp_partition.h" +#include "hal_store.h" + +static const esp_partition_t *s_part; + +hal_err_t hal_store_init(void) +{ + if (s_part != NULL) { + return HAL_OK; + } + s_part = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, + ESP_PARTITION_SUBTYPE_ANY, "evlog"); + return (s_part != NULL) ? HAL_OK : HAL_ERR_STATE; +} + +void hal_store_geometry(hal_store_geom_t *g) +{ + if (g == NULL) { + return; + } + if (s_part == NULL) { + g->sector_bytes = 0; + g->sectors = 0; + return; + } + /* ⚠️ 用分区自己报的 `erase_size`,不要写死 4096:esp_partition_erase_range() + 的对齐要求就是拿这个字段说的,两处不一致时报的是 ESP_ERR_INVALID_ARG。 */ + g->sector_bytes = s_part->erase_size; + g->sectors = s_part->size / s_part->erase_size; +} + +static bool range_ok(uint32_t off, uint32_t len) +{ + return s_part != NULL && len <= s_part->size && off <= s_part->size - len; +} + +hal_err_t hal_store_read(uint32_t off, void *buf, uint32_t len) +{ + if (s_part == NULL) { + return HAL_ERR_STATE; + } + if (buf == NULL || !range_ok(off, len)) { + return HAL_ERR_ARG; + } + return (esp_partition_read(s_part, off, buf, len) == ESP_OK) ? HAL_OK : HAL_ERR_BUS; +} + +hal_err_t hal_store_write(uint32_t off, const void *buf, uint32_t len) +{ + if (s_part == NULL) { + return HAL_ERR_STATE; + } + if (buf == NULL || !range_ok(off, len) || (off % 4u) != 0u || (len % 4u) != 0u) { + return HAL_ERR_ARG; + } + return (esp_partition_write(s_part, off, buf, len) == ESP_OK) ? HAL_OK : HAL_ERR_BUS; +} + +hal_err_t hal_store_erase(uint32_t sector) +{ + if (s_part == NULL) { + return HAL_ERR_STATE; + } + const uint32_t off = sector * s_part->erase_size; + if (off >= s_part->size) { + return HAL_ERR_ARG; + } + return (esp_partition_erase_range(s_part, off, s_part->erase_size) == ESP_OK) + ? HAL_OK + : HAL_ERR_BUS; +} diff --git a/firmware/components/phonerail_hal/include/hal_lock.h b/firmware/components/phonerail_hal/include/hal_lock.h index 035147b..a269226 100644 --- a/firmware/components/phonerail_hal/include/hal_lock.h +++ b/firmware/components/phonerail_hal/include/hal_lock.h @@ -54,6 +54,27 @@ bool hal_lock_held(hal_lock_id_t id); /* 累计等锁失败次数(超时)。遥测里报出去,用来看总线是不是排队了。 */ uint32_t hal_lock_timeouts(hal_lock_id_t id); +/* ---- 临界区(A11)——**这不是第三把锁** ---------------------------------- + + 事件环形缓冲的入队要在几条指令里改完两个下标,而入队方与出队方是 + 优先级两端的两个任务:`t_safety`(最高,而且是在**断电之后**调的) + 与 `t_log`(最低)。这里不能用互斥锁,理由有两条,都不是风格问题: + + · **不许阻塞。** t_safety.h 写着「接进来的东西不许阻塞」—— + 入队要是可能停在一把 t_log 持着的锁上,巡检的节拍就被最低优先级的任务 + 决定了。优先级继承能救回来,但那是「让阻塞变短」,不是「不阻塞」。 + · **§10.3 的共享资源只有两个**(I²C 与 595 链),这条架构结论不该被 + 一个环形缓冲的下标改写。 + + 所以给的是关中断的临界区:不阻塞、没有持有者、不参与「不许嵌套两把锁」 + 那条规则,也不出现在 `hal_lock_id_t` 里。 + + ⚠️ **里面只许改内存。** 关着中断去等锁、睡、跑 I²C 事务,都是错的 + —— sim 里 `sim_crit_blocking()` 会计数,`test_evlog.c` 拿它当断言。 + 可以嵌套(按层数计),但没有理由这么写。 */ +void hal_crit_enter(void); +void hal_crit_exit(void); + #ifdef __cplusplus } #endif diff --git a/firmware/components/phonerail_hal/include/hal_store.h b/firmware/components/phonerail_hal/include/hal_store.h new file mode 100644 index 0000000..7e122da --- /dev/null +++ b/firmware/components/phonerail_hal/include/hal_store.h @@ -0,0 +1,59 @@ +/* 掉电不丢的一块字节区(需求 §10.7 的「落盘」)—— HAL 的第六样东西。 + + 计划 §4.1 写着「HAL 只抽象四样」,§10.3 的两把锁是第五样。这里的第六样同样 + 要给个理由:§10.7 要求事件**在上位机不在线时也记得下来**,而「记得下来」在 + ESP32-S3 上就是往 NOR flash 的一个分区里写。事件日志本身(环形、批量、 + 按序号增量拉取)是与平台无关的逻辑,宿主机上必须跑得到;能跑不到的只有 + 「怎么擦一个扇区、怎么写一段字节」这三行。抽的就是这三行。 + + ---- 抽象的是 NOR flash,不是文件 ---------------------------------------- + 接口上带着 NOR 的两条物理事实,因为**上层绕不开它们**: + 1. **写只能把 1 变成 0。** 同一个位置写两遍不是覆盖,是按位与。 + 所以 sim 的实现也是按位与 —— 真板上「以为覆盖成功了」的那种错, + 在一个 memcpy 式的假实现里一次也复现不出来。 + 2. **擦除的粒度是扇区**(4 KB),擦完全是 0xFF。 + 还有一条没写进接口但同样要记住的:擦一次就少一次寿命(十万量级)。 + §10.7 的量级是「每天几十条」,17 个扇区轮着用,一辈子擦不了几轮。 + + ⚠️ **不做 wear leveling、不做坏块管理、不做文件系统。** 这块区只有一个用户 + (evlog),它自己就是个环形缓冲,天然轮流擦全部扇区。套一层 SPIFFS 反而 + 多出一堆掉电一致性问题 —— 那正是这一节最不想要的东西。 */ +#ifndef HAL_STORE_H +#define HAL_STORE_H + +#include + +#include "hal_err.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct { + uint32_t sector_bytes; /* 擦除粒度。ESP32-S3 的 NOR 是 4096 */ + uint32_t sectors; /* 这块区有几个扇区 */ +} hal_store_geom_t; + +/* 幂等。ESP 侧是找到 `evlog` 分区;找不到返回 HAL_ERR_STATE + (分区表里漏了这一项 —— 那是编译期就该发现的错,不是硬件故障)。 */ +hal_err_t hal_store_init(void); + +/* 没 init 时两个字段都是 0。调用方拿它算容量,所以**不许猜**这两个数。 */ +void hal_store_geometry(hal_store_geom_t *g); + +/* 读。off/len 以字节计,越界返回 HAL_ERR_ARG。 */ +hal_err_t hal_store_read(uint32_t off, void *buf, uint32_t len); + +/* 写。⚠️ **按位与语义**(见上):往没擦过的地方写,得到的是两次内容的与, + 而且**不报错**。想覆盖就得先擦整个扇区。 + off 与 len 都要求 4 字节对齐(esp_flash 的要求,sim 里同样拦)。 */ +hal_err_t hal_store_write(uint32_t off, const void *buf, uint32_t len); + +/* 擦一个扇区(整块变 0xFF)。sector 是索引,不是字节偏移。 */ +hal_err_t hal_store_erase(uint32_t sector); + +#ifdef __cplusplus +} +#endif + +#endif /* HAL_STORE_H */ diff --git a/firmware/components/phonerail_hal/sim/hal_lock_sim.c b/firmware/components/phonerail_hal/sim/hal_lock_sim.c index 4fefdff..8eb4d99 100644 --- a/firmware/components/phonerail_hal/sim/hal_lock_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_lock_sim.c @@ -23,6 +23,9 @@ static uint32_t s_violations[HAL_LOCK_COUNT]; static bool s_require[HAL_LOCK_COUNT]; static int s_task; static bool s_inited; +static int s_crit_depth; +static uint32_t s_crit_enters; +static uint32_t s_crit_blocking; void sim_lock_reset_internal(void); /* 只给 sim_hal_reset() 用 */ @@ -37,6 +40,9 @@ void sim_lock_reset_internal(void) memset(s_require, 0, sizeof(s_require)); s_task = 0; s_inited = false; + s_crit_depth = 0; + s_crit_enters = 0; + s_crit_blocking = 0; } static bool valid(hal_lock_id_t id) { return id >= 0 && id < HAL_LOCK_COUNT; } @@ -54,6 +60,7 @@ bool hal_lock_held(hal_lock_id_t id) hal_err_t hal_lock_take(hal_lock_id_t id, uint32_t timeout_ms) { + sim_crit_blocking_note(); /* 关着中断去等一把锁:真板上这是把系统钉死 */ if (!valid(id) || !s_inited) { return HAL_ERR_STATE; } @@ -105,3 +112,30 @@ void sim_lock_check(hal_lock_id_t id) s_violations[id]++; } } + +/* ---- 临界区(不是第三把锁,见 hal_lock.h) ------------------------------- + 单线程 sim 里它没有任何互斥作用 —— 这里模拟的同样是**纪律**: + 进出配不配对、里面有没有干会阻塞的事。 */ + +void hal_crit_enter(void) +{ + s_crit_depth++; + s_crit_enters++; +} + +void hal_crit_exit(void) +{ + assert(s_crit_depth > 0); /* 多放一次 = 真板上中断被提前打开,谁也发现不了 */ + s_crit_depth--; +} + +void sim_crit_blocking_note(void) +{ + if (s_crit_depth > 0) { + s_crit_blocking++; + } +} + +int sim_crit_depth(void) { return s_crit_depth; } +uint32_t sim_crit_enters(void) { return s_crit_enters; } +uint32_t sim_crit_blocking(void) { return s_crit_blocking; } diff --git a/firmware/components/phonerail_hal/sim/hal_store_sim.c b/firmware/components/phonerail_hal/sim/hal_store_sim.c new file mode 100644 index 0000000..b14594e --- /dev/null +++ b/firmware/components/phonerail_hal/sim/hal_store_sim.c @@ -0,0 +1,153 @@ +/* 掉电不丢区的假实现 —— 一块 RAM,但**按 NOR flash 的规矩来**。 + + 三条规矩,缺一条就会让 evlog 里最难查的那类错在宿主机上永远复现不出来: + 1. 写 = **按位与**(`*p &= v`),不是赋值。往没擦过的槽里再写一条记录, + 真板上得到的是两条记录的与(多半是一条校验不过的垃圾),而**不报错**; + 2. 擦 = 整个扇区变 0xFF,粒度就是扇区; + 3. **`sim_hal_reset()` 会把这块区擦干净(= 一块新板子), + `sim_hal_reboot()` 不会(= 掉电重启)。** §10.7 的验收「掉电重启后能读回」 + 全靠这两个的区别,别把 store 的清理塞进 reboot 里。 + + 还多做一件真板会做而假实现容易漏的事:**掉电可以打断一次写** + (`sim_store_tear_next_write()`),落进去的是前面若干字节,后面还是 0xFF。 + 这正是 evlog 每条记录都要带校验字节的理由。 */ +#include + +#include "hal_store.h" +#include "sim_hal.h" + +static uint8_t s_mem[SIM_STORE_SECTORS * SIM_STORE_SECTOR_BYTES]; +static bool s_inited; +static bool s_formatted; /* 这块「flash」出过厂没有(全 0xFF 才是出厂态) */ +static uint32_t s_writes; +static uint32_t s_erases; +static uint32_t s_bytes; +static int s_fail_in; /* >0:第 n 次写整条失败;0 = 不注入 */ +static uint32_t s_tear_at; /* >0:下一次写只落这么多字节然后失败 */ + +void sim_store_reset_internal(void); /* 只给 sim_hal_reset() 用:连内容一起擦 */ +void sim_store_reboot_internal(void); /* 只给 sim_hal_reboot() 用:内容留着 */ + +static void wipe(void) +{ + memset(s_mem, 0xFF, sizeof(s_mem)); + s_formatted = true; +} + +void sim_store_reset_internal(void) +{ + wipe(); + s_inited = false; + s_writes = 0; + s_erases = 0; + s_bytes = 0; + s_fail_in = 0; + s_tear_at = 0; +} + +void sim_store_reboot_internal(void) +{ + /* 掉电重启:CPU 侧什么都不剩,flash 里的内容一个字节不变。 */ + if (!s_formatted) { + wipe(); /* 从来没 reset 过的进程:至少给它一块出厂态的 flash */ + } + s_inited = false; + s_writes = 0; + s_erases = 0; + s_bytes = 0; + s_fail_in = 0; + s_tear_at = 0; +} + +hal_err_t hal_store_init(void) +{ + if (!s_formatted) { + wipe(); + } + s_inited = true; + return HAL_OK; +} + +void hal_store_geometry(hal_store_geom_t *g) +{ + if (g == NULL) { + return; + } + g->sector_bytes = s_inited ? SIM_STORE_SECTOR_BYTES : 0u; + g->sectors = s_inited ? SIM_STORE_SECTORS : 0u; +} + +static bool range_ok(uint32_t off, uint32_t len) +{ + if (len > sizeof(s_mem) || off > sizeof(s_mem) - len) { + return false; + } + return true; +} + +hal_err_t hal_store_read(uint32_t off, void *buf, uint32_t len) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (buf == NULL || !range_ok(off, len)) { + return HAL_ERR_ARG; + } + memcpy(buf, &s_mem[off], len); + return HAL_OK; +} + +hal_err_t hal_store_write(uint32_t off, const void *buf, uint32_t len) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (buf == NULL || !range_ok(off, len) || (off % 4u) != 0u || (len % 4u) != 0u) { + return HAL_ERR_ARG; + } + + uint32_t n = len; + bool fail = false; + if (s_fail_in > 0 && --s_fail_in == 0) { + return HAL_ERR_BUS; /* 一个字节都没落进去 */ + } + if (s_tear_at > 0u) { + n = (s_tear_at < len) ? s_tear_at : len; + s_tear_at = 0u; + fail = true; /* 掉电:前 n 个字节落进去了,然后就没有然后了 */ + } + + const uint8_t *src = (const uint8_t *)buf; + for (uint32_t i = 0; i < n; i++) { + s_mem[off + i] &= src[i]; /* ⚠️ NOR:只能 1→0 */ + } + s_writes++; + s_bytes += n; + return fail ? HAL_ERR_BUS : HAL_OK; +} + +hal_err_t hal_store_erase(uint32_t sector) +{ + if (!s_inited) { + return HAL_ERR_STATE; + } + if (sector >= SIM_STORE_SECTORS) { + return HAL_ERR_ARG; + } + memset(&s_mem[sector * SIM_STORE_SECTOR_BYTES], 0xFF, SIM_STORE_SECTOR_BYTES); + s_erases++; + return HAL_OK; +} + +/* ---- 假板子这一侧的控制面 ------------------------------------------------ */ + +void sim_store_fail_next_write(int nth) { s_fail_in = nth; } +void sim_store_tear_next_write(uint32_t bytes) { s_tear_at = bytes; } +uint32_t sim_store_writes(void) { return s_writes; } +uint32_t sim_store_erases(void) { return s_erases; } +uint32_t sim_store_bytes(void) { return s_bytes; } + +uint8_t sim_store_byte(uint32_t off) +{ + return (off < sizeof(s_mem)) ? s_mem[off] : 0xFFu; +} diff --git a/firmware/components/phonerail_hal/sim/hal_time_sim.c b/firmware/components/phonerail_hal/sim/hal_time_sim.c index 23fe8bf..2f9683d 100644 --- a/firmware/components/phonerail_hal/sim/hal_time_sim.c +++ b/firmware/components/phonerail_hal/sim/hal_time_sim.c @@ -25,6 +25,7 @@ uint32_t hal_time_elapsed_ms(uint32_t since_ms) void hal_time_delay_ms(uint32_t ms) { + sim_crit_blocking_note(); /* 关着中断去睡:见 hal_lock.h 的临界区那一段 */ s_delay_calls++; s_now_us += (uint64_t)ms * 1000u; } diff --git a/firmware/components/phonerail_hal/sim/sim_hal.c b/firmware/components/phonerail_hal/sim/sim_hal.c index 2a9edc0..3eb2db8 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.c +++ b/firmware/components/phonerail_hal/sim/sim_hal.c @@ -1,5 +1,9 @@ -/* sim 的总闸:一次把四路都恢复出厂。每个测试的 setUp() 调它。 - 各路的清理函数是内部符号,不进 sim_hal.h —— 测试没有理由单独重置某一路。 */ +/* sim 的总闸:一次把各路都恢复出厂。每个测试的 setUp() 调它。 + 各路的清理函数是内部符号,不进 sim_hal.h —— 测试没有理由单独重置某一路。 + + ⚠️ 这里有**两个**总闸:`sim_hal_reset()` 是「换了一块新板子」, + `sim_hal_reboot()` 是「同一块板子掉电重启」。两者只差掉电不丢区那一块, + 而 §10.7 的验收正好卡在这个差上(见 hal_store_sim.c 开头)。 */ #include "hal_i2c_acct.h" #include "sim_hal.h" @@ -10,8 +14,11 @@ void sim_pca9555_reset_internal(void); void sim_pwm_reset_internal(void); void sim_time_reset_internal(void); void sim_lock_reset_internal(void); +void sim_store_reset_internal(void); +void sim_store_reboot_internal(void); -void sim_hal_reset(void) +/* 除了掉电不丢区之外的一切。reset 与 reboot 的唯一区别就在那一块。 */ +static void reset_the_volatile_half(void) { sim_i2c_reset_internal(); sim_gpio_reset_internal(); @@ -22,3 +29,15 @@ void sim_hal_reset(void) sim_lock_reset_internal(); hal_i2c_acct_reset(); } + +void sim_hal_reset(void) +{ + reset_the_volatile_half(); + sim_store_reset_internal(); /* 新板子:flash 也是出厂态 */ +} + +void sim_hal_reboot(void) +{ + reset_the_volatile_half(); + sim_store_reboot_internal(); /* 掉电重启:flash 里的内容一个字节不动 */ +} diff --git a/firmware/components/phonerail_hal/sim/sim_hal.h b/firmware/components/phonerail_hal/sim/sim_hal.h index e0f4bcf..e4656b9 100644 --- a/firmware/components/phonerail_hal/sim/sim_hal.h +++ b/firmware/components/phonerail_hal/sim/sim_hal.h @@ -14,16 +14,23 @@ #include "hal_i2c_acct.h" #include "hal_lock.h" #include "hal_pwm.h" +#include "hal_store.h" #include "hal_time.h" #ifdef __cplusplus extern "C" { #endif -/* 四路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、 - init 标志清掉。每个测试的 setUp() 里调它。 */ +/* 各路 sim 全部恢复出厂:器件表清空、引脚未配置、PWM 未配置、时钟归零、 + init 标志清掉,**掉电不丢区整块擦成 0xFF**。每个测试的 setUp() 里调它。 + 语义是「换了一块新板子」。 */ void sim_hal_reset(void); +/* **掉电重启**:除了掉电不丢区里的内容,其余与 `sim_hal_reset()` 完全一样。 + §10.7 的验收「掉电重启后能读回」就是靠这两个的区别 —— + 拿 reset 去测重启,测的是「刚擦干净的 flash 读不出东西」,永远绿。 */ +void sim_hal_reboot(void); + /* ---------------- I²C ---------------- */ #define SIM_I2C_MAX_DEV 16 @@ -172,6 +179,34 @@ void sim_lock_require(hal_lock_id_t id, bool on); uint32_t sim_lock_violations(hal_lock_id_t id); void sim_lock_check(hal_lock_id_t id); /* 给 sim 各路自己调,测试一般用不到 */ +/* ---------------- 掉电不丢区(NOR flash 那块) ---------------- */ + +/* 假 flash 的几何。与 partitions.csv 里 `evlog` 分区的大小是同一个数, + `test_evlog.c` 拿 `EVLOG_SECTORS` 把两边钉在一起。 */ +#define SIM_STORE_SECTOR_BYTES 4096 +#define SIM_STORE_SECTORS 17 + +/* 故障注入:第 nth 次写整条失败(一个字节都不落)。0 = 清除。 */ +void sim_store_fail_next_write(int nth); + +/* **掉电打断一次写**:下一次写只落前 bytes 个字节,然后返回 HAL_ERR_BUS。 + 这是每条记录都要带校验字节的理由 —— 半条记录在 flash 里长得和一条真记录一样。 */ +void sim_store_tear_next_write(uint32_t bytes); + +uint32_t sim_store_writes(void); /* 成功或半成功的写事务次数 */ +uint32_t sim_store_erases(void); /* 擦过几个扇区 —— 寿命就花在这上面 */ +uint32_t sim_store_bytes(void); /* 真正落进去的字节数 */ +uint8_t sim_store_byte(uint32_t off); /* 越界返回 0xFF */ + +/* ---------------- 临界区(不是第三把锁,见 hal_lock.h) ---------------- */ + +int sim_crit_depth(void); /* 当前嵌套层数。测试结束时必须是 0 */ +uint32_t sim_crit_enters(void); /* 进过几次 —— 断言「入队真的是在临界区里做的」*/ + +/* 在临界区里干了会阻塞的事(等锁 / 睡)的次数。**必须恒为 0**。 */ +uint32_t sim_crit_blocking(void); +void sim_crit_blocking_note(void); /* 给 sim 各路自己调 */ + /* ---------------- 时钟 ---------------- */ void sim_time_set_us(uint64_t us); diff --git a/firmware/host/CMakeLists.txt b/firmware/host/CMakeLists.txt index ec35747..e319f2f 100644 --- a/firmware/host/CMakeLists.txt +++ b/firmware/host/CMakeLists.txt @@ -28,6 +28,7 @@ set(PHONERAIL_CORE_SRCS ${FW_ROOT}/main/tasks.c ${FW_ROOT}/main/t_safety.c ${FW_ROOT}/main/t_actuator.c + ${FW_ROOT}/main/evlog.c ) # HAL:接口在 include/,宿主机链 sim/ 的实现(esp/ 那份只有 IDF 目标编)。 @@ -43,6 +44,7 @@ set(PHONERAIL_HAL_SIM_SRCS ${PHONERAIL_HAL_DIR}/sim/sim_pca9555.c ${PHONERAIL_HAL_DIR}/sim/hal_pwm_sim.c ${PHONERAIL_HAL_DIR}/sim/hal_time_sim.c + ${PHONERAIL_HAL_DIR}/sim/hal_store_sim.c ) # 器件驱动:与平台无关(只经 hal_i2c_*),两个目标编的是同一份源码。 diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 12ce980..e6123b1 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -10,5 +10,6 @@ idf_component_register( "tasks.c" "t_safety.c" "t_actuator.c" + "evlog.c" INCLUDE_DIRS "." REQUIRES phonerail_hal drv) diff --git a/firmware/main/app_main.c b/firmware/main/app_main.c index 16b3b45..8d5cace 100644 --- a/firmware/main/app_main.c +++ b/firmware/main/app_main.c @@ -3,6 +3,7 @@ #include "board_pins.h" #include "esp_log.h" +#include "evlog.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "hal_i2c.h" @@ -70,12 +71,14 @@ static bool act_pg_from_safety(void *ctx, int rail, bool *pg) return true; } -/* A10 的事件出口。**A11 会把这里换成环形缓冲的入队**(§10.7:`t_safety` 只入队、 - 不落盘),在那之前先打到日志上 —— 一条也不留才是最坏的结果。 - ⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的,所以里面不许阻塞。 */ +/* A10 的事件出口,A11 把它接到了环形缓冲上(§10.7:`t_safety` 只入队、不落盘)。 + `evlog_put_safety()` 自己就是 `safety_ev_fn` 的形状,这里包一层只为了顺带 + 打一行串口日志 —— 板子插着 USB 调试时,事件同时进日志流与 flash。 + ⚠️ 这个回调是在断电之后、不持任何锁的情况下被调的:**入队在前,打印在后**。 + `ESP_LOGW` 会去抢串口,把它放在入队之前就是在事件最要紧的那一刻多等一会儿。 */ static void log_safety_event(void *ctx, const safety_event_t *ev) { - (void)ctx; + evlog_put_safety(ctx, ev); ESP_LOGW(TAG, "安全事件 t=%u ms CH%u %s V=%d mV I=%d uA", (unsigned)ev->t_ms, (unsigned)ev->rail, t_safety_ev_str((safety_ev_t)ev->type), (int)ev->bus_mv, (int)ev->current_ua); @@ -145,12 +148,29 @@ static void task_link(void *arg) } } +/* A11:唯一碰 flash 的任务。§10.7「`t_log` 空闲时批量写」—— + 醒得比攒批周期密(100 ms),但 `evlog_due()` 说不用写就立刻回去睡: + 代价是每秒 10 次几十条指令的问询,换来的是掉电丢失窗口的上界就是 + `EVLOG_MAX_AGE_MS`,而不是「上一次醒到下一次醒」。 */ static void task_log(void *arg) { (void)arg; + evlog_stats_t st; + uint32_t reported = 0; for (;;) { - /* A11:事件环形缓冲落盘。 */ - vTaskDelay(pdMS_TO_TICKS(1000)); + if (evlog_due()) { + (void)evlog_flush(EVLOG_FLUSH_MAX); + } + /* 丢过事件就报出来。§10.7 的日志是排查排线接触不良的唯一线索, + 而「日志自己丢过东西」这件事必须比日志本身更显眼。 */ + evlog_stats(&st); + if (st.dropped != reported) { + reported = st.dropped; + ESP_LOGW(TAG, "事件缓冲丢过 %u 条(落盘 %u / 入队 %u,IO 错 %u)", + (unsigned)st.dropped, (unsigned)st.stored, (unsigned)st.queued, + (unsigned)st.io_errors); + } + vTaskDelay(pdMS_TO_TICKS(100)); } } @@ -203,6 +223,22 @@ void app_main(void) 而没 init 的执行器只会直接返回,那时序列还在跑,电关掉几秒又会被抬回来。 */ t_actuator_init(act_pg_from_safety, NULL); + /* 事件日志(§10.7)。**要在接事件出口之前 init**,否则第一条事件进的是 + 一个还没扫过 flash 的缓冲。 + ⚠️ 失败**不 abort**:没有 `evlog` 分区时日志落不了盘,但五路照供电 + —— 日志是排查手段,不是安全链的一环(§10.2)。这与 `selftest_or_die()` + 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。 */ + err = evlog_init(); + if (err != HAL_OK) { + ESP_LOGE(TAG, "事件日志落盘不可用:%s —— 事件只留在 RAM 里", hal_err_str(err)); + } else { + evlog_stats_t st; + evlog_stats(&st); + ESP_LOGI(TAG, "事件日志:容量 %u 条,flash 里已有 %u~%u", + (unsigned)evlog_capacity(), (unsigned)st.oldest_seq, + (unsigned)st.next_seq); + } + /* 事件出口。同样要在巡检起来之前接上,否则第一批事件没人接。 */ t_safety_set_event_sink(log_safety_event, NULL); diff --git a/firmware/main/evlog.c b/firmware/main/evlog.c new file mode 100644 index 0000000..84f09fc --- /dev/null +++ b/firmware/main/evlog.c @@ -0,0 +1,570 @@ +/* 事件日志的实现。接口与「为什么这么分层」在 evlog.h,这里只写 flash 上的样子 + 与几处不写下来就一定会被改坏的地方。 + + ---- flash 上的样子(17 个扇区 × 4 KB) ---------------------------------- + + 扇区 n: [ 16 B 扇区头 ][ 记录 0 ][ 记录 1 ] … [ 记录 254 ] + magic/ver/ 每条 16 B,255 条 + recs/epoch/chk + + · **扇区头里的 `epoch` 是全局递增的**(第几个被写的扇区), + 所以 `扇区号 = epoch % 17`,重启时不用记住任何东西:读 17 个头, + epoch 最大的那个就是正在写的扇区。 + · **记录的序号 = `epoch × 255 + 槽号`**。序号因此就是「flash 里第几条」, + 不需要额外存一个计数器,也不会在掉电后倒退。 + · 环形:写满一个扇区就**擦掉下一个**(也就是最老的那个)再写它的头。 + 擦除是 4 KB 粒度的,所以「盖掉最旧的」一次盖 255 条 —— 这正是 §10.7 + 的容量算法里那 4096 条的来源。 + + ---- 为什么每条记录要带一个校验字节 -------------------------------------- + NOR flash 的写可以被掉电打断在任意一个字节上,落进去的是**半条记录**, + 而半条记录在字节层面看跟一条真记录长得一模一样(时间戳合法、路号合法、 + 类型合法)。没有校验的话,重启后的扫描会把它当成有效记录继续往后写, + 而上位机拉到的是一条时间戳来自过去、电压是 0xFFFF 的「事件」。 + 校验字节让扫描停在它前面 —— `test_a_torn_write_does_not_become_a_record()`。 + + ⚠️ 校验的是**完整性**,不是正确性:它认得出「写了一半」,认不出「写错了地方」。 + 后者由扇区头里的 epoch 与 recs 兜着。 + + ---- 字节序 -------------------------------------------------------------- + 记录是把结构体原样写下去的,所以 flash 里是**本机小端**。这不成问题: + 读它的只有同一颗 MCU 上的同一份固件。上位机拿到的记录是经链路层 + (A13/A14)序列化过去的,不是直接读 flash 的镜像。 */ +#include +#include + +#include "evlog.h" +#include "hal_lock.h" +#include "hal_store.h" +#include "hal_time.h" + +#define EVLOG_MAGIC 0x474F4C45u /* "ELOG" */ +#define EVLOG_VER 1 + +/* 一次 `hal_store_write()` 最多带几条。256 B 的栈上缓冲,够摊薄写事务的开销了; + 再大也只是让 `evlog_flush()` 的最坏耗时变长。 */ +#define EVLOG_CHUNK 16 + +typedef struct { + uint32_t magic; + uint16_t ver; + uint16_t recs; /* 本扇区能装几条。几何变了(换了 flash / 换了分区大小)时, + 旧扇区认不出来 —— 这比按新几何去解释旧内容好得多 */ + uint32_t epoch; /* 全局递增:第几个被写的扇区 */ + uint32_t chk; +} evlog_shdr_t; + +/* 两个「结构体就是 flash 布局」的地方,编译期钉死。 */ +typedef char evlog_rec_is_16b[(sizeof(evlog_rec_t) == 16) ? 1 : -1]; +typedef char evlog_hdr_is_16b[(sizeof(evlog_shdr_t) == 16) ? 1 : -1]; + +/* ---- RAM 环形缓冲 -------------------------------------------------------- + 入队方可能是任何一个任务(t_safety 优先级最高),出队方只有 t_log。 + 两个下标的改动在临界区里做完(hal_lock.h:那不是第三把锁)。 */ +static evlog_rec_t s_ring[EVLOG_RAM_CAP]; +static uint32_t s_head; /* 下一条写进去的位置 */ +static uint32_t s_tail; /* 下一条要落盘的位置 */ +static uint32_t s_count; /* 环里现有几条 */ +static uint32_t s_oldest_ms; /* 环里最老那条的入队时刻(攒批的计时起点) */ +static uint32_t s_lost; /* 欠着一条 LOST 记录,值是丢了多少条 */ + +/* ---- flash 侧 ------------------------------------------------------------ */ +static bool s_have_store; +static uint32_t s_sector_bytes; +static uint32_t s_sectors; +static uint32_t s_recs; /* 每扇区能装几条记录(= sector_bytes/16 − 1) */ +static uint32_t s_epoch; /* 正在写的扇区的 epoch */ +static uint32_t s_slot; /* 该扇区里下一个空槽;== s_recs 表示这个扇区写满了 */ +static uint32_t s_next_seq; +static uint32_t s_oldest_seq; + +/* ---- 计数器 -------------------------------------------------------------- */ +static uint32_t s_queued; +static uint32_t s_stored; +static uint32_t s_dropped; +static uint32_t s_io_errors; + +/* ---- 校验 ---------------------------------------------------------------- */ + +/* CRC-8(多项式 0x07,初值 0xA5)。 + + ⚠️ **不要「优化」成异或和。** 第一版就是异或和,它认不出下面这条: + 一条 `bus_mv` 与 `current_ua` 都是 0 的记录(BOOT 就长这样),被掉电切在 + 第 8 个字节上,后 8 个字节留在 0xFF —— 而 8 个 0xFF 的异或恰好是 0, + 于是半条记录的异或和与整条**一模一样**,校验照过。 + `test_a_torn_write_with_zero_payload_is_still_rejected()` 就是这条。 + CRC 对「一段字节整体变成 0xFF」敏感,初值也不是 0(认得出全 0 的一片)。 */ +static uint8_t rec_chk(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + uint8_t c = 0xA5u; + for (size_t i = 0; i < sizeof(*r); i++) { + if (i == offsetof(evlog_rec_t, chk)) { + continue; + } + c ^= b[i]; + for (int k = 0; k < 8; k++) { + c = (uint8_t)((c & 0x80u) ? ((uint8_t)(c << 1) ^ 0x07u) : (uint8_t)(c << 1)); + } + } + return c; +} + +/* 擦干净的槽(16 个 0xFF)在这里必须是 false —— 扫描就靠它找「上次写到哪了」, + 而这是整份日志唯一的位置信息。所以不靠 CRC 去撞那 1/256,直接认死: + **全 0xFF = 没写过**。真记录里 `t_ms` 是 0xFFFFFFFF 且五个字段全 0xFF 的 + 那一条不存在(那是 49.7 天整、−1 mV、−1 µA、路号 255 同时成立)。 */ +static bool rec_all_ff(const evlog_rec_t *r) +{ + const uint8_t *b = (const uint8_t *)r; + for (size_t i = 0; i < sizeof(*r); i++) { + if (b[i] != 0xFFu) { + return false; + } + } + return true; +} + +static bool rec_valid(const evlog_rec_t *r) +{ + return !rec_all_ff(r) && r->chk == rec_chk(r); +} + +static uint32_t hdr_chk(const evlog_shdr_t *h) +{ + return h->magic ^ ((uint32_t)h->ver | ((uint32_t)h->recs << 16)) ^ h->epoch ^ 0x5A5A5A5Au; +} + +static bool hdr_valid(const evlog_shdr_t *h, uint32_t sector) +{ + return h->magic == EVLOG_MAGIC && h->ver == EVLOG_VER && h->recs == (uint16_t)s_recs && + h->chk == hdr_chk(h) && (h->epoch % s_sectors) == sector; +} + +static uint32_t rec_off(uint32_t sector, uint32_t slot) +{ + return sector * s_sector_bytes + (slot + 1u) * (uint32_t)sizeof(evlog_rec_t); +} + +/* ---- 入队(谁产生事件谁调;不阻塞、不碰 flash) -------------------------- */ + +static void push(const evlog_rec_t *r) +{ + /* ⚠️ 临界区里**只改内存**:时间戳、校验都在外面算完了。 + 往里面加一行「顺手」的活,抖的是 t_safety 那 20 ms 的节拍。 */ + hal_crit_enter(); + if (s_count == EVLOG_RAM_CAP) { + /* 满了盖最旧的一条 —— §10.7 的原则是「最新的一条永远进得来」。 + 丢掉的这条会在下一次落盘时以一条 LOST 记录留下痕迹。 */ + s_tail = (s_tail + 1u) % EVLOG_RAM_CAP; + s_count--; + s_dropped++; + s_lost++; + } + if (s_count == 0u) { + s_oldest_ms = r->t_ms; /* 攒批的计时从环里第一条算起 */ + } + s_ring[s_head] = *r; + s_head = (s_head + 1u) % EVLOG_RAM_CAP; + s_count++; + s_queued++; + hal_crit_exit(); +} + +void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua) +{ + evlog_rec_t r; + memset(&r, 0, sizeof(r)); /* 记录会被原样写进 flash:不留没初始化的字节 */ + r.t_ms = hal_time_now_ms(); + r.rail = rail; + r.type = (uint8_t)type; + r.arg = arg; + r.bus_mv = bus_mv; + r.current_ua = ua; + r.chk = rec_chk(&r); + push(&r); +} + +void evlog_put_safety(void *ctx, const safety_event_t *ev) +{ + (void)ctx; + if (ev == NULL) { + return; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + /* ⚠️ 时间戳用事件里带的那个:判据记的是**断电那一刻**, + 而这个回调是断完电才被调的。差的那几十微秒不重要,重要的是别把 + 「事件发生的时刻」悄悄换成「记账的时刻」—— 排查时序问题时会差出一整圈。 */ + r.t_ms = ev->t_ms; + r.rail = ev->rail; + r.type = (uint8_t)evlog_from_safety(ev->type); + r.bus_mv = ev->bus_mv; + r.current_ua = ev->current_ua; + r.chk = rec_chk(&r); + push(&r); +} + +/* 显式的一一对应。**不许写成 `LOG_EV_OVER_VOLT + (ev − 1)`** —— + 那样 t_safety.h 里插进一个新事件类型,flash 里所有旧记录的含义就整体挪位了, + 而且不会有任何一处报错。`test_the_safety_mapping_is_explicit()` 拦这件事。 */ +log_ev_t evlog_from_safety(uint8_t safety_ev) +{ + switch ((safety_ev_t)safety_ev) { + case SAFETY_EV_OVER_VOLT: return LOG_EV_OVER_VOLT; + case SAFETY_EV_UNDER_VOLT: return LOG_EV_UNDER_VOLT; + case SAFETY_EV_REVERSE: return LOG_EV_REVERSE; + case SAFETY_EV_PUSHED_UP: return LOG_EV_PUSHED_UP; + case SAFETY_EV_NONE: break; + } + return LOG_EV_NONE; +} + +/* ---- 落盘(只有 t_log 与 A12 的拉日志命令调) ---------------------------- */ + +static hal_err_t write_header(uint32_t sector, uint32_t epoch) +{ + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + h.magic = EVLOG_MAGIC; + h.ver = EVLOG_VER; + h.recs = (uint16_t)s_recs; + h.epoch = epoch; + h.chk = hdr_chk(&h); + return hal_store_write(sector * s_sector_bytes, &h, sizeof(h)); +} + +/* 换到下一个扇区:先擦(这一下盖掉的就是最老的 255 条),再写头。 */ +static hal_err_t advance_sector(void) +{ + const uint32_t epoch = s_epoch + 1u; + const uint32_t sector = epoch % s_sectors; + hal_err_t err = hal_store_erase(sector); + if (err != HAL_OK) { + return err; + } + err = write_header(sector, epoch); + if (err != HAL_OK) { + return err; + } + s_epoch = epoch; + s_slot = 0; + s_next_seq = epoch * s_recs; + if (epoch >= s_sectors) { + s_oldest_seq = (epoch - s_sectors + 1u) * s_recs; + } + return HAL_OK; +} + +/* 把 n 条连续记录写进当前扇区(跨扇区就自己换)。返回真的写进去几条。 */ +static uint32_t store_run(const evlog_rec_t *r, uint32_t n) +{ + uint32_t done = 0; + while (done < n) { + if (s_slot >= s_recs) { + if (advance_sector() != HAL_OK) { + s_io_errors++; + break; + } + } + uint32_t k = n - done; + if (k > s_recs - s_slot) { + k = s_recs - s_slot; + } + const uint32_t off = rec_off(s_epoch % s_sectors, s_slot); + if (hal_store_write(off, &r[done], k * (uint32_t)sizeof(evlog_rec_t)) != HAL_OK) { + s_io_errors++; + /* 写失败时**这一槽可能只落进去了半条**(掉电、或者 flash 真坏了)。 + 半条记录后面再接着写的话,重启扫描会停在半条那里,把后面写的 + 全部当成不存在。所以整个扇区的剩余部分就此放弃,下一条换扇区去。 + 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」 + 这条不变量永远成立。 */ + s_slot = s_recs; + break; + } + s_slot += k; + s_next_seq += k; + s_stored += k; + done += k; + } + return done; +} + +/* 补一条「这里丢了 n 条」。它自己也占一个序号 —— 日志里看得见的那种丢失。 + 返回真的写进去几条(0 或 1):它和普通记录一样算进 `evlog_flush()` 的返回值, + 否则会出现「flush 说写了 0 条,序号却往前走了一格」这种对不上的账。 */ +static uint32_t store_lost_marker(void) +{ + uint32_t n; + hal_crit_enter(); + n = s_lost; + s_lost = 0; + hal_crit_exit(); + if (n == 0u) { + return 0u; + } + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + r.t_ms = hal_time_now_ms(); + r.type = (uint8_t)LOG_EV_LOST; + r.bus_mv = (int32_t)n; /* 丢了几条 */ + r.chk = rec_chk(&r); + if (store_run(&r, 1u) != 1u) { + hal_crit_enter(); + s_lost += n; /* 连这条都没写进去,欠着,下次再补 */ + hal_crit_exit(); + return 0u; + } + return 1u; +} + +static uint32_t pop(evlog_rec_t *dst, uint32_t want) +{ + hal_crit_enter(); + uint32_t n = (want < s_count) ? want : s_count; + for (uint32_t i = 0; i < n; i++) { + dst[i] = s_ring[(s_tail + i) % EVLOG_RAM_CAP]; + } + s_tail = (s_tail + n) % EVLOG_RAM_CAP; + s_count -= n; + if (s_count > 0u) { + s_oldest_ms = s_ring[s_tail].t_ms; + } + hal_crit_exit(); + return n; +} + +bool evlog_due(void) +{ + bool due; + hal_crit_enter(); + due = (s_lost > 0u) || (s_count >= EVLOG_BATCH) || + (s_count > 0u && hal_time_elapsed_ms(s_oldest_ms) >= EVLOG_MAX_AGE_MS); + hal_crit_exit(); + return due; +} + +uint32_t evlog_flush(uint32_t max) +{ + if (!s_have_store) { + return 0u; /* 没有那块分区:事件留在 RAM 里,别把它们弄丢 */ + } + const uint32_t budget = (max == 0u) ? EVLOG_RAM_CAP : max; + /* ⚠️ 补记的 LOST 不占预算:它是上一次落盘欠下的账,拖到下一次只会让 + 「日志里丢了一段」这件事更晚才被看见。最多超出预算 1 条。 */ + uint32_t total = store_lost_marker(); + + uint32_t from_ring = 0; + evlog_rec_t buf[EVLOG_CHUNK]; + while (from_ring < budget) { + uint32_t want = budget - from_ring; + if (want > EVLOG_CHUNK) { + want = EVLOG_CHUNK; + } + const uint32_t n = pop(buf, want); + if (n == 0u) { + break; + } + const uint32_t w = store_run(buf, n); + total += w; + from_ring += w; + if (w < n) { + /* 已经从环里取出来了却没写进去 —— 这些是真丢了, + 记进 LOST,下一次落盘时它会以一条记录的形式出现在日志里。 */ + hal_crit_enter(); + s_dropped += (n - w); + s_lost += (n - w); + hal_crit_exit(); + break; + } + } + return total; +} + +/* ---- 读回 ---------------------------------------------------------------- */ + +uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max) +{ + if (!s_have_store || out == NULL || max == 0u) { + return 0u; + } + uint32_t seq = (from_seq < s_oldest_seq) ? s_oldest_seq : from_seq; + if (seq >= s_next_seq) { + return 0u; + } + uint32_t n = s_next_seq - seq; + if (n > max) { + n = max; + } + for (uint32_t i = 0; i < n; i++) { + const uint32_t s = seq + i; + const uint32_t epoch = s / s_recs; + const uint32_t slot = s % s_recs; + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + if (hal_store_read(rec_off(epoch % s_sectors, slot), &r, sizeof(r)) != HAL_OK || + !rec_valid(&r)) { + /* 序号空间里的一个洞(写失败之后被放弃的槽)。**照样占一个位置**, + 否则调用方的游标 `from_seq + n` 会对不上,拉日志会卡在洞上。 */ + memset(&r, 0, sizeof(r)); + r.type = (uint8_t)LOG_EV_NONE; + } + out[i] = r; + } + return n; +} + +/* ---- 启动:扫一遍 flash 找到接着写的位置 --------------------------------- */ + +static hal_err_t format_all(void) +{ + for (uint32_t i = 0; i < s_sectors; i++) { + const hal_err_t err = hal_store_erase(i); + if (err != HAL_OK) { + return err; + } + } + const hal_err_t err = write_header(0u, 0u); + if (err != HAL_OK) { + return err; + } + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + return HAL_OK; +} + +hal_err_t evlog_init(void) +{ + /* RAM 侧无条件清干净:真板上这里刚上电,宿主机上这条让 `sim_hal_reboot()` + 之后的行为和真重启一致(statics 在进程里是活着的)。 */ + hal_crit_enter(); + s_head = 0; + s_tail = 0; + s_count = 0; + s_oldest_ms = 0; + s_lost = 0; + hal_crit_exit(); + s_queued = 0; + s_stored = 0; + s_dropped = 0; + s_io_errors = 0; + s_have_store = false; + s_sector_bytes = 0; + s_sectors = 0; + s_recs = 0; + s_epoch = 0; + s_slot = 0; + s_next_seq = 0; + s_oldest_seq = 0; + + hal_err_t err = hal_store_init(); + if (err != HAL_OK) { + /* 没有分区。**不 abort、不影响入队**:日志是排查手段,不是安全链 + (evlog.h 上面那段)。事件照进 RAM,只是没人能把它们写下去。 */ + evlog_put(0, LOG_EV_BOOT, 0, 0, 0); + return err; + } + + hal_store_geom_t g; + hal_store_geometry(&g); + if (g.sector_bytes < 2u * sizeof(evlog_rec_t) || + (g.sector_bytes % sizeof(evlog_rec_t)) != 0u || g.sectors < 2u) { + s_have_store = false; + evlog_put(0, LOG_EV_BOOT, 0, 0, 0); + return HAL_ERR_STATE; + } + s_sector_bytes = g.sector_bytes; + s_sectors = g.sectors; + s_recs = g.sector_bytes / (uint32_t)sizeof(evlog_rec_t) - 1u; /* 头占一个槽 */ + s_have_store = true; + + /* 17 个扇区头里 epoch 最大的那个 = 上次正在写的扇区。 + `hdr_valid()` 顺带查了 `epoch % 扇区数 == 扇区号` —— 几何变了、 + 或者这块区被别人写过,都会在这里落空,然后整块重来。 */ + bool found = false; + uint32_t best = 0; + for (uint32_t i = 0; i < s_sectors; i++) { + evlog_shdr_t h; + memset(&h, 0, sizeof(h)); + if (hal_store_read(i * s_sector_bytes, &h, sizeof(h)) != HAL_OK) { + continue; + } + if (hdr_valid(&h, i) && (!found || h.epoch > best)) { + found = true; + best = h.epoch; + } + } + + if (!found) { + err = format_all(); + if (err != HAL_OK) { + s_io_errors++; + s_have_store = false; + } + } else { + s_epoch = best; + /* 扫到第一个校验不过的槽为止 —— 那就是上次写到的地方。 + 「校验不过」包含两种:擦干净的(0xFF)和被掉电切断的半条。 */ + s_slot = 0; + for (uint32_t r = 0; r < s_recs; r++) { + evlog_rec_t rec; + memset(&rec, 0, sizeof(rec)); + if (hal_store_read(rec_off(best % s_sectors, r), &rec, sizeof(rec)) != HAL_OK || + !rec_valid(&rec)) { + break; + } + s_slot = r + 1u; + } + s_next_seq = best * s_recs + s_slot; + s_oldest_seq = (best >= s_sectors) ? (best - s_sectors + 1u) * s_recs : 0u; + } + + /* §10.7「上电从 0 计」⇒ 时间戳会跳回去。没有这一条的话,重启前后的记录 + 在时间轴上互相穿插,而日志里看不出重启发生过。 */ + evlog_put(0, LOG_EV_BOOT, 0, 0, 0); + return s_have_store ? HAL_OK : HAL_ERR_STATE; +} + +/* ---- 杂项 ---------------------------------------------------------------- */ + +void evlog_stats(evlog_stats_t *st) +{ + if (st == NULL) { + return; + } + hal_crit_enter(); + st->queued = s_queued; + st->stored = s_stored; + st->dropped = s_dropped; + st->pending = s_count; + hal_crit_exit(); + st->io_errors = s_io_errors; + st->oldest_seq = s_oldest_seq; + st->next_seq = s_next_seq; +} + +uint32_t evlog_capacity(void) { return s_have_store ? s_sectors * s_recs : 0u; } + +const char *evlog_ev_str(log_ev_t ev) +{ + switch (ev) { + case LOG_EV_NONE: return "(空)"; + case LOG_EV_BOOT: return "上电"; + case LOG_EV_LOST: return "事件丢失"; + case LOG_EV_OVER_VOLT: return "过压关路"; + case LOG_EV_UNDER_VOLT: return "欠压关路"; + case LOG_EV_REVERSE: return "反灌断VBUS"; + case LOG_EV_PUSHED_UP: return "顶高断VBUS"; + case LOG_EV_EFUSE_FAULT: return "eFuse故障"; + case LOG_EV_PG_CHANGE: return "PG变化"; + case LOG_EV_I2C_LOST: return "I2C失联"; + case LOG_EV_THERMAL_DERATE: return "过温降载"; + case LOG_EV_FAN_STALL: return "风扇停转"; + case LOG_EV_REBOOT: return "重启序列"; + case LOG_EV_CMD_REJECT: return "命令被拒"; + case LOG_EV_COUNT: break; + } + return "?"; +} diff --git a/firmware/main/evlog.h b/firmware/main/evlog.h new file mode 100644 index 0000000..fea1fee --- /dev/null +++ b/firmware/main/evlog.h @@ -0,0 +1,160 @@ +/* 事件日志(需求 §10.7)——「RAM 环形缓冲 + 批量落盘 + 按序号增量拉取」。 + + §10.7 的第一句话是这一节全部设计的来源:**记录路径不能依赖上位机在线**。 + 排线接触不良、手机把电压顶回来、eFuse 断了 —— 这些事发生时, + 最可能同时发生的就是「WiFi 也断了」。所以事件先进 RAM,再由最低优先级的 + `t_log` 攒够一批写进 flash;上位机什么时候连上来,什么时候按序号拉走。 + + ---- 三层,各自只干一件事 ------------------------------------------------ + 1. **入队**(`evlog_put*`):谁产生事件谁调。几条指令,临界区里做完, + **绝不阻塞、绝不碰 flash**。t_safety 是在断电之后调它的(t_safety.h)。 + 2. **落盘**(`evlog_flush`):只有 `t_log` 调。它是唯一碰 flash 的人。 + 3. **读回**(`evlog_read`):按全局序号取,给 A12 的 `events 起始序号` 用。 + + ---- 为什么序号不在入队时分配 -------------------------------------------- + 序号是**记录在 flash 里的位置**算出来的(`epoch × 每扇区条数 + 槽号`), + 不是一个自增计数器。这样一来「序号」和「flash 里第几条」永远是同一件事, + 重启之后接着数也不用把计数器存起来。代价是**没落盘的事件还没有序号**: + `evlog_read()` 只服务已经落盘的部分,要看最新的就先 `evlog_flush(0)`。 + 反过来做(入队时发号)会在 RAM 缓冲溢出丢事件时留下一段永远补不上的空号, + 而上位机那边的现象是「拉日志卡在某个序号上不动」。 + + ---- 丢事件是可以的,**悄悄丢**不行 -------------------------------------- + RAM 缓冲满了就覆盖最旧的(§10.7 的 4096 条同理:满了盖最旧,最新的一条 + 永远进得去)。被盖掉的那些会在下一次落盘时留下一条 `LOG_EV_LOST`, + `bus_mv` 字段记着丢了多少条。一份会**安静地**少掉几行的日志, + 比没有日志更糟 —— 排查的人会拿它当完整的时间线看。 */ +#ifndef EVLOG_H +#define EVLOG_H + +#include +#include + +#include "hal_err.h" +#include "t_safety.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* ---- 事件类型 ------------------------------------------------------------ + ⚠️⚠️ **这些数字是 flash 里的格式,不是一个内部枚举。** 板子上跑了半年的 + 日志是按数字存的,改一个值就等于把历史记录全部重新解释了一遍, + 而且**读出来仍然是合法的记录**,没有任何一处会报错。 + 只许在末尾追加,不许插入、不许重排、不许删除。 + `test_flash_format_is_frozen()` 把每一个数字都钉死了。 */ +typedef enum { + LOG_EV_NONE = 0, + LOG_EV_BOOT = 1, /* 每次上电的第一条。§10.7「上电从 0 计」⇒ 时间戳会跳回去, + 没有这一条的话,重启前后的记录混在一起没法读 */ + LOG_EV_LOST = 2, /* RAM 缓冲溢出,丢了 bus_mv 条(见文件头) */ + + /* A10 的四条判据事件。⚠️ 这里的数字与 `safety_ev_t` 的**不一样**, + 两边靠 `evlog_from_safety()` 里那个显式 switch 对应 —— 不许写成加个偏移。 */ + LOG_EV_OVER_VOLT = 3, /* SR-5:高于设定 >100 mV 持续 20 ms → 关这一路 */ + LOG_EV_UNDER_VOLT = 4, /* SR-5:低于设定 >100 mV 持续 20 ms → 关这一路 */ + LOG_EV_REVERSE = 5, /* SW-8:电流转负 → 断这一路 VBUS */ + LOG_EV_PUSHED_UP = 6, /* SW-8:VBUS 期间电压被顶高 → 断这一路 VBUS */ + + /* §10.7 必记事件里**还没人产生**的那几类。号先占住(见上面那条警告), + 产生它们的代码各归各的包:括号里是包号。 */ + LOG_EV_EFUSE_FAULT = 7, /* eFuse FLT# 拉低(A15) */ + LOG_EV_PG_CHANGE = 8, /* PGOOD 变化,arg = 新电平(A15) */ + LOG_EV_I2C_LOST = 9, /* I²C 器件失联,arg = 地址(A15) */ + LOG_EV_THERMAL_DERATE = 10,/* 过温降载,bus_mv = 温度 m°C(A16) */ + LOG_EV_FAN_STALL = 11, /* 风扇停转(A16) */ + LOG_EV_REBOOT = 12, /* 每次重启序列,arg = 软/硬 + 谁下的命令(A9/A12) */ + LOG_EV_CMD_REJECT = 13, /* 越界命令被拒,arg = 命令号(A12) */ + + LOG_EV_COUNT, +} log_ev_t; + +/* §10.7 的「单条」:时间戳 + 路号 + 事件类型 + 当时的 V/I,正好 16 B。 + ⚠️ 这个结构体的**内存布局就是 flash 布局**,直接按字节写下去的。 + 加字段 = 换格式(见上),改字段顺序同理。 */ +typedef struct { + uint32_t t_ms; /* 本次上电起算的毫秒(§10.7:上位机连上后下发墙钟做偏移) */ + uint8_t rail; /* 1~5;0 = 整板级事件(BOOT / LOST / 风扇 …) */ + uint8_t type; /* log_ev_t */ + uint8_t arg; /* 事件相关的小参数,没有就 0 */ + uint8_t chk; /* 另外 15 个字节的 CRC-8。见 evlog.c「为什么要校验」 */ + int32_t bus_mv; /* 出事那一刻的实测值,不是设定值 */ + int32_t current_ua; +} evlog_rec_t; + +/* RAM 环形缓冲的条数。§10.7 写的是「≥256 条」。 + 256 × 16 B = 4 KB —— 在 512 KB 的内部 RAM 里可以忽略,而它换来的是 + 「t_log 被饿住 4 秒(按 §10.7 的量级远不止)也不丢事件」。 */ +#define EVLOG_RAM_CAP 256 + +/* 攒够这么多条就落盘一次。一次 flash 写事务的开销与条数几乎无关, + 16 条一批 ⇒ 每条事件的擦写摊薄到 1/16。 */ +#define EVLOG_BATCH 16 + +/* 但最老的那一条最多等这么久 —— 攒批不能把「掉电前记下来」这件事攒没了。 + 1 s 是 §10.3 里 t_log「空闲时」的量级,也是掉电丢失窗口的上界。 */ +#define EVLOG_MAX_AGE_MS 1000 + +/* 一次 `evlog_flush()` 最多写这么多条。t_log 是最低优先级,长时间占着 flash + 本身没有危害,但一次写 4000 条会让**这个函数**变成一个几百毫秒的黑洞, + 而它是要被 A12 的命令处理器同步调用的(拉日志之前先 flush)。 */ +#define EVLOG_FLUSH_MAX 64 + +typedef struct { + uint32_t queued; /* 累计入队几条 */ + uint32_t stored; /* 累计落盘几条(含补记的 LOST) */ + uint32_t dropped; /* 累计被覆盖掉几条(RAM 缓冲溢出) */ + uint32_t pending; /* 此刻还在 RAM 里没落盘的条数 */ + uint32_t io_errors;/* flash 写/擦失败次数 */ + uint32_t oldest_seq; /* flash 里还留着的最老一条的序号 */ + uint32_t next_seq; /* 下一条落盘的记录会拿到的序号 */ +} evlog_stats_t; + +/* 扫一遍 flash 找到接着写的位置,并**入队一条 `LOG_EV_BOOT`**。 + 幂等:重复调等于「又重启了一次」,会再记一条 BOOT。 + 返回 HAL_ERR_STATE = 没有那块分区(ESP 上是分区表漏了 `evlog`)—— + ⚠️ 调用方**不要因此 abort**:日志是排查手段,不是安全链的一环(§10.2)。 + 这种情况下入队照常工作(事件留在 RAM 里),只是落不了盘。 */ +hal_err_t evlog_init(void); + +/* 入队一条。**不阻塞、不碰 flash、不会失败** —— 缓冲满了就盖掉最旧的一条 + 并记一次 `dropped`(下一次落盘时会补一条 LOST)。 + 任何任务、任何优先级都可以调,包括 t_safety 断电之后的那一下。 */ +void evlog_put(uint8_t rail, log_ev_t type, uint8_t arg, int32_t bus_mv, int32_t ua); + +/* A10 的事件出口。直接 `t_safety_set_event_sink(evlog_put_safety, NULL)`。 + ⚠️ 时间戳用的是**事件里带的那个**(判据记的是断电那一刻),不是现在。 */ +void evlog_put_safety(void *ctx, const safety_event_t *ev); + +/* `safety_ev_t` → `log_ev_t`。落盘格式与判据的枚举是两件事,见 log_ev_t 上的警告。 + 认不出来的返回 LOG_EV_NONE(不会发生;发生了也不该把这条记录扔掉)。 */ +log_ev_t evlog_from_safety(uint8_t safety_ev); + +/* 该落盘了吗:攒够 `EVLOG_BATCH` 条,或者最老的一条等了 `EVLOG_MAX_AGE_MS`。 + t_log 每一圈问一次,false 就接着睡。 */ +bool evlog_due(void); + +/* 把 RAM 里的写进 flash。max = 0 表示「现在有多少写多少」(上界就是缓冲的 + 256 条);t_log 每圈传的是 `EVLOG_FLUSH_MAX`。返回**真的写进 flash 几条** + —— 含一条补记的 LOST,所以它可能比预算多 1(那条是上次欠下的账)。 + 只有 t_log 与 A12 的拉日志命令调它 —— 它是**唯一**碰 flash 的地方。 */ +uint32_t evlog_flush(uint32_t max); + +/* 从 from_seq 起,按序号读回**已落盘**的记录(没落盘的先 flush,见文件头)。 + 返回真的读到几条。from_seq 比 `oldest_seq` 小时从 `oldest_seq` 开始 + —— 上位机据此就能发现「中间那一段已经被环形缓冲盖掉了」。 */ +uint32_t evlog_read(uint32_t from_seq, evlog_rec_t *out, uint32_t max); + +void evlog_stats(evlog_stats_t *st); + +/* flash 里最多能留几条(§10.7 要求 ≥4096)。没有分区时返回 0。 */ +uint32_t evlog_capacity(void); + +/* 只用于日志与测试失败信息,返回静态字符串,不会是 NULL。 */ +const char *evlog_ev_str(log_ev_t ev); + +#ifdef __cplusplus +} +#endif + +#endif /* EVLOG_H */ diff --git a/firmware/main/phonerail.h b/firmware/main/phonerail.h index 459b073..4e6ea10 100644 --- a/firmware/main/phonerail.h +++ b/firmware/main/phonerail.h @@ -3,7 +3,7 @@ #define PHONERAIL_H /* 固件版本。规则:<主>.<次>.<修订>-<最后完成的任务包> */ -#define PHONERAIL_FW_VERSION "0.1.0-a10" +#define PHONERAIL_FW_VERSION "0.1.0-a11" /* 通道数。五路直供,见设计文档 §2。 */ #define PHONERAIL_RAIL_COUNT 5 diff --git a/firmware/partitions.csv b/firmware/partitions.csv new file mode 100644 index 0000000..9418b94 --- /dev/null +++ b/firmware/partitions.csv @@ -0,0 +1,18 @@ +# PhoneRail 的分区表。默认的 partitions_singleapp.csv 里没有给事件日志留地方, +# 而 §10.7 要求「记录路径不依赖上位机在线」—— 那就得有一块自己的 flash。 +# +# evlog:**裸的 data 分区**,不是 nvs、也不是 spiffs(理由见 hal_store_esp.c 开头)。 +# 大小 0x11000 = 17 个扇区。为什么是 17 而不是 §10.7 里写的「4096 条 ≈ 64 KB」: +# 每个扇区要拿一个 16 B 的槽当扇区头(epoch),于是一扇区只装得下 255 条, +# 16 扇区 = 4080 条,**差 16 条不够 4096**。17 扇区 = 4335 条, +# `test_capacity_meets_the_requirement()` 卡的就是这个 ≥4096。 +# 8 MB flash 上多这 4 KB 没有任何代价。 +# +# ⚠️ 改了这里要跟着改 `sim_hal.h` 的 SIM_STORE_SECTORS —— 两边不一致时 +# 宿主机测试仍然全绿,板子上却是另一个容量。测试里有一条断言把它们钉在一起。 +# +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x6000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x100000, +evlog, data, undefined, 0x110000, 0x11000, diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index af01101..4c11aac 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -6,3 +6,7 @@ CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y # 日志:默认 INFO,串口日志与命令混流的分帧问题在 A13 处理。 CONFIG_LOG_DEFAULT_LEVEL_INFO=y + +# 自定义分区表:默认那张里没有事件日志的地方(需求 §10.7)。见 partitions.csv。 +CONFIG_PARTITION_TABLE_CUSTOM=y +CONFIG_PARTITION_TABLE_CUSTOM_FILENAME="partitions.csv" diff --git a/firmware/test/test_evlog.c b/firmware/test/test_evlog.c new file mode 100644 index 0000000..32667b5 --- /dev/null +++ b/firmware/test/test_evlog.c @@ -0,0 +1,650 @@ +/* A11:事件日志(需求 §10.7)。 + + 验收是两句话:**「写满 4096 条不丢最新」**与**「掉电重启后能读回」**。 + 这两条各有一个「测试写法不对就永远绿」的坑,先写在这里: + + · 「不丢最新」不是「写 4096 条能读回 4096 条」。要写**超过**容量, + 然后断言读回来的是**最新的那些**。写不满的话,环形缓冲那一段代码 + 一行都没被跑到。 + · 「掉电重启」不能用 `sim_hal_reset()` —— 那是「换了一块新板子」, + flash 一起擦掉了,于是测的是「刚擦干净的 flash 读不出东西」,永远绿。 + 重启用 `sim_hal_reboot()`:CPU 侧全清、flash 一个字节不动。 + + 还有一类只有在这里才测得到的东西:**掉电可以把一次写切成两半**。 + `sim_store_tear_next_write()` 造的就是它,而它正是每条记录带一个校验字节的理由。 */ +#include + +#include "evlog.h" +#include "hal_store.h" +#include "hal_time.h" +#include "sim_hal.h" +#include "t_safety.h" +#include "unity.h" + +void setUp(void) { sim_hal_reset(); } + +/* 全局不变量,每个用例结束都查一遍:临界区进出配对,而且里面没干过会阻塞的事。 + 这两条错了不会让任何一个断言变红,只会让板子偶尔抖一下(hal_lock.h)。 */ +void tearDown(void) +{ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, sim_crit_depth(), "临界区没配对"); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_crit_blocking(), "在临界区里阻塞了"); +} + +static void fill(uint32_t n) +{ + for (uint32_t i = 0; i < n; i++) { + evlog_put(1, LOG_EV_PG_CHANGE, (uint8_t)i, (int32_t)i, 0); + } +} + +static uint32_t next_seq(void) +{ + evlog_stats_t st; + evlog_stats(&st); + return st.next_seq; +} + +static evlog_rec_t read_one(uint32_t seq) +{ + evlog_rec_t r; + memset(&r, 0, sizeof(r)); + TEST_ASSERT_EQUAL_UINT32(1, evlog_read(seq, &r, 1)); + return r; +} + +/* ---- flash 格式:改了就是把历史记录重新解释了一遍 ------------------------ */ + +/* 事件类型的数字**存在 flash 里**。改一个值,板子上跑了半年的日志全部变了意思, + 而且读出来仍然是合法记录,没有一处会报错。所以这里把每一个数字都钉死; + 要加新类型只能往末尾追加。 */ +static void test_flash_format_is_frozen(void) +{ + TEST_ASSERT_EQUAL_UINT32(16, (uint32_t)sizeof(evlog_rec_t)); /* §10.7「单条 16 B」*/ + + TEST_ASSERT_EQUAL_INT(0, LOG_EV_NONE); + TEST_ASSERT_EQUAL_INT(1, LOG_EV_BOOT); + TEST_ASSERT_EQUAL_INT(2, LOG_EV_LOST); + TEST_ASSERT_EQUAL_INT(3, LOG_EV_OVER_VOLT); + TEST_ASSERT_EQUAL_INT(4, LOG_EV_UNDER_VOLT); + TEST_ASSERT_EQUAL_INT(5, LOG_EV_REVERSE); + TEST_ASSERT_EQUAL_INT(6, LOG_EV_PUSHED_UP); + TEST_ASSERT_EQUAL_INT(7, LOG_EV_EFUSE_FAULT); + TEST_ASSERT_EQUAL_INT(8, LOG_EV_PG_CHANGE); + TEST_ASSERT_EQUAL_INT(9, LOG_EV_I2C_LOST); + TEST_ASSERT_EQUAL_INT(10, LOG_EV_THERMAL_DERATE); + TEST_ASSERT_EQUAL_INT(11, LOG_EV_FAN_STALL); + TEST_ASSERT_EQUAL_INT(12, LOG_EV_REBOOT); + TEST_ASSERT_EQUAL_INT(13, LOG_EV_CMD_REJECT); + TEST_ASSERT_EQUAL_INT(14, LOG_EV_COUNT); +} + +/* §10.7 的「必记事件」有八类,A10 只产生其中两类。剩下的号先占住, + 免得 A12/A15/A16 各自往中间插一个,把 flash 里的旧记录挪了位。 */ +static void test_every_event_type_has_a_name(void) +{ + for (int i = 0; i < LOG_EV_COUNT; i++) { + const char *s = evlog_ev_str((log_ev_t)i); + TEST_ASSERT_NOT_NULL(s); + TEST_ASSERT_TRUE_MESSAGE(s[0] != '?', "有事件类型没给名字"); + } + TEST_ASSERT_EQUAL_STRING("?", evlog_ev_str((log_ev_t)LOG_EV_COUNT)); +} + +/* `safety_ev_t` 与 `log_ev_t` 是两套编号,中间是一个**显式的 switch**。 + 写成「加个偏移」的话,t_safety.h 里插一个新事件类型就会把 flash 里 + 所有旧记录的含义整体挪位 —— 而且不会有任何一处报错。 */ +static void test_the_safety_mapping_is_explicit(void) +{ + TEST_ASSERT_EQUAL_INT(LOG_EV_OVER_VOLT, evlog_from_safety(SAFETY_EV_OVER_VOLT)); + TEST_ASSERT_EQUAL_INT(LOG_EV_UNDER_VOLT, evlog_from_safety(SAFETY_EV_UNDER_VOLT)); + TEST_ASSERT_EQUAL_INT(LOG_EV_REVERSE, evlog_from_safety(SAFETY_EV_REVERSE)); + TEST_ASSERT_EQUAL_INT(LOG_EV_PUSHED_UP, evlog_from_safety(SAFETY_EV_PUSHED_UP)); + TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, evlog_from_safety(SAFETY_EV_NONE)); + TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, evlog_from_safety(200)); /* 认不出的不许瞎猜 */ + + /* 两套编号**不重合**:重合的话「忘了转换」这个错测不出来。 */ + TEST_ASSERT_NOT_EQUAL((int)SAFETY_EV_OVER_VOLT, (int)LOG_EV_OVER_VOLT); + TEST_ASSERT_NOT_EQUAL((int)SAFETY_EV_PUSHED_UP, (int)LOG_EV_PUSHED_UP); +} + +/* A10 的事件出口是 `safety_ev_fn`。A11 要能**原样**接上去(HANDOFF 里 + 「只接 sink,不动判据」那条),所以这里就按那个函数指针类型调一次。 */ +static void test_the_sink_plugs_into_the_safety_layer_unchanged(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + const safety_ev_fn sink = evlog_put_safety; + t_safety_set_event_sink(sink, NULL); + + const safety_event_t ev = { + .t_ms = 1234, .rail = 3, .type = SAFETY_EV_REVERSE, + .bus_mv = 3902, .current_ua = -61000, + }; + sim_time_advance_ms(9999); /* 记账发生在断电之后 */ + sink(NULL, &ev); + TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); + + const evlog_rec_t r = read_one(1); + TEST_ASSERT_EQUAL_INT(LOG_EV_REVERSE, r.type); + TEST_ASSERT_EQUAL_UINT8(3, r.rail); + TEST_ASSERT_EQUAL_INT32(3902, r.bus_mv); + TEST_ASSERT_EQUAL_INT32(-61000, r.current_ua); + /* ⚠️ 时间戳是**断电那一刻**的,不是入队那一刻的。 */ + TEST_ASSERT_EQUAL_UINT32(1234, r.t_ms); +} + +/* ---- 容量:§10.7 的 4096 条 ---------------------------------------------- */ + +/* 每个扇区要拿一个 16 B 的槽当扇区头,于是 4 KB 的扇区只装得下 255 条。 + 16 个扇区 = 4080,**差 16 条不到 4096** —— 分区因此是 17 个扇区。 + 这条断言同时把 partitions.csv 与 sim 的几何钉在一起。 */ +static void test_capacity_meets_the_requirement(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + const uint32_t per_sector = SIM_STORE_SECTOR_BYTES / sizeof(evlog_rec_t) - 1u; + TEST_ASSERT_EQUAL_UINT32(255, per_sector); + TEST_ASSERT_EQUAL_UINT32(SIM_STORE_SECTORS * per_sector, evlog_capacity()); + TEST_ASSERT_TRUE_MESSAGE(evlog_capacity() >= 4096u, "装不下 §10.7 要求的 4096 条"); +} + +/* ---- 入队:不阻塞、不碰 flash ------------------------------------------- */ + +/* t_safety 是在**断电之后**调入队的(t_safety.h),所以它一条 flash 事务都不许发。 + 200 条事件进来,写次数必须一次都不涨。 */ +static void test_putting_events_never_touches_flash(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + + const uint32_t w0 = sim_store_writes(); + const uint32_t e0 = sim_store_erases(); + const uint32_t c0 = sim_crit_enters(); + fill(200); + TEST_ASSERT_EQUAL_UINT32(w0, sim_store_writes()); + TEST_ASSERT_EQUAL_UINT32(e0, sim_store_erases()); + /* 而且每一条都是在临界区里进的环 —— 两个下标不这样改是会撕的。 */ + TEST_ASSERT_TRUE(sim_crit_enters() - c0 >= 200u); + + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(200, st.pending); + TEST_ASSERT_EQUAL_UINT32(0, st.dropped); +} + +/* 缓冲满了盖掉的是**最旧**的一条。反过来(拒收新的)在真板上的现象是: + 出事的那一串事件里,最有价值的最后几条恰好一条都没有。 */ +static void test_the_ring_overwrites_the_oldest_not_the_newest(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); /* 这一条 BOOT 也占一格 */ + fill(EVLOG_RAM_CAP + 10u); /* 一共 267 条进 256 格 */ + + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(11, st.dropped); + TEST_ASSERT_EQUAL_UINT32(EVLOG_RAM_CAP, st.pending); + + TEST_ASSERT_EQUAL_UINT32(EVLOG_RAM_CAP + 1u, evlog_flush(0)); /* +1 = 那条 LOST */ + const evlog_rec_t last = read_one(next_seq() - 1u); + TEST_ASSERT_EQUAL_INT32((int32_t)(EVLOG_RAM_CAP + 9u), last.bus_mv); /* 最后一条在 */ +} + +/* 丢事件可以,**悄悄丢**不行:一份会安静少掉几行的日志,比没有日志更糟 —— + 排查的人会拿它当完整的时间线看。丢掉的那些在日志里留下一条 LOST。 */ +static void test_dropped_events_leave_a_mark_in_the_log(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + fill(EVLOG_RAM_CAP + 10u); + (void)evlog_flush(0); + + const evlog_rec_t first = read_one(0); + TEST_ASSERT_EQUAL_INT(LOG_EV_LOST, first.type); + TEST_ASSERT_EQUAL_INT32(11, first.bus_mv); /* 丢了几条 */ +} + +/* ---- 攒批:§10.7「t_log 空闲时批量写」 ---------------------------------- */ + +/* 一条一次的话,flash 的擦写寿命按事件条数算;攒 16 条一批,摊薄到 1/16。 */ +static void test_flush_is_batched_not_one_write_per_event(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + (void)evlog_flush(0); + + const uint32_t w0 = sim_store_writes(); + fill(EVLOG_BATCH); + TEST_ASSERT_EQUAL_UINT32(EVLOG_BATCH, evlog_flush(0)); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, sim_store_writes() - w0, "没攒批,一条写一次"); +} + +/* 攒批不能把「掉电前记下来」攒没了:最老的一条最多等 EVLOG_MAX_AGE_MS。 */ +static void test_the_oldest_event_never_waits_longer_than_the_window(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + (void)evlog_flush(0); + + fill(1); + TEST_ASSERT_FALSE(evlog_due()); /* 一条,刚进来 */ + sim_time_advance_ms(EVLOG_MAX_AGE_MS - 1u); + TEST_ASSERT_FALSE(evlog_due()); + sim_time_advance_ms(1); + TEST_ASSERT_TRUE(evlog_due()); /* 等够了:一条也要写 */ + + (void)evlog_flush(0); + fill(EVLOG_BATCH - 1u); + TEST_ASSERT_FALSE(evlog_due()); /* 差一条不够一批,而且没等够 */ + fill(1); + TEST_ASSERT_TRUE(evlog_due()); /* 攒够一批:不等了 */ + + /* ⚠️ 计时的起点是**最老**那一条,不是最新那一条。写成后者的话, + 事件慢慢地一条一条来(真板上就是这个样子)时窗口每次都被重置, + 于是永远不 due —— 现象是「日志一直不落盘」,而没有任何一条断言会红。 */ + (void)evlog_flush(0); + fill(1); + sim_time_advance_ms(600); + fill(1); + sim_time_advance_ms(600); /* 最老那条已经等了 1200 ms */ + TEST_ASSERT_TRUE_MESSAGE(evlog_due(), "攒批的计时起点取成了最新那一条"); +} + +/* `evlog_flush(max)` 要守住预算 —— A12 的拉日志命令是同步调它的, + 一次写几千条会把命令处理器变成一个几百毫秒的黑洞。 */ +static void test_flush_respects_its_budget(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + (void)evlog_flush(0); + + fill(50); + TEST_ASSERT_EQUAL_UINT32(10, evlog_flush(10)); + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(40, st.pending); + TEST_ASSERT_EQUAL_UINT32(40, evlog_flush(0)); +} + +/* ---- 验收一:写满 4096 条不丢最新 --------------------------------------- */ + +static void test_writing_past_the_capacity_keeps_the_newest(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + + const uint32_t total = 5000; /* 比 4335 条的容量多出一截,环一定绕过 */ + for (uint32_t i = 0; i < total; i++) { + evlog_put(1, LOG_EV_PG_CHANGE, 0, (int32_t)i, 0); + if (evlog_due()) { + (void)evlog_flush(EVLOG_FLUSH_MAX); + } + } + (void)evlog_flush(0); + + evlog_stats_t st; + evlog_stats(&st); + /* ① 一条都没在 RAM 里丢(t_log 跟得上)。 */ + TEST_ASSERT_EQUAL_UINT32(0, st.dropped); + TEST_ASSERT_EQUAL_UINT32(0, st.io_errors); + TEST_ASSERT_EQUAL_UINT32(0, st.pending); + /* ② 序号一直往前数,flash 里留着的是最后那一段。 */ + TEST_ASSERT_EQUAL_UINT32(total + 1u, st.next_seq); /* +1 = 开机那条 BOOT */ + TEST_ASSERT_TRUE_MESSAGE(st.oldest_seq > 0, "没绕过,这条用例没测到环形"); + TEST_ASSERT_TRUE(st.next_seq - st.oldest_seq <= evlog_capacity()); + + /* ③ **最新的五条一条不少**,而且就是最后写进去的那五条。 */ + evlog_rec_t buf[5]; + TEST_ASSERT_EQUAL_UINT32(5, evlog_read(st.next_seq - 5u, buf, 5)); + for (uint32_t i = 0; i < 5; i++) { + TEST_ASSERT_EQUAL_INT(LOG_EV_PG_CHANGE, buf[i].type); + TEST_ASSERT_EQUAL_INT32((int32_t)(total - 5u + i), buf[i].bus_mv); + } +} + +/* 擦除次数要按「条数 / 每扇区 255」走,不是按条数走 —— 后者的现象是 + flash 一年就写穿了,而在测试里只是「跑得慢一点」。 */ +static void test_erase_count_stays_proportional_to_the_log(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + const uint32_t after_format = sim_store_erases(); /* 出厂格式化擦了整块 */ + TEST_ASSERT_EQUAL_UINT32(SIM_STORE_SECTORS, after_format); + + for (uint32_t i = 0; i < 5000; i++) { + evlog_put(1, LOG_EV_PG_CHANGE, 0, (int32_t)i, 0); + if (evlog_due()) { + (void)evlog_flush(EVLOG_FLUSH_MAX); + } + } + (void)evlog_flush(0); + const uint32_t rolled = sim_store_erases() - after_format; + TEST_ASSERT_TRUE_MESSAGE(rolled <= 5001u / 255u + 2u, "擦得太勤,是不是没按扇区攒"); + TEST_ASSERT_TRUE(rolled >= 5001u / 255u); +} + +/* 上位机拿一个早就被盖掉的序号来拉日志时,从**还留着的最老一条**给起 —— + 它对着 `oldest_seq` 一比就知道中间断了一段(而不是以为自己拉全了)。 */ +static void test_reading_before_the_oldest_starts_at_the_oldest(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + for (uint32_t i = 0; i < 5000; i++) { + evlog_put(1, LOG_EV_PG_CHANGE, 0, (int32_t)i, 0); + if (evlog_due()) { + (void)evlog_flush(EVLOG_FLUSH_MAX); + } + } + (void)evlog_flush(0); + + evlog_stats_t st; + evlog_stats(&st); + evlog_rec_t a, b; + TEST_ASSERT_EQUAL_UINT32(1, evlog_read(0, &a, 1)); + TEST_ASSERT_EQUAL_UINT32(1, evlog_read(st.oldest_seq, &b, 1)); + TEST_ASSERT_EQUAL_INT32(b.bus_mv, a.bus_mv); + TEST_ASSERT_EQUAL_UINT32(b.t_ms, a.t_ms); +} + +static void test_reading_past_the_end_returns_nothing(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + fill(3); + (void)evlog_flush(0); + + evlog_rec_t buf[8]; + TEST_ASSERT_EQUAL_UINT32(0, evlog_read(next_seq(), buf, 8)); + TEST_ASSERT_EQUAL_UINT32(0, evlog_read(next_seq() + 100u, buf, 8)); + TEST_ASSERT_EQUAL_UINT32(4, evlog_read(0, buf, 8)); /* BOOT + 3 条 */ +} + +/* ---- 验收二:掉电重启后能读回 ------------------------------------------- */ + +static void test_a_new_board_formats_itself(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(0, st.next_seq); + TEST_ASSERT_EQUAL_UINT32(0, st.oldest_seq); + TEST_ASSERT_EQUAL_UINT32(1, st.pending); /* 开机那条 BOOT 已经在队里 */ + + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, read_one(0).type); +} + +static void test_a_reboot_reads_back_what_was_written(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + sim_time_advance_ms(4321); + fill(20); + TEST_ASSERT_EQUAL_UINT32(21, evlog_flush(0)); + const uint32_t before = next_seq(); + + sim_hal_reboot(); /* ⚠️ 不是 sim_hal_reset():flash 里的内容要留着 */ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + + /* 没有重新格式化 —— 扫描认错了扇区的现象就是「日志每次重启都空了」。 */ + TEST_ASSERT_EQUAL_UINT32_MESSAGE(0, sim_store_erases(), "重启把日志擦了"); + TEST_ASSERT_EQUAL_UINT32(before, next_seq()); + + evlog_rec_t buf[21]; + TEST_ASSERT_EQUAL_UINT32(21, evlog_read(0, buf, 21)); + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, buf[0].type); + for (uint32_t i = 0; i < 20; i++) { + TEST_ASSERT_EQUAL_INT(LOG_EV_PG_CHANGE, buf[i + 1u].type); + TEST_ASSERT_EQUAL_INT32((int32_t)i, buf[i + 1u].bus_mv); + TEST_ASSERT_EQUAL_UINT32(4321, buf[i + 1u].t_ms); + } + + /* 重启之后接着写的记录排在旧的后面,序号不倒退。 */ + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, read_one(before).type); +} + +/* §10.7「上电从 0 计」:重启之后时间戳会跳回去。没有 BOOT 这条记录的话, + 两次上电的记录在时间轴上互相穿插,而日志里看不出中间重启过。 */ +static void test_the_log_survives_many_reboots_and_shows_each_one(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + fill(5); + (void)evlog_flush(0); + + for (int i = 0; i < 3; i++) { + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + sim_time_advance_ms(100); + fill(5); + (void)evlog_flush(0); + } + + static evlog_rec_t buf[64]; + const uint32_t n = evlog_read(0, buf, 64); + TEST_ASSERT_EQUAL_UINT32(24, n); /* 4 次上电 × (1 条 BOOT + 5 条) */ + uint32_t boots = 0; + for (uint32_t i = 0; i < n; i++) { + if (buf[i].type == LOG_EV_BOOT) { + boots++; + } + } + TEST_ASSERT_EQUAL_UINT32_MESSAGE(4, boots, "分不出这是第几次上电"); +} + +/* 绕过扇区边界之后再重启:扫描要认出 **epoch 最大**的那个扇区。 + 取最小的(或者取扇区号最小的)在只写过一个扇区时看不出任何区别 —— + 这条用例存在的唯一理由就是「写超过 255 条再重启」。 */ +static void test_a_reboot_after_wrapping_finds_the_newest_sector(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + for (uint32_t i = 0; i < 700; i++) { /* 700 > 255×2:跨过两次扇区边界 */ + evlog_put(1, LOG_EV_PG_CHANGE, 0, (int32_t)i, 0); + if (evlog_due()) { + (void)evlog_flush(EVLOG_FLUSH_MAX); + } + } + (void)evlog_flush(0); + const uint32_t before = next_seq(); + TEST_ASSERT_EQUAL_UINT32(701, before); /* 700 + 那条 BOOT */ + + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(before, next_seq(), "重启后接错了扇区"); + TEST_ASSERT_EQUAL_UINT32(0, sim_store_erases()); + TEST_ASSERT_EQUAL_INT32(699, read_one(before - 1u).bus_mv); /* 最后一条还在 */ + TEST_ASSERT_EQUAL_INT32(0, read_one(1).bus_mv); /* 第一条也还在 */ + + /* 接着写的落在原来那个扇区的后面,不是另起一个。 */ + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, read_one(before).type); +} + +/* 半个扇区的时候重启,要接着那半个扇区往下写,而不是换一个新扇区 —— + 后者的现象是「每重启一次就浪费 255 个槽」,日志容量按重启次数缩水。 */ +static void test_a_reboot_resumes_in_the_same_sector(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + fill(5); + (void)evlog_flush(0); + + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(6, next_seq()); /* 6,不是 255 */ + TEST_ASSERT_EQUAL_UINT32(0, sim_store_erases()); +} + +/* ---- 掉电打断一次写 ------------------------------------------------------ */ + +/* 半条记录在字节层面跟一条真记录长得一模一样。校验字节的用处就是让 + 重启扫描停在它前面,而不是把它当成有效记录继续往后写。 */ +static void test_a_torn_write_does_not_become_a_record(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); /* BOOT 落在槽 0 */ + + evlog_put(2, LOG_EV_EFUSE_FAULT, 0, 3900, 1200); + sim_store_tear_next_write(8); /* 掉电切在第 8 个字节上 */ + TEST_ASSERT_EQUAL_UINT32(0, evlog_flush(0)); + + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32_MESSAGE(1, next_seq(), "半条记录被当成了一条真记录"); + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, read_one(0).type); +} + +/* ⚠️ 这条是第一版校验(异或和)唯一漏掉的情况,也是它被换成 CRC-8 的理由: + 一条 `bus_mv` 与 `current_ua` 都是 0 的记录被切在第 8 个字节上时, + 后 8 个字节留在 0xFF,而 **8 个 0xFF 的异或恰好是 0** —— + 半条记录的异或和与整条一模一样,校验照过,扫描把它认成一条真事件。 */ +static void test_a_torn_write_with_zero_payload_is_still_rejected(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + + evlog_put(0, LOG_EV_FAN_STALL, 0, 0, 0); /* 后 8 个字节全是 0 */ + sim_store_tear_next_write(8); + TEST_ASSERT_EQUAL_UINT32(0, evlog_flush(0)); + + sim_hal_reboot(); + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(1, next_seq()); +} + +/* 写失败之后这个扇区的**剩下部分整个放弃**。接着往同一个扇区写的话, + 重启扫描会停在那半条上,把后面写进去的全部当成不存在 —— + 代价是最多浪费 254 个槽,换来的是「扫描停在哪 = 日志到哪为止」永远成立。 */ +static void test_a_torn_write_abandons_the_rest_of_the_sector(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); + + evlog_put(2, LOG_EV_EFUSE_FAULT, 0, 3900, 0); + sim_store_tear_next_write(8); + TEST_ASSERT_EQUAL_UINT32(0, evlog_flush(0)); + + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(1, st.io_errors); + TEST_ASSERT_EQUAL_UINT32(1, st.dropped); /* 出了环却没写进去 = 真丢了 */ + + /* 下一条走的是新扇区:序号跳到 255 的整数倍(LOST 补记占第一条)。 */ + evlog_put(2, LOG_EV_EFUSE_FAULT, 0, 3901, 0); + TEST_ASSERT_EQUAL_UINT32(2, evlog_flush(0)); + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(255u + 2u, st.next_seq); + TEST_ASSERT_EQUAL_INT(LOG_EV_LOST, read_one(255).type); + TEST_ASSERT_EQUAL_INT32(3901, read_one(256).bus_mv); +} + +/* 被放弃的那一段槽在序号空间里是个洞。读到洞时给一条空记录**占住位置** —— + 跳过它的话,上位机的游标 `from_seq + n` 会对不上,拉日志会卡在洞上不动。 */ +static void test_a_hole_does_not_stall_the_cursor(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + (void)evlog_flush(0); + evlog_put(2, LOG_EV_EFUSE_FAULT, 0, 3900, 0); + sim_store_tear_next_write(8); + (void)evlog_flush(0); + evlog_put(2, LOG_EV_EFUSE_FAULT, 0, 3901, 0); + (void)evlog_flush(0); + + /* ⚠️ 先把缓冲填成非零:`static` 数组是零初始化的,而「读到洞就跳过」 + 留下的正是零 —— 不填的话那个错看起来和「填了一条空记录」一模一样。 */ + static evlog_rec_t buf[260]; + memset(buf, 0xAA, sizeof(buf)); + const uint32_t n = evlog_read(0, buf, 260); + TEST_ASSERT_EQUAL_UINT32(257, n); /* 序号 0~256 一个不少 */ + TEST_ASSERT_EQUAL_INT(LOG_EV_BOOT, buf[0].type); + TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, buf[1].type); /* 半条那一槽 */ + TEST_ASSERT_EQUAL_INT(LOG_EV_NONE, buf[254].type); /* 被放弃的那一段 */ + TEST_ASSERT_EQUAL_INT(LOG_EV_LOST, buf[255].type); + TEST_ASSERT_EQUAL_INT32(3901, buf[256].bus_mv); +} + +/* 一次写整个失败(flash 真坏了)时,那一批事件是真丢了 —— 但同样要留下痕迹。 */ +static void test_a_failed_write_is_counted_and_marked_lost(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, evlog_init()); + (void)evlog_flush(0); + + fill(3); + sim_store_fail_next_write(1); + TEST_ASSERT_EQUAL_UINT32(0, evlog_flush(0)); + + evlog_stats_t st; + evlog_stats(&st); + TEST_ASSERT_EQUAL_UINT32(1, st.io_errors); + TEST_ASSERT_EQUAL_UINT32(3, st.dropped); + + TEST_ASSERT_EQUAL_UINT32(1, evlog_flush(0)); /* 补记那条 LOST */ + const evlog_rec_t r = read_one(next_seq() - 1u); + TEST_ASSERT_EQUAL_INT(LOG_EV_LOST, r.type); + TEST_ASSERT_EQUAL_INT32(3, r.bus_mv); + + /* 补完就清账。不清的话每一次落盘都再补一条,日志会被 LOST 刷屏 —— + 而「日志里全是 LOST」这件事,光看丢失计数是看不出来的。 */ + const uint32_t seq_after = next_seq(); + TEST_ASSERT_EQUAL_UINT32(0, evlog_flush(0)); + TEST_ASSERT_EQUAL_UINT32(seq_after, next_seq()); +} + +/* ---- 假 flash 自己:它必须像 NOR,不是像 memcpy ------------------------- */ + +/* 写只能把 1 变成 0。假实现要是写成赋值,「忘了先擦」这个错在宿主机上 + 一次也复现不出来,而真板上的现象是「记录读出来是两条的与」。 */ +static void test_the_store_is_nor_flash_not_memcpy(void) +{ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_init()); + + uint32_t v = 0xF0F0F0F0u; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_write(0, &v, sizeof(v))); + v = 0x0F0F0F0Fu; + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_write(0, &v, sizeof(v))); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_read(0, &v, sizeof(v))); + TEST_ASSERT_EQUAL_HEX32(0x00000000u, v); /* 按位与,不是覆盖 */ + + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_erase(0)); + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_read(0, &v, sizeof(v))); + TEST_ASSERT_EQUAL_HEX32(0xFFFFFFFFu, v); +} + +static void test_the_store_refuses_what_the_flash_driver_refuses(void) +{ + uint32_t v = 0; + TEST_ASSERT_EQUAL_INT(HAL_ERR_STATE, hal_store_read(0, &v, sizeof(v))); /* 没 init */ + TEST_ASSERT_EQUAL_INT(HAL_OK, hal_store_init()); + + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_store_write(1, &v, sizeof(v))); /* 偏移没对齐 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_store_write(0, &v, 2)); /* 长度没对齐 */ + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, hal_store_erase(SIM_STORE_SECTORS)); + TEST_ASSERT_EQUAL_INT(HAL_ERR_ARG, + hal_store_read(SIM_STORE_SECTORS * SIM_STORE_SECTOR_BYTES, &v, 4)); + + hal_store_geom_t g; + hal_store_geometry(&g); + TEST_ASSERT_EQUAL_UINT32(SIM_STORE_SECTOR_BYTES, g.sector_bytes); + TEST_ASSERT_EQUAL_UINT32(SIM_STORE_SECTORS, g.sectors); +} + +int main(void) +{ + UNITY_BEGIN(); + RUN_TEST(test_flash_format_is_frozen); + RUN_TEST(test_every_event_type_has_a_name); + RUN_TEST(test_the_safety_mapping_is_explicit); + RUN_TEST(test_the_sink_plugs_into_the_safety_layer_unchanged); + RUN_TEST(test_capacity_meets_the_requirement); + RUN_TEST(test_putting_events_never_touches_flash); + RUN_TEST(test_the_ring_overwrites_the_oldest_not_the_newest); + RUN_TEST(test_dropped_events_leave_a_mark_in_the_log); + RUN_TEST(test_flush_is_batched_not_one_write_per_event); + RUN_TEST(test_the_oldest_event_never_waits_longer_than_the_window); + RUN_TEST(test_flush_respects_its_budget); + RUN_TEST(test_writing_past_the_capacity_keeps_the_newest); + RUN_TEST(test_erase_count_stays_proportional_to_the_log); + RUN_TEST(test_reading_before_the_oldest_starts_at_the_oldest); + RUN_TEST(test_reading_past_the_end_returns_nothing); + RUN_TEST(test_a_new_board_formats_itself); + RUN_TEST(test_a_reboot_reads_back_what_was_written); + RUN_TEST(test_the_log_survives_many_reboots_and_shows_each_one); + RUN_TEST(test_a_reboot_resumes_in_the_same_sector); + RUN_TEST(test_a_reboot_after_wrapping_finds_the_newest_sector); + RUN_TEST(test_a_torn_write_does_not_become_a_record); + RUN_TEST(test_a_torn_write_with_zero_payload_is_still_rejected); + RUN_TEST(test_a_torn_write_abandons_the_rest_of_the_sector); + RUN_TEST(test_a_hole_does_not_stall_the_cursor); + RUN_TEST(test_a_failed_write_is_counted_and_marked_lost); + RUN_TEST(test_the_store_is_nor_flash_not_memcpy); + RUN_TEST(test_the_store_refuses_what_the_flash_driver_refuses); + return UNITY_END(); +} diff --git a/需求文档.md b/需求文档.md index 7602b50..fa61fad 100644 --- a/需求文档.md +++ b/需求文档.md @@ -664,6 +664,52 @@ ESP32.KEY_SER → U21 → U22 → U33 (先移入的字节落在链尾 U 必记事件:eFuse FAULT / PG 变化、SR-5 触发关路、SW-8 反灌断 VBUS、过温降载、 每次重启序列(软 / 硬、谁下的命令)、越界命令被拒、I²C 器件失联、风扇停转。 +> **A11 已落地**(2026-09-07,`firmware/main/evlog.{h,c}` + 新增的 +> `hal_store`(掉电不丢区)+ `partitions.csv` 的 `evlog` 分区)。落地时定下来的八条: +> +> 1. **落盘不用 NVS,也不用 SPIFFS,是一块裸的 data 分区。** NVS 是键值表 +> (4096 条记录 = 4096 个键,页式 GC 会把「写一条事件」变成一次不可预测的搬移), +> SPIFFS 的掉电一致性自己就是一整章。evlog 本身已经是环形缓冲,它要的只是 +> 「擦一个扇区 / 写一段字节」—— HAL 因此多出第六样 `hal_store`。 +> 2. **容量是 17 个扇区 = 4335 条,不是 16 个。** 每个扇区要拿一个 16 B 的槽 +> 当扇区头(epoch),于是 4 KB 只装得下 255 条,16 扇区 = 4080, +> **差 16 条不到本表要求的 4096**。8 MB flash 上多这 4 KB 没有代价。 +> 3. **序号 = flash 里的位置**(`epoch × 255 + 槽号`),不是一个自增计数器。 +> 重启之后接着数不用把计数器存起来,掉电也不会倒退。代价是**没落盘的事件 +> 还没有序号** ⇒ `events` 命令(10.8)要先 `evlog_flush()` 再读。 +> 反过来做(入队时发号)会在缓冲溢出丢事件时留下一段永远补不上的空号, +> 上位机那边的现象是「拉日志卡在某个序号上不动」。 +> 4. **入队在临界区里做完,不是加第三把锁**(§10.3 的共享资源只有两个)。 +> 入队方是 `t_safety`(最高优先级、断电之后调),停在一把 `t_log` 持着的锁上 +> 就等于让最低优先级的任务决定巡检的节拍。关中断的临界区里**只改内存**: +> 时间戳与校验都在外面算完。 +> 5. **丢事件可以,悄悄丢不行。** RAM 缓冲(256 条)满了盖最旧的 ——「最新的一条 +> 永远进得去」——但被盖掉的会在下一次落盘时留下一条 `LOG_EV_LOST`, +> 值是丢了多少条。一份会安静少掉几行的日志比没有日志更糟:排查的人会拿它 +> 当完整的时间线看。flash 写失败丢掉的那一批同样补这条。 +> 6. **每条记录带一个 CRC-8,而且只能是 CRC-8。** NOR 的写会被掉电打断在任意 +> 字节上,半条记录在字节层面跟真记录长得一模一样。第一版用异或和, +> 漏掉的正是最常见的那条:`bus_mv` 与 `current_ua` 都是 0 的记录被切在第 8 个 +> 字节上时,后 8 个字节留在 0xFF,而 **8 个 0xFF 的异或恰好是 0** —— +> 半条的校验和与整条一模一样。 +> 7. **写失败之后放弃这个扇区的剩余部分。** 接着写的话,重启扫描会停在那半条上, +> 把后面写进去的全部当成不存在。代价是最多浪费 254 个槽,换来的是 +> 「扫描停在哪 = 日志到哪为止」这条不变量永远成立。被放弃的那段在序号空间里 +> 是个洞,`evlog_read()` 给它一条空记录**占住位置** —— 跳过它的话上位机的 +> 游标会卡在洞上。 +> 8. **每次上电记一条 `LOG_EV_BOOT`。** 本表写着「上电从 0 计」,所以时间戳会 +> 跳回去;没有这条记录的话,两次上电的事件在时间轴上互相穿插, +> 而日志里看不出中间重启过。 +> +> 事件类型的**数字是 flash 格式的一部分**(改一个值就等于把跑了半年的历史记录 +> 重新解释了一遍,而且读出来仍然是合法记录、没有一处报错),所以 `log_ev_t` +> 只许在末尾追加;`safety_ev_t` → `log_ev_t` 是一个显式的 switch,不是加偏移。 +> 上表里还没人产生的那几类(eFuse FAULT / PG 变化 / 过温降载 / 重启序列 / +> 命令被拒 / I²C 失联 / 风扇停转)**号已经占住**,各归各的包去填。 +> +> 27 条 ctest 用例;23 条「一定会被写出来的错」逐个注进去跑过,**21 条变红**。 +> 剩下两条抓不到,理由见 `firmware/HANDOFF.md`。 + ### 10.8 上位机接口 ✅ 已定稿(2026-09-06 用户拍板:方案 C) **这一节回答的问题**:SW-1 的触发条件写的是「`adb` 探活连续失败 N 次」,