补两份主机单测说明文档:卷锁(fail-open)与时间同步(N-17)

- 卷锁单元测试说明.md:33 断言点。钉死 fail-open 不变量(互斥量创建失败时
  vol_lock_wait 必须返回 1 且绝不阻塞——defaultTask 是喂狗点)、幂等、超时透传、
  判空 release;并说明"白盒置 s_lock=NULL"的取舍与桩只验调用契约的边界。
- 时间同步单元测试说明.md:80 断言点。钉死分片喂狗延时、监听退避、时间包重组
  (半包/粘连/EOF/真错/预算耗尽/tick 回绕)、任务主循环;含 setjmp/longjmp 跳出
  for(;;) 的手法,以及"C 暂定定义静默合并同名 static 变量"这一无告警缺陷的记录。
- test/host/README.md:补齐 7 套主机单测的清单、运行方式与钩子(原表缺
  phasor / adc_input / vol_lock / time_sync 四套),并修正 cfg_file 断言数。
- docs/README.md:主机侧单测(L0.5)表登记两份新文档,并同步修正 cfg_file 断言数。
This commit is contained in:
2026-10-10 18:06:43 +08:00
parent 317e97fca6
commit 0b0cd0048b
4 changed files with 474 additions and 7 deletions
+3 -1
View File
@@ -124,9 +124,11 @@ docs/
|---|---|
| [`看门狗单元测试说明.md`](测试说明/看门狗单元测试说明.md) | Task Watchdog 判定状态机(位图 / 掩码 / 节流 / tick 回绕),39 用例 |
| [`系统时钟单元测试说明.md`](测试说明/系统时钟单元测试说明.md) | sys_clock 时间换算 / 星期 Zeller / 30 天惰性再基准化 / RTC 故障降级,100 断言点 |
| [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,139 断言点(含抓出的外科手术保存死码 P0) |
| [`配置文件单元测试说明.md`](测试说明/配置文件单元测试说明.md) | cfg_file INI 解析 / 值编解码 / load-save-poll 契约 / 超长守卫,149 断言点(含抓出的外科手术保存死码 P0) |
| [`ADC输入层单元测试说明.md`](测试说明/ADC输入层单元测试说明.md) | adc_input 语义通道映射 / ADC1 顺序扫描(**Rank↔通道判据**)/ poll 契约 / HAL 故障路径,70 用例 |
| [`相量层单元测试说明.md`](测试说明/相量层单元测试说明.md) | phasor 全周傅氏系数表 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换,57 用例 |
| [`卷锁单元测试说明.md`](测试说明/卷锁单元测试说明.md) | vol_lock 创建 / 获取 / 释放契约(**fail-open 不变量**:创建失败仍返回 1 且零阻塞)/ 幂等 / 超时透传,33 断言点 |
| [`时间同步单元测试说明.md`](测试说明/时间同步单元测试说明.md) | time_sync 分片喂狗延时 / 监听退避 / 时间包重组 / 任务主循环(**N-17 的全部逻辑贡献者**),80 断言点 |
**模块测试指南**(动手写测试前先读)
@@ -0,0 +1,194 @@
# 卷锁单元测试说明(主机侧)
> **模块**:Volume Lock(`App/fs/vol_lock.c` + `App/fs/vol_lock.h`)
> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 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-VL-008`(返回 1)与 `TC-VL-008b`(`osMutexWait` 调用次数 == 0)
两条断言共同钉死该语义——只断言返回 1 是不够的,必须同时证明**没有进入任何等待路径**。
> `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` 变动 |
| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 |
| **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-VL-001** | 首次调用返回 `0`;`osMutexNew` 恰好调用 1 次;模块静态态已持有句柄 |
| **TC-VL-002** | 重复调用仍返回 `0`(幂等);**不再**创建第二个互斥量(`osMutexNew` 仍为 1 次) |
| **TC-VL-003** | `osMutexNew` 返回 `NULL` 时 `init` 返回 `-1`;**不留下半初始化句柄**(`s_lock == NULL`) |
| **TC-VL-004** | 失败后重试可成功(返回 `0`);重试确实**再次**调用了 `osMutexNew` |
### 2.2 `vol_lock_wait` — 获取与 fail-open ⭐
| 用例 | 断言 |
|---|---|
| **TC-VL-005** | 获取成功返回 `1`;`osMutexWait` 调用 1 次;**`timeout` 原样透传**(100 → 100) |
| **TC-VL-014** | `wait(0)` 的 try 语义:返回 `1` 且透传值为 `0`(defaultTask 路径) |
| **TC-VL-006** | `osMutexWait` 返回非 `osOK` → `wait` 返回 `0`;超时路径确实尝试过等待 |
| **TC-VL-007** | 锁未创建时 `wait` **自动 `init`** 并正常获取(创建 1 次 + 等待 1 次) |
| **TC-VL-008** ⭐ | **fail-open**:锁未创建且创建失败 → 返回 **`1`**;**`osMutexWait` 调用次数 == 0**(零阻塞);`s_lock` 仍为 `NULL` |
| **TC-VL-009** | fail-open 之后 `release` 无害:`osMutexRelease` **未被调用**(判空静默返回) |
| **TC-VL-010** | 已创建时多次 `wait` **不重复创建**(`osMutexNew` 恒 1 次);每次 `wait` 各等待一次 |
> **TC-VL-008 是本套件存在的主要理由**:真机上需堆耗尽才可能触发,板端无法构造。
### 2.3 `vol_lock_release` — 释放与判空
| 用例 | 断言 |
|---|---|
| **TC-VL-011** | 已持锁时 `release` 调用 `osMutexRelease` **一次** |
| **TC-VL-012** | 锁未创建时 `release` **静默返回**(不调用、不崩溃) |
| **TC-VL-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 与板端的互补关系
| 层次 | 判据 | 手段 |
|---|---|---|
| 本单测(L0.5) | 锁的**调用契约**(fail-open / 幂等 / 透传 / 判空) | 主机侧桩注入 |
| 板端(L1) | 卷访问**真的串行**了(无 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`)。
---
## 5. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-10 | 创建;33 断言点全绿(`TC-VL-001..014`);覆盖 `init` 创建与幂等、`wait` 获取 / 超时 / 自动 init / **fail-open**、`release` 释放与判空;挂 pre-commit 钩子 `vol-lock-unit-test` |
@@ -0,0 +1,239 @@
# 时间同步单元测试说明(主机侧)
> **模块**:Time Sync(`App/sync/time_sync.c` + `App/sync/time_sync.h`)
> **测试类型**:**主机侧纯逻辑单元测试**(L0.5 层)——在 PC 上离线验证 TCP 时间同步协议
> 状态机(分片喂狗延时 / 监听建立与退避 / 时间包重组 / 任务主循环),零硬件、零烧录、
> 毫秒级、不占 MCU 的 RAM。
> **状态**:✅ 已落地(80 断言点,`/W4` 0 告警,已挂 pre-commit 钩子)
> **日期**:2026-10-10
> **关联文档**:
> [`../../AGENTS.md`](../../AGENTS.md)(平台约束与看门狗铁律)、
> [`测试开发标准流程.md`](测试开发标准流程.md)(测试分层与门禁总览)、
> [`看门狗单元测试说明.md`](看门狗单元测试说明.md)(喂狗位图判定状态机)、
> [`卷锁单元测试说明.md`](卷锁单元测试说明.md)(同批落地的姊妹篇)、
> [`../子系统设计/看门狗设计说明.md`](../子系统设计/看门狗设计说明.md)(`required_mask` / 抽样窗 / 长耗时白名单)、
> [`../待处理内容.md`](../待处理内容.md)(**N-17** 登记条目)、
> [`../问题记录/错误码传播审计.md`](../问题记录/错误码传播审计.md)(**E-02** 头阻塞)
---
## 1. 概述
### 1.1 为什么单独做主机侧单测
本模块是**两件已知风险的当事方**,而两件风险的成因**全部是本模块的纯逻辑**:
1. **N-17(看门狗余量)**:登记簿 N-17 记录"time_sync 监听重建期间心跳缺口 800ms~1s,
距 IWDG 最坏超时 1701ms 余量仅约 0.7s"。该缺口的每一段都由本模块产生——
`time_sync_delay_fed()` 的**分片喂狗节奏**、监听重试退避(500 / 2000ms)、
`accept` 轮询退避(200ms)、收尾延时(50ms),以及"就绪屏障等待态也要喂心跳"。
真机上只能靠"跑起来看复位不复位"间接观察;主机侧可**逐片断言**心跳次数与片长。
2. **E-02(头阻塞)**:旧实现用单次 30s 阻塞 `net_recv` 等时间包,独占 `netTask`,
使其余任务的网络操作整体停摆。现改为"分片有界读 + 总预算",本单测钉死其**分片语义**
(单次等待片长恒 ≤ 50ms、总预算 30s 不因分片而改变)。
次要理由:**时间包重组状态机**(累积到 8 字节才解析、TCP 分段送达、EOF / 真错误 /
本片超时三种返回码分支、tick 回绕)是纯逻辑,板端套件无法精确构造
"分段 + 预算耗尽 + tick 回绕"这类组合。
### 1.2 被测对象与桩
测试单元**原样 `#include` 生产源文件**,平台依赖由 7 个桩头替身 + 1 个真实头组成:
```
test/host/time_sync/time_sync_test.c
├── #include "FreeRTOS.h" / "task.h" / "cmsis_os2.h" ← 桩
├── #include "net_socket.h" / "sys_clock.h" /
│ "sys_events.h" / "dbg_log.h" ← 桩
├── #include "task_watchdog.h" ← 真实头(App/util/)
└── #include "time_sync.c" ← 生产源文件(App/sync/),原样纳入
```
| 头文件 | 来源 | 桩提供的能力 |
|---|---|---|
| `FreeRTOS.h` | 桩 | `TickType_t` = `uint32_t`;`configTICK_RATE_HZ` = 1000(故 `pdMS_TO_TICKS(ms) == ms`) |
| `task.h` | 桩 | `vTaskDelay`(记录每片时长)、`xTaskGetTickCount`(返回可控 tick) |
| `cmsis_os2.h` | 桩 | `osEventFlagsWait`(按脚本返回就绪 / 超时) |
| `sys_events.h` | 桩 | `sys_evtHandle` + `SYS_EVT_NET_READY` 等位定义 |
| `sys_clock.h` | 桩 | `sys_clock_set_unix`(记录 epoch 与调用次数) |
| `dbg_log.h` | 桩 | `DBG_ERROR` / `DBG_INFO` 计数(`g_tstest_err_count` / `g_tstest_info_count`) |
| `net_socket.h` | 桩 | `socket` / `bind` / `listen` / `accept` / `close` / `recv_to` / `get_generation` / `htons` **全部脚本化** |
| `task_watchdog.h` | **真实**(`App/util/`) | 提供 `task_wd_id_t` / `TASK_WD_TIMESYNC`;`task_wd_beat` / `task_wd_bind` 由测试文件实现(记录心跳节奏与位号) |
| `time_sync.h` | **真实**(`App/sync/`) | 只 `#include <stdint.h>`,无平台依赖 |
`include_dirs` 顺序为 **`stub` → `App/util` → `App/sync` → 本目录**:桩目录必须排在
前面,否则会被真实头抢先命中。
> ⚠ `dbg_log.h` 桩**不得**定义 `DBG_LINE_BUF_MAX` / `DBG_TAG`——生产源(`time_sync.c:28-29`)
> 已先定义它们,桩再定义会触发 C4005 宏重定义告警。
### 1.3 关键约定:分片喂心跳与报文重组契约
**(一)心跳必须落在同一个 100ms 抽样窗内。**
看门狗抽样窗为 `WD_POLL_PERIOD_MS = 100ms`(`App/util/task_watchdog.c`)。若一次长等待
(如 2000ms 退避)期间不喂心跳,或心跳节奏恰好滑过两个连续抽样窗,该任务就会被判"卡死"。
故 `time_sync_delay_fed()` 把任意长等待切成 **≤ `TIME_SYNC_WAIT_SLICE_MS` = 50ms** 的短片,
**每片前**喂一次心跳——语义等价于一次长延时,仅把心跳节奏钉在窗内(`round 8b` 消抖)。
**(二)`net_recv_to()` 的四态返回。**
`time_sync_handle_conn()` 的重组循环依赖该契约:
| 返回 | 含义 | 本模块动作 |
|---|---|---|
| `> 0` | 收到 `n` 字节 | 累加;`>= 8` 即收齐并解析,否则继续等余下字节 |
| `0` | 对端关闭(EOF) | 退出循环,**不解析** |
| `-1` | 真错误 | 退出循环,**不解析** |
| `-2` | 本片预算等满、无数据 | 重算剩余预算,**下一片**继续 |
**(三)总预算不因分片而改变。**
单次 `net_recv_to` 的等待片长 ≤ 50ms,但**跨片累积**仍以 `t0 = xTaskGetTickCount()`
为基准、总预算 `TIME_SYNC_TOTAL_BUDGET_MS` = 30000ms。tick 回绕由无符号减法天然处理。
### 1.4 测试环境
| 依赖 | 版本 / 说明 |
|---|---|
| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 `run_unit_test.py` 自动探测 |
| Python | 仓库 `.venv` 或任意 python3(**纯标准库**) |
| 硬件 | 无(不需要开发板 / 串口 / ST-LINK / 对端 PC) |
工具链探测 / 编译运行 / 退出码判定全部委托通用 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/time_sync/run_unit_test.py
```
退出码:`0`=PASS / `1`=测试失败 / `2`=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
### 1.6 回归接入
| 项 | 说明 |
|---|---|
| **触发范围** | `App/sync/time_sync.c` / `time_sync.h`、`App/util/task_watchdog.h`,或 `test/host/time_sync/**`、`test/host/common/unit_test_harness.py` 变动 |
| **门禁层级** | **L0.5 层**(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 |
| **pre-commit 钩子** | `time-sync-unit-test`(本地钩子,`.pre-commit-config.yaml` §7g) |
| **汇总行** | `=== Time Sync Tests: 80/80 PASSED (failed=0) ===` |
| **与板端测试的关系** | 互补:本单测保证**延时节奏与重组逻辑**符合设计;板端保证**真实 TCP 时序 + IWDG 不误复位**(N-17 的实际余量只能由板端长跑判定) |
---
## 2. 测试用例
> 命名 `TC-TS-NNN`(TS = Time Sync)。断言数(80)多于用例数(27),因为每个用例通常
> 包含 2~9 条子断言(后缀 `b` / `c` …)。桩状态在每用例前由 `fakes_reset()` 复位。
### 2.1 `time_sync_delay_fed` — 分片喂心跳延时(TC-TS-001..006)
| 用例 | 断言 |
|---|---|
| **TC-TS-001** | `delay_fed(0)` 既不睡眠也不喂狗(零开销快路径) |
| **TC-TS-002** | 恰好一片(50ms):1 次 `vTaskDelay(50)` + 1 次心跳 |
| **TC-TS-003** | 非整片(120ms)切成 **50 / 50 / 20** 三片,每片喂一次心跳 |
| **TC-TS-004** | 长延时(2000ms)= **40 片 × 50ms**,每片喂心跳(监听退避路径) |
| **TC-TS-005** | 不足一片(49ms)单片处理,**不**被拆成 0 |
| **TC-TS-006** | 总预算 30s = **600 片**,片长上限**恒** 50ms |
### 2.2 `time_sync_open_listener` — 监听建立与退避(TC-TS-010..015)
| 用例 | 断言 |
|---|---|
| **TC-TS-010** | 首次成功:`socket` / `bind` / `listen` 各 1 次;端口经 `net_htons(8888)`;`sin_family` = `NET_AF_INET`;成功路径**无退避延时** |
| **TC-TS-011** | `bind` 失败 → **关闭该 socket** + 退避 2000ms(40 片)+ 留痕 `DBG_ERROR`,随后重试成功 |
| **TC-TS-012** | `listen` 失败 → 关闭该 socket + 退避 2000ms,随后重试成功 |
| **TC-TS-013** | `socket` 连续失败 → **5 次后返回 -1**;5 条 `DBG_ERROR`;5 × 500ms 退避(50 片) |
| **TC-TS-014** | 第 3 次重试成功并返回**新**描述符;恰好尝试 3 次;两次失败退避 500ms × 2 |
| **TC-TS-015** | 心跳总次数 = 每轮重试(5)+ 每片延时(50);**心跳位恒为 `TASK_WD_TIMESYNC`** |
### 2.3 `time_sync_handle_conn` — 时间包重组状态机(TC-TS-020..029)
| 用例 | 断言 |
|---|---|
| **TC-TS-020** | 8 字节一次收齐 → **大端**解析 epoch + 更新墙钟 1 次 + 关连接;首次请求 16 字节;单次等待片长 = 50ms;留痕 `wall clock updated` |
| **TC-TS-021** | 分段送达(4+4)→ 累积后解析正确;读了两次;第二次**只请求剩余 12 字节**;每片 50ms |
| **TC-TS-022** | 一次到达 16 字节 → **只取前 8 字节**;一次读够即停 |
| **TC-TS-023** | 前缀非 `"TIME"` → **不更新墙钟**,但仍关闭连接 |
| **TC-TS-024** | 对端关闭(`n == 0`)→ 立即退出、不解析;关闭连接 |
| **TC-TS-025** | 真错误(`n == -1`)→ 立即退出、不解析 |
| **TC-TS-026** | 只收到 4 字节后 EOF → **不足 8 字节不解析**;期间继续等待后续字节 |
| **TC-TS-027** | 30s 预算耗尽 → 不再读(只读 1 次)+ 留痕 `no TIME packet in 30s, drop conn` + 关连接 |
| **TC-TS-028** | 剩余预算不足一片 → **末片按剩余(20ms)切分** |
| **TC-TS-029** | **32 位 tick 回绕** → 预算计算仍正确(无符号减法) |
### 2.4 `time_sync_task` — 任务主循环(TC-TS-040..044)
| 用例 | 断言 |
|---|---|
| **TC-TS-040** | `sys_evtHandle == NULL` → **跳过就绪屏障**;`accept` 轮询 3 次(2 次无连接 + 1 次成功);收到时间包并更新墙钟;延时片数 = 4+4+1(200+200+50ms)且**全部按 50ms 分片**;心跳次数 = 主循环 1 + 退避 8 + 收连接 1 + 收尾 1 + 次轮 1 = **13**;所有心跳均打 `TASK_WD_TIMESYNC` 位 |
| **TC-TS-041** | 就绪屏障轮询 3 次(2 次未就绪 + 1 次就绪);屏障等待按 **100ms 分片**;等待 `SYS_EVT_NET_READY` 位;`osFlagsWaitAny \| osFlagsNoClear`(**就绪位不被消费**);未就绪的两次各喂一次心跳;仍先 `task_wd_bind` 登记心跳路由 |
| **TC-TS-042** | 屏障等待 4 次均未就绪 → **循环未挂死**(跳出并继续);每次未就绪后各喂一次心跳(2026-10-02 台架根因的**回归防线**) |
| **TC-TS-043** | 网络代次变化 → **关闭旧监听**并重监听;留痕 `network re-init, relisten`(另含 2 条 listen 成功);每次循环只轮询一次 `accept` 即放弃 |
| **TC-TS-044** | 监听建立失败 → 内部重试 5 次后放弃;6 条 `DBG_ERROR`(5 条 `net_socket FAIL` + 1 条 `retry later`);退避 5×500ms + 2000ms = **90 片**;心跳次数 = 2 + 5 + 50 + 40 = **97**;所有心跳均打 `TIMESYNC` 位 |
**当前结果**:80/80 PASSED(`failed=0`)。
---
## 3. 如何扩展用例
1. **纯延时类**:直接调 `time_sync_delay_fed(ms)`,断言 `s_delay_calls`(片数)与
`s_delays[]`(每片时长)以及 `s_beat_calls`(心跳数)。
2. **脚本化网络**:给 `s_gen_seq[]` / `s_evt_seq[]` / `recv_step_t` 数组填序列,
再驱动被测函数。
3. **跳出任务循环**:生产 `time_sync_task` 是 `for(;;)`,无法自然返回。桩在脚本耗尽时
调 **`longjmp(s_jmp, N)`** 强行跳出(见 §4.1),跳出后正常断言。
4. `CHECK(cond, "描述")` 断言;在 `main()` 里注册(并同步更新本文档 §2 与变更记录)。
---
## 4. 已知边界与设计取舍
### 4.1 用 `setjmp` / `longjmp` 跳出 `for(;;)`
`time_sync_task` 是永不返回的任务循环。本单测在 `main()` 里 `setjmp` 设跳转点,
桩在"脚本化调用计数达到预设值"时 `longjmp` 回来,然后断言循环期间产生的副作用
(心跳次数 / 延时片数 / `accept` 次数 / 墙钟更新)。
**取舍**:这比"把循环体抽成可测函数"更贴近真实——**测的就是任务主体本身**,
不需要为了可测性改动生产代码结构。代价是**必须精确设定跳出点**:跳出点设错
(多一次或少一次桩调用)会导致断言数不符,`TC-TS-040` 的跳出点曾因此修正。
### 4.2 ⚠ C 的"暂定定义"会静默合并同名静态量
本文件曾出现一个**无任何编译告警**的缺陷:文件作用域下两处 `static int s_bind_calls;`
(一处给 `task_wd_bind` 桩、一处给 `net_bind` 桩)被 C 的**暂定定义(tentative
definition)规则静默合并为同一个对象**——于是 `task_wd_bind` 与 `net_bind` 共用一个计数器,
8 条断言失败(72/80)。
**教训**:同一编译单元内**所有** `static` 变量名必须唯一,哪怕是不同桩的"各自的"计数器。
修复手法是把看门狗侧改名为 `s_wd_bind_calls` / `s_wd_bind_id_bad`,并加注释说明。
扩展用例时**务必**先 `grep` 目标名字是否已被占用。
### 4.3 桩不模拟真实 TCP 时序
`net_recv_to` / `net_accept` 桩按脚本返回,**不**模拟真实的分段到达时刻、重传、窗口、
CH395F 缓冲区边界。因此本单测能保证的是"**给定返回码序列,状态机反应正确**",
**不能**保证真实链路上的时序行为——后者属板端与 PC 对端(`test/host/time_sync_server.py`)职责。
### 4.4 N-17 的实际余量仍由板端判定
本单测把"每片喂心跳、片长 ≤ 50ms、退避值正确"这些**逻辑前提**钉死,但 N-17 关注的
是**端到端心跳缺口 vs IWDG 超时**——这还取决于真实调度延迟、`netTask` 排队时长、
Flash 写耗时等主机侧无法建模的因素。故:
- 本单测 ✅ 保证"逻辑上不会出现超过 50ms 的**无喂狗**窗口";
- 板端 ✅ 保证"实测缺口 + 调度抖动仍小于 1701ms"。
两者都过,N-17 才能闭环。
---
## 5. 变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-10 | 创建;80 断言点全绿(`TC-TS-001..006` / `010..015` / `020..029` / `040..044`);覆盖分片喂狗延时、监听建立与退避、时间包重组(半包 / 粘连 / EOF / 真错 / 预算耗尽 / tick 回绕)、任务主循环(就绪屏障 / accept 轮询 / 代次变化重听 / 监听失败退避);修复静态量暂定定义合并缺陷;挂 pre-commit 钩子 `time-sync-unit-test` |