# 时间同步单元测试说明(主机侧) > **模块**:Time Sync(`App/sync/time_sync.c` + `App/sync/time_sync.h`) > **测试类型**:**主机侧纯逻辑单元测试**——在 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 `,无平台依赖 | `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` 变动 | | **门禁层级** | **主机侧纯逻辑单测**(介于静态门禁与板端测试套件之间):主机侧、秒级、纯逻辑 | | **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"。 两者都过,这个心跳缺口才能闭环。