# 交接 更新:2026-09-07 完成任务包 **A11**(事件日志:环形缓冲 + 落盘) ## 一句话现状 事件从 `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 条变红**。 ## 下一步 **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/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 14 # +test_evlog(27),共 240 例 $ .\build_idf.ps1 # PowerShell phonerail.bin binary size 0x3ec30 bytes. ... 0xc13d0 bytes (75%) free. Project build complete. $ python .../gen_esp32part.py build/partition_table/partition-table.bin evlog,data,undefined,0x110000,68K, # 17 个扇区 # 23 个「一定会被写出来的错」逐个注进去跑过(一次性脚本,没入库): # 环形: 满了丢最新而不是盖最旧 / 丢了不补 LOST / 补记 LOST 之后不清零 / # 攒批计时从最新一条算起 # 校验: 退回异或和 / 记录从扇区头那一槽开始写 / 假 flash 写成覆盖而不是按位与 # 落盘: 换扇区时不擦 / 绕圈后不更新 oldest_seq / 写失败后不放弃这个扇区 / # 落盘时不补记 LOST / flush 不守预算 / 攒批没有超时兜底 / 攒够一批也不写 # 重启: 每次上电都重新格式化 / 扫描取 epoch 最小的扇区 / 扫描停在上一条 # 读回: 读到洞就跳过 / 读不 clamp 到 oldest_seq # 其它: 事件时间戳改成记账时刻 / 映射写成加个偏移 # → 21 条变红。剩下 2 条见下面「抓不到的那两条」。 ``` ## 踩到的坑(只写会再犯的) - ⚠️⚠️ **校验和用异或是错的,必须用 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 的几何是编译期常量。 两条都留着 —— 它们保的是别人改代码时的假设,不是当前这一版的行为。 ## 这一层的几条约定(写 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()` 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。