分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/, 顶层只留新增的目录导航 README.md(归层判据 + 硬约束 + 本次处置记录)。 - 搬迁 28 个路径:测试数据三个目录 58 个文件为纯改名(R100,内容逐字节不变) - 全库引用同步 124 个文件:AGENTS / docs 索引 / skills / 门禁注释 / scripts - 门禁面:scripts/check_test_traceability.py 的 glob 改 rglob (分层后不递归扫描=追溯门禁直接瞎掉) - scripts/rename_ref.py:收尾的 ℹ 提示在 GBK 代码页抛 UnicodeEncodeError, 让进程以退出码 1 收场而改写其实已落盘(RC 与真实结果相反);改为把 stdout/stderr reconfigure 成 UTF-8,与 check_doc_links.py 等同法 - docs/文档规范.md §1.1 补「子目录文件过多时再分一层」;§3.6 补 rename_ref 搬整目录的两个实测坑:① 会改写目录内部文件的内容 ② 内部文件的 ../ 出链 不补层级(本次 23 个被误改的证据文件已按 HEAD 原始字节逐字节还原) - docs/README.md §6 改为与六个子目录一一对应,并补登原先漏登记的 「启动日志原子性复验流程」一行 - .cubemx_survival_baseline.json:App/util/task_watchdog.c 的注释路径也随分层更新, 该文件是存活审计的 watchdog-module 漂移锚点,按脚本自己的提示跑 --write 重建基线 (实测重建后仅 watchdog-module 一条哈希变化) - 铁律 4 例外(本次在任务树里改了单写者文件):AGENTS.md 与 .pre-commit-config.yaml 只被 rename_ref 改写路径字面量(后者全在注释行), 任务表块与钩子声明一字未动 验证:check_doc_links.py --sections-strict 覆盖全部 88 个改动 .md 全绿; check_test_traceability.py 输出与主树逐行一致;196 个文本文件无 BOM、无新增 CR、无 U+FFFD、无行尾空白;陈旧引用面由主树 25 处降到 6 处(无新增断链)。
12 KiB
卷锁单元测试说明(主机侧)
模块:Volume Lock(
App/fs/vol_lock.c+App/fs/vol_lock.h) 测试类型:主机侧纯逻辑单元测试——在 PC 上离线验证 NAND FatFs 卷串行化 互斥锁的创建 / 获取 / 释放契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 状态:✅ 已落地(33 断言点,/W40 告警,已挂 pre-commit 钩子) 日期:2026-10-10 关联文档:../../../AGENTS.md(平台约束与看门狗铁律)、测试开发标准流程.md(测试分层与门禁总览)、看门狗单元测试说明.md(同源的喂狗点语义)、相量层单元测试说明.md(同批落地的姊妹篇)、../../子系统设计/看门狗设计说明.md(required_mask与长耗时白名单)
1. 概述
1.1 为什么单独做主机侧单测
vol_lock 只有 3 个函数、不到 40 行,看起来"不值得"单测。但它的核心语义是 fail-open:
互斥量创建失败时,
vol_lock_wait()必须返回 1 且绝不阻塞。
这不是一个"顺手写的兜底",而是整个系统最后一道防复位保险:defaultTask 是看门狗
喂狗点(task_wd_poll),一旦它在等锁上阻塞,主循环即停摆 → 停喂 → IWDG 复位。
vol_lock.h 的文件头把这条写成"使用约定(铁律)",本单测就是它的回归防线。
真机上 osMutexNew 失败几乎不可能复现(需 FreeRTOS 堆耗尽),板端套件(test/board/)
构造不出这个前置条件。主机侧只需给桩注入一个 NULL 返回值即可穷举该分支——这是本单测
存在的首要理由。
次要理由:幂等性(重复 vol_lock_init 不得重复创建)、超时值透传、未创建时
release 无害都是纯状态逻辑,主机侧一次钉死。
1.2 被测对象与桩
测试单元原样 #include 生产源文件(保证测的是生产代码而非副本),平台依赖由桩替身:
test/host/vol_lock/vol_lock_test.c
├── #include "cmsis_os.h" ← 桩(test/host/vol_lock/stub/),可注入 创建失败/超时/成功
└── #include "vol_lock.c" ← 生产源文件(App/fs/),原样纳入
| 头文件 | 来源 | 说明 |
|---|---|---|
vol_lock.h |
真实(App/fs/) |
只 #include <stdint.h>,无平台依赖,无需替身 |
cmsis_os.h |
桩(stub/) |
真实版在 CMSIS-RTOS V2 下把 osMutexWait 宏映射到 osMutexAcquire;桩直接提供 V1 名 osMutexWait,与生产源调用名一致 |
include_dirs 顺序为 stub → App/fs → 本目录:桩目录必须排在前面,否则会被真实
cmsis_os.h 抢先命中。源文件被纳入同一编译单元,故静态量 s_lock 亦可直接观测与置位
(白盒)。
1.3 关键约定:fail-open 是"返回 1",不是"返回 0"
读代码时最容易看错的一点(vol_lock.c:42-50):
| 情形 | vol_lock_wait() 返回 |
是否调用 osMutexWait |
调用方该做什么 |
|---|---|---|---|
| 已创建 + 取得锁 | 1 |
是(osOK) |
正常进入临界区,事后 release |
| 已创建 + 超时 | 0 |
是(非 osOK) |
不得 release;本轮跳过 |
| 未创建 + 创建失败 | 1 |
否(零阻塞) | 照常进入临界区(无保护),事后 release 判空静默 |
即:返回 1 = "你可以往下走",其代价是"可能没有真正持锁"。这保证了喂狗点在任何情况下
都不会被锁阻塞。TC-VOLLOCK-008(返回 1)与 TC-VOLLOCK-008b(osMutexWait 调用次数 == 0)
两条断言共同钉死该语义——只断言返回 1 是不够的,必须同时证明没有进入任何等待路径。
(道G task-17 补充)"返 1 可能是零互斥"这件事现在可查了:锁层为 5 态各记一个计数
(acquired / try_timeout / blk_timeout / failopen,共 16 B 静态,见 App/fs/vol_lock.c
的计数块),读口 vol_lock_get_stats() 不取卷锁、不关中断、任意上下文可调、NULL 无害。
判 failopen 用差值与边沿:累计 > 0 = 本 boot 内至少发生过一次"无互斥读写卷";
计数跨任务读-改-写,竞态下少计不会多计,所以别拿精确值当账。
一次性留痕由 syslog 唯一写者按差值补投(syslog_post_dropped() 同型先例),锁层自身绝不
产日志——能把事写下来的通道(/log/wdev.log、run.log)自己也要取这把锁,而卷锁
非递归:在 try 失败分支里补日志既写不出东西,还会自取死锁。
⚠ 计数刻意不记时刻(记时刻要读 tick,等于给 defaultTask 的取锁链添 callee 与本帧)。
因此消费方自行盖的时间戳是「被观测到的时刻」,不等于发生时刻——真实发生可以早到
一整个轮询周期之前;命名请用 obs_first_ms / obs_last_ms,文案里不得出现"首次/末次发生"。
vol_lock_init()的返回值与wait()的语义无关:init失败返回-1,但wait()仍返回1。调用方不得用init的返回值决定是否调wait。
1.4 测试环境
| 依赖 | 版本 / 说明 |
|---|---|
| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 run_unit_test.py 自动探测 |
| Python | 仓库 .venv 或任意 python3(纯标准库) |
| 硬件 | 无(不需要开发板 / 串口 / ST-LINK) |
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托通用 harness
../../../test/host/common/unit_test_harness.py,
本模块 run_unit_test.py 只声明 TestBuild(源文件 / 桩目录 / 汇总行标记)。
1.5 运行方式
# 仓库根目录(统一用 .venv)
& '.venv\Scripts\python.exe' test/host/vol_lock/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
1.6 回归接入
| 项 | 说明 |
|---|---|
| 触发范围 | App/fs/vol_lock.c / vol_lock.h,或 test/host/vol_lock/**、test/host/common/unit_test_harness.py 变动 |
| 门禁层级 | 主机侧纯逻辑单测(介于静态门禁与板端测试套件之间):主机侧、秒级、纯逻辑 |
| pre-commit 钩子 | vol-lock-unit-test(本地钩子,.pre-commit-config.yaml §7f) |
| 汇总行 | === Volume Lock Tests: 33/33 PASSED (failed=0) === |
| 与板端测试的关系 | 互补:本单测保证锁的调用契约(fail-open / 幂等 / 透传);板端保证真实的 FTL 并发不互踩(dhara journal 竞争),后者需真机 + FTP 并发压测 |
2. 测试用例
命名
TC-VL-NNN(VL = Volume Lock)。桩状态在每用例前由fakes_reset()复位到 "刚上电"(含把s_lock置NULL)。断言数(33)多于用例数(14),因为每个用例 通常包含 2~3 条子断言(后缀b/c)。
2.1 vol_lock_init — 创建与幂等
| 用例 | 断言 |
|---|---|
| TC-VOLLOCK-001 | 首次调用返回 0;osMutexNew 恰好调用 1 次;模块静态态已持有句柄 |
| TC-VOLLOCK-002 | 重复调用仍返回 0(幂等);不再创建第二个互斥量(osMutexNew 仍为 1 次) |
| TC-VOLLOCK-003 | osMutexNew 返回 NULL 时 init 返回 -1;不留下半初始化句柄(s_lock == NULL) |
| TC-VOLLOCK-004 | 失败后重试可成功(返回 0);重试确实再次调用了 osMutexNew |
2.2 vol_lock_wait — 获取与 fail-open ⭐
| 用例 | 断言 |
|---|---|
| TC-VOLLOCK-005 | 获取成功返回 1;osMutexWait 调用 1 次;timeout 原样透传(100 → 100) |
| TC-VOLLOCK-014 | wait(0) 的 try 语义:返回 1 且透传值为 0(defaultTask 路径) |
| TC-VOLLOCK-006 | osMutexWait 返回非 osOK → wait 返回 0;超时路径确实尝试过等待 |
| TC-VOLLOCK-007 | 锁未创建时 wait 自动 init 并正常获取(创建 1 次 + 等待 1 次) |
| TC-VOLLOCK-008 ⭐ | fail-open:锁未创建且创建失败 → 返回 1;osMutexWait 调用次数 == 0(零阻塞);s_lock 仍为 NULL |
| TC-VOLLOCK-009 | fail-open 之后 release 无害:osMutexRelease 未被调用(判空静默返回) |
| TC-VOLLOCK-010 | 已创建时多次 wait 不重复创建(osMutexNew 恒 1 次);每次 wait 各等待一次 |
| TC-VOLLOCK-015 | 成功路径只涨 acquired:try_timeout / blk_timeout / failopen 必须仍为 0(异常计数不得被正常路径污染) |
| TC-VOLLOCK-016 | timeout=0 未取到 → 只进 try_timeout,且 blk_timeout 与 acquired 保持 0(try 超时态:压根没等,卷被别人占住) |
| TC-VOLLOCK-017 | timeout=100 未取到 → 只进 blk_timeout,且 try_timeout 保持 0(分片退让态:等了没等到;与 try 超时态互不冒领是本卡唯一"语义合并"风险)。四态定义只在 App/fs/vol_lock.h 一处,本表只描述断言不重定义 |
| TC-VOLLOCK-018 ⭐ | fail-open 仍返回 1(口径不变,同 TC-VOLLOCK-008),但 failopen 计数自增而 acquired 保持 0;连续两次 → failopen == 2(消费方按差值 latch,一个 episode 一条) |
| TC-VOLLOCK-019 | vol_lock_get_stats(NULL) 无害;重复读取不消耗证据(计数不因读而归零) |
TC-VOLLOCK-008 是本套件存在的主要理由:真机上需堆耗尽才可能触发,板端无法构造。
2.3 vol_lock_release — 释放与判空
| 用例 | 断言 |
|---|---|
| TC-VOLLOCK-011 | 已持锁时 release 调用 osMutexRelease 一次 |
| TC-VOLLOCK-012 | 锁未创建时 release 静默返回(不调用、不崩溃) |
| TC-VOLLOCK-013 | 完整周期 init → wait → release → wait → release:全程只创建 1 个互斥量、释放 2 次、第二次 wait 的 timeout 透传正确(100) |
当前结果:33/33 PASSED(failed=0)。
3. 如何扩展用例
- 加一个
static void tc_xxx(void);首行调fakes_reset(); - 通过桩变量造场景:
s_new_fail = 1(创建失败)、s_wait_ok = 0(等待超时); CHECK(cond, "描述")断言;在main()里注册(并同步更新本文档 §2)。
⚠ 用例之间必须
fakes_reset():s_lock是全局态,不复位会把上一用例的句柄串进来。
4. 已知边界与设计取舍
4.1 白盒置 s_lock = NULL 是可接受的代价
fakes_reset() 直接写模块静态量 s_lock(源文件已被纳入同一编译单元,故可见)。
这等价于"未初始化过",是主机侧白盒单测的常规手法。代价:若将来把 s_lock 改为
static const 或移入结构体,fakes_reset() 需同步调整。
4.2 桩只验证"调用契约",不验证"互斥语义"
桩的 osMutexWait 不模拟真实的互斥/阻塞/优先级继承行为,只记录调用次数与参数。
因此本单测能保证的是:
- ✅ 该调
osMutexNew时调了、且只调一次(幂等); - ✅ 该调
osMutexWait时调了、timeout值正确; - ✅ 不该调时绝对没调(fail-open 的零阻塞、判空 release)。
不能保证的是:真实互斥下的互斥性、优先级继承、超时精度——那些属 FreeRTOS 内核职责,
且板端 FTP 并发压测才是对应判据。
4.3 与板端的互补关系
| 层次 | 判据 | 手段 |
|---|---|---|
| 本单测(主机侧纯逻辑单测) | 锁的调用契约(fail-open / 幂等 / 透传 / 判空) | 主机侧桩注入 |
| 板端测试套件 | 卷访问真的串行了(无 dhara journal 竞争、无 IWDG 误复位) | 真机 + FTP 与 syslog 并发压测 |
vol_lock.h 记录的历史根因(2026-10-02:defaultTask 与 ftpTask 并发 FTL 写 → dhara journal
竞争 → 恢复旋转饥饿喂狗点 → IWDG 误复位)只能由板端判据复现;本单测负责防止契约被改坏
(例如有人把 fail-open 的 return 1 误改成 return 0,或给 fail-open 路径加上
osMutexWait)。