feat(存储): 写回缓存空闲 flush 兜底——卷空闲周期 CTRL_SYNC(登记簿 N-22 收口)

道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 零触点、脏态时长、无心跳扰动)属实际
测量采样类——本批跳过,留「待台架实测」。
This commit is contained in:
2026-10-25 09:23:20 +08:00
parent 13ef0715bb
commit 484d068250
17 changed files with 712 additions and 4 deletions
+55
View File
@@ -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))
+29
View File
@@ -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 <stdint.h>
#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 */
+14
View File
@@ -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 */
+32
View File
@@ -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 <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
typedef void *osEventFlagsId_t;
uint32_t osEventFlagsGet(osEventFlagsId_t ef_id);
#ifdef __cplusplus
}
#endif
#endif /* CMSIS_OS2_H */
+17
View File
@@ -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 */
+24
View File
@@ -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 */
@@ -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 */
@@ -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 <stddef.h>
#include <stdint.h>
#include <stdio.h>
/* ===================== 真实中间件头(与 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;
}