f754d59218
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/, 顶层只留新增的目录导航 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 处(无新增断链)。
204 lines
12 KiB
Markdown
204 lines
12 KiB
Markdown
# 卷锁单元测试说明(主机侧)
|
||
|
||
> **模块**: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`)。
|