A11:事件日志(环形缓冲 + 落盘)
事件从 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 <[email protected]>
这个提交包含在:
+106
-90
@@ -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()`
|
||||
的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 <stdbool.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 <string.h>
|
||||
|
||||
#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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 里的内容一个字节不动 */
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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_*),两个目标编的是同一份源码。
|
||||
|
||||
@@ -10,5 +10,6 @@ idf_component_register(
|
||||
"tasks.c"
|
||||
"t_safety.c"
|
||||
"t_actuator.c"
|
||||
"evlog.c"
|
||||
INCLUDE_DIRS "."
|
||||
REQUIRES phonerail_hal drv)
|
||||
|
||||
+42
-6
@@ -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);
|
||||
|
||||
|
||||
@@ -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 <stddef.h>
|
||||
#include <string.h>
|
||||
|
||||
#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 "?";
|
||||
}
|
||||
@@ -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 <stdbool.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#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 */
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
@@ -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"
|
||||
|
||||
@@ -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 <string.h>
|
||||
|
||||
#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();
|
||||
}
|
||||
@@ -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 次」,
|
||||
|
||||
在新工单中引用
屏蔽一个用户