# 卷锁单元测试说明(主机侧) > **模块**:Volume Lock(`App/fs/vol_lock.c` + `App/fs/vol_lock.h`) > **测试类型**:**主机侧纯逻辑单元测试**——在 PC 上离线验证 NAND FatFs 卷串行化 > 互斥锁的创建 / 获取 / 释放契约,零硬件、零烧录、毫秒级、不占 MCU 的 RAM。 > **状态**:✅ 已落地(33 断言点,`/W4` 0 告警,已挂 pre-commit 钩子) > **日期**:2026-10-10 > **关联文档**: > [`../../../AGENTS.md`](../../../AGENTS.md)(平台约束与看门狗铁律)、 > [`测试开发标准流程.md`](../流程与规范/测试开发标准流程.md)(测试分层与门禁总览)、 > [`看门狗单元测试说明.md`](看门狗单元测试说明.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 `,无平台依赖,无需替身 | | `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`](../../../test/host/common/unit_test_harness.py), 本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 ### 1.5 运行方式 ```powershell # 仓库根目录(统一用 .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. 如何扩展用例 1. 加一个 `static void tc_xxx(void)`;首行调 `fakes_reset()`; 2. 通过桩变量造场景:`s_new_fail = 1`(创建失败)、`s_wait_ok = 0`(等待超时); 3. `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`)。