Files
wandering1 f754d59218 docs(测试说明): 按用途分六个子目录并同步全库引用
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/,
顶层只留新增的目录导航 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 处(无新增断链)。
2026-10-30 07:16:07 +08:00

204 lines
12 KiB
Markdown
Raw Permalink 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.
# 卷锁单元测试说明(主机侧)
> **模块**: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 <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`](../../../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`)。