/* * 模块名称:SOE Store — 事件顺序记录落盘(实现) * 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build + * SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部) * 排空落 /log/soe.log(64B 定长,容量满删除重建)。 * 修改记录: * v1.0 2026-10-12 王建锋 创建初始版本(B9,设计见 docs/算法/继电保护功能复现规划.md §3.4) * v1.1 2026-10-24 王建锋 仅注释/文档更正(D 项,无行为改动):s_soe_append * 函数头原写"打开/写入结构性错误 → unlink 重试一次 * (同 run.log)",与实现不符且方向错误——实现里 * f_open 失败直接返回 -1、不 unlink,这是**有意**的: * SOE 是事件序列,打开失败就删文件等于销毁已发生的 * 事件。改为如实描述"仅写失败才重试一次",并补上 * 与 sys_log_mcu.c s_is_selfheal_code 同源的逐码表 * (名字/参数类错误码两侧都绝不 unlink) * 创建日期:2026-10-12(B9) */ /* 头文件包含区 */ #include #define DBG_TAG "[SOE]" #include "dbg_log.h" #include "soe_store.h" #include "soe_rec.h" #include "ff.h" #include "cmsis_os.h" #include "sys_clock.h" #include "vol_lock.h" /* 静态变量定义区 */ #define SOE_PATH "/log/soe.log" #define SOE_QUEUE_MAX 8u #define SOE_RING_MAX 8u /* 待发环容量(须为 2 的幂) */ #define SOE_RING_MASK (SOE_RING_MAX - 1u) /* 待发环(SPSC 无锁):队列惰性创建(defaultTask 首次 drain)之前的窗口 * 兜底。生产者 = protectTask(只写 head 侧),消费者 = defaultTask(只写 * tail 侧)——单核 Cortex-M + volatile 下按 console_log 环形缓冲同一模型, * 免锁安全。条目 = 完整 64B 记录(post 侧已组好,drain 侧原样落盘)。 */ static uint8_t s_ring[SOE_RING_MAX][SOE_REC_SIZE]; static volatile uint8_t s_ring_head; /* 生产者写位 */ static volatile uint8_t s_ring_tail; /* 消费者读位 */ static osMessageQueueId_t s_queue; /* 运行期队列(drain 首次调用惰性创建) */ static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" }; static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */ static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */ static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */ static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */ /* 函数定义区 */ /* * 函数功能:投递一条 SOE 记录(见 soe_store.h 声明注释) * 函数说明:时标 = sys_clock_get()(纯 RAM,无 I/O);UINT32_MAX(墙钟未建) * → flags 置 IV、epoch 字段 0、CP56 IV 位如实置位(B9 口径: * 不造假时标)。 * ⚠ 本函数**不得调用 DBG_***:protectTask 静态最坏链 = 经 * s_log_active_edge 的 console 尾链(872 B,B5 实测),post 内任何 * 日志调用点都会把该尾链再垫高一帧(stack_report 实证 +320 B → * FAIL)。丢弃只累加计数,留痕由 drain(defaultTask)差值上报。 */ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) { uint8_t rec[SOE_REC_SIZE]; /* 64B:protectTask 栈(链对比见 B9) */ uint32_t epoch = sys_clock_get(); uint8_t flags = 0u; uint32_t seq; if (epoch == UINT32_MAX) { epoch = 0u; flags = SOE_FLG_IV; } seq = s_seq; s_seq = seq + 1u; soe_rec_build(rec, type, elm_id, flags, seq, epoch, value); if ((s_queue != NULL) && (osKernelGetState() == osKernelRunning)) { if (osMessageQueuePut(s_queue, rec, 0u, 0u) == osOK) { return; } } else { uint8_t head; /* 队列未建窗口(内核启动 → defaultTask * 首次 drain):SPSC 待发环 */ head = s_ring_head; if ((uint8_t)(head - s_ring_tail) < SOE_RING_MAX) { (void)memcpy(s_ring[head & SOE_RING_MASK], rec, SOE_REC_SIZE); s_ring_head = (uint8_t)(head + 1u); return; } } /* 队列满 / 环满:丢弃 + 计数(留痕在 drain 侧,见函数头警告) */ if (s_drop_cnt < UINT32_MAX) { s_drop_cnt++; } } /* * ── 自愈触发集逐码依据(与 sys_log_mcu.c 的同名资料块同源;2026-10-24 逐条核对 * Lib/FatFs/ff.c)───────────────────────────────────────────────────────── * 进触发集(run.log 侧允许 unlink 重建一次):介质/结构类 FR_DISK_ERR(1) / * FR_INT_ERR(2) / FR_NO_FILESYSTEM(13) / FR_DENIED(7)。本模块**只在写失败路径** * 上碰自己的文件——打开失败连这些码也不 unlink。 * 不算自愈:FR_NO_FILE(4)——带 FA_OPEN_APPEND 打开时文件会被直接建出来;返回本 * 码的唯一现实场景是同名对象是目录(ff.c:3854),unlink 修不好它。 * 两侧都绝不 unlink:名字/参数类 FR_INVALID_NAME(6) / FR_NO_PATH(5) / * FR_INVALID_OBJECT(9),以及 FR_NOT_READY(3) / FR_NOT_ENABLED(12) / * FR_TIMEOUT(15)——同名同因,删了也修不好,反而每轮丢一份历史(§10.1 第 10 条 * 严禁的正是这种归集)。 * 写失败路径不接这套判定:f_write 的返回码里不存在名字/参数类(能走到那里说明 * 已经打开成功),而簇分配失败是 FR_OK + 短写、根本没有错误码可判,只能无条件 * 重试一次。 * 为什么 SOE 比 run.log 保守(两侧资料块各写一份、互指,改动必须同步):run.log * 是滚动运行日志,坏了重建损失有限;SOE 是**事件序列**,文件里是一条条已经发生 * 过的保护事件,"打开失败就删整个历史文件"等于**销毁事件证据**——SOE 的存在就 * 是为了留这些事件,删了比写不进去更糟。正确出路是等 syslog_mcu_mounted() 每 2s * 重试补目录 / 等卷恢复,条目留在待发环与队列里,不丢。 * (run.log 侧的同一张表 + 同一理由见 App/log/sys_log_mcu.c 的同名资料块,测试 * 侧两侧对齐由 TC-SYSLOG-101/102 与 TC-SOESTORE-022/022b 分别钉住。) */ /* * 函数功能:追加一条 64B SOE 记录到 /log/soe.log * 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t * * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载、/log 已建 * 函数说明:照 sys_log_mcu.c s_append 的台架实证纪律—— * ① vol_lock try(本任务 = 看门狗喂狗点,绝不阻塞等锁); * ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE, * 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证); * ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码); * ④ 容量满 unlink 重建;**仅写入失败(含 FR_OK+短写)才 unlink 重试 * 一次**——f_open 失败一律直接返回 -1、不 unlink。 * (2026-10-24 定案:本条旧注释写的是"打开/写入结构性错误都 unlink * 重试一次(同 run.log)",与实际实现不符,也与本模块的语义相反: * run.log 是**滚动运行日志**,坏了重建损失有限;SOE 是**事件序列**, * 文件里是已经发生的事件,打开失败就把整个历史文件删掉等于销毁 * 证据,方向是错的。正确出路是让 syslog_mcu_mounted() 每 2s 重试 * 补目录/等卷恢复,条目留在待发环与队列里,不丢。) * ⑤ 自愈触发集与 sys_log_mcu.c 的 s_is_selfheal_code() 同源,但本模块取 * 更保守的子集:打开失败**任何错误码都不 unlink**,只有写失败才重试 * 一次。逐码依据与两侧差异见上方同名资料块。 * ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、 * 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃 * 计数,绝不升级成卷操作(全模块无 f_mount/f_mkfs/f_opendir)。 */ static int s_soe_append(const uint8_t *rec) { static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈; * 全程在 vol_lock 区间内,跨任务由卷锁串行化 */ FRESULT fr; UINT bw = 0u; FILINFO finfo; uint32_t cnt; int rc = 0; if (vol_lock_wait(0u) == 0u) { return -2; } /* 容量护栏:满 → 删除重建(seq 不重置:RAM 计数继续,主机按 boot 段看) */ cnt = (f_stat(SOE_PATH, &finfo) == FR_OK) ? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u; if (cnt >= SOE_STORE_MAX) { (void)f_unlink(SOE_PATH); DBG_INFO("cap full, recreate " SOE_PATH); } fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { fr = f_write(&f, rec, SOE_REC_SIZE, &bw); (void)f_close(&f); if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { /* L1 文件级自愈:**只有写失败**才删除重建、重试一次(打开失败走下面 * 的 else,不删——见函数头 ④)。unlink 返回值刻意不检查:失败也只是 * 重试失败 → 留痕计数,绝不升级成卷操作。 */ (void)f_unlink(SOE_PATH); bw = 0u; fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { fr = f_write(&f, rec, SOE_REC_SIZE, &bw); (void)f_close(&f); } if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) { rc = -1; } } } else { /* 打开失败:只留痕、**不 unlink**(SOE 是事件序列,删文件 = 销毁历史; * 名字/参数类错误码 unlink 也修不好,见函数头的逐码表) */ rc = -1; } if (rc == 0) { s_fault_reported = 0u; } else if (s_fault_reported == 0u) { s_fault_reported = 1u; DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr); } vol_lock_release(); return rc; } /* * 函数功能:排空 SOE 待发(待发环 + 队列,FIFO)并落盘 * 入口参数:无 * 返回值:无 * 限定条件:仅 defaultTask 调用(syslog_drain 尾部,卷就绪门之后); * 首次调用惰性创建消息队列(内核对象创建在 osKernelStart 之后) */ void soe_store_drain(void) { uint8_t rec[SOE_REC_SIZE]; /* 丢弃留痕(defaultTask 上下文 = 日志安全区):post 侧禁 DBG(protect 链), * 此处按差值上报一次,不刷屏 */ if (s_drop_cnt != s_drop_logged) { s_drop_logged = s_drop_cnt; DBG_ERROR("soe dropped total=%lu (queue/ring full)", (unsigned long)s_drop_cnt); } if (s_queue == NULL) { s_queue = osMessageQueueNew(SOE_QUEUE_MAX, SOE_REC_SIZE, &s_queue_attr); if (s_queue == NULL) { DBG_ERROR("queue create fail"); /* 条目滞留待发环,下轮再试 */ } } /* 待发环优先(保时序:环内条目早于队列条目) */ while (s_ring_tail != s_ring_head) { uint8_t tail = s_ring_tail; (void)memcpy(rec, s_ring[tail & SOE_RING_MASK], SOE_REC_SIZE); if (s_soe_append(rec) != 0) { return; /* 写失败/卷忙:不推进 tail,下轮重试 */ } s_ring_tail = (uint8_t)(tail + 1u); } if (s_queue == NULL) { return; } for (;;) { if (osMessageQueueGet(s_queue, rec, 0u, 0u) != osOK) { break; /* 队列空 */ } if (s_soe_append(rec) == -2) { break; /* 卷被占用:本轮顺延(条目已出队,尽力而为) */ } /* rc==-1 写失败:s_soe_append 已留痕;继续排空(SOE 尽力而为语义, * 单条坏记录不卡死后续事件) */ } } /* * 函数功能:累计丢弃计数(见 soe_store.h 声明注释) */ uint32_t soe_store_dropped(void) { return s_drop_cnt; }