Files
STM32F4-Base/docs/测试说明/单元测试/时间同步单元测试说明.md
wandering1 f754d59218 docs(测试说明): 按用途分六个子目录并同步全库引用
分层:流程与规范/ 单元测试/ 板级测试/ 专项验证/ 测试工具/ 测试数据/,
顶层只留新增的目录导航 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 处(无新增断链)。
2026-10-30 07:16:07 +08:00

15 KiB
Raw Permalink Blame History

时间同步单元测试说明(主机侧)

模块: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(平台约束与看门狗铁律)、 测试开发标准流程.md(测试分层与门禁总览)、 看门狗单元测试说明.md(喂狗位图判定状态机)、 卷锁单元测试说明.md(同批落地的姊妹篇)、 ../../子系统设计/看门狗设计说明.md(required_mask / 抽样窗 / 长耗时白名单)、 ../../任务规划/README.md(登记条目「探针窗口心跳缺口」)、 历史审计(netTask 头阻塞;属主文档已于 2026-10-15 删除,现状见 ../../网络/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, 本模块 run_unit_test.py 只声明 TestBuild。

1.5 运行方式

# 仓库根目录(统一用 .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"。

两者都过,这个心跳缺口才能闭环。