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]>
这个提交包含在:
zhou yang
2026-09-07 09:20:02 +08:00
共同撰写人 Claude Opus 5
父节点 273c53347e
当前提交 18d3d69856
共修改 21 个文件,包含 2025 行新增和 103 行删除
+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);