文件
phonerail/firmware/main/evlog.h
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

161 行
9.2 KiB
C
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
/* 事件日志(需求 §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 */