/* * 模块名称:System Log (MCU 后端) — 运行/自检记录落盘 * 模块功能:FatFS /log 目录**七类**定长 64B 记录(run/selftest/accid/alarm/start/oper/yk, * 一类记录 = 一个文件 = 一种记录类型)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列, * defaultTask(唯一写者)排空落盘;容量满删除重建;上电/重启分类 * 适用平台:STM32F407ZGTx * 作者:王建锋 * 创建日期:2026-09-14 * 修改记录: * v1.8 2026-10-27 DTU 项目组 道G task-40:syslog_drain() 尾部按**边沿**补投一条 * 卷锁 fail-open(零互斥那一态)自检记录。锁层只自增计数不产日志 * (唯一能写的通道自己也要这把**非递归**卷锁),留痕必须由 * 唯一写者做;一个 episode(= 一次上电)只投一条,不打 * DBG 错级、不打 [ERR]([ERR] 是"设备出故障"的唯一信号 * 通道,塞进可观测性事件就变成第二个信号源——与 task-23 * "日志让路不计 [ERR]"同一根判据)。 * 成本:.bss +48 B(3 枚计数/标志 + 静态快照 + 静态行缓冲), * **零栈增量**(缓冲全静态;前后两表见 task-40 交付)。 * v1.7 2026-10-26 王建锋 「记录 5 页」的数据面(task-10):syslog_id_t 尾部追加 * SYSLOG_ACCIDENT / ALARM / START / OPER / YK 五枚,本文件 * s_paths[]/s_caps[] 同步到七项(**必须与枚举同序**,越界 * 守卫只挡 id>=SYSLOG_COUNT,序错会静默把事故写进告警文件)。 * 记录体内不加类型字段——文件即类型(决策与理由见 sys_log.h * 的 syslog_id_t 注释、App/log/README.md)。 * 成本:.bss **+40 B**(s_rd_gen 4B×5 + s_requeue_reported/ * s_clear_req/s_clear_st/s_fault_reported 各 1B×5), * .const **+111 B**(5 枚路径指针 20B + 5 项容量 20B + * 5 条 8.3 路径串 71B);零新增缓冲、零堆分配、零栈增量。 * 写入点全部在别处(protectTask / iec104 / 配置保存 / * 本模块 request_clear),本文件只多七张表的五项。 * v1.6 2026-10-26 王建锋 新增「记录删除」(道F task-4,UI 装置维护「清除记录」 * 的前置):请求 syslog_request_clear() / 状态 * syslog_clear_state() / 落地 syslog_clear_apply()。 * 仍然只有 defaultTask 碰盘——非写者上下文只置一个 * volatile 单字节,f_unlink 在 syslog_drain() 尾部执行 * (范式同 prot_elm_request_reset);卷锁一律 try, * 本任务 = 看门狗喂狗点绝不阻塞。 * 新增静态 4 B .bss(s_clear_req[2] + s_clear_st[2]), * 零新增缓冲、零堆分配。 * 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 诱导 * (第 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 王建锋 syslog_mcu_mounted() 改为幂等 + 卷锁保护; * defaultTask 每 2s 复查,建 /log 曾失败时自动重试 * (原"仅调用一次"→ 失败后 drain 永久 no-op)。 * 读者 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" /* 静态变量定义区 */ /* ⚠ 两张表的下标 = syslog_id_t 枚举序,**逐项同序追加**,中间不得插值(错序 = 静默 * 把某类记录写进别的文件,越界守卫拦不住)。文件名一律严格 8.3(FF_USE_LFN=0: * 主名 ≤8 字符、扩展 ≤3、不得以点开头),由 TC-SYSLOG-081 逐条机器核。 * 七项合计 .const ≈ 111 B(含 5 条路径串 71 B);容量是**逻辑上限非预分配**。 */ static const char *const s_paths[SYSLOG_COUNT] = { "/log/run.log", "/log/selftest.log", "/log/accid.log", "/log/alarm.log", "/log/start.log", "/log/oper.log", "/log/yk.log" }; static const uint32_t s_caps[SYSLOG_COUNT] = { SYSLOG_RUN_MAX, SYSLOG_SELFTEST_MAX, SYSLOG_ACCIDENT_MAX, SYSLOG_ALARM_MAX, SYSLOG_START_MAX, SYSLOG_OPER_MAX, SYSLOG_YK_MAX }; /* 「清除记录」成功后的操作记录文案(task-10 的第五个操作写入点)。一类一枚 ROM * 常量、零拼接:投递发生在 defaultTask 的 drain 尾部,那里不该为一条日志再放 * 格式化缓冲;最长 20 字符 << SYSLOG_DESC_MAX(28)。 * ⚠ SYSLOG_OPER 自己那一格**永远不会被用到**:清 oper.log 时不投递操作记录, * 否则"我清除了记录"这条会把刚删空的 oper.log 又写成 1 条,直接砸掉 task-4 * 定死、UI 页面照它出反馈的判据「删除成功后 syslog_count() 归零」(与 * sys_log.h 里刻意不写 "records cleared" 留痕是同一条理由的两个面)。 */ static const char *const s_clear_rec[SYSLOG_COUNT] = { "clear run.log", "clear selftest.log", "clear accid.log", "clear alarm.log", "clear start.log", "clear oper.log", "clear yk.log" }; #define SYSLOG_QUEUE_MAX 8u /* 日志读写放大收口(2026-10-15,写侧):一次排空的合并写入上限。 * 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]; /* 本批条目的原始来源(未落盘的尾部要回投队列,故必须留住 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; /* 建目录失败日志去重(成功复位) */ /* 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;/* 启动前数组满丢弃:已留痕? */ /* syslog_post_quiet() 通道(task-10,2026-10-26):给"静态最坏链放不下日志内联 * 行缓冲"的任务用的投递口(protectTask 872 B / 57% 是 B9 的 DoD 硬线,当年在 * post 里加一条 DBG_* 实测把它垫到 1192 B = stack_report FAIL)。本通道丢弃时 * **只累加计数**,留痕挪到 syslog_drain()(defaultTask)按差值上报——与 * App/log/soe_store.c 的 s_drop_cnt + drain 侧差值上报完全同构。 * RAM:两枚 uint32 = **8 B .bss**;volatile 单字自增在 Cortex-M 上原子,最坏 * 少报一条,不会漏计到永久静默(水位只在本函数读到差值时才前进)。 */ static volatile uint32_t s_quiet_drop; /* quiet 通道累计丢弃条数 */ static uint32_t s_quiet_reported; /* drain 侧已上报水位(唯一写者私有) */ /* 卷锁 fail-open(零互斥那一态)的边沿留痕(道G task-40,2026-10-27) * ------------------------------------------------------------------ * 锁层只**自增计数**、绝不产日志:唯一能把事写下来的通道(/log/wdev.log 等)自己也要 * 取这把卷锁,而卷锁**非递归**——被卷忙挡住时既写不出东西,也不许在失败分支里再去取 * 同一把锁(App/fs/vol_lock.h「可观测性」段 + task-17 查出的事实)。所以留痕由唯一写者 * (defaultTask)在这里按**边沿**补投,投的是自检记录。 * ⚠ episode = 一次上电,只投一条。fail-open 的成因是"互斥体从未创建成功"(vol_lock.c * 的那一支),一旦成立,后面每一次取锁都零互斥;若"计数每前进一次报一条" * 就等于每轮 drain 一条记录 + 一次 NAND 写——诊断自己变成负载,正是上面 s_quiet_drop * 那条纪律要避免的事(同款先例另有 App/fs/vol_idle_sync.c 的 s_vol_sync_fail_latch)。 * ⚠ **不打 DBG 错级、不打 [ERR]**:[ERR] 在本仓是"设备出故障"的唯一信号通道(/serial-boot-test 的 * Forbidden 标记也依赖它,四个 Fault 的崩溃日志都以 [ERR] 开头)。把一个可观测性事件 * 塞进 [ERR],它就同时不再是故障、也不再是禁令,两边一起失去意义——与 task-23 里 * "日志让路不计 [ERR]"是同一根判据。机器判据 = TC-SYSLOG-137 里的 g_sys_err_count == 0。 * 时刻:本模块在这里**不读 tick**(读一次 tick 就给 defaultTask 的 drain 最坏链多挂一个 * callee,而这条链的余量我们一直在省着花)。记录自带的 ts 字段就是"**被观测到的时刻**" * ——不是发生时刻,粒度 = 一轮 drain;这正是 vol_lock.h 第②条告诫要求消费方自己说清的 * 那件事,所以这里不另造 obs_first_ms/obs_last_ms 字段(那只会造出第二个"看起来像发生 * 时刻"的东西)。要"何时开始忙"的精确窗口,请读 vol_lock_get_stats() 的现值。 * 成本:3 枚计数/标志 + 一份静态快照 + 一行静态文本 ≈ 48 B .bss,**零栈增量** * (缓冲全静态,照 App/util/task_prof.c 的 s_prof_line 先例;前后 stack_report 两表 * 见 task-40 交付)。 */ static volatile uint32_t s_vl_failopen_seen; /* drain 侧上次读到的 fail-open 计数 */ static uint8_t s_vl_reported; /* 本 boot 已留痕(1 = 本 episode 不再投) */ static vol_lock_stats_t s_vl_now; /* 静态快照,不走调用方栈 */ static char s_vl_note[32]; /* 记录文本(落盘按 SYSLOG_DESC_MAX=28 截断) */ /* 日志读写放大收口(2026-10-15,读侧):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 后自增 */ /* 「清除记录」的请求 / 落地交接(道F task-4)。两枚各 SYSLOG_COUNT 字节 = **共 4 B * .bss**(本模块唯一的新增静态;无缓冲、无堆,MicroLIB C 堆只有 512 B): * s_clear_req[id] —— 1 = 有未落地的删除请求。非写者上下文(uiTask)置 1, * defaultTask 落地时先清 0 再执行 → 期间到达的新请求会 * 重新置 1,**下一次排空再删一次**,不会因为"正在删"而丢; * s_clear_st[id] —— 最近一次落地的结果,取值 SYSLOG_CLEAR_NONE/OK/FAIL * (WAIT 不落在这里,由 s_clear_req 现场推,见 syslog_clear_state)。 * 为什么拆两枚而不是合成一个四态状态机:单字节状态机在"写者读完状态到写回结果" * 之间收到的新请求会被结果覆盖掉(用户的第二次点击被无声吞掉)。拆开后各自的写者 * 分明(req:任意上下文置 1 / 写者清 0;st:只有写者写),单字节写在 Cortex-M 上 * 原子,剩下的竞态只有"读到上一次结果"这一种同向偏差 → 无锁,代价换来 0 个临界区、 * 0 个头文件依赖(本文件不 include FreeRTOS.h)。 */ static volatile uint8_t s_clear_req[SYSLOG_COUNT]; /* 1 = 待落地的清除请求 */ static volatile int8_t s_clear_st[SYSLOG_COUNT]; /* SYSLOG_CLEAR_NONE / OK / FAIL */ /* 函数定义区 */ /* * ── 自愈触发集逐码依据(配套下方 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 返回码是否属于"把自己的日志文件删掉重建一次"能修好 * 的那一类,即文件级自愈的触发集(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" 占位串 * 函数说明:日志读写放大收口(2026-10-15)时从 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; } } /* 文件级自愈:陈旧/损坏条目 → 丢弃重建、重试一次(与上方容量满重建 * 同一机制);错误去重:持续故障只报第一次,恢复前静默(消除"每次 * 开机同一错误刷屏")。打开模式必须带 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(唯一写者)调用;卷已挂载 * 函数说明:日志读写放大收口(2026-10-15,写侧)。旧实现每条记录一轮 * 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;漏网脏页 * (留脏后再无调用触发刷出)已有写回缓存空闲 flush 兜底 * (App/fs/vol_idle_sync.h:卷空闲 + 周期 CTRL_SYNC),掉电残余只到 * "单次写事务进行中掉电/复位"为止。 * 自愈重试与错误去重的判据与 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 * 函数说明:读者纳入卷锁(try),与写者 s_append 的 * "容量满 unlink 重建"互斥——原实现读时无锁,写者 unlink 与读者 * open/read 可交错,读到的可能是半删除态文件。try 语义(timeout=0): * 本路径可由 uiTask 调用,uiTask 在 required_mask 内、须每 100ms 喂 * 心跳,绝不能阻塞等锁;busy = 本次读失败(UI 下轮刷新重读,非故障)。 * * 日志读写放大收口(2026-10-15,读侧):加**8 条滑动窗口**。旧实现每读 * 一条记录都要 open + lseek + read + close 一轮,UI 翻 20 条记录 = * 20 次目录项/ FAT 往返(即"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 串行化,见上方读者卷锁说明) */ 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; } /* * 函数功能:落地执行"删掉本模块自己的日志文件"(记录删除的落地半边,内部三态版) * 入口参数:id - 日志 ID syslog_id_t * 返回值:0 - 已删除,或文件本就不存在(FR_NO_FILE = 幂等成功); * -1 - 删除真失败(原文件一个字节都没少); * -2 - 卷被占用(本轮顺延,非故障,与 s_append_rec 的 -2 同一含义) int * 限定条件:仅 defaultTask(syslog 唯一写者)上下文;调用处不得持卷锁 * (卷锁非递归锁,重入即自死锁,见 App/fs/vol_lock.h 使用约定) * 函数说明:本函数只做一件事——对自己那一个文件 f_unlink 一次。因此存储架构说明 * §10.1 第 10 条(名字/参数类错误码严禁升级成卷操作)在这里的形态是 * "压根没有卷级动作":不 f_mkdir、不 f_mkfs、不重开、不重试, * FR_INVALID_NAME / FR_NO_PATH / FR_DENIED 一律如实返 -1。 * 幂等只认 FR_NO_FILE(文件不在 = 没有记录可删,目标已达成); * FR_NO_PATH 刻意不算幂等,理由写在 syslog_clear_apply() 的头注释里。 * 只有真删成功(含幂等路径)才让 s_rd_gen[id] 自增:文件确实不在盘上了, * 读者的 8 条窗口必须作废;失败路径内容没变,窗口仍是有效数据, * 不去做无谓的失效(与 s_append_rec"失败也一律失效"不同——那边失败 * 伴随过 unlink/重建,本函数失败时盘上什么都没动)。 * 刻意不在删除成功后补一条 "records cleared":判据要求 syslog_count() * 归零,写了立刻变 1;要留痕归操作记录/SOE(UI补齐.md 第 6 项)。 */ static int s_clear_try(syslog_id_t id) { FRESULT fr; int rc; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { return -1; /* 守卫在取锁之前:非法入参一个字节都不碰盘,也不占用卷 */ } /* 卷锁 try:本任务 = 看门狗喂狗点,绝不阻塞等锁(与 s_append_rec 同一条铁律) */ if (vol_lock_wait(0u) == 0u) { return -2; } fr = f_unlink(s_paths[id]); if ((fr == FR_OK) || (fr == FR_NO_FILE)) { s_rd_gen[id]++; /* 文件已不在盘上:读者的 8 条窗口作废重填 */ rc = 0; } else { rc = -1; } vol_lock_release(); if (rc != 0) { /* 一次用户动作至多一条留痕(失败不重试,见 s_clear_service),不会刷屏, * 所以这里不需要像 B/F 项那样另立节流位。 * ⚠ 留这条的成本约 90 B Flash(AGENTS「日志与工具链」);留它的理由: * "清除失败但盘上还有记录"在串口之外没有任何可观测面(UI 只有一个整数状态)。 */ DBG_ERROR("clear %s fail (%d)", s_paths[id], (int)fr); } return rc; } /* * 函数功能:消费「清除记录」请求(syslog_request_clear 的请求 → 落地执行点) * 入口参数:无 * 返回值:无 * 限定条件:仅 syslog_drain() 调用 = defaultTask(唯一写者)上下文,且不持卷锁; * 卷未就绪 / 队列未建成时 syslog_drain 在本函数之前就已早退, * 请求原地保持挂起(与日志条目滞留同一处置),状态一直是 WAIT * 函数说明:两个决定都是刻意的: * 1) 排在条目排空**之后**(调用位置见 syslog_drain):删除必须是本轮对 * run.log/selftest.log 的最后一次盘上动作,否则本轮刚排空的旧记录 * 会在删除之后又落回去,count 不归零,"清除"就成了没实际效果的按钮 * (判据出处 docs/任务规划/UI补齐.md §2 第 3 行)。 * 副作用照实登记:**先排空再删** = 请求受理时仍在队列/preboot 里的 * 条目也一并被删掉(用户按下的就是"把现在这些记录清掉"), * 只有删除之后新投递的记录才会留下。 * 2) 卷占用(-2)把请求**挂回**而不是判失败:与 A 项整改后的条目 * "整批回投、静默顺延"同一纪律(本函数每 50 ms 被排空调一次, * 一打日志就自己刷屏);真失败(-1)则销账 + 置 FAIL,绝不自动重试 * ——名字/参数类错误码反复 unlink 自己的文件 = §10.1 第 10 条老账。 */ static void s_clear_service(void) { uint32_t i; for (i = 0u; i < (uint32_t)SYSLOG_COUNT; i++) { int rc; if (s_clear_req[i] == 0u) { continue; } s_clear_req[i] = 0u; /* 先销账再执行:期间的新请求留到下轮,不丢 */ rc = s_clear_try((syslog_id_t)i); if (rc == -2) { s_clear_req[i] = 1u; /* 卷占用:挂回请求,状态继续是 WAIT,静默 */ } else { s_clear_st[i] = (rc == 0) ? (int8_t)SYSLOG_CLEAR_OK : (int8_t)SYSLOG_CLEAR_FAIL; /* 清除是一次真实的运维动作 → 操作记录(为何只在成功时记、为何 * 独独跳过 SYSLOG_OPER,见上面 s_clear_rec 的表头注释)。 * 失败不记:那一次什么都没改,记了反而让"操作记录"里出现 * 一个没发生过的动作。 */ if ((rc == 0) && (i != (uint32_t)SYSLOG_OPER)) { syslog_post_quiet(SYSLOG_OPER, s_clear_rec[i]); } } } } /* * 函数功能:日志引擎初始化(内核启动前安全空操作) * 入口参数:无 * 返回值: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 复查一次(见下方幂等说明) * 函数说明:原实现"仅调用一次",若此处建 /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; } /* 日志读写放大收口(2026-10-15,写侧):一批一批地排空——同一文件、最多 * 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; /* 本轮到此为止,下轮再试 */ } } /* quiet 通道的丢弃留痕(task-10):投递侧不许打日志(见上面 s_quiet_drop 的 * 存在理由),所以由唯一写者按差值补一条。与 soe_store 的 dropped 上报同款, * 也是"日志自己不许变成负载"的既有纪律:只在计数真的前进时打。 */ if (s_quiet_drop != s_quiet_reported) { s_quiet_reported = s_quiet_drop; DBG_ERROR("quiet drop total=%lu", (unsigned long)s_quiet_drop); } /* 卷锁 fail-open(零互斥那一态)边沿补投一条自检记录(约束与理由全在上面那组静态量旁边)。 * vol_lock_get_stats() 不取锁、不关中断、不阻塞:卷被长期占住时正是最需要读它的 * 时刻,观测口若再去抢同一把锁,就把观测做成了故障的一部分。 * 必须排在 s_clear_service() 之前吗?不必——两者无先后关系(清除的是记录文件, * 本条是**新增**一条自检记录);但必须排在下面 soe_store_drain() 之前,因为本函数 * 这条支路不持卷锁,而 soe_store_drain() 自己 try 锁(沿用本函数既有纪律)。 */ vol_lock_get_stats(&s_vl_now); if (s_vl_now.failopen != s_vl_failopen_seen) { s_vl_failopen_seen = s_vl_now.failopen; /* 水位每轮都追:别让下一轮误判"新增" */ if (s_vl_reported == 0U) { s_vl_reported = 1U; /* 一个 episode 一条(= 一次上电) */ (void)snprintf(s_vl_note, sizeof(s_vl_note), "vollock failopen=%lu", (unsigned long)s_vl_failopen_seen); syslog_post(SYSLOG_SELFTEST, s_vl_note); } } /* 「清除记录」的落地半边——必须在上面的条目排空**之后**(顺序是判据的一部分: * 先删后写会把本轮刚排空的旧记录又落回去,count 不归零 = 没实际效果的按钮), * 也在 soe_store_drain() 之前:本函数自带卷锁 try,绝不在此持锁状态下调用。 * 卷未就绪/队列未建时上面已经早退,请求保持挂起(状态 WAIT),不会丢。 */ s_clear_service(); /* B9:SOE 落盘挂本函数尾部——同一唯一写者(defaultTask)、同一卷就绪门 (上方 s_fs_ready 早退蕴含 /log 已建)、同一卷锁 try 语义;protectTask 只投递不写盘(动作路径零 NAND I/O,DoD 硬约束)。 */ soe_store_drain(); } /* * 函数功能:立即删除某日志的全部记录(「清除记录」的落地半边,MCU 后端专属) * 入口参数:id - 日志 ID syslog_id_t * 返回值:0 - 记录已清空(含"文件本就不存在"的幂等成功) * -1 - 未清空:卷被占用或删除失败,**原文件未被删除** int * 限定条件:仅 defaultTask(syslog 唯一写者)上下文,且调用处不得持卷锁; * 非写者上下文一律走 syslog_request_clear()(本文件不实现"跨上下文删除", * 那是把 uiTask 直接接上 FAT 回写的开始) * 函数说明:内部三态里"卷占用 = -2(顺延)"在本 API 折成 -1 —— 对调用方来说 * "没删掉、记录还在"是同一个事实,该不该重试由调用方定; * 请求侧的顺延重试策略(-2 挂回请求、-1 判失败不重试)在 s_clear_service(), * 不外溢成第三种返回值。逐码依据与幂等口径见 sys_log.h 里本函数声明处的 * 头注释,以及 s_clear_try() 的实现注释。 */ int syslog_clear_apply(syslog_id_t id) { return (s_clear_try(id) == 0) ? 0 : -1; } /* * 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃) * 入口参数:id - 日志 ID syslog_id_t * desc - 描述串(UTF-8,静态存储期,超长截断) const char * * 返回值:无 * 限定条件:FreeRTOS 启动前走静态待发数组;desc 必须为静态/常量串 * 函数说明:丢弃留痕按"正常→故障"节流(F 项整改 2026-10-24):每个通道只在 * 转入故障的那一次打一条,恢复(下一次成功收下)后复位。 * **丢弃语义本身没变**(队列满/数组满仍然是丢),变的只有留痕次数—— * 卷被 FTP 长期占用时 A 项修复让整批回投、队列常驻满,若这里不节流, * 串口就按业务投递频率刷"queue full, drop",日志自己变成负载。 * 抑制位是 volatile 单字节、由任意上下文读写:竞态最坏只多打/少打一行。 */ /* 投递结果编码(s_put 返回值)。分通道返回是**必须的**,不是讲究:F 项那两个节流 * 抑制位按通道分开(队列 / 启动前数组),塌成一个"失败"就会把"数组满"报成"队列 * 满",还会把另一通道的抑制位误复位(TC-SYSLOG-105..107c 逐条钉着这条)。 */ #define SYSLOG_PUT_Q_OK 0u /* 队列收下 */ #define SYSLOG_PUT_PB_OK 1u /* 启动前待发数组收下 */ #define SYSLOG_PUT_Q_FULL 2u /* 队列满:本条丢弃 */ #define SYSLOG_PUT_PB_FULL 3u /* 数组满:本条丢弃 */ /* * 函数功能:填一条待发条目——投递时就拷贝描述(post / post_quiet 共用,内部) * 入口参数:p_e - 输出条目(不可为 NULL) syslog_entry_t * * id - 日志 ID(调用方已核过界) syslog_id_t * desc - 描述串,NUL 结尾;静态串或调用方的临时缓冲都可以 const char * * 返 回 值:无 * 函数说明:拷贝之后待发链不再引用调用方的缓冲区——这条纪律是 B 方案整改用真机 * 实测换来的(旧实现只存指针,复用栈缓冲时一整批 12 条全部别名到最后一次 * 写入的内容),见本文件上方 syslog_entry_t 的注释。 */ static void s_entry_fill(syslog_entry_t *p_e, syslog_id_t id, const char *desc) { size_t dl = strlen(desc); p_e->id = (uint8_t)id; if (dl > SYSLOG_DESC_MAX) { dl = SYSLOG_DESC_MAX; } memcpy(p_e->desc, desc, dl); p_e->desc[dl] = '\0'; } /* * 函数功能:把条目送进消息队列;队列不存在或内核未跑则进启动前待发数组(内部) * 入口参数:p_e - 已填好的条目(不可为 NULL) const syslog_entry_t * * 返 回 值:SYSLOG_PUT_Q_OK / SYSLOG_PUT_PB_OK / SYSLOG_PUT_Q_FULL / * SYSLOG_PUT_PB_FULL uint8_t * 限定条件:⚠ **本函数内禁止出现任何 DBG_***(照 soe_store_post 的同款禁令) * 函数说明:它是 post 与 post_quiet 的共同落点,而 post_quiet 存在的唯一理由就是 * "调用方任务的静态最坏链放不下日志的内联行缓冲"(每处约 216 B 栈)。 * 这里一旦出现日志,quiet 就退化成 post 的别名,protectTask / iec104Task * 的记录投递又会把链垫回 stack_report 的 FAIL 区——B9 当年实测 * 872 B -> 1192 B 就是这条,别用"这里只有一条"说服自己。 * 留痕因此分两侧:post 的调用侧节流位 + drain 的 quiet 差值上报。 */ static uint8_t s_put(const syslog_entry_t *p_e) { if (s_queue != NULL && osKernelGetState() == osKernelRunning) { return (osMessageQueuePut(s_queue, p_e, 0u, 0u) == osOK) ? (uint8_t)SYSLOG_PUT_Q_OK : (uint8_t)SYSLOG_PUT_Q_FULL; } if (s_preboot_cnt < SYSLOG_QUEUE_MAX) { s_preboot[s_preboot_cnt] = *p_e; s_preboot_cnt = (uint8_t)(s_preboot_cnt + 1u); return (uint8_t)SYSLOG_PUT_PB_OK; } return (uint8_t)SYSLOG_PUT_PB_FULL; } /* * 函数功能:投递一条日志(零日志缓冲版本;收/丢语义与 syslog_post 完全一致) * 入口参数:id - 日志 ID syslog_id_t * desc - 描述串(NUL 结尾,静态/常量串;超长截断) const char * * 返 回 值:无 * 限定条件:任意上下文;不碰 FatFs、不碰卷锁、不阻塞。**专为静态最坏链吃紧的 * 任务准备**(本仓当前 = protectTask、iec104Task) * 函数说明:与 syslog_post 的差别只有两处:① 本调用链上不内联任何 DBG 行缓冲, * 丢弃时只累加 s_quiet_drop,由 defaultTask 在 syslog_drain() 里按差值 * 补一条留痕;② 不参与 F 项那两个节流位——那两个位描述的是 syslog_post * 自己的历史,混用会让"post 已恢复"被 quiet 的成功投递误复位。 * 丢弃计数读 syslog_post_dropped()。其它任务请继续用 syslog_post: * 即时留痕,可观测性更好,别为不存在的栈压力付两倍的认知成本。 */ void syslog_post_quiet(syslog_id_t id, const char *desc) { syslog_entry_t e; uint8_t res; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { return; } s_entry_fill(&e, id, desc); res = s_put(&e); if ((res == (uint8_t)SYSLOG_PUT_Q_FULL) || (res == (uint8_t)SYSLOG_PUT_PB_FULL)) { if (s_quiet_drop < UINT32_MAX) { s_quiet_drop = s_quiet_drop + 1u; /* 留痕在 drain 侧,理由见函数头 */ } } } /* * 函数功能:取 quiet 通道累计丢弃条数(可观测性;照 soe_store_dropped 同款) * 入口参数:无 * 返 回 值:累计丢弃条数 uint32_t */ uint32_t syslog_post_dropped(void) { return s_quiet_drop; } void syslog_post(syslog_id_t id, const char *desc) { syslog_entry_t e; uint8_t res; if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT || desc == NULL) { return; } s_entry_fill(&e, id, desc); res = s_put(&e); switch (res) { case SYSLOG_PUT_Q_OK: /* 恢复正常:清抑制位,下一次故障重新留痕。这里刻意无条件写 * (曾试成"仅置位时才清",被 cppcheck duplicateConditionalAssign * 判成等价赋值并拦提交闸;本函数上方已做过一次条目拷贝,多一条 * STR 可以忽略,别为它加 inline-suppr)。 */ s_post_qfull_reported = 0u; break; case SYSLOG_PUT_PB_OK: s_post_pboot_full_reported = 0u; /* 恢复正常:同上,无条件复位 */ break; case SYSLOG_PUT_Q_FULL: /* F 项:只报"从正常转入故障"那一次;期间条目照常丢弃(丢弃语义不变, * 变的只是留痕次数)。下一次投递成功即复位。 */ if (s_post_qfull_reported == 0u) { s_post_qfull_reported = 1u; DBG_ERROR("queue full, drop: %s", desc); } break; default: /* SYSLOG_PUT_PB_FULL:同一纪律的另一通道——启动前数组只有 * SYSLOG_QUEUE_MAX 槽,挂载慢 + 开机事件密集时会在整个"未就绪窗口"里 * 每条投递打一行(比队列通道更早发生、也更密集)。数组重新收下第一条 * 即视为恢复(上面 PB_OK 已复位)。故障期间静默,条目照常丢弃。 */ if (s_post_pboot_full_reported == 0u) { s_post_pboot_full_reported = 1u; DBG_ERROR("preboot full, drop: %s", desc); } break; } } /* * 函数功能:登记「清除某日志全部记录」的请求(任意上下文;不碰存储、不阻塞) * 入口参数:id - 日志 ID syslog_id_t * 返回值:0 - 请求已登记(同一 id 重复登记合并为一次,幂等) * -1 - id 越界(该 id 的请求位与结果字节一个都没动) int * 限定条件:任意任务上下文(含 uiTask);零 FatFs 触点、零卷锁触点, * 因此**不会**把调用者的喂狗节拍拖进存储路径 * 函数说明:MCU 端本函数只代表"已受理",真正的 f_unlink 在 defaultTask 的 * syslog_drain() 尾部(s_clear_service)执行,结果读 syslog_clear_state()。 * 先清结果再置请求,是为了让 UI 在"排队中"这一拍不会拿上一次的 * OK/FAIL 当本次的结论(顺序反了就会出现"按了按钮仍显示上次失败")。 * 竞态面(写在 sys_log.h 同一位置,勿只信这里):置请求与写者清请求都是 * 单字节写,Cortex-M 上原子;最坏是写者已清账、本函数又置回 1 → * 下一轮多删一次(unlink 幂等),不会丢请求、也不会误清别人的记录。 */ int syslog_request_clear(syslog_id_t id) { if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { return -1; } s_clear_st[id] = (int8_t)SYSLOG_CLEAR_NONE; s_clear_req[id] = 1u; return 0; } /* * 函数功能:查「清除记录」的状态(给 UI 出真实反馈,不是"按了就算成功") * 入口参数:id - 日志 ID syslog_id_t * 返回值:SYSLOG_CLEAR_NONE(0) / SYSLOG_CLEAR_OK(1) / SYSLOG_CLEAR_WAIT(-1) / * SYSLOG_CLEAR_FAIL(-2);id 越界按 NONE 返回 int * 限定条件:任意上下文(只读两个 volatile 单字节,无锁) * 函数说明:排队位优先于结果字节 —— 否则连点两次时会先把上一次的结果当成本次的 * 结论,还在排队的请求被显示成"已清空"。两个字节都是 defaultTask 与 * uiTask 之间的无锁交接,最坏竞态与为什么不加临界区,见 sys_log.h * syslog_clear_state() 的头注释(同一事实只在那一处详述)。 */ int syslog_clear_state(syslog_id_t id) { if ((uint32_t)id >= (uint32_t)SYSLOG_COUNT) { return (int)SYSLOG_CLEAR_NONE; } if (s_clear_req[id] != 0u) { return (int)SYSLOG_CLEAR_WAIT; } if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_OK) { return (int)SYSLOG_CLEAR_OK; } if (s_clear_st[id] == (int8_t)SYSLOG_CLEAR_FAIL) { return (int)SYSLOG_CLEAR_FAIL; } return (int)SYSLOG_CLEAR_NONE; } /* * 函数功能:获取记录条数(= 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(掉电不丢;第 7 轮纯取证) * 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char * * 返回值:0 - 成功;-1 - 卷占用或写失败(本条证据丢失,不阻断判定) int * 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞) * 函数说明: * 为什么必须落盘:误复位后 RAM 态随复位消失、`[WD] task stall` 行经僵尸串口 * 可能丢失(2026-10-02 台架第 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 台架第 3~8 轮那次"复位后写入整段丢失"的根因是 dhara resume 锚错 * checkpoint 使 journal 整段回退(docs/架构设计/STM32F4-Base存储架构说明.md * §5.4.1),不是这层缓存:单页缓存最多丢最后未跨页那笔(≤512 B),丢不出 * "整段"。空闲周期 flush 兜底已接线(App/fs/vol_idle_sync.h:STORAGE_READY * 门控 + 周期节流 + 卷锁 try + CTRL_SYNC,脏页驻留窗由周期封顶);本板无 * 掉电检测硬件,"掉电前强制 flush"仍是物理边界,不在该兜底承诺之内。 * 顺序硬约束不变: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; }