文件
phonerail/firmware/HANDOFF.md
T
zhou yang和Claude Opus 5 18d3d69856 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]>
2026-09-07 09:20:02 +08:00

9.6 KiB
原始文件 Blame 文件历史

交接

更新: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() 的区别在于:那里错的是「命令会发到哪一路」,这里错的只是「记不下来」。