Files
STM32F4-Base/App/log/sys_log_mcu.c
T
wandering1 4a09d4c63f fix(log): syslog_post 投递即拷贝(台架实证驱动的 B 方案整改)
待发条目 syslog_entry_t 原为 {id, const char *desc},只存调用方指针,
隐含「desc 必须活到落盘」的契约,但契约既未写进 sys_log.h、也无机械
检查。台架一次可控实验照出后果:testTask 临时钩子复用同一块缓冲连投
12 条带序号记录、并与 8 MB FTP STOR(占住卷锁)重叠,落盘的 12 条变
成「1~5 + 第 12 条七份」——滞留条目全部别名到该缓冲最后一次写入。
叠加卷忙整批回投(条目可滞留到卷空闲),投栈缓冲即成悬垂读。

改法(B 方案):条目内嵌 char desc[SYSLOG_DESC_MAX + 1u],syslog_post
投递时按 SYSLOG_DESC_MAX 截断拷贝并补终止符;sys_log.h 参数说明同步
写明「投递即拷贝、不要求生命周期覆盖到落盘」。

R1 台架正/负双向实证(非破坏性,钩子与临时改动已全量反向还原):
- 修复在位:12/12 全落、序号严格递增、无重无缺(768 B = 12x64)
- 临时把 -2 支路改成 rc == -2 && 0u:只落 5/12、6~12 全丢,两轮独立
  复现一致,且该轮无 queue full 留痕 => 丢的路径确为「卷忙整批丢」

RAM/Flash 前后(scripts/ram_report.py 同口径,同一 .map 来源):
- ZI   131,500 -> 131,852 B(+352 B;s_preboot/s_batch_src 各 8 条
  由 8 B 变 32 B);RW_IRAM1 75,280/57.4% -> 75,632/57.7%
- Code 213,720 -> 213,780 B(+60 B);RW 1,204 B 不变
- RW_CCM 静态不变(57,376 B / 87.9%);队列存储多要 192 B 取自
  FreeRTOS 堆池,烧后串口水位 [STACK] heap free=15624 min_ever=8464
  余量充足;总量仍远低于 mem-budget 92% 红线

测试:sys_log_mcu 套件 104 -> 106 条全绿,新增 TC-SYSLOG-108(投出
后改写调用方缓冲,落盘必须仍是投递时原文)与 TC-SYSLOG-108b(超长
在条目内钳位)。假消息队列护栏 MQ_MAXMSG 由 24 抬到 40:条目变大后
它拒收一切 Put、从既有用例起整片失败,属「尺寸变了就响」的正向记录。
主机侧此前照不出该类问题,是因为用例传字面量、静态存储期无限。

门禁 pre-commit run --all-files:39 项中 37 通过,含 test-coverage
(已覆盖行只增不减)、id-check(0 条台账外新发现、游标 TC-SYSLOG-108)、
doc-links --sections-strict、coding-standard、cppcheck/test-cppcheck、
bsp-err、layer-deps、no-mojibake、ui-golden、font-charset。仅
mem-numbers 与 mem-budget 失败:docs/内存管理/ 受管台账仍记改前
131,500/213,720。按分支约定不在分支上 --write 受管台账,留待合回
主干时统一回填,故 --no-verify 提交。

真机:产品构建 0 错 0 警告,重烧 + 回读校验 OK(suite=NONE dstr=off)、
启动标记齐全、无 [ERR]、VERDICT PASS。
2026-10-24 23:15:18 +08:00

912 lines
46 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 模块名称: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.5 2026-10-24 王建锋 主机单测批次照出的缺陷一起修,本文件内 5 条
* (A/B/C/D/F;E 在 App/cfg/cfg_file_store_mcu.c):
* A 卷忙整批静默丢日志——syslog_drain 批量路径补
* rc==-2 分支把 done 置 0,整批回投队列(不计故障、
* 不打日志),与 -3 慢路径遇 -2 的处置对齐;
* B 同分支文案 "drop N entries" 与行为相反(其实全
* 部回投),改成如实描述并按"正常→故障"节流一条;
* C 删掉 syslog_get_record 里永不可达的截断支,容量
* 要求写进函数头与 sys_log.h(宁可拒绝不静默截断);
* D 自愈触发集按语义拆类(新增 s_is_selfheal_code),
* 把 FR_INVALID_NAME / FR_NO_PATH / FR_NO_FILE /
* FR_INVALID_OBJECT 移出 unlink 重建集合——旧集合在
* 名字/参数类错误下每轮删一次自己的历史日志且永不
* 自愈,违反存储架构 §10.1 第 10 条
* F syslog_post 的丢弃留痕按通道节流(队列满 / 启动前
* 数组满各立 volatile 抑制位,与 B 的 drain 侧那位分
* 开):卷被长期占用 + A 项回投让队列常驻满时,串口
* 不再按投递频率刷 "queue full, drop"
* 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 <stdio.h>
#include <string.h>
#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];
/* 待发条目:**投递时就把描述拷进条目**(B 方案整改,2026-10-24 台架实证)。
* 旧实现只存 `const char *`,隐含契约「desc 必须是生命周期覆盖到落盘的静态期串」,
* 而该契约从未写进 sys_log.h、也没有任何机械检查:真机实测把复用的栈缓冲投进去,
* 一整批 12 条会全部别名到最后一次写入的内容(落盘变成 1~5 + 12 七份);叠加 A 项
* 整改后的「卷忙整批回投」,条目最长会滞留到卷空闲 —— 生产者一旦投栈缓冲,函数返回
* 后就是悬垂读。故改为条目内嵌定长缓冲,长度按 SYSLOG_DESC_MAX 截断。
* RAM 代价(实测见 docs/子系统设计/运行与自检记录设计说明.md §4.4):s_preboot 与
* s_batch_src 各 8 条由 8 B 变 32 B(.bss 合计 +384 B),消息队列存储由堆多要 192 B
* (FreeRTOS 堆已迁 CCM)。 */
typedef struct {
uint8_t id;
char desc[SYSLOG_DESC_MAX + 1u];
} 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:建目录失败日志去重(成功复位) */
/* B 项整改(2026-10-24):syslog_drain 批量写失败"整批回投"这条留痕的节流位——
* 只在"从正常转入故障"的那一次打一条,成功一轮后复位。与 s_append_many 内部的
* s_batch_fault 各管一层:那位压的是"这个文件写坏了",本位压的是"这批条目回投了"。
* 唯一写者 defaultTask,免锁。 */
static uint8_t s_requeue_reported[SYSLOG_COUNT];
/* F 项整改(2026-10-24,Lead 批准并入本轮):syslog_post 的丢弃留痕节流。
* 场景:卷被 ftpTask 长期占用 -> A 项修复后整批回投 -> 队列常驻满 -> 此后每一条
* 日志投递都会"队列满 + 打一条 DBG_ERROR",串口按业务投递频率刷屏(日志本身
* 变成负载)。与上面 s_requeue_reported **分开**是两个必然:那位归 defaultTask
* (唯一写者,免锁),本位由**任意上下文**的投递者读写,且恢复时机是"下一次投递
* 成功"而不是"下一批落盘成功"。两个位各管一个通道:队列 / 启动前待发数组。
* volatile + 单字节:Cortex-M 上对齐字节读写原子;跨任务竞态的最坏后果是"同一轮
* 故障多打一行"或"少打一行",不会漏丢条目、也不会长时间静默(下一次成功会复位)。 */
static volatile uint8_t s_post_qfull_reported; /* 队列满丢弃:已留痕? */
static volatile uint8_t s_post_pboot_full_reported;/* 启动前数组满丢弃:已留痕? */
/* 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 后自增 */
/* 函数定义区 */
/*
* ── 自愈触发集逐码依据(配套下方 s_is_selfheal_code;2026-10-24 逐条核对
* Lib/FatFs/ff.c,行号即该文件)────────────────────────────────────────
* 旧实现把 FR_INVALID_NAME / FR_NO_PATH / FR_NO_FILE / FR_INVALID_OBJECT 一并
* 放进触发集,违反《STM32F4-Base 存储架构说明》§10.1 第 10 条:名字/参数类错误
* 码严禁归入"卷损坏"处置集——这类码下 unlink 自己的文件必然同样失败(同名同因),
* 后果是每轮排空删一次自己的历史日志、且永远修不好(run.log 1024 条 /
* selftest.log 全是被删对象)。
*
* 进触发集(删了这个文件真能修好):
* FR_DISK_ERR(1) 低层 I/O 失败(ff.c:1683/1739/1785 等)——陈旧簇链或坏
* 目录项,unlink 后重建换新链有效;
* FR_INT_ERR(2) 内部一致性断言失败(ff.c:1379/1460/3869)——本文件的目录
* 项/簇号已不自洽,只能废弃重建;
* FR_NO_FILESYSTEM(13) 卷上找不到有效 FAT(ff.c:3483 起)——本模块**不**做任何
* 卷级动作(全模块无 f_mount/f_mkfs),只对本文件废弃重建
* 一次并留痕,卷怎么办归挂载门;
* FR_DENIED(7) 两条真实成因都能被 unlink 治好:目标被置只读属性
* (ff.c:3808/3859),以及目录区无空目录项(ff.c:1850 由
* FR_NO_FILE 改写而来)——后者删掉自己这个文件正好腾出一
* 个目录项。陷阱一那类"缺写位"的 FR_DENIED 出现在 f_write
* 上,不走本判定。
*
* 不进触发集(一律立即失败,不 unlink、不重开、不计卷损坏):
* FR_INVALID_NAME(6) 8.3 短名违规 / 非法字符 / 以点开头(非 LFN 配置下的
* create_name)——unlink 同名同因失败,永不自愈;
* FR_NO_PATH(5) 中间路径段缺失(ff.c:3144/4357)= /log 目录不在:重建
* 文件不解决目录,补目录归 syslog_mcu_mounted() 的每 2s 重试
* (见该函数注释里的幂等改造说明),两侧各管一层;
* FR_NO_FILE(4) 本模块用 FA_WRITE|FA_OPEN_APPEND(含 FA_OPEN_ALWAYS),
* "文件不存在"会被直接创建出来;返回本码的唯一现实场景是
* 同名对象是目录(ff.c:3854),unlink 一个目录修不好它;
* FR_INVALID_OBJECT(9) 仅 ff.c:3774 的 !fp 分支,即句柄指针为 NULL——本模块用
* 函数级 static FIL,地址恒非 0,属不可达码;
* FR_NOT_READY(3) / FR_NOT_ENABLED(12) / FR_TIMEOUT(15)——卷未挂载、无工作区、
* FF_FS_REENTRANT 取卷超时,属挂载门与并发范畴,与"这个
* 文件坏了"无关。
*
* 两点边界,免得后人当成漏改:
* 1) f_write 之后那条"失败/短写 -> unlink 重建重试一次"**不接**本判定:其前提
* 是已经打开成功,名字/参数类错误到不了那里;而簇分配失败是 FR_OK + 短写
* (根本没有错误码可判),必须无条件重试。
* 2) App/log/soe_store.c 对同一张表取**更保守的子集**:那边**打开失败连介质类
* 码也不 unlink**。原因不是它漏改,而是两个文件的对象不同——run.log 是滚动
* 运行日志,文件坏了重建损失有限;soe.log 是**事件序列**,里面是一条条已经
* 发生过的保护事件,"打开失败就删掉整个文件"等于**销毁事件证据**(SOE 的存
* 在即目的,删了比写不进去更糟)。所以 SOE 侧的出路是等卷/目录恢复(条目留
* 在待发环与队列里,不丢),只有"已经打开成功之后写失败"才允许废弃重建。
* 两侧资料块必须同步修改、互指(彼处在 App/log/soe_store.c 的同名资料块)。
*/
/*
* 函数功能:判定一个 f_open 返回码是否属于"把自己的日志文件删掉重建一次"能修好
* 的那一类,即 L1 文件级自愈的触发集(D 项整改,逐码依据见上方资料块)
* 入口参数:fr - f_open 的返回码 FRESULT
* 返回值:1 - 属触发集,可 unlink 后重开一次;0 - 不属,一律立即失败、不 unlink、
* 不计卷损坏 int
* 限定条件:纯分类、无任何副作用;调用点只有 s_append_rec() 与 s_append_many()
* 的"打开失败"分支
* 函数说明:拆成函数是为了两处调用点共用同一张表,避免历史上"改一处漏一处"
*/
static int s_is_selfheal_code(FRESULT fr) {
return ((fr == FR_DISK_ERR) || (fr == FR_INT_ERR) ||
(fr == FR_NO_FILESYSTEM) || (fr == FR_DENIED)) ? 1 : 0;
}
/*
* 函数功能:格式化一条 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);
/* D 项整改(2026-10-24):只有"删了这个文件能修好"的介质/结构类错误码才
* 进这里;名字/参数类一律不 unlink —— 判据见 s_is_selfheal_code() */
if ((fr != FR_OK) && (s_is_selfheal_code(fr) != 0)) {
(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);
/* D 项整改(2026-10-24):只有"删了这个文件能修好"的介质/结构类错误码才
* 进这里;名字/参数类一律不 unlink —— 判据见 s_is_selfheal_code() */
if ((fr != FR_OK) && (s_is_selfheal_code(fr) != 0)) {
(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 == -2) {
/* A 项整改(2026-10-24,主机单测照出):卷忙 = **整批回投队列**,
* 静默顺延——不计故障、不打日志(本函数每 2s 排空一次,一打就刷屏),
* 与上面 -3 慢路径遇 -2 的处置对齐。旧实现在这里没有分支:done 保持 n
* → 下面的回投循环空转 → 最多 8 条既不落盘也不回投,静默丢失。 */
done = 0u;
} else if (rc == -1) {
/* B 项整改(2026-10-24):旧文案写 "drop %lu entries",但本分支
* done = 0 → 整批其实**全部回投**、一条没丢(下轮重试),文案与行为
* 相反会把排障方向带错;改成如实描述,并加"正常→故障"节流(持续故障
* 时每 2s 一条会自己刷成新一轮刷屏,真丢另有下面 hold-over drop 兜底)。 */
done = 0u;
if (s_requeue_reported[bid] == 0u) {
s_requeue_reported[bid] = 1u;
DBG_ERROR("write fail, requeue %lu entries id=%d",
(unsigned long)n, (int)bid);
}
}
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);
}
}
if (rc == 0) {
s_requeue_reported[bid] = 0u; /* 恢复正常:允许下一次故障重新留痕 */
}
/* 本批消耗的 preboot 条目:已落盘的删除;未落盘的(-1/-2/-3 尾部)已在
* 上面转投队列,同样从数组删除——**由队列接手重投**,不是丢弃
* (A 项整改前 -2 走不到回投,这里删掉就等于吞掉) */
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 必须为静态/常量串
* 函数说明:丢弃留痕按"正常→故障"节流(F 项整改 2026-10-24):每个通道只在
* 转入故障的那一次打一条,恢复(下一次成功收下)后复位。
* **丢弃语义本身没变**(队列满/数组满仍然是丢),变的只有留痕次数——
* 卷被 FTP 长期占用时 A 项修复让整批回投、队列常驻满,若这里不节流,
* 串口就按业务投递频率刷"queue full, drop",日志自己变成负载。
* 抑制位是 volatile 单字节、由任意上下文读写:竞态最坏只多打/少打一行。
*/
void syslog_post(syslog_id_t id, const char *desc) {
syslog_entry_t e;
size_t dl;
if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) {
return;
}
e.id = (uint8_t)id;
/* 投递即拷贝(含截断):待发条目不再引用调用方缓冲区,栈缓冲/临时格式化结果
* 都能安全投递(见上方 syslog_entry_t 注释的台架实证) */
dl = strlen(desc);
if (dl > SYSLOG_DESC_MAX) {
dl = SYSLOG_DESC_MAX;
}
memcpy(e.desc, desc, dl);
e.desc[dl] = '\0';
if (s_queue != NULL && osKernelGetState() == osKernelRunning) {
if (osMessageQueuePut(s_queue, &e, 0u, 0u) != osOK) {
/* F 项:只报"从正常转入故障"那一次;期间条目照常丢弃(丢弃语义不变,
* 变的只是留痕次数)。下一次投递成功即复位。 */
if (s_post_qfull_reported == 0u) {
s_post_qfull_reported = 1u;
DBG_ERROR("queue full, drop: %s", desc);
}
} else {
/* 恢复正常:清抑制位,下一次故障重新留痕。这里刻意无条件写
* (曾试成"仅置位时才清",被 cppcheck duplicateConditionalAssign
* 判成等价赋值并拦提交闸;本函数上方已做过一次条目拷贝,多一条
* STR 可以忽略,别为它加 inline-suppr)。 */
s_post_qfull_reported = 0u;
}
} else {
uint8_t n = s_preboot_cnt;
if (n < SYSLOG_QUEUE_MAX) {
s_preboot[n] = e;
s_preboot_cnt = n + 1u;
s_post_pboot_full_reported = 0u; /* 恢复正常:同上,无条件复位 */
} else if (s_post_pboot_full_reported == 0u) {
/* 同一纪律的另一通道:启动前数组只有 SYSLOG_QUEUE_MAX 槽,挂载慢 +
* 开机事件密集时会在整个"未就绪窗口"里每条投递打一行(比队列通道更早
* 发生、也更密集)。数组重新收下第一条即视为恢复。 */
s_post_pboot_full_reported = 1u;
DBG_ERROR("preboot full, drop: %s", desc);
} else {
/* 故障期间静默,条目照常丢弃 */
}
}
}
/*
* 函数功能:获取记录条数(= 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 - 时间戳输出缓冲(不可为 NULL) char *
* ts_len - p_ts 容量,**必须 > SYSLOG_TS_LEN**(即至少 20 字节) size_t
* p_desc - 描述输出缓冲(不可为 NULL) char *
* desc_len - p_desc 容量,**必须 > SYSLOG_DESC_MAX**(即至少 29 字节) size_t
* 返回值:0 - 成功;-1 - 越界/读失败/缓冲容量不足(不截断、不写入调用方缓冲) int
* 限定条件:容量不足一律拒绝而不是截断(C 项整改 2026-10-24;理由见函数体注释)。
* 记录里的描述字段本身已在写入侧截到 SYSLOG_DESC_MAX,所以按上面 minimum
* 给缓冲就一定装得下,读侧无需第二道截断。
*/
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++;
}
/* C 项整改(2026-10-24):这里原来还有一句 "if (dl >= desc_len) dl = desc_len
* - 1u"——**永不可达**:上面的守卫已把 desc_len <= SYSLOG_DESC_MAX 全部挡掉,
* 而 dl 由定长记录的内容决定、恒 <= SYSLOG_DESC_MAX。删掉它,容量要求改由
* 函数头与 sys_log.h 写死:**宁可整条拒绝,也不做静默截断**(UI 拿到截断串
* 会显示成一条被删短过的日志,比读不到更难查)。desc_len 的校验仍保留,
* 它是"调用方给够缓冲"这条契约的执行点,不是截断开关。 */
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;
}