/* 事件日志的实现。接口与「为什么这么分层」在 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 #include #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 "?"; }