0f7b5a87fb
登记簿、文档与代码注释里的存量字母加数字编号(N-x / S-x / T-x / E-x / A-x / 卡号) 一律改写为语义短语,净删 2,028 处引用;删除基线与待确认、并行分道方案、排期详述、 采集保护收尾 4 篇(内容已并入 docs/任务规划/README 与登记簿)。 门禁机器与三张账同批重打:check_ids.py 新增变更记录行豁免与第二根护栏 undecidable_total_ceiling;check_test_traceability.py 只加 stdout/stderr UTF-8 reconfigure(TC_RE 未动);check_cubemx_survival.py 的 --write 固定 newline=LF; id_registry / id_aliases(+19 条 retired-to-phrase)/ id_known_backlog 重打。 test/ 56 个文件同步 TC 标签语义化,其中 18 个 TU 改了 CHECK / printf 里的标签文字, 是本批唯一改变构建产物的文件集合。 为什么合成一笔、不拆四笔(实测撞闸,非偏好):pre-commit 跑钩子前会 stash 未 staged 部分,拆开后中间提交必然是「新规则 + 旧语料」——实跑可判定 85.15% < 基线 86.00%、 不可判定 1,453 > 上限 1,166,编号门禁直接拦下。编号识别面与它描述的语料只能同码移动。 实跑基线(工作区全量):754 文件 / 8,230 处编号字面量 / 可判定 7,079 = 86.01% (基线 86.0%,容差 0.1pp)/ 不可判定绝对数 1,151(上限 1,166)/ 校验 1-6 错误级 0 / 告警 = 悬空 2(App/algo/freq.c 的 S2,属 §4.4 同号不同义)+ 孤儿 50。 基线从 84.0 抬到 86.0 的理由见 docs/门禁说明/id-check.md §7.6:本批真删了 2,028 处 引用,可判定面随真实内容上升,不是把分母做小。同批新增排除 docs/硬件平台/ 语料,使 识别面少 264 处——芯片手册位号与网表料号不属编号空间(registry 有 excluded_paths_note)。 ⚠ 余量只剩 0.11pp(约 10 处)与绝对数 15 处:把「可判定的引用」改成散文会立刻撞第二 根护栏。另遗留一项:id_aliases 里 retired 的 N / S / T / A 别名没有 scope(U1~U5 有), 今后有人新写一个 A-4,校验 3 不会响,只能靠人,建议下批补 scope。 固件不变证据:115 个 .c/.h 剥注释并归一字符串后与 HEAD 逐字节全等,残留代码差异 0; App / Src / Drivers 的字符串字面量零改动。@build 0 错误 0 警告;40/40 主机套件、2,889 断言全通过;覆盖率 line 89.2% / func 92.6%;pre-commit 48 钩子 --all-files 全绿; check_doc_links --all 通过(176 文档,章节引用无法判定 0);逐字节卫生核过:14 个带 BOM 文件与 HEAD 一致、0 个 CR、0 个 U+FFFD,字符串字面量纯 ASCII。 .cubemx_survival_baseline.json 的 watchdog-module 哈希 56bf27 变 2bb051 属合法: Src/freertos.c 的 USER CODE 区内只有注释文字随编号改写,代码未变。 同批带上体积同码凭证与 RAM 台账行(@build 之后盖戳):RW_IRAM1 79,008 B / 60.3%, RW_CCM 57,376 B / 87.9%,阈值 92%。
240 lines
15 KiB
Markdown
240 lines
15 KiB
Markdown
# 时间同步单元测试说明(主机侧)
|
||
|
||
> **模块**: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` / 抽样窗 / 长耗时白名单)、
|
||
> [`../任务规划/README.md`](../任务规划/README.md)(登记条目「探针窗口心跳缺口」)、
|
||
> 历史审计(netTask 头阻塞;属主文档已于 2026-10-15 删除,现状见 [`../网络/Net层阻塞模型架构设计.md`](../网络/Net层阻塞模型架构设计.md))
|
||
|
||
---
|
||
|
||
## 1. 概述
|
||
|
||
### 1.1 为什么单独做主机侧单测
|
||
|
||
本模块是**两件已知风险的当事方**,而两件风险的成因**全部是本模块的纯逻辑**:
|
||
|
||
1. **探针窗口心跳缺口(看门狗余量)**:登记簿条目记录"time_sync 监听重建期间心跳缺口 800ms~1s,
|
||
距 IWDG 最坏超时 1701ms 余量仅约 0.7s"。该缺口的每一段都由本模块产生——
|
||
`time_sync_delay_fed()` 的**分片喂狗节奏**、监听重试退避(500 / 2000ms)、
|
||
`accept` 轮询退避(200ms)、收尾延时(50ms),以及"就绪屏障等待态也要喂心跳"。
|
||
真机上只能靠"跑起来看复位不复位"间接观察;主机侧可**逐片断言**心跳次数与片长。
|
||
2. **头阻塞**:旧实现用单次 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** 的短片,
|
||
**每片前**喂一次心跳——语义等价于一次长延时,仅把心跳节奏钉在窗内(第 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 不误复位**(该心跳缺口的实际余量只能由板端长跑判定) |
|
||
|
||
---
|
||
|
||
## 2. 测试用例
|
||
|
||
> 命名 `TC-TS-NNN`(TS = Time Sync)。断言数(80)多于用例数(27),因为每个用例通常
|
||
> 包含 2~9 条子断言(后缀 `b` / `c` …)。桩状态在每用例前由 `fakes_reset()` 复位。
|
||
|
||
### 2.1 `time_sync_delay_fed` — 分片喂心跳延时(TC-TIMESYNC-001..006)
|
||
|
||
| 用例 | 断言 |
|
||
|---|---|
|
||
| **TC-TIMESYNC-001** | `delay_fed(0)` 既不睡眠也不喂狗(零开销快路径) |
|
||
| **TC-TIMESYNC-002** | 恰好一片(50ms):1 次 `vTaskDelay(50)` + 1 次心跳 |
|
||
| **TC-TIMESYNC-003** | 非整片(120ms)切成 **50 / 50 / 20** 三片,每片喂一次心跳 |
|
||
| **TC-TIMESYNC-004** | 长延时(2000ms)= **40 片 × 50ms**,每片喂心跳(监听退避路径) |
|
||
| **TC-TIMESYNC-005** | 不足一片(49ms)单片处理,**不**被拆成 0 |
|
||
| **TC-TIMESYNC-006** | 总预算 30s = **600 片**,片长上限**恒** 50ms |
|
||
|
||
### 2.2 `time_sync_open_listener` — 监听建立与退避(TC-TIMESYNC-010..015)
|
||
|
||
| 用例 | 断言 |
|
||
|---|---|
|
||
| **TC-TIMESYNC-010** | 首次成功:`socket` / `bind` / `listen` 各 1 次;端口经 `net_htons(8888)`;`sin_family` = `NET_AF_INET`;成功路径**无退避延时** |
|
||
| **TC-TIMESYNC-011** | `bind` 失败 → **关闭该 socket** + 退避 2000ms(40 片)+ 留痕 `DBG_ERROR`,随后重试成功 |
|
||
| **TC-TIMESYNC-012** | `listen` 失败 → 关闭该 socket + 退避 2000ms,随后重试成功 |
|
||
| **TC-TIMESYNC-013** | `socket` 连续失败 → **5 次后返回 -1**;5 条 `DBG_ERROR`;5 × 500ms 退避(50 片) |
|
||
| **TC-TIMESYNC-014** | 第 3 次重试成功并返回**新**描述符;恰好尝试 3 次;两次失败退避 500ms × 2 |
|
||
| **TC-TIMESYNC-015** | 心跳总次数 = 每轮重试(5)+ 每片延时(50);**心跳位恒为 `TASK_WD_TIMESYNC`** |
|
||
|
||
### 2.3 `time_sync_handle_conn` — 时间包重组状态机(TC-TIMESYNC-020..029)
|
||
|
||
| 用例 | 断言 |
|
||
|---|---|
|
||
| **TC-TIMESYNC-020** | 8 字节一次收齐 → **大端**解析 epoch + 更新墙钟 1 次 + 关连接;首次请求 16 字节;单次等待片长 = 50ms;留痕 `wall clock updated` |
|
||
| **TC-TIMESYNC-021** | 分段送达(4+4)→ 累积后解析正确;读了两次;第二次**只请求剩余 12 字节**;每片 50ms |
|
||
| **TC-TIMESYNC-022** | 一次到达 16 字节 → **只取前 8 字节**;一次读够即停 |
|
||
| **TC-TIMESYNC-023** | 前缀非 `"TIME"` → **不更新墙钟**,但仍关闭连接 |
|
||
| **TC-TIMESYNC-024** | 对端关闭(`n == 0`)→ 立即退出、不解析;关闭连接 |
|
||
| **TC-TIMESYNC-025** | 真错误(`n == -1`)→ 立即退出、不解析 |
|
||
| **TC-TIMESYNC-026** | 只收到 4 字节后 EOF → **不足 8 字节不解析**;期间继续等待后续字节 |
|
||
| **TC-TIMESYNC-027** | 30s 预算耗尽 → 不再读(只读 1 次)+ 留痕 `no TIME packet in 30s, drop conn` + 关连接 |
|
||
| **TC-TIMESYNC-028** | 剩余预算不足一片 → **末片按剩余(20ms)切分** |
|
||
| **TC-TIMESYNC-029** | **32 位 tick 回绕** → 预算计算仍正确(无符号减法) |
|
||
|
||
### 2.4 `time_sync_task` — 任务主循环(TC-TIMESYNC-040..044)
|
||
|
||
| 用例 | 断言 |
|
||
|---|---|
|
||
| **TC-TIMESYNC-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-TIMESYNC-041** | 就绪屏障轮询 3 次(2 次未就绪 + 1 次就绪);屏障等待按 **100ms 分片**;等待 `SYS_EVT_NET_READY` 位;`osFlagsWaitAny \| osFlagsNoClear`(**就绪位不被消费**);未就绪的两次各喂一次心跳;仍先 `task_wd_bind` 登记心跳路由 |
|
||
| **TC-TIMESYNC-042** | 屏障等待 4 次均未就绪 → **循环未挂死**(跳出并继续);每次未就绪后各喂一次心跳(2026-10-02 台架根因的**回归防线**) |
|
||
| **TC-TIMESYNC-043** | 网络代次变化 → **关闭旧监听**并重监听;留痕 `network re-init, relisten`(另含 2 条 listen 成功);每次循环只轮询一次 `accept` 即放弃 |
|
||
| **TC-TIMESYNC-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-TIMESYNC-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 探针窗口心跳缺口的实际余量仍由板端判定
|
||
|
||
本单测把"每片喂心跳、片长 ≤ 50ms、退避值正确"这些**逻辑前提**钉死,但该缺口关注的
|
||
是**端到端心跳缺口 vs IWDG 超时**——这还取决于真实调度延迟、`netTask` 排队时长、
|
||
Flash 写耗时等主机侧无法建模的因素。故:
|
||
|
||
- 本单测 ✅ 保证"逻辑上不会出现超过 50ms 的**无喂狗**窗口";
|
||
- 板端 ✅ 保证"实测缺口 + 调度抖动仍小于 1701ms"。
|
||
|
||
两者都过,这个心跳缺口才能闭环。
|
||
|
||
---
|
||
|
||
## 5. 变更记录
|
||
|
||
| 日期 | 变更 |
|
||
|---|---|
|
||
| 2026-10-10 | 创建;80 断言点全绿(`TC-TS-001..006` / `010..015` / `020..029` / `040..044`);覆盖分片喂狗延时、监听建立与退避、时间包重组(半包 / 粘连 / EOF / 真错 / 预算耗尽 / tick 回绕)、任务主循环(就绪屏障 / accept 轮询 / 代次变化重听 / 监听失败退避);修复静态量暂定定义合并缺陷;挂 pre-commit 钩子 `time-sync-unit-test` |
|