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 行删除
+1
查看文件
@@ -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);
+570
查看文件
@@ -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 "?";
}
+160
查看文件
@@ -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 */
+1 -1
查看文件
@@ -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