Files
STM32F4-Base/docs/测试说明/时间同步单元测试说明.md
T
wandering1 0f7b5a87fb chore(编号): 存量编号全量转语义短语,门禁识别面与语料同批重打
登记簿、文档与代码注释里的存量字母加数字编号(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%。
2026-10-26 12:28:14 +08:00

15 KiB
Raw Blame History

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

模块: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(平台约束与看门狗铁律)、 测试开发标准流程.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 变动
门禁层级 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