Files
STM32F4-Base/App/log/sys_log_mcu.c
T
wandering1 77779d49d6 feat(ui+记录): 整定写路径与四类页落地,五类记录写入方补齐
UI 面:ui_data 增加深度 4 参数写队列(请求 168 B、队列 .bss 672 B)与状态机
OK/ERR_RANGE/ERR_LOCKED/ERR_BUSY,ui_data_feed 出队后按注入 ops 走「闭锁 → 整批
预检 → 逐项 prot_set_set_sealed」,只提交改动位图;par 帧尾追加 cal_block
(口径 = UA/UN/IA 三路 ADC_CAL_F_VALID,与 protect_task 判据同构,不用 rt.eng
反推,V0.1 下 eng 恒 0)、UI_DATA_VER 升到 2。参数/定值/控制字/软压板四叶绑定
内容页(UI_PAGE_MAP_COUNT 12→16,复用四个查看模块,零新增 .c),编辑态按
is_editable 分派,LOCK(写不进去)与「未标定」(仍可改值)两源两通道两词。

记录面:syslog_request_clear/state/apply 三段式删除 API(UI 只发请求,落地在
defaultTask 的 drain 尾部且必须后置,卷锁一律 try,幂等只认 FR_NO_FILE);
事故/告警/启动/操作/遥控五类各有一个真实写入点,记录类型 = 日志 ID 本身
(一类一个 /log 严格 8.3 文件;64B 定长体没有类型字段位,塞字段会砸掉既有读者)。
为守 protectTask 872 B 静态最坏链另拆 syslog_post_quiet()(无 DBG 的投递核,
丢弃留痕挪到 drain 侧差值补发),syslog_post 对外语义逐字不变。

为什么两道合成一笔而不是各一笔(实测,不是省事):simulator/main.c 同时消费
两道新增 API(整定 pump 与 logtest 清除用例),按道切文件会让中间提交编不过;
RAM 受管快照与体积同码凭证描述的是合并后的 .map,拆开必落在「台账与 .map 不同
码」这条红线上。pre-commit 跑钩子前会 stash 未 staged 部分,这类耦合没法绕。

实测:@build 0 错误 0 警告;Code 228,260 / RO 24,960 / RW 1,440 / ZI 135,976 B;
RW_IRAM1 79,992 B = 61.0%、RW_CCM 87.9%(阈值 92%);九个任务静态最坏链全 PASS,
protectTask 872 B / 57% 改前后逐字节不变(记录投递没垫栈)。主机单测 ui_data 82→
164、sys_log_mcu 106→137、prot_elm 47→60、iec104 141→154、iec104_serv 28→34,
既有断言零删改;48 个钩子 --all-files 全绿;编号门禁可判定 86.34%(基线 86.0%)、
不可判定 1,153 处低于上限 1,166;doc-links 无法判定 0;UI 黄金 18 页扩到 27 页
连跑两次逐字节一致;字库 diff 空。

顺手修掉三处既有缺陷(都是本轮第一个消费者才暴露):sim_syslog.c 的 s_paths 行宽
16 而 "sim_selftest.log" 正好 16 字符没有 NUL 位(stat/remove 读越过数组尾,此前
靠后面 .rodata 碰巧为 0);ui_w_setlist 二态行 ENTER 不回绕(控制字与软压板出厂值
全在最后一项,按了像没反应而叠层里已是越界值);生成物 ui_menu_tab.h 与 gen_menu.py
模板漂移(上一笔改了模板没重跑生成器),现随生成器输出对齐。

未收口(不算完成):保存后重启值保持与主站 C_RD 读回两条只能上板,台架窗口执行;
LOCK 屏显与越界反显的页面侧在模拟器造不出来(模拟器不跑定值自检、无 NAND),
已由生成侧单测与代码注释点名差距;同类页数与钩子数陈述残留另立待办。
2026-10-26 15:51:12 +08:00

1247 lines
66 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 目录**七类**定长 64B 记录(run/selftest/accid/alarm/start/oper/yk,
* 一类记录 = 一个文件 = 一种记录类型)单写者落盘;启动前静态待发数组 + FreeRTOS 消息队列,
* defaultTask(唯一写者)排空落盘;容量满删除重建;上电/重启分类
* 适用平台:STM32F407ZGTx
* 作者:王建锋
* 创建日期:2026-09-14
* 修改记录:
* 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 <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"
/* 静态变量定义区 */
/* ⚠ 两张表的下标 = 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 侧已上报水位(唯一写者私有) */
/* 日志读写放大收口(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 返回码是否属于"把自己的日志文件删掉重建一次"能修好
* 的那一类,即 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" 占位串
* 函数说明:日志读写放大收口(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;
}
}
/* 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(唯一写者)调用;卷已挂载
* 函数说明:日志读写放大收口(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);
}
/* 「清除记录」的落地半边——必须在上面的条目排空**之后**(顺序是判据的一部分:
* 先删后写会把本轮刚排空的旧记录又落回去,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;
}