Files
STM32F4-Base/docs/测试说明/卷锁单元测试说明.md
T
wandering1 511fd30cc6 docs: 清理全仓变更记录流水(43 篇整节删 / 23 篇留一行 / 5 篇保留)
用户裁定:文档只写现状,历史流水一律删掉,过程查 git log。

- 43 篇「变更记录 / 修订记录」整节删除:继电保护规划 12.6 KB、产品
  需求 11.8 KB、项目部署环境 5.5 KB、BSD Socket 指南 5.3 KB、架构
  总览 5.0 KB、实机测试实施指南 3.8 KB 等
- 23 篇别处仍有指向:只留节名 + 一行「本文件不再记流水账,过程查
  git log」,节号与锚点不位移(doc-links 不因此断链)
- 5 篇整表保留(被当唯一证据 / 活台账引用):看门狗设计说明、配置
  文件设计说明、RAM预算台账、内容页架构、本地工具路径环境变量方案
- 规范同步:文档规范 §5.1 改新裁定;测试文档模板规范删示例节与
  登记句;测试开发标准流程 / UI 两篇 / 实机测试实施指南 / 四篇单测
  说明 / 并行任务工作树方案 / 编号规范 的「登记变更记录」改「写进
  正文」
- 技能同步:stale-doc-count-sweep / ui-verify / id-family 三个 SKILL
  正文不再要求追加流水行(顺带消掉 SKILL.md:121「产品需求 §12」
  失效引用)
- 修 5 处悬空指针:采样有效性校验目录锚点、BSD 指南「文末变更
  记录」、docs/README 过期描述、网络自愈架构两处指向面

72 篇文档净减 600 余行;check_doc_links --all --sections-strict 可判
定比例 61% → 78%、失效引用 0;静态门禁 passed=50 failed=0;gitlint
10 条历史提交信息 0 failed。

未提交他人在飞改动:.dsh/skills/keil-build/*(%SystemDrive% 守卫)。
2026-10-28 22:00:34 +08:00

12 KiB
Raw Blame History

卷锁单元测试说明(主机侧)

模块: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(平台约束与看门狗铁律)、 测试开发标准流程.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. 如何扩展用例

  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)。