/* * 模块名称:System Log (MCU 后端) — 运行/自检记录落盘 * 模块功能:FatFS /log 目录日志文件(run.log/selftest.log)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列, * defaultTask(唯一写者)排空落盘;容量满删除重建;上电/重启分类 * 适用平台:STM32F407ZGTx * 作者:王建锋 * 创建日期:2026-09-14 * 修改记录: * v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4.4) * v1.1 2026-09-21 王建锋 消息队列改为内核启动后由 syslog_drain 惰性创建 * (修复 osKernelStart 前创建 FreeRTOS 内核对象导致的启动失败) * v1.2 2026-10-02 王建锋 s_append 落盘序列纳入卷锁 try 语义(vol_lock_wait(0)): * 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 → * dhara journal 竞争实证);busy 返回 -2 静默顺延 * (本任务 = 看门狗喂狗点,绝不阻塞等锁) * v1.3 2026-10-02 王建锋 新增 sys_log_evidence():把看门狗停喂现场 * 证据落 /log/wdev.log(掉电不丢)+ flush 诱导 * (round 7 纯取证,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录) * v1.4 2026-10-04 王建锋 P2-9:syslog_mcu_mounted() 改为幂等 + 卷锁保护; * defaultTask 每 2s 复查,建 /log 曾失败时自动重试 * (原"仅调用一次"→ 失败后 drain 永久 no-op)。 * P2-10:s_read_raw() 纳入卷锁 try 语义,与写者 * s_append 的"容量满 unlink 重建"互斥,消除 * "UI 读记录(FA_READ) 时文件被 unlink"竞态窗口。 */ /* 头文件包含区 */ #include #include #define DBG_TAG "[SYSLOG]" #include "dbg_log.h" #include "sys_log.h" #include "soe_store.h" /* B9:SOE 落盘挂本函数尾部(同写者、同卷就绪门) */ #include "ff.h" #include "cmsis_os.h" #include "sys_clock.h" #include "vol_lock.h" /* 静态变量定义区 */ static const char *const s_paths[SYSLOG_COUNT] = { "/log/run.log", "/log/selftest.log" }; static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX }; #define SYSLOG_QUEUE_MAX 8u /* N-24(2026-10-15,报告 N-16 写侧):一次排空的合并写入上限。 * 8 条 × 64 B = 512 B 静态缓冲(不吃任务栈),与队列深度 SYSLOG_QUEUE_MAX 同值 * ——即"一轮排空最多一次 open/write/close"。 */ #define SYSLOG_BATCH_MAX 8u static uint8_t s_batch[SYSLOG_BATCH_MAX * SYSLOG_REC_SIZE]; /* 待发条目:desc 为调用方静态串(Flash 字面量),只存指针不拷贝 */ typedef struct { uint8_t id; const char *desc; } syslog_entry_t; static syslog_entry_t s_preboot[SYSLOG_QUEUE_MAX]; /* 启动前待发数组(RTOS 前单生产者/后单消费者,免锁) */ static syslog_entry_t s_batch_src[SYSLOG_BATCH_MAX]; /* 本批条目的原始来源(N-24:未落盘的尾部要回投队列,故必须留住 id + desc 指针); * 唯一写者 defaultTask,走静态不吃栈 */ static volatile uint8_t s_preboot_cnt; /* 数组条目数 */ static osMessageQueueId_t s_queue; /* 运行期消息队列(syslog_drain 首次调用时惰性创建) */ static const osMessageQueueAttr_t s_queue_attr = { .name = "syslog" }; static volatile uint8_t s_fs_ready; /* 卷挂载 + /log 目录就绪后置位 */ static volatile uint8_t s_mount_fail_reported; /* P2-9:建目录失败日志去重(成功复位) */ /* N-24(2026-10-15,报告 N-16 读侧):8 条滑动读窗口 + 写者代数。 * 窗口 512 B 走静态(不吃任务栈;defaultTask/uiTask 最坏链见 stack_report.py, * 预算余量见 docs/内存管理/RAM预算台账.md)。s_rd_gen 由写者自增、读者比对, * 对齐 32 位在 ARM 上原子,跨任务无需临界区。 */ #define SYSLOG_RD_WINDOW 8u static uint8_t s_rd_win[SYSLOG_RD_WINDOW * SYSLOG_REC_SIZE]; static int32_t s_rd_win_id = -1; /* -1 = 窗口空 */ static uint32_t s_rd_win_base; /* 窗口首条序号 */ static uint32_t s_rd_win_cnt; /* 窗口内有效条数 */ static uint32_t s_rd_win_gen; /* 填充时看到的写者代数 */ static volatile uint32_t s_rd_gen[SYSLOG_COUNT]; /* 每次成功落盘 / unlink 后自增 */ /* 函数定义区 */ /* * 函数功能:格式化一条 64 B 定长记录(时间戳 + 空格 + epoch + 空格 + 描述) * 入口参数:id - 日志 ID(当前不参与编码,仅为接口对称) syslog_id_t * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * * p_rec - 输出缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) uint8_t * * 返回值:无 * 限定条件:时间戳未就绪时落 "1970-01-01 08:00:00" 占位串 * 函数说明:N-24(2026-10-15,报告 N-16)从 s_append 抽出,供"单条写"与 * "批量写"共用——两条路径必须产出逐字节相同的记录,否则 UI 按定长 * 字段偏移解析会错位。 */ static void s_format_rec(syslog_id_t id, const char *desc, uint8_t *p_rec) { char ts[SYSLOG_TS_LEN + 1u]; uint32_t epoch; size_t dl; (void)id; memset(p_rec, 0u, SYSLOG_REC_SIZE); if (sys_clock_get_str(ts, sizeof(ts)) == NULL) { memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN); } ts[SYSLOG_TS_LEN] = '\0'; epoch = sys_clock_get(); if (epoch == UINT32_MAX) { epoch = 0u; } memcpy(p_rec, ts, SYSLOG_TS_LEN); p_rec[SYSLOG_TS_LEN] = ' '; (void)snprintf((char *)(p_rec + SYSLOG_EPOCH_OFFS), SYSLOG_EPOCH_LEN + 1u, "%010u", (unsigned int)epoch); p_rec[SYSLOG_DESC_OFFS - 1u] = ' '; dl = strlen(desc); if (dl > SYSLOG_DESC_MAX) { dl = SYSLOG_DESC_MAX; } memcpy(p_rec + SYSLOG_DESC_OFFS, desc, dl); } /* * 函数功能:把一条**已格式化**的 64 B 记录追加落盘(容量满先删除重建) * 入口参数:id - 日志 ID syslog_id_t * p_rec - 记录缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) const uint8_t * * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 */ static int s_append_rec(syslog_id_t id, const uint8_t *p_rec) { static FIL f; /* static:FF_FS_TINY=0 → 局部 FIL 吃 528 B 栈; * 本函数全程在 vol_lock 区间内,跨任务被卷锁串行化 */ static uint8_t s_fault_reported[SYSLOG_COUNT]; FRESULT fr; UINT bw = 0u; int rc = 0; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || p_rec == NULL) { return -1; } /* 卷锁 try:本任务 = 看门狗喂狗点,绝不允许阻塞等锁(阻塞 = 喂狗停摆 = * 误复位);busy = FTP 文件操作持有卷,本轮顺延(条目滞留队列/待发 * 数组),静默跳过、不计故障 */ if (vol_lock_wait(0u) == 0u) { return -2; } if (syslog_count(id) >= s_caps[id]) { fr = f_unlink(s_paths[id]); DBG_INFO("cap full, recreate %s", s_paths[id]); if (fr != FR_OK && fr != FR_NO_FILE) { vol_lock_release(); return -1; } } /* L1 文件级自愈:陈旧/损坏条目 → 丢弃重建、重试一次(与上方容量满重建 * 同一机制);L2 错误去重:持续故障只报第一次,恢复前静默(消除"每次 * 开机同一错误刷屏")。打开模式必须带 FA_WRITE 位:本 FatFs 移植的 * FA_OPEN_APPEND(0x30) 不含 FA_WRITE(0x02),不带写位打开的文件 f_write * 恒返回 FR_DENIED(7)——2026-09-20 台架实测 /log 写失败 ×5/开机 真根因 */ fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || fr == FR_INT_ERR || fr == FR_DISK_ERR || fr == FR_NO_FILE || fr == FR_NO_PATH || fr == FR_INVALID_NAME || fr == FR_NO_FILESYSTEM)) { (void)f_unlink(s_paths[id]); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); } if (fr == FR_OK) { fr = f_write(&f, p_rec, SYSLOG_REC_SIZE, &bw); (void)f_close(&f); if (fr != FR_OK || bw != SYSLOG_REC_SIZE) { (void)f_unlink(s_paths[id]); bw = 0u; fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { fr = f_write(&f, p_rec, SYSLOG_REC_SIZE, &bw); (void)f_close(&f); } if (fr != FR_OK || bw != SYSLOG_REC_SIZE) { rc = -1; } } } else { rc = -1; } if (rc == 0) { s_fault_reported[id] = 0u; } else if (s_fault_reported[id] == 0u) { s_fault_reported[id] = 1u; DBG_ERROR("write %s fail (%d)", s_paths[id], (int)fr); } /* 读窗口失效:本函数可能改过文件内容(追加 / 容量满重建 / 自愈 unlink), * 一律自增代数让读者重填——失败路径多失效一次只是多读一轮,不会读错。 */ s_rd_gen[id]++; vol_lock_release(); return rc; } /* * 函数功能:追加一条记录(格式化 + 单条落盘) * 入口参数:id - 日志 ID syslog_id_t * desc - 描述串(UTF-8,超长截断至 SYSLOG_DESC_MAX) const char * * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障) int * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 * 函数说明:慢路径 / 兼容入口。批量排空走 s_append_many();容量轮转、 * sys_log_evidence() 的 flush 诱导、开机首条"power on/reboot"仍走这里。 */ static int s_append(syslog_id_t id, const char *desc) { uint8_t rec[SYSLOG_REC_SIZE]; if (desc == NULL) { return -1; } s_format_rec(id, desc, rec); return s_append_rec(id, rec); } /* * 函数功能:把 n 条**已格式化**的记录一次性追加到同一日志文件(一次 open/write/close) * 入口参数:id - 日志 ID syslog_id_t * p_buf - 连续 n 条 64 B 记录(n × SYSLOG_REC_SIZE 字节) const uint8_t * * n - 记录条数(1 ~ SYSLOG_BATCH_MAX) uint32_t * 返回值:0 - 成功;-1 - 写失败;-2 - 卷被占用(顺延,非故障); * -3 - 剩余容量不足 n 条,本批不能走快路径(交回按条慢路径做轮转) int * 限定条件:仅 defaultTask(唯一写者)调用;卷已挂载 * 函数说明:N-24(2026-10-15,报告 N-16 写侧)。旧实现每条记录一轮 * f_open + f_write(64 B) + f_close,而 f_close 要回写目录项、f_write * 要对目标扇区做读-改-写 → 每条日志 2~4 次 NAND 页读写。本函数把 * 同一次排空里属于同一文件的最多 8 条合并成一次 512 B 追加写。 * **不改掉电语义**:批次仍在同一次 syslog_drain() 调用内写完并 * f_close;close 内部 sync_fs → disk_ioctl(CTRL_SYNC) 会调 * ftl_flush_cache() 把 FTL 的单页写回缓存刷进 NAND(nand_ftl.c)。 * 落盘时机与旧实现每条 close 等价,批次不跨调用驻留 RAM;残余只到 * "单次写事务进行中掉电/复位"为止(登记簿 N-22,2026-10-22 裁定暂不做)。 * 自愈重试与错误去重的判据与 s_append_rec 一致;容量不足时返回 -3 * 而不是就地 unlink——"满了就重建"必须由按条慢路径统一执行, * 否则一批 8 条会跨越多轮重建、语义说不清。 */ static int s_append_many(syslog_id_t id, const uint8_t *p_buf, uint32_t n) { static FIL f; static uint8_t s_batch_fault[SYSLOG_COUNT]; FRESULT fr; UINT bw = 0u; UINT total; int rc = 0; if (((uint32_t)id >= (uint32_t)SYSLOG_COUNT) || (p_buf == NULL) || (n == 0u) || (n > SYSLOG_BATCH_MAX)) { return -1; } if (vol_lock_wait(0u) == 0u) { return -2; } if ((syslog_count(id) + n) > s_caps[id]) { vol_lock_release(); return -3; } total = (UINT)(n * SYSLOG_REC_SIZE); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr != FR_OK && (fr == FR_DENIED || fr == FR_INVALID_OBJECT || fr == FR_INT_ERR || fr == FR_DISK_ERR || fr == FR_NO_FILE || fr == FR_NO_PATH || fr == FR_INVALID_NAME || fr == FR_NO_FILESYSTEM)) { (void)f_unlink(s_paths[id]); fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); } if (fr == FR_OK) { fr = f_write(&f, p_buf, total, &bw); (void)f_close(&f); if (fr != FR_OK || bw != total) { (void)f_unlink(s_paths[id]); bw = 0u; fr = f_open(&f, s_paths[id], FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { fr = f_write(&f, p_buf, total, &bw); (void)f_close(&f); } if (fr != FR_OK || bw != total) { rc = -1; } } } else { rc = -1; } if (rc == 0) { s_batch_fault[id] = 0u; } else if (s_batch_fault[id] == 0u) { s_batch_fault[id] = 1u; DBG_ERROR("write %s x%lu fail (%d)", s_paths[id], (unsigned long)n, (int)fr); } s_rd_gen[id]++; vol_lock_release(); return rc; } /* * 函数功能:读取第 idx 条原始记录(64B) * 入口参数:id - 日志 ID syslog_id_t * idx - 记录序号(0 起) uint32_t * p_rec- 输出缓冲(SYSLOG_REC_SIZE 字节,不可为 NULL) uint8_t * * 返回值:0 - 成功;-1 - 越界/读失败/卷忙 int * 函数说明:P2-10(2026-10-04):读者纳入卷锁(try),与写者 s_append 的 * "容量满 unlink 重建"互斥——原实现读时无锁,写者 unlink 与读者 * open/read 可交错,读到的可能是半删除态文件。try 语义(timeout=0): * 本路径可由 uiTask 调用,uiTask 在 required_mask 内、须每 100ms 喂 * 心跳,绝不能阻塞等锁;busy = 本次读失败(UI 下轮刷新重读,非故障)。 * * N-24(2026-10-15,报告 N-16 读侧):加**8 条滑动窗口**。旧实现每读 * 一条记录都要 open + lseek + read + close 一轮,UI 翻 20 条记录 = * 20 次目录项/ FAT 往返(报告 N-16 的"UI 翻记录逐条 open/seek/close")。 * 记录定长 64 B,故一次 open + 一次 lseek + 一次 f_read 就能取回连续 * 8 条(512 B),命中窗口时零 I/O 直接从 RAM 返回。API 与调用方 * (ui_page_runlog / ui_page_selftest 的逐行取数)一律不变。 * 失效机制:写者每次成功落盘/重建后把 s_rd_gen[id] 自增,读者比对 * 填充时记录的代数——不匹配就重填。ARM 上对齐的 32 位读写是原子的, * 跨任务(uiTask 填、defaultTask 增)无需额外临界区;最坏情况是 * 多读一轮,不会读到错数据。 */ static int s_read_raw(syslog_id_t id, uint32_t idx, uint8_t *p_rec) { static FIL f; /* static:省 528 B 栈;open~close 全在卷锁区间内 * (uiTask 与写者任务由 vol_lock 串行化,见上方 P2-10 说明) */ FRESULT fr; UINT br = 0u; uint32_t total; uint32_t base; uint32_t want; if (p_rec == NULL) { return -1; } total = syslog_count(id); if (idx >= total) { return -1; } /* 快路径:窗口命中(同 id、代数未变、落在窗口区间内)→ 零 I/O */ if ((s_rd_win_id == (int32_t)id) && (s_rd_win_gen == s_rd_gen[id]) && (idx >= s_rd_win_base) && (idx < s_rd_win_base + s_rd_win_cnt)) { (void)memcpy(p_rec, s_rd_win + ((size_t)(idx - s_rd_win_base) * SYSLOG_REC_SIZE), SYSLOG_REC_SIZE); return 0; } if (vol_lock_wait(0u) == 0u) { return -1; /* 卷被占用:本次读失败(非故障,下轮重读) */ } /* 重新校验:等锁期间写者可能已推进(虽然 try 语义下不会阻塞等待) */ total = syslog_count(id); if (idx >= total) { vol_lock_release(); return -1; } base = (idx / SYSLOG_RD_WINDOW) * SYSLOG_RD_WINDOW; /* 窗口按 8 条对齐 */ want = total - base; if (want > SYSLOG_RD_WINDOW) { want = SYSLOG_RD_WINDOW; } fr = f_open(&f, s_paths[id], FA_READ); if (fr != FR_OK) { vol_lock_release(); return -1; } fr = f_lseek(&f, (FSIZE_t)base * SYSLOG_REC_SIZE); if (fr == FR_OK) { fr = f_read(&f, s_rd_win, (UINT)(want * SYSLOG_REC_SIZE), &br); } (void)f_close(&f); vol_lock_release(); if ((fr != FR_OK) || (br != (UINT)(want * SYSLOG_REC_SIZE))) { s_rd_win_cnt = 0u; /* 填充失败:作废窗口,避免半窗被当有效命中 */ s_rd_win_id = -1; return -1; } s_rd_win_base = base; s_rd_win_cnt = want; s_rd_win_id = (int32_t)id; s_rd_win_gen = s_rd_gen[id]; (void)memcpy(p_rec, s_rd_win + ((size_t)(idx - base) * SYSLOG_REC_SIZE), SYSLOG_REC_SIZE); return 0; } /* * 函数功能:日志引擎初始化(内核启动前安全空操作) * 入口参数:无 * 返回值:0 - 成功 int * 限定条件:app_main_init() 起始处调用(osKernelStart 之前); * 不得调用任何 FreeRTOS API(内核对象须在内核启动后才可创建) * 函数说明:启动前投递走 s_preboot 静态数组(syslog_post 按内核状态自动分流); * FreeRTOS 消息队列由 syslog_drain 在运行期(defaultTask 上下文)惰性创建。 * 历史缺陷:此处曾 osMessageQueueNew 创建队列,早于 osKernelInitialize * 提前拉起内核,其后任务创建在未完成初始化的内核状态上执行,导致启动失败 */ int syslog_mcu_init(void) { return 0; } /* * 函数功能:卷挂载成功后的一次性处理(建 /log 目录 + 写"上电/重启"分类记录) * 入口参数:无 * 返回值:无 * 限定条件:defaultTask f_mount/f_mkfs 成功后调用;**可重复调用(幂等)**—— * 产品运行期 defaultTask 每 2s 复查一次(见下 P2-9 说明) * 函数说明:P2-9(2026-10-04):原实现"仅调用一次",若此处建 /log 失败则 * s_fs_ready 恒 0 → syslog_drain 永久 no-op(运行期日志与自检全部 * 滞留/丢弃),且无任何重试。现改为幂等:s_fs_ready 已置位则立即 * 返回;否则重试建目录。defaultTask 在 STORAGE_READY 置位后每 2s * 调用本函数——卷已就绪但日志后端未就绪时自动补建目录。 * FS 操作纳入卷锁 try:运行期重试时 ftpTask 可能正操作卷,try 失败 * = 本轮顺延(下轮再试),绝不阻塞(本任务 = 看门狗喂狗点)。 */ void syslog_mcu_mounted(void) { FILINFO finfo; uint32_t last = 0u; const char *desc = "power on"; if (s_fs_ready != 0u) { return; /* 幂等:已就绪 */ } /* 卷锁 try:运行期重试时与 ftpTask 卷访问串行;busy = 本轮顺延 */ if (vol_lock_wait(0u) == 0u) { return; } if (f_stat("/log", &finfo) != FR_OK) { FRESULT fr = f_mkdir("/log"); if (fr != FR_OK && fr != FR_EXIST) { /* 失败日志去重:持续失败只报第一次(防 2s 周期刷屏),成功后复位 */ if (s_mount_fail_reported == 0u) { s_mount_fail_reported = 1u; DBG_ERROR("mkdir /log fail (%d), will retry", (int)fr); } vol_lock_release(); return; } } vol_lock_release(); s_mount_fail_reported = 0u; s_fs_ready = 1u; /* 上电/重启分类:末条 epoch 距 now < SYSLOG_REBOOT_WINDOW_S → 重启,否则上电 */ if (syslog_last_epoch(SYSLOG_RUN, &last) == 0 && sys_clock_get() != UINT32_MAX) { if ((uint32_t)(sys_clock_get() - last) < SYSLOG_REBOOT_WINDOW_S) { desc = "reboot"; } } (void)s_append(SYSLOG_RUN, desc); DBG_INFO("runlog boot entry: %s", desc); } /* * 函数功能:排空待发日志并落盘(启动前数组 + 运行期队列,FIFO);首次调用时惰性创建运行期队列 * 入口参数:无 * 返回值:无 * 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试) */ void syslog_drain(void) { /* 运行期惰性创建消息队列:本函数仅由 defaultTask(调度器运行中)调用, 保证内核对象创建发生在 osKernelStart 之后 */ if (s_queue == NULL) { s_queue = osMessageQueueNew(SYSLOG_QUEUE_MAX, sizeof(syslog_entry_t), &s_queue_attr); if (s_queue == NULL) { DBG_ERROR("queue create fail"); } } if (!s_fs_ready || s_queue == NULL) { return; } /* N-24(2026-10-15,报告 N-16 写侧):一批一批地排空——同一文件、最多 * SYSLOG_BATCH_MAX 条合并成一次 open/write/close(旧实现每条一轮 * open/write/close,每条日志 2~4 次 NAND 页读写)。 * 取数规则:preboot 数组按下标"看而不删",整批落盘成功才真正删除; * 队列必须取出才能看下一条,故未落盘的尾部一律回投队列—— * **条目不丢**(旧实现 -2 卷忙会直接丢掉那一条)。 */ for (;;) { syslog_entry_t e; uint32_t n = 0u; uint32_t n_pre = 0u; uint32_t i; uint32_t done; int rc; syslog_id_t bid = SYSLOG_RUN; while (n < SYSLOG_BATCH_MAX) { uint8_t from_pre = 0u; if (n_pre < s_preboot_cnt) { e = s_preboot[n_pre]; n_pre++; from_pre = 1u; } else if (osMessageQueueGet(s_queue, &e, 0u, 0u) != osOK) { break; /* 队列空 */ } if (n == 0u) { bid = (syslog_id_t)e.id; } else if ((syslog_id_t)e.id != bid) { /* 换文件:本批先落盘,这条留给下一批(原处不动或回投队列) */ if (from_pre != 0u) { n_pre--; } else { (void)osMessageQueuePut(s_queue, &e, 0u, 0u); } break; } s_batch_src[n] = e; s_format_rec(bid, e.desc, s_batch + ((size_t)n * SYSLOG_REC_SIZE)); n++; } if (n == 0u) { break; /* 无更多条目 */ } rc = s_append_many(bid, s_batch, n); done = n; if (rc == -3) { /* 剩余容量装不下一批:退回按条慢路径("满了 unlink 重建"只在 * 那里做,避免一批跨越多轮重建、语义说不清) */ done = 0u; for (i = 0u; i < n; i++) { int r1 = s_append(bid, s_batch_src[i].desc); if (r1 == -2) { break; /* 卷忙:尾部回投队列 */ } done++; if (r1 != 0) { DBG_ERROR("drop entry id=%d", (int)bid); break; /* 写失败:该条已丢,尾部回投 */ } } } else if (rc == -1) { done = 0u; DBG_ERROR("drop %lu entries id=%d", (unsigned long)n, (int)bid); } /* rc == -2(卷忙):整批回投队列,静默顺延,不计故障 */ for (i = done; i < n; i++) { if (osMessageQueuePut(s_queue, &s_batch_src[i], 0u, 0u) != osOK) { DBG_ERROR("hold-over drop (queue full): id=%d", (int)s_batch_src[i].id); } } /* 本批消耗的 preboot 条目:已落盘的删除;未落盘的已转投队列,同样删除 * (否则会重复落盘) */ if (n_pre > 0u) { memmove(&s_preboot[0], &s_preboot[n_pre], sizeof(syslog_entry_t) * (size_t)(s_preboot_cnt - n_pre)); s_preboot_cnt = (uint8_t)(s_preboot_cnt - n_pre); } if (rc != 0) { break; /* 本轮到此为止,下轮再试 */ } } /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask 只投递不写盘(动作路径零 NAND I/O,DoD 硬约束)。 */ soe_store_drain(); } /* * 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃) * 入口参数:id - 日志 ID syslog_id_t * desc - 描述串(UTF-8,静态存储期,超长截断) const char * * 返回值:无 * 限定条件:FreeRTOS 启动前走静态待发数组;desc 必须为静态/常量串 */ void syslog_post(syslog_id_t id, const char *desc) { syslog_entry_t e; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { return; } e.id = (uint8_t)id; e.desc = desc; if (s_queue != NULL && osKernelGetState() == osKernelRunning) { if (osMessageQueuePut(s_queue, &e, 0u, 0u) != osOK) { DBG_ERROR("queue full, drop: %s", desc); } } else { uint8_t n = s_preboot_cnt; if (n < SYSLOG_QUEUE_MAX) { s_preboot[n] = e; s_preboot_cnt = n + 1u; } else { DBG_ERROR("preboot full, drop: %s", desc); } } } /* * 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1)) * 入口参数:id - 日志 ID syslog_id_t * 返回值:条数;文件缺失/未挂载返回 0 uint32_t */ uint32_t syslog_count(syslog_id_t id) { FILINFO finfo; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { return 0u; } if (f_stat(s_paths[id], &finfo) != FR_OK) { return 0u; } return (uint32_t)finfo.fsize / SYSLOG_REC_SIZE; } /* * 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾) * 入口参数:id - 日志 ID syslog_id_t * idx - 记录序号(0 起) uint32_t * p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节,不可为 NULL) char * * ts_len - p_ts 容量 size_t * p_desc - 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节,不可为 NULL) char * * desc_len - p_desc 容量 size_t * 返回值:0 - 成功;-1 - 越界/读失败 int */ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len) { uint8_t rec[SYSLOG_REC_SIZE]; size_t dl = 0u; if (p_ts == NULL || p_desc == NULL || ts_len <= SYSLOG_TS_LEN || desc_len <= SYSLOG_DESC_MAX) { return -1; } if (s_read_raw(id, idx, rec) != 0) { return -1; } memcpy(p_ts, rec, SYSLOG_TS_LEN); p_ts[SYSLOG_TS_LEN] = '\0'; while (dl < SYSLOG_DESC_MAX && rec[SYSLOG_DESC_OFFS + dl] != '\0') { dl++; } if (dl >= desc_len) { dl = desc_len - 1u; } memcpy(p_desc, rec + SYSLOG_DESC_OFFS, dl); p_desc[dl] = '\0'; return 0; } /* * 函数功能:读取末条记录的 epoch 秒(上电/重启分类用) * 入口参数:id - 日志 ID syslog_id_t * p_epoch- 输出缓冲(不可为 NULL;无记录时置 0) uint32_t * * 返回值:0 - 成功读到末条;-1 - 文件缺失/空 int */ int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) { uint8_t rec[SYSLOG_REC_SIZE]; uint32_t v = 0u; uint32_t i; if (syslog_count(id) == 0u) { if (p_epoch != NULL) { *p_epoch = 0u; } return -1; } if (s_read_raw(id, syslog_count(id) - 1u, rec) != 0) { if (p_epoch != NULL) { *p_epoch = 0u; } return -1; } for (i = 0u; i < SYSLOG_EPOCH_LEN; i++) { char c = (char)rec[SYSLOG_EPOCH_OFFS + i]; if (c >= '0' && c <= '9') { v = v * 10u + (uint32_t)(c - '0'); } } if (p_epoch != NULL) { *p_epoch = v; } return 0; } /* * 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;round 7 纯取证) * 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char * * 返回值:0 - 成功;-1 - 卷占用或写失败(本条证据丢失,不阻断判定) int * 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞) * 函数说明: * 为什么必须落盘:误复位后 RAM 态随复位消失、`[WD] task stall` 行经僵尸串口 * 可能丢失(2026-10-02 台架 round 3~6 绕圈的判据缺口),故证据必须落到 * **掉电不丢**的盘上,复位后经 FTP RETR 回读。 * 第二条写(旧称"flush 诱导"):写完 wdev.log 后立刻 s_append(SYSLOG_RUN, ...) * 再落一条 run.log。保留理由是**多一条独立证据**(run.log 有 UI 记录页与 FTP * 回读两个消费者),**不是持久性手段**:f_close 内部 sync_fs → * disk_ioctl(CTRL_SYNC) 已经调 ftl_flush_cache() 把 wdev.log 的脏页刷进 NAND * (nand_ftl.c 的 CTRL_SYNC 分支),所以"写另一个文件(不同 LPN)逼出前一脏页" * 从来不是必要条件。 * ⚠ 2026-10-02 round 3~8 那次"复位后写入整段丢失"的根因是 dhara resume 锚错 * checkpoint 使 journal 整段回退(docs/架构设计/STM32F4-Base存储架构说明.md * §5.4.1),不是这层缓存:单页缓存最多丢最后未跨页那笔(≤512 B),丢不出 * "整段"。真正的残余(无空闲/掉电前周期 flush)= 登记簿 N-22,2026-10-22 * 用户裁定暂不做。 * 顺序硬约束不变:s_append 自行取卷锁(非递归锁),必须在 vol_lock_release() * **之后**调用,绝不可在持锁状态下调用。 * 容量护栏:> 8KB 先 unlink 重建,防长跑无界增长(8.3 合法名 wdev.log)。 */ int sys_log_evidence(const char *p_desc) { static FIL f; /* static:省 528 B 栈(FF_FS_TINY=0 → FIL=528 B) */ static char line[192]; /* static:省 192 B 栈;本函数只有 poller(defaultTask) * 一个调用者(task_wd_poll 内),且全程在卷锁区间内 */ FRESULT fr; UINT bw = 0u; UINT line_len; char ts[SYSLOG_TS_LEN + 1u]; int n; int rc = 0; if (p_desc == NULL) { return -1; } /* 卷锁 try:本上下文 = 看门狗喂狗点,绝不阻塞等锁 */ if (vol_lock_wait(0u) == 0u) { return -1; } /* 容量护栏:超过 8KB 先删除重建(防长跑无界增长) */ { FILINFO finfo; if (f_stat("/log/wdev.log", &finfo) == FR_OK && finfo.fsize > 8192u) { (void)f_unlink("/log/wdev.log"); } } if (sys_clock_get_str(ts, sizeof(ts)) == NULL) { memcpy(ts, "1970-01-01 08:00:00", SYSLOG_TS_LEN); } ts[SYSLOG_TS_LEN] = '\0'; n = snprintf(line, sizeof(line), "%s %s\r\n", ts, p_desc); if (n <= 0) { vol_lock_release(); return -1; } line_len = (UINT)strlen(line); fr = f_open(&f, "/log/wdev.log", FA_WRITE | FA_OPEN_APPEND); if (fr == FR_OK) { fr = f_write(&f, line, line_len, &bw); (void)f_close(&f); if (fr != FR_OK || bw != line_len) { rc = -1; } } else { rc = -1; } vol_lock_release(); /* 第二条写:再落一条 run.log 作为独立证据;不是为了刷 NAND——上面的 * f_close 已经 CTRL_SYNC 落盘(机制见本函数头"第二条写"段)。 * 必须在释放卷锁之后调用(s_append 自行取锁,非递归锁不可重入)。 */ (void)s_append(SYSLOG_RUN, "wd stall declared"); return rc; }