From 484d0682508475c70d5b0491cf68cdb9a1352256 Mon Sep 17 00:00:00 2001
From: Wanderingss <1624155937@qq.com>
Date: Sun, 25 Oct 2026 09:23:20 +0800
Subject: [PATCH] =?UTF-8?q?feat(=E5=AD=98=E5=82=A8):=20=E5=86=99=E5=9B=9E?=
=?UTF-8?q?=E7=BC=93=E5=AD=98=E7=A9=BA=E9=97=B2=20flush=20=E5=85=9C?=
=?UTF-8?q?=E5=BA=95=E2=80=94=E2=80=94=E5=8D=B7=E7=A9=BA=E9=97=B2=E5=91=A8?=
=?UTF-8?q?=E6=9C=9F=20CTRL=5FSYNC=EF=BC=88=E7=99=BB=E8=AE=B0=E7=B0=BF=20N?=
=?UTF-8?q?-22=20=E6=94=B6=E5=8F=A3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
道F 批 2 / task-16。FTL 单页写回缓存(nand_ftl.c s_cache_dirty)与 dhara
journal 只在换页/整页写/f_close 时刷出,漏网脏页的掉电驻留窗无上限。本
提交以最小面收口:不碰 GD5F/dhara 驱动,全部走既有 FatFs CTRL_SYNC 路径。
实现 App/fs/vol_idle_sync.[ch](新增,仅 MCU 端,uvprojx App 组注册):
- 四级门:STORAGE_READY 就绪门 → 5 s 节流(无符号差回绕安全判据)→
vol_lock_wait(0) try(锁被持有=文件序列在做,跳过,不改 STOR 持有期
语义)→ disk_ioctl(0, CTRL_SYNC);
- clean 态脏标志+journal 双短路、零 NAND 触点;脏态最坏 = 一次 f_close
的同步段同型成本,不引入新磨损来源;
- 失败按边沿 latch 一条 DBG_ERROR、成功重新武装;失败路径也成对放锁;
- TEST_SUITE_STORAGE 构建整体 no-op(卷由 testTask 独占)。
接线:defaultTask 主循环 syslog_drain 之后(default_task.c v1.3)。
文档:docs/架构设计/STM32F4-Base存储架构说明.md §5.5 补空闲兜底与边界;
本板无掉电检测硬件、不做掉电前强制 flush、勿当掉电保护——口径同步落
注释与文档(sys_log_mcu.c 两处旧「暂不做」注释随实现改口)。
测试:新主机单测 test/host/vol_idle_sync/(TC-VOLSYNC-001..012,19 断言
全过);scripts/id_registry.json 建 TC-HOST-VOLSYNC 族(仿 ADCCAL 先例);
.pre-commit-config.yaml 加 vol-idle-sync-unit-test hook(只加自己条目)。
越界申报(①②③ 均 lead 批准,④ 为门禁连带必要):
① App/task/default_task.c 单点接线(include + 1 调用 + 头修改记录);
② docs/架构设计/STM32F4-Base存储架构说明.md §5.5;
③ .pre-commit-config.yaml 单 hook + scripts/id_registry.json 单族;
④ scripts/check_registration.py SIM_EXEMPT 加一行(vol_idle_sync.c 模拟器
侧豁免,registration-check GAP 所需;与批准③同类别但未列名,特此申报)。
体积增量(vs scripts/ram_baseline.tsv 2026-10-25 行):
Flash(Code+RO) 237140→237328(+188 B)、RW 1224→1232(+8 B)、ZI 0;
增量 = 3 个文件级 static + 一处 DBG_ERROR 调用点;栈/堆无变化。
gate: only mem-budget/mem-numbers pending lead ledger backfill
全量 pre-commit --all-files:40 项 Passed,仅 mem-numbers(D-7 体积档)与
mem-budget 两条红 = 台账数字待回填(增量如上,属 lead 单写者禁区);
其余含 doc-links/id-check/registration/test-coverage/全部主机单测皆绿。
@build 0 错误 0 警告(Code=215932 RO=21396 RW=1232 ZI=131984)。
台架:空闲 sync 实机节奏(clean 零触点、脏态时长、无心跳扰动)属实际
测量采样类——本批跳过,留「待台架实测」。
---
.pre-commit-config.yaml | 15 +
App/fs/vol_idle_sync.c | 125 ++++++++
App/fs/vol_idle_sync.h | 50 ++++
App/log/sys_log_mcu.c | 11 +-
App/task/default_task.c | 9 +
MDK-ARM/STM32F407-Demo.uvprojx | 5 +
docs/架构设计/STM32F4-Base存储架构说明.md | 10 +
scripts/check_registration.py | 1 +
scripts/id_registry.json | 22 ++
test/host/vol_idle_sync/run_unit_test.py | 55 ++++
test/host/vol_idle_sync/stub/FreeRTOS.h | 29 ++
test/host/vol_idle_sync/stub/cmsis_os.h | 14 +
test/host/vol_idle_sync/stub/cmsis_os2.h | 32 +++
test/host/vol_idle_sync/stub/dbg_log.h | 17 ++
test/host/vol_idle_sync/stub/task.h | 24 ++
test/host/vol_idle_sync/stub/test_config.h | 15 +
test/host/vol_idle_sync/vol_idle_sync_test.c | 282 +++++++++++++++++++
17 files changed, 712 insertions(+), 4 deletions(-)
create mode 100644 App/fs/vol_idle_sync.c
create mode 100644 App/fs/vol_idle_sync.h
create mode 100644 test/host/vol_idle_sync/run_unit_test.py
create mode 100644 test/host/vol_idle_sync/stub/FreeRTOS.h
create mode 100644 test/host/vol_idle_sync/stub/cmsis_os.h
create mode 100644 test/host/vol_idle_sync/stub/cmsis_os2.h
create mode 100644 test/host/vol_idle_sync/stub/dbg_log.h
create mode 100644 test/host/vol_idle_sync/stub/task.h
create mode 100644 test/host/vol_idle_sync/stub/test_config.h
create mode 100644 test/host/vol_idle_sync/vol_idle_sync_test.c
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 5e8162f1..2513e251 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -312,6 +312,21 @@ repos:
files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$
pass_filenames: false
+ # 7f2) 卷空闲同步(写回缓存空闲 flush)主机侧单元测试(2026-10-25 加,道F 批 2)
+ # 背景:该模块是写回缓存掉电窗口的兜底网——掉电与"脏页驻留"本身台架
+ # 不可按需构造;卷忙跳过 / ioctl 失败边沿 latch / 32 位 tick 回绕 /
+ # 失败路径成对放锁(不得空放)全是纯状态逻辑。本钩子用 MSVC 离线编译
+ # 真实源文件(#include 纳入;桩:可控时钟 + 事件位图 + 卷忙注入 +
+ # 可注入 disk_ioctl),把四级门与 latch 分支一次钉死。
+ # files 含被测源与其头、就绪位属主 sys_events 的消费面 default_task.c、
+ # 以及 harness 本身。
+ - id: vol-idle-sync-unit-test
+ name: 卷空闲同步单测(主机侧)
+ entry: python test/host/vol_idle_sync/run_unit_test.py
+ language: system
+ files: ^(App/fs/vol_idle_sync\.(c|h)|App/task/default_task\.c|test/host/vol_idle_sync/.*|test/host/common/unit_test_harness\.py)$
+ pass_filenames: false
+
# 7g) 时间同步(time_sync)主机侧单元测试(2026-10-10 加)
# 背景:time_sync.c 是 TCP 时间同步协议状态机,也是 N-17 号看门狗风险的**全部
# 逻辑贡献者**——"listen 重建 -> 800ms~1s 心跳间隔"由本模块的分片延时/退避/
diff --git a/App/fs/vol_idle_sync.c b/App/fs/vol_idle_sync.c
new file mode 100644
index 00000000..bb155f7b
--- /dev/null
+++ b/App/fs/vol_idle_sync.c
@@ -0,0 +1,125 @@
+/*
+ * 模块名称:Volume Idle Sync(写回缓存空闲 flush)— MCU 实现
+ * 模块功能:就绪门控 + 周期节流 + 卷锁 try + disk_ioctl(CTRL_SYNC) 的兜底
+ * 状态机(设计/使用约定/边界见 vol_idle_sync.h)
+ * 适用平台:STM32F407ZGTx + FreeRTOS
+ * 作者:DTU 项目组
+ * 创建日期:2026-10-25
+ * 修改记录:
+ * v1.0 2026-10-25 DTU 项目组 创建(道F 批 2:写回缓存空闲 flush 收口)
+ */
+
+/* 头文件包含区 */
+#include "vol_idle_sync.h"
+
+#include "test_config.h"
+
+#if !defined(TEST_SUITE_STORAGE)
+
+#include "cmsis_os.h"
+#include "FreeRTOS.h"
+#include "task.h"
+#include "ff.h" /* FatFs 基础类型(diskio.h 依赖 BYTE/LBA_t 等,须先纳) */
+#include "diskio.h" /* disk_ioctl / CTRL_SYNC / DRESULT */
+#include "vol_lock.h"
+#include "sys_events.h"
+/* 本模块日志仅一条失败留痕(≤60 字符),默认行缓冲即可,不调 DBG_LINE_BUF_MAX */
+#include "dbg_log.h"
+
+/* Private defines -----------------------------------------------------------*/
+/* 空闲同步检查周期(= 漏网脏页的最大掉电驻留窗,另加一个主循环拍 ≈50ms)。
+ * 取值依据:clean 态为"脏标志 + journal"双短路(零 NAND 触点),高频不伤介质
+ * 只是白跑;脏态最多一次页编程 + journal 收账,与 f_close 同型,但落在卷
+ * 已空闲的窗口、远离活跃写会话。5 s 与主循环其余慢观测(栈/堆水位 2 s)同
+ * 量级而更稀疏——本模块是兜底网,不是持久性主路径(主路径 = close 内同步)。
+ * 锁忙(卷不空闲)同样按本周期重试:不做欠账补偿,节奏恒定好推理。 */
+#define VOL_IDLE_SYNC_PERIOD_MS 5000u
+
+/* 静态变量定义区 */
+/* 状态量放文件级 static(非函数内):主机侧白盒单测(test/host/vol_idle_sync/)
+ * 以 #include 纳入本文件后需要复位它们;命名带 vol 前缀避免与测试桩撞名 */
+static uint32_t s_vol_sync_last_ms; /* 上次尝试时刻(tick ms,uint32 回绕) */
+static uint8_t s_vol_sync_armed; /* 0 = 从未尝试(首拍直接放行) */
+static uint8_t s_vol_sync_fail_latch; /* 失败留痕边沿 latch:一个 episode 一条 */
+
+/* 函数定义区 */
+
+/*
+ * 函数功能:存储空闲周期同步轮询(就绪门控 → 节流 → try 卷锁 → CTRL_SYNC)
+ * 入口参数:无 void
+ * 返回值:无 void
+ * 限定条件:仅 defaultTask 主循环调用;内部绝不阻塞(try 锁 + 常数级耗时,
+ * 最坏成本 = 一次 f_close 的同步段)
+ * 函数说明:四级门,逐级变贵:
+ * ① 就绪门:sys_evtHandle 未建或 STORAGE_READY 未置 → 静默
+ * (挂载前 disk_ioctl 只会 RES_NOTRDY,且 bring-up 失败重试期
+ * 卷状态未定,一律不碰);
+ * ② 节流门:距上次尝试不足 VOL_IDLE_SYNC_PERIOD_MS → 返回。
+ * 无符号差值判据((uint32)(now - last) < 周期)天然跨 32 位回绕
+ * 正确,与 stack_probe_poll 同一 idiom;勿"优化"成大小比较;
+ * ③ 空闲门:vol_lock_wait(0) try——锁被持有 = 有文件操作序列在做
+ * (syslog 落盘 / 配置保存 / lftpd open...close),本轮跳过,
+ * 绝不改变"文件持有期内不同步"的既有语义;
+ * ④ 动作:disk_ioctl(0, CTRL_SYNC)——FatFs 官方幂等落盘原语
+ * (f_close 尾部同一条路径):FTL 脏页刷入 + dhara_map_sync。
+ * clean 时两级短路立即返回,零 NAND 操作。
+ * 失败留痕:RES_ERROR 只按"边沿"打一条 DBG_ERROR(latch),持续失败
+ * 不刷屏;下一次成功后 latch 复位,新 episode 再报。底层失败形态
+ * (Cache flush FAIL / Map sync failed)已由 nand_ftl 就地打点,
+ * 本条只补"空闲路径也救不回"的上下文,不重复堆日志锚点。
+ */
+void vol_idle_sync_poll(void) {
+ const uint32_t now = (uint32_t)xTaskGetTickCount();
+ DRESULT dr;
+
+ /* ① 就绪门 */
+ if (sys_evtHandle == NULL) {
+ return;
+ }
+ if ((osEventFlagsGet(sys_evtHandle) & SYS_EVT_STORAGE_READY) == 0u) {
+ return;
+ }
+
+ /* ② 节流门(回绕安全判据,见函数说明②) */
+ if ((s_vol_sync_armed != 0u)
+ && ((uint32_t)(now - s_vol_sync_last_ms)
+ < (uint32_t)pdMS_TO_TICKS(VOL_IDLE_SYNC_PERIOD_MS))) {
+ return;
+ }
+ s_vol_sync_last_ms = now;
+ s_vol_sync_armed = 1u;
+
+ /* ③ 空闲门:try 锁,拿不到 = 卷不空闲,本轮跳过(下周期再试) */
+ if (vol_lock_wait(0u) == 0u) {
+ return;
+ }
+
+ /* ④ 幂等落盘:脏页刷入 + journal 收账;clean 时零 NAND 触点 */
+ dr = disk_ioctl(0, CTRL_SYNC, NULL);
+ vol_lock_release(); /* 成败都放锁:同步失败不产生锁泄漏 */
+
+ if (dr != RES_OK) {
+ if (s_vol_sync_fail_latch == 0u) {
+ s_vol_sync_fail_latch = 1u;
+ DBG_ERROR("[FS] idle sync FAIL (dr=%d)", (int)dr);
+ }
+ } else {
+ s_vol_sync_fail_latch = 0u; /* 成功:重新武装,下个 episode 可再报 */
+ }
+}
+
+#else /* TEST_SUITE_STORAGE */
+
+/*
+ * 函数功能:STORAGE 套件构建下的空实现(编译期裁剪落点)
+ * 入口参数:无 void
+ * 返回值:无 void
+ * 限定条件:仅当 test_config.h 选中 TEST_SUITE_STORAGE 时编入本分支
+ * 函数说明:该构建里挂载/盘写由 testTask 的 storage_test_run 独占(测试含
+ * nand_ftl_deinit 掉电模拟),后台同步与它并发会互相踩坏 FTL/FatFs
+ * 状态——必须彻底静默,而非仅靠运行时门控。
+ */
+void vol_idle_sync_poll(void) {
+}
+
+#endif /* TEST_SUITE_STORAGE */
diff --git a/App/fs/vol_idle_sync.h b/App/fs/vol_idle_sync.h
new file mode 100644
index 00000000..94cfd4a9
--- /dev/null
+++ b/App/fs/vol_idle_sync.h
@@ -0,0 +1,50 @@
+/*
+ * 模块名称:Volume Idle Sync(写回缓存空闲 flush)
+ * 模块功能:随 defaultTask 主循环周期轮询,在存储**空闲**时对 NAND FatFs 卷
+ * 补发一次 CTRL_SYNC(FatFs 幂等落盘语义 = FTL 单页写回缓存脏页刷入
+ * + dhara journal 待办收账,见 docs/架构设计/STM32F4-Base存储架构说明.md
+ * §5.5),作为写回缓存持久性的兜底网:
+ * - 正常写事务语义不变:f_close 内部 sync_fs 已同步,本模块零干预;
+ * - 价值在漏网兜底:任何"缓存留脏、之后再无调用触发 sync"的状态
+ * (异常路径中途放锁等)其掉电驻留窗从"无限期,直到下一笔写"
+ * 收敛为"不超过本模块周期";
+ * - 卷不空闲(卷锁被持有:syslog 落盘 / 配置保存 / lftpd
+ * open...close 序列进行中)本轮跳过——不改变文件持有期(如 FTP
+ * STOR 进行中)的既有语义。
+ * clean 态零 NAND 触点(脏标志与 journal 双短路);脏态成本与一次
+ * f_close 同型。周期 5 s(.c 内 VOL_IDLE_SYNC_PERIOD_MS)。
+ * ⚠ 边界(勿当成掉电保护):本板无掉电检测硬件,"掉电前强制 flush"
+ * 不可行;"单次写事务进行中掉电"的丢失边界也不因本模块改变。
+ * 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2);仅 MCU 端,
+ * 主机侧 L0.5 单测 test/host/vol_idle_sync/
+ * 作者:DTU 项目组
+ * 创建日期:2026-10-25(道F 批 2:写回缓存空闲 flush 收口)
+ * 修改记录:
+ * v1.0 2026-10-25 DTU 项目组 创建:STORAGE_READY 门控 + 周期节流 +
+ * 卷锁 try + disk_ioctl(CTRL_SYNC) + 失败边沿 latch
+ */
+
+#ifndef VOL_IDLE_SYNC_H
+#define VOL_IDLE_SYNC_H
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/*
+ * 函数功能:存储空闲周期同步的轮询入口(内部自带就绪门控 / 节流 / 非阻塞
+ * try 锁 / clean 短路,调用点每轮无条件调用即可)
+ * 入口参数:无 void
+ * 返回值:无 void
+ * 限定条件:仅 defaultTask 主循环调用(存储属主任务;本函数内部绝不阻塞——
+ * defaultTask 是看门狗喂狗点,见 vol_lock.h 使用约定);
+ * TEST_SUITE_STORAGE 构建下为空实现(卷由 testTask 独占,后台同步
+ * 必须彻底静默)
+ */
+void vol_idle_sync_poll(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* VOL_IDLE_SYNC_H */
diff --git a/App/log/sys_log_mcu.c b/App/log/sys_log_mcu.c
index 74cf1256..52e75e5e 100644
--- a/App/log/sys_log_mcu.c
+++ b/App/log/sys_log_mcu.c
@@ -334,8 +334,10 @@ static int s_append(syslog_id_t id, const char *desc) {
* **不改掉电语义**:批次仍在同一次 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 裁定暂不做)。
+ * 落盘时机与旧实现每条 close 等价,批次不跨调用驻留 RAM;漏网脏页
+ * (留脏后再无调用触发刷出)已有写回缓存空闲 flush 兜底
+ * (App/fs/vol_idle_sync.h:卷空闲 + 周期 CTRL_SYNC),掉电残余只到
+ * "单次写事务进行中掉电/复位"为止。
* 自愈重试与错误去重的判据与 s_append_rec 一致;容量不足时返回 -3
* 而不是就地 unlink——"满了就重建"必须由按条慢路径统一执行,
* 否则一批 8 条会跨越多轮重建、语义说不清。
@@ -846,8 +848,9 @@ int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch) {
* ⚠ 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
- * 用户裁定暂不做。
+ * "整段"。空闲周期 flush 兜底已接线(App/fs/vol_idle_sync.h:STORAGE_READY
+ * 门控 + 周期节流 + 卷锁 try + CTRL_SYNC,脏页驻留窗由周期封顶);本板无
+ * 掉电检测硬件,"掉电前强制 flush"仍是物理边界,不在该兜底承诺之内。
* 顺序硬约束不变:s_append 自行取卷锁(非递归锁),必须在 vol_lock_release()
* **之后**调用,绝不可在持锁状态下调用。
* 容量护栏:> 8KB 先 unlink 重建,防长跑无界增长(8.3 合法名 wdev.log)。
diff --git a/App/task/default_task.c b/App/task/default_task.c
index f5e9920f..30f69d6b 100644
--- a/App/task/default_task.c
+++ b/App/task/default_task.c
@@ -23,6 +23,9 @@
* 耗时(task_prof_report),并在**心跳位图出现 1→0 边沿**
* 的瞬间留一份现场快照(task_prof_report_raw,裸 UART);
* 另给控制台排空与 syslog 落盘各加一个耗时探针
+ * v1.3 2026-10-25 DTU 项目组 写回缓存空闲 flush 接线:主循环 syslog 排空
+ * 之后调 vol_idle_sync_poll()(卷空闲周期 CTRL_SYNC 兜底,
+ * 模块 = App/fs/vol_idle_sync.[ch],道F 批 2)
*/
#include "default_task.h"
@@ -46,6 +49,7 @@
#include "task_watchdog.h"
#include "task_prof.h" /* 各任务最坏耗时上报 */
#include "vol_lock.h"
+#include "vol_idle_sync.h" /* 写回缓存空闲 flush 兜底(App/fs/vol_idle_sync.h) */
#include "task_handles.h" /* 任务句柄与属性块的引用契约(定义处仍是 Src/freertos.c) */
/* Private defines -----------------------------------------------------------*/
@@ -275,6 +279,11 @@ void default_task_func(void *argument) {
task_prof_enter(TASK_PROF_DEF_SYSLOG);
syslog_drain();
task_prof_exit(TASK_PROF_DEF_SYSLOG);
+ /* 写回缓存空闲 flush 兜底:卷空闲(try 锁)+ 周期节流 + 就绪门控全在
+ * 模块内(App/fs/vol_idle_sync.h);clean 态零 NAND 触点,脏态最坏 =
+ * 一次 f_close 同型的同步段。放在 drain 之后:本拍若写过日志,close
+ * 已同步过,这里随即 clean 短路——漏网脏页的驻留窗由周期封顶 */
+ vol_idle_sync_poll();
#endif
/* 栈水位观测:首次 5s 后报告,此后每 2s 采样、出现更深峰值才打印 */
task_wd_seg(TASK_WD_SEG_STACK);
diff --git a/MDK-ARM/STM32F407-Demo.uvprojx b/MDK-ARM/STM32F407-Demo.uvprojx
index b324cb42..de82a6ac 100644
--- a/MDK-ARM/STM32F407-Demo.uvprojx
+++ b/MDK-ARM/STM32F407-Demo.uvprojx
@@ -937,6 +937,11 @@
1
..\App\fs\vol_lock.c
+
+ vol_idle_sync.c
+ 1
+ ..\App\fs\vol_idle_sync.c
+
time_sync.c
1
diff --git a/docs/架构设计/STM32F4-Base存储架构说明.md b/docs/架构设计/STM32F4-Base存储架构说明.md
index ad95d3b3..94aa83a1 100644
--- a/docs/架构设计/STM32F4-Base存储架构说明.md
+++ b/docs/架构设计/STM32F4-Base存储架构说明.md
@@ -391,6 +391,16 @@ FTL 之上还有一个 **单页写回缓存 (write-back cache)**:
- **写**: 写入缓存 → 标记脏
- **全页写入**: 直接刷出 (跳过缓存)
- **同步 (CTRL_SYNC)**: 刷出脏页 + dhara_map_sync
+- **空闲 flush 兜底(2026-10-25,道F 批 2)**: defaultTask 主循环按"存储就绪 +
+ 卷空闲(`vol_lock_wait(0)` try)+ 周期节流(5 s)"周期性下发一次 `CTRL_SYNC`,
+ 把"缓存留脏、之后再无调用触发 sync"的漏网脏页其掉电驻留窗封顶为一个周期;
+ 卷锁被持有(syslog 落盘 / 配置保存 / lftpd open...close 进行中)时跳过,
+ **不改变文件持有期的既有语义**。clean 态 = 脏标志 + journal 双短路、零 NAND
+ 触点;脏态成本与一次 `f_close` 的同步段同型。实现:`App/fs/vol_idle_sync.[ch]`
+ (单测 `test/host/vol_idle_sync/`)。
+- ⚠ **边界(勿当成掉电保护)**: 本板**无掉电检测硬件**,"掉电前强制 flush"
+ 不可行;单次写事务进行中掉电、以及 FTP STOR 文件持有期的落盘边界,均不因
+ 空闲兜底改变。
---
diff --git a/scripts/check_registration.py b/scripts/check_registration.py
index baaf42b9..6a2f34cc 100644
--- a/scripts/check_registration.py
+++ b/scripts/check_registration.py
@@ -97,6 +97,7 @@ SIM_EXEMPT = [
("App/algo/adc_cal.c", "L1.5 标定折算纯算法 + cfg 段注册(消费者 = MCU 侧 adcTask 每拍;模拟器不跑采样任务),模拟器无消费路径"),
("App/app/app_main.c", "固件启动/就绪屏障,MCU 专属"),
("App/fs/vol_lock.c", "FreeRTOS 互斥量卷锁,MCU 专属"),
+ ("App/fs/vol_idle_sync.c", "MCU 存储属主后台空闲同步(FatFs disk_ioctl + 卷锁 + sys_events),L0.5 覆盖于 test/host/vol_idle_sync/,模拟器无消费路径"),
("App/log/console_log.c", "MCU 控制台输出,模拟器无消费路径"),
("App/sync/time_sync.c", "对时任务(net+RTOS),MCU 专属"),
("App/task/adc_task.c", "固件 FreeRTOS 任务,模拟器不跑固件任务"),
diff --git a/scripts/id_registry.json b/scripts/id_registry.json
index 67e663de..cea583b1 100644
--- a/scripts/id_registry.json
+++ b/scripts/id_registry.json
@@ -1730,6 +1730,28 @@
"owner_note": "B 层口径(用户裁定 2026-10-21,同 TC-HOST-ADCHEALTH/TC-HOST-ADCCAL 先例;2026-10-25 道A 卡7 建族):属主登记处 = 单测代码目录,不逐号补文档",
"cursor_note": "游标按建族时实测最大号收录(2026-10-25):越界即视为未登记的冒号牌"
},
+ {
+ "name": "TC-HOST-VOLSYNC",
+ "class": "TC",
+ "title": "主机侧单测用例号:写回缓存空闲 flush(test/host/vol_idle_sync,道F 批 2)",
+ "pattern": "\\bTC-VOLSYNC-[0-9]{1,4}[a-z]?\\b",
+ "digit_width": [
+ 2,
+ 4
+ ],
+ "owner_docs_glob": [
+ "test/host/vol_idle_sync/*"
+ ],
+ "scope": "global",
+ "frozen": false,
+ "cursor": "TC-VOLSYNC-012",
+ "cursor_owner_only": true,
+ "check_dangling": true,
+ "allow_orphans": true,
+ "subitem_ok": true,
+ "owner_note": "B 层口径(用户裁定 2026-10-21,同 TC-HOST-ADCHEALTH/TC-HOST-ADCCAL 先例;2026-10-25 道F 批 2 建族):属主登记处 = 单测代码目录,不逐号补文档",
+ "cursor_note": "游标按建族时实测最大号收录(2026-10-25):越界即视为未登记的冒号牌"
+ },
{
"name": "TC-HOST-IEC104",
"class": "TC",
diff --git a/test/host/vol_idle_sync/run_unit_test.py b/test/host/vol_idle_sync/run_unit_test.py
new file mode 100644
index 00000000..cc97a038
--- /dev/null
+++ b/test/host/vol_idle_sync/run_unit_test.py
@@ -0,0 +1,55 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""写回缓存空闲 flush(vol_idle_sync)的主机侧单元测试运行器(L0.5,纯标准库)。
+
+做什么:
+ 用本机 MSVC 编译 test/host/vol_idle_sync/vol_idle_sync_test.c(它以 #include
+ 纳入**生产源文件** App/fs/vol_idle_sync.c),在 PC 上离线跑就绪门控 / 节流边界 /
+ 卷忙跳过 / 失败边沿 latch / 成对放锁 / tick 回绕,失败即非 0 退出。
+ 零硬件、零烧录、毫秒级。
+
+ 工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托给通用 harness
+ `test/host/common/unit_test_harness.py`(本文件只做声明)。
+
+为什么要主机侧单测(而不是只用台架):
+ 本模块是「掉电窗口兜底网」——掉电与脏页驻留窗在台架上都不可按需复现;卷忙 /
+ ioctl 失败 / 32 位 tick 回绕这些分支在真机上构造成本极高。门控、节流、latch
+ 全是纯状态逻辑,主机侧一次钉死。
+
+用法(仓库根目录):
+ python test/host/vol_idle_sync/run_unit_test.py
+
+退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
+
+设计依据:App/fs/vol_idle_sync.h 文件头(含「无掉电检测硬件」边界声明);
+创建日期:2026-10-25
+"""
+import os
+import sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+ROOT = os.path.dirname(os.path.dirname(os.path.dirname(HERE)))
+# harness 位于 test/host/common/
+sys.path.insert(0, os.path.join(ROOT, "test", "host", "common"))
+
+from unit_test_harness import TestBuild, run_host_unit_test # noqa: E402
+
+# 顺序敏感:stub 目录必须**先于**生产目录,才能覆盖 test_config.h / cmsis_os(_2).h /
+# FreeRTOS.h / task.h / dbg_log.h(真实头会拖入整套 FreeRTOS/CMSIS/HAL)。
+# ff.h / diskio.h / vol_lock.h / sys_events.h 用**真实头**(接口契约与就绪位数值
+# 原汁,同 lftpd_io / nand_port 先例;本模块经真实 sys_events.h 拿就绪位定义)。
+BUILD = TestBuild(
+ name="Volume Idle Sync Tests",
+ src=os.path.join(HERE, "vol_idle_sync_test.c"),
+ include_dirs=[
+ os.path.join(HERE, "stub"),
+ os.path.join(ROOT, "App", "fs"),
+ os.path.join(ROOT, "App", "app"),
+ os.path.join(ROOT, "Lib", "FatFs"),
+ HERE,
+ ],
+ summary_marker="=== Volume Idle Sync Tests:",
+)
+
+if __name__ == "__main__":
+ sys.exit(run_host_unit_test(BUILD))
diff --git a/test/host/vol_idle_sync/stub/FreeRTOS.h b/test/host/vol_idle_sync/stub/FreeRTOS.h
new file mode 100644
index 00000000..a67a01e5
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/FreeRTOS.h
@@ -0,0 +1,29 @@
+/*
+ * 主机侧单测桩:FreeRTOS.h(vol_idle_sync 模块专用)
+ * 模块功能:最小 FreeRTOS 时基类型与宏(真实头会拉整棵 portmacro/projdefs)。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 保真点:TickType_t = uint32_t;configTICK_RATE_HZ = 1000
+ * (Inc/FreeRTOSConfig.h)→ pdMS_TO_TICKS(ms) == ms。
+ * 节流用例的 4999/5000 拍边界直接依赖该等式。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef FREERTOS_H
+#define FREERTOS_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef uint32_t TickType_t;
+
+#define configTICK_RATE_HZ 1000U
+#define pdMS_TO_TICKS(ms) ((TickType_t)((uint32_t)(ms) * (1000U / configTICK_RATE_HZ)))
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* FREERTOS_H */
diff --git a/test/host/vol_idle_sync/stub/cmsis_os.h b/test/host/vol_idle_sync/stub/cmsis_os.h
new file mode 100644
index 00000000..761b1a07
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/cmsis_os.h
@@ -0,0 +1,14 @@
+/*
+ * 主机侧单测桩:cmsis_os.h(vol_idle_sync 模块专用)
+ * 模块功能:真实 cmsis_os.h(CMSIS-RTOS V2 包装)会拖入整套 FreeRTOS;本模块
+ * 只需要事件标志接口,全部收敛在 cmsis_os2.h 桩里。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef CMSIS_OS_H
+#define CMSIS_OS_H
+
+#include "cmsis_os2.h"
+
+#endif /* CMSIS_OS_H */
diff --git a/test/host/vol_idle_sync/stub/cmsis_os2.h b/test/host/vol_idle_sync/stub/cmsis_os2.h
new file mode 100644
index 00000000..b83ed0ed
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/cmsis_os2.h
@@ -0,0 +1,32 @@
+/*
+ * 主机侧单测桩:cmsis_os2.h(vol_idle_sync 模块专用)
+ * 模块功能:为 App/fs/vol_idle_sync.c(含经 sys_events.h)提供主机可编译的
+ * CMSIS-RTOS v2 事件标志最小子集。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 保真点(对齐真实 cmsis_os2.h):
+ * - osEventFlagsId_t = void*;
+ * - uint32_t osEventFlagsGet(osEventFlagsId_t)(返回当前位图,不消费——
+ * 就绪位 osFlagsNoClear 语义在生产调用点之外,本桩只回静态值);
+ * - 就绪位数值一律来自**真实** App/app/sys_events.h(不在本桩复刻)。
+ * osEventFlagsGet 由测试单元给强符号桩(可注入位图)。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef CMSIS_OS2_H
+#define CMSIS_OS2_H
+
+#include
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void *osEventFlagsId_t;
+
+uint32_t osEventFlagsGet(osEventFlagsId_t ef_id);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* CMSIS_OS2_H */
diff --git a/test/host/vol_idle_sync/stub/dbg_log.h b/test/host/vol_idle_sync/stub/dbg_log.h
new file mode 100644
index 00000000..5ed50d6c
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/dbg_log.h
@@ -0,0 +1,17 @@
+/*
+ * 主机侧单测桩:dbg_log.h(vol_idle_sync 模块专用)
+ * 模块功能:DBG_* 换成计数,让"失败留痕 = 边沿 latch、成功不刷屏"可断言。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef DBG_LOG_H
+#define DBG_LOG_H
+
+extern int g_vis_error_count;
+extern int g_vis_info_count;
+
+#define DBG_INFO(fmt, ...) do { (void)(fmt); g_vis_info_count++; } while (0)
+#define DBG_ERROR(fmt, ...) do { (void)(fmt); g_vis_error_count++; } while (0)
+
+#endif /* DBG_LOG_H */
diff --git a/test/host/vol_idle_sync/stub/task.h b/test/host/vol_idle_sync/stub/task.h
new file mode 100644
index 00000000..b82da055
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/task.h
@@ -0,0 +1,24 @@
+/*
+ * 主机侧单测桩:task.h(vol_idle_sync 模块专用)
+ * 模块功能:只声明 xTaskGetTickCount,由测试单元给可控时钟强符号桩
+ * (驱动节流边界与 32 位回绕用例)。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef TASK_H
+#define TASK_H
+
+#include "FreeRTOS.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+TickType_t xTaskGetTickCount(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* TASK_H */
diff --git a/test/host/vol_idle_sync/stub/test_config.h b/test/host/vol_idle_sync/stub/test_config.h
new file mode 100644
index 00000000..0807f152
--- /dev/null
+++ b/test/host/vol_idle_sync/stub/test_config.h
@@ -0,0 +1,15 @@
+/*
+ * 主机侧单测桩:test_config.h(vol_idle_sync 模块专用)
+ * 模块功能:给主机编译一个"产品构建"形态——四个 TEST_SUITE_* 全部未定义,
+ * vol_idle_sync.c 走产品分支(STORAGE 套件的 no-op 分支由板端
+ * test-suite-switch 构建侧覆盖,主机侧不重复裁剪测试)。
+ * ⚠ 仅供 test/host/vol_idle_sync/ 使用,不参与 MCU 构建。
+ * 创建日期:2026-10-25
+ */
+
+#ifndef TEST_CONFIG_H
+#define TEST_CONFIG_H
+
+/* 刻意不定义任何 TEST_SUITE_* / ENABLE_DESTRUCTIVE_TESTS */
+
+#endif /* TEST_CONFIG_H */
diff --git a/test/host/vol_idle_sync/vol_idle_sync_test.c b/test/host/vol_idle_sync/vol_idle_sync_test.c
new file mode 100644
index 00000000..efe71642
--- /dev/null
+++ b/test/host/vol_idle_sync/vol_idle_sync_test.c
@@ -0,0 +1,282 @@
+/*
+ * 模块名称:Volume Idle Sync(写回缓存空闲 flush)主机侧单元测试
+ * 模块功能:在 PC 上对 App/fs/vol_idle_sync.c 做离线验证(产品分支全函数覆盖)。
+ *
+ * 被测对象:真实源文件 ../../../App/fs/vol_idle_sync.c(以 #include 原样纳入,
+ * 保证测的是生产代码而非副本)。平台依赖由本文件给出的**可注入强符号桩**:
+ * - xTaskGetTickCount -> 可控时钟(节流边界 + 32 位回绕)
+ * - osEventFlagsGet -> 可注入就绪位图 / 句柄判空
+ * - vol_lock_wait -> 可注入 空闲/忙(try 语义)+ 透传记录
+ * - vol_lock_release -> 计数(断言成对、失败不泄漏)
+ * - disk_ioctl -> 可注入 RES_OK/RES_ERROR + pdrv/cmd 记录
+ * 事件位数值取自**真实** App/app/sys_events.h(不在桩里复刻)。
+ *
+ * 为什么要主机侧单测(而不是只用台架):
+ * 本模块是"掉电窗口兜底网"——掉电本身不可台架复现,脏页驻留窗更不能;
+ * 失败路径(卷忙 / ioctl 失败 / tick 回绕)在真机上极难按需构造。
+ * 门控/节流/latch 全是纯状态逻辑,主机侧一次钉死,板端只验证接入存在性。
+ *
+ * 覆盖点:
+ * TC-VOLSYNC-001 句柄未建 -> 完全静默(不读事件/不取锁/不 ioctl)
+ * TC-VOLSYNC-002 STORAGE_READY 未置 -> 静默(读了事件但不取锁)
+ * TC-VOLSYNC-003 就绪首拍:try 锁(timeout=0) + CTRL_SYNC(pdrv=0) + 放锁各一次
+ * TC-VOLSYNC-004 周期内重复拍 -> 完全抑制(不取锁不 ioctl)
+ * TC-VOLSYNC-005 节流边界:周期-1 拍抑制 / 满周期拍放行(1ms tick 等式)
+ * TC-VOLSYNC-006 卷忙(try 失败)-> 不 ioctl、不 release(不得空放锁)
+ * TC-VOLSYNC-007 卷忙跳过不欠账:节奏恒定(下次尝试 = 满周期之后)
+ * TC-VOLSYNC-008 ioctl 失败一个 episode 只打一条(边沿 latch,持续失败不刷屏)
+ * TC-VOLSYNC-009 成功后 latch 重新武装:下一个失败 episode 再报一条
+ * TC-VOLSYNC-010 失败路径同样成对放锁(release 次数 == 有效 ioctl 次数)
+ * TC-VOLSYNC-011 tick 32 位回绕:跨 0 后节流判据仍正确(该抑制抑制/该放行放行)
+ * TC-VOLSYNC-012 成功路径零日志(不打 DBG_ERROR/DBG_INFO)
+ *
+ * 通过:全部 TC PASS(main 返回 0)。
+ * 入口:int main(void),返回 0=PASS / 1=FAIL(由 run 脚本判读)。
+ *
+ * 创建日期:2026-10-25
+ */
+
+#include
+#include
+#include
+
+/* ===================== 真实中间件头(与 lftpd_io/nand_port 同一份) ======== */
+#include "ff.h" /* FatFs 基础类型(diskio.h 依赖,先纳) */
+#include "diskio.h"
+
+/* ===================== 平台桩(stub 目录优先于真实头) ==================== */
+#include "test_config.h"
+#include "cmsis_os.h"
+#include "FreeRTOS.h"
+#include "task.h"
+#include "sys_events.h" /* 真实头:SYS_EVT_STORAGE_READY / sys_evtHandle extern */
+#include "dbg_log.h"
+
+/* ===================== 强符号桩 =====================
+ * 时钟:非零起步(1000),避免"初值 0"与"回绕"在断言里混同 */
+static uint32_t s_tick_now;
+
+TickType_t xTaskGetTickCount(void) {
+ return (TickType_t)s_tick_now;
+}
+
+/* 事件标志:句柄实体在本测试单元定义(真实 sys_events.h 只 extern) */
+static uint32_t s_evt_flags;
+static int s_efget_calls;
+
+osEventFlagsId_t sys_evtHandle;
+
+uint32_t osEventFlagsGet(osEventFlagsId_t ef_id) {
+ (void)ef_id;
+ s_efget_calls++;
+ return s_evt_flags;
+}
+
+/* 卷锁:1 = 获取成功,0 = 忙(try 语义下即"本轮跳过") */
+static int s_lock_ok;
+static int s_lock_calls;
+static int s_release_calls;
+static uint32_t s_last_wait_to;
+
+int vol_lock_wait(uint32_t timeout_ms) {
+ s_lock_calls++;
+ s_last_wait_to = timeout_ms;
+ return s_lock_ok;
+}
+
+void vol_lock_release(void) {
+ s_release_calls++;
+}
+
+/* 磁盘控制:只接住被测代码真正会发的那一发(pdrv=0 / CTRL_SYNC) */
+static DRESULT s_diskio_res;
+static int s_diskio_calls;
+static BYTE s_diskio_pdrv;
+static BYTE s_diskio_cmd;
+
+DRESULT disk_ioctl(BYTE pdrv, BYTE cmd, void *buff) {
+ (void)buff;
+ s_diskio_calls++;
+ s_diskio_pdrv = pdrv;
+ s_diskio_cmd = cmd;
+ return s_diskio_res;
+}
+
+/* dbg_log 桩计数(stub/dbg_log.h 声明 extern) */
+int g_vis_error_count;
+int g_vis_info_count;
+
+/* ===================== 被测代码(原样纳入生产源) ===================== */
+#include "vol_idle_sync.c"
+
+/* ===================== 断言 ===================== */
+static int s_total = 0, s_passed = 0, s_failed = 0;
+
+#define CHECK(cond, name) do { \
+ s_total++; \
+ if (cond) { s_passed++; printf(" [PASS] %s\n", name); } \
+ else { s_failed++; printf(" [FAIL] %s (line %d)\n", name, __LINE__); } \
+} while (0)
+
+#define FAKE_EVT_HANDLE ((osEventFlagsId_t)0xE7)
+#define PERIOD_TICKS 5000u /* = pdMS_TO_TICKS(VOL_IDLE_SYNC_PERIOD_MS),
+ * 1 kHz tick 下与毫秒数值等值(桩保真点) */
+
+/* 复位所有桩与被测模块状态量(白盒:生产源已纳入本编译单元,文件级 static
+ * 可直接复位,等价"刚上电") */
+static void fakes_reset(void) {
+ s_tick_now = 1000u;
+ s_evt_flags = 0u;
+ sys_evtHandle = NULL;
+ s_efget_calls = 0;
+ s_lock_ok = 1;
+ s_lock_calls = 0;
+ s_release_calls = 0;
+ s_last_wait_to = 0xFFFFFFFFu;
+ s_diskio_res = RES_OK;
+ s_diskio_calls = 0;
+ s_diskio_pdrv = 0xFFu;
+ s_diskio_cmd = 0xFFu;
+ g_vis_error_count = 0;
+ g_vis_info_count = 0;
+ s_vol_sync_last_ms = 0u;
+ s_vol_sync_armed = 0u;
+ s_vol_sync_fail_latch = 0u;
+}
+
+/* 便捷:推进时钟并空转主循环拍(模拟 defaultTask 循环里的连续 poll) */
+static void advance_and_poll(uint32_t ticks) {
+ s_tick_now += ticks;
+ vol_idle_sync_poll();
+}
+
+/* ===================== TC-VOLSYNC-001/002:就绪门控 ===================== */
+
+static void tc_gate(void) {
+ fakes_reset();
+ vol_idle_sync_poll();
+ CHECK((s_efget_calls == 0) && (s_lock_calls == 0) && (s_diskio_calls == 0),
+ "TC-VOLSYNC-001 handle NULL: fully silent (no flags read, no lock, no ioctl)");
+
+ fakes_reset();
+ sys_evtHandle = FAKE_EVT_HANDLE; /* 句柄已建但未就绪 */
+ vol_idle_sync_poll();
+ CHECK((s_efget_calls == 1) && (s_lock_calls == 0) && (s_diskio_calls == 0),
+ "TC-VOLSYNC-002 STORAGE_READY clear: read flags then bail (no lock, no ioctl)");
+}
+
+/* ===================== TC-VOLSYNC-003..007:放行/节流/卷忙 ===================== */
+
+static void tc_run_throttle(void) {
+ fakes_reset();
+ sys_evtHandle = FAKE_EVT_HANDLE;
+ s_evt_flags = SYS_EVT_STORAGE_READY;
+ vol_idle_sync_poll();
+ CHECK((s_lock_calls == 1) && (s_last_wait_to == 0u),
+ "TC-VOLSYNC-003 first ready tick: try-lock taken with timeout=0 (non-blocking)");
+ CHECK((s_diskio_calls == 1) && (s_diskio_pdrv == 0u) && (s_diskio_cmd == (BYTE)CTRL_SYNC),
+ "TC-VOLSYNC-003b ioctl exactly once with pdrv=0 / CTRL_SYNC");
+ CHECK(s_release_calls == 1,
+ "TC-VOLSYNC-003c lock released exactly once after ioctl");
+
+ vol_idle_sync_poll();
+ vol_idle_sync_poll();
+ CHECK((s_lock_calls == 1) && (s_diskio_calls == 1),
+ "TC-VOLSYNC-004 repeats within period fully suppressed (no lock, no ioctl)");
+
+ /* 边界:+4999 抑制 / +1(累计满 5000)放行 */
+ advance_and_poll(PERIOD_TICKS - 1u);
+ CHECK(s_diskio_calls == 1, "TC-VOLSYNC-005 period-1 tick: still suppressed");
+ advance_and_poll(1u);
+ CHECK(s_diskio_calls == 2, "TC-VOLSYNC-005b exact period tick: fires again");
+
+ /* 卷忙:ioctl 不加、release 不得空放(相对断言:忙尝试整段零增量) */
+ {
+ const int lk = s_lock_calls;
+ const int rel = s_release_calls;
+ s_lock_ok = 0;
+ advance_and_poll(PERIOD_TICKS);
+ CHECK((s_lock_calls == lk + 1) && (s_diskio_calls == 2) && (s_release_calls == rel),
+ "TC-VOLSYNC-006 lock busy: attempt logged, no ioctl and NO release (never unlock what we do not hold)");
+
+ /* 跳过不欠账:忙的那拍消费了槽位 -> 下次尝试在一个满周期之后 */
+ s_lock_ok = 1;
+ advance_and_poll(PERIOD_TICKS - 1u);
+ CHECK(s_lock_calls == lk + 1,
+ "TC-VOLSYNC-007 busy skip consumed the slot: constant cadence");
+ advance_and_poll(1u);
+ CHECK((s_lock_calls == lk + 2) && (s_diskio_calls == 3) && (s_release_calls == rel + 1),
+ "TC-VOLSYNC-007b next attempt exactly one full period after the skip");
+ }
+}
+
+/* ===================== TC-VOLSYNC-008..010/012:失败 latch / 成对放锁 ======= */
+
+static void tc_fail_latch(void) {
+ fakes_reset();
+ sys_evtHandle = FAKE_EVT_HANDLE;
+ s_evt_flags = SYS_EVT_STORAGE_READY;
+
+ vol_idle_sync_poll(); /* 首拍成功 */
+ CHECK((g_vis_error_count == 0) && (g_vis_info_count == 0),
+ "TC-VOLSYNC-012 success path logs nothing");
+
+ s_diskio_res = RES_ERROR;
+ advance_and_poll(PERIOD_TICKS);
+ CHECK(g_vis_error_count == 1, "TC-VOLSYNC-008 first failure logs exactly one line");
+
+ advance_and_poll(PERIOD_TICKS);
+ advance_and_poll(PERIOD_TICKS);
+ CHECK((g_vis_error_count == 1) && (s_diskio_calls == 4),
+ "TC-VOLSYNC-008b continuing failures do not spam (edge latch holds)");
+
+ CHECK(s_release_calls == s_diskio_calls,
+ "TC-VOLSYNC-010 release paired with every attempt incl. failures (no lock leak)");
+
+ s_diskio_res = RES_OK;
+ advance_and_poll(PERIOD_TICKS);
+ CHECK((g_vis_error_count == 1) && (s_vol_sync_fail_latch == 0u),
+ "TC-VOLSYNC-009 success clears latch (re-armed for next episode)");
+
+ s_diskio_res = RES_ERROR;
+ advance_and_poll(PERIOD_TICKS);
+ CHECK(g_vis_error_count == 2, "TC-VOLSYNC-009b new failing episode logs again (one line)");
+}
+
+/* ===================== TC-VOLSYNC-011:tick 回绕 ===================== */
+
+static void tc_wrap(void) {
+ fakes_reset();
+ sys_evtHandle = FAKE_EVT_HANDLE;
+ s_evt_flags = SYS_EVT_STORAGE_READY;
+ /* 白盒置态:上次尝试贴着 32 位顶端,当前拍刚跨过 0 */
+ s_vol_sync_armed = 1u;
+ s_vol_sync_last_ms = 0xFFFFF000u;
+ s_tick_now = 0x0300u; /* 无符号差 = 0x1300 = 4864 < 5000 */
+ vol_idle_sync_poll();
+ CHECK(s_diskio_calls == 0, "TC-VOLSYNC-011 wrap: unsigned-diff still suppresses early tick");
+
+ s_vol_sync_last_ms = 0xFFFFF000u;
+ s_tick_now = 0x0800u; /* 无符号差 = 0x1800 = 6144 >= 5000 */
+ vol_idle_sync_poll();
+ CHECK(s_diskio_calls == 1, "TC-VOLSYNC-011b wrap past zero fires when diff says so");
+}
+
+/* ===================== main ===================== */
+
+int main(void) {
+ printf("=== Volume Idle Sync Tests ===\n");
+
+ printf("-- gate (readiness) --\n");
+ tc_gate();
+ printf("-- run / throttle / idle-skip --\n");
+ tc_run_throttle();
+ printf("-- failure latch / pairing --\n");
+ tc_fail_latch();
+ printf("-- tick wraparound --\n");
+ tc_wrap();
+
+ printf("\n=== Volume Idle Sync Tests: %d/%d PASSED (failed=%d) ===\n",
+ s_passed, s_total, s_failed);
+ return (s_failed == 0) ? 0 : 1;
+}