- 12/14 条建议已落地,按"只保留待办"约定重写本文件 - 保留:S12 断言宏、S1-c 可选单测、A-4 采集链路测试约束 - 保留:A-5 残余(CI 体积门禁盲区) - 保留:错误码通道四要素(设计基线,供后续驱动接入沿用) - 删除:S1-a/S1-b、S2~S11、S13、S14;T-1~T-5;A-1~A-3/A-6/A-7 - 删除:评价表/盘点表、路线图、总结、变更记录 - 同步修正 6 个外部文档对本文件章节的死引用 - 新增"编号约定":S/T/A 编号不随条目删除而回收
14 KiB
系统时钟单元测试说明(主机侧)
模块:System Clock(墙钟时间维护,
App/util/sys_clock.c) 测试类型:主机侧纯逻辑单元测试(L0.5 层)——在 PC 上离线验证时间换算与 墙钟基准逻辑,零硬件、零烧录、秒级、不占 MCU 的 RAM。 状态:✅ 已落地(20 用例 / 100 断言点,@build0/0,已挂 pre-commit 钩子) 日期:2026-10-02 关联文档:../../AGENTS.md(平台约束)、测试开发标准流程.md(测试分层与门禁总览)、看门狗单元测试说明.md(同层范本)
1. 概述
1.1 为什么单独做主机侧单测
sys_clock.c 提供墙钟时间的换算与维持两类逻辑:
| 半边 | 内容 | 可测性 |
|---|---|---|
| 纯逻辑(被测) | Unix↔日历换算(闰年 / 月末 / 世纪 / 2038 上限)、星期(Zeller)、"30 天惰性再基准化"(防 2^32 ms ≈ 49.7 天 tick 回绕)、RTC 故障降级 | 纯逻辑,与硬件无关 → 可在 PC 上位穷举 |
| 硬件副作用 | HAL_GetTick()、sd2506_get_time/set_time(I2C RTC)、taskENTER/EXIT_CRITICAL、DBG_* |
需真机或桩 |
板端套件(test/board/)无法穷举边界,例如:
- 闰年规则中 2000(世纪闰)/ 1900(非闰)/ 2024(普通闰)的差异;
- 2038-01-19 03:14:07 =
0x7FFFFFFF的上限; - 星期 Zeller 公式在 1/2 月(需上调为上年 13/14 月)与 3~12 月的分野;
- 惰性再基准化:30 天阈值触发前后的差值窗口行为、亚秒余数结转、跨 49.7 天 tick 回绕;
- RTC 读失败 / 越界 / 写回失败三种故障路径的降级语义。
这些用主机侧单测一次性钉死,且不占 MCU 的 RAM(本项目 RAM 已 95%,见
docs/内存管理/)。
本单测首次落地即抓到一处生产缺陷——
rtc_calc_week()/unix_to_sd2506()的 Zeller 公式错误,导致 1970~2099 全部日期的星期皆为错值。详见 §5。
1.2 被测对象与桩(不改生产代码)
关键设计:sys_clock.c 的全部硬件依赖都是外部符号(HAL_GetTick /
sd2506_get_time / sd2506_set_time)或宏(taskENTER/EXIT_CRITICAL /
DBG_*),因此测试单元只需提供同名强符号桩即可编译链接——无需在
sys_clock.c 内加任何 #ifdef 钩子(比看门狗更"零侵入")。
test/host/sys_clock/sys_clock_test.c
├── #include "main.h" → stub/main.h (HAL_GetTick 声明)
├── #include "FreeRTOS.h" → stub/FreeRTOS.h (临界区宏 → 空操作)
├── #include "task.h" → stub/task.h (经 FreeRTOS.h)
├── #include "dbg_log.h" → stub/dbg_log.h (DBG_* → 计数)
├── #include "sd2506.h" → 真实头(其内部 #include "main.h" 命中 stub)
├── #include "sys_clock.h" → 真实头(被测 API 原型)
└── #include "sys_clock.c" ← 生产源文件(App/util/),原样纳入
| 硬件符号 | MCU 端 | 主机侧桩 |
|---|---|---|
HAL_GetTick() |
TIM7 中断累加 | 返回单测可控的 s_tick |
sd2506_get_time(p) |
I2C 读 7 字节 | 从 阴影 RTC s_rtc 复制;可注入返回码 s_rtc_read_rc |
sd2506_set_time(p) |
I2C 写 7 字节 | 写入阴影 RTC;可注入返回码 s_rtc_write_rc;计次 s_rtc_set_calls |
taskENTER/EXIT_CRITICAL |
关中断 | 空操作(主机单线程,复核逻辑照跑) |
DBG_ERROR |
USART + RTC 时间戳 | 计数 g_sclk_err_count(断言故障路径打了错误日志) |
生产端零增量:
sys_clock.c未因本单测改动一行(Zeller 修正是缺陷修复, 见 §5,与测试基建无关)。
1.3 测试环境
| 依赖 | 版本 / 说明 |
|---|---|
| 编译器 | 本机 MSVC(VS2019/2022/2026 任一),由 run_unit_test.py 自动探测 |
| Python | 仓库 .venv 或任意 python3(纯标准库) |
| 硬件 | 无(不需要开发板 / 串口 / ST-LINK) |
工具链探测 / INCLUDE-LIB 拼接 / 编译运行 / 退出码判定全部委托通用 harness
../../test/host/common/unit_test_harness.py,
本模块 run_unit_test.py 只声明 TestBuild(源文件 / 桩目录 / 汇总行标记)。
1.4 运行方式
# 仓库根目录(统一用 .venv)
& '.venv\Scripts\python.exe' test/host/sys_clock/run_unit_test.py
退出码:0=PASS / 1=测试失败 / 2=环境错误(找不到编译器 / 编译失败 / 无汇总行)。
1.5 回归接入
| 项 | 说明 |
|---|---|
| 触发范围 | App/util/sys_clock.c / sys_clock.h、Drivers/BSP/SD2506/sd2506.h,或 test/host/sys_clock/**、test/host/common/unit_test_harness.py 变动 |
| 门禁层级 | L0.5 层(介于 L0 静态门禁与 L1 板端套件之间):主机侧、秒级、纯逻辑 |
| pre-commit 钩子 | sys-clock-unit-test(本地钩子,.pre-commit-config.yaml §7b) |
| 汇总行 | === System Clock Tests: N/N PASSED (failed=0) === |
| 与板端测试的关系 | 互补:本单测保证换算与基准逻辑正确;板端 SD2506_Test_Guide.md 保证真机 I2C 读写链路正确 |
2. 测试用例
命名
TC-CLK-NNN(CLK = Clock)。全部在sys_clock_test.c内,主机侧运行。
| 用例 | 断言 | 验证的设计点 |
|---|---|---|
| TC-CLK-000 | 未初始化时 get()==UINT32_MAX、get_time()==-1、get_str() 输出占位串 |
"未初始化"哨兵语义(必须最先跑) |
| TC-CLK-001 | RTC 合法 → init()==0,基准 = 换算 Unix 秒;不写回 RTC |
正常初始化路径 |
| TC-CLK-002 | 2024-02-29 / 2000-02-29(世纪闰)换算正确;2/29→3/1 间隔恰 86400 s | 闰年规则(含世纪闰) |
| TC-CLK-003 | 1/31、2/28、4/30 精确值;平年 2/28→3/1 间隔 86400 s | 月末边界 |
| TC-CLK-004 | 2000-01-01 换算;2038-01-19 03:14:07 → 0x7FFFFFFF;set(0) → 1970-01-01 |
世纪边界 / uint32 上限 |
| TC-CLK-005 | RTC 读失败 → init()==-1,降级为编译期默认时间,打 DBG_ERROR |
RTC 读故障降级 |
| TC-CLK-006 | 年/月/日越界 → 自愈写回默认时间,init()==0 |
越界自愈(启用默认值时) |
| TC-CLK-020 | 越界 + 写回失败 → 降级默认时间,init()==-1 |
组合故障降级 |
| TC-CLK-007 | 越界 → 写回 RTC 一次,阴影 RTC 被修正,init()==0 |
自愈写回 + 复验 |
| TC-CLK-008 | 999ms 不变 / 1000ms +1s / 再 +2500ms 共 +3s | 正常推进 + 亚秒截断 |
| TC-CLK-009 | +29 天线性推进(未达 30 天阈值,不触发再基准化) | 阈值下不触发 |
| TC-CLK-010 | 30 天 + 1500ms → 值无损(+30 天 1 秒);再 +500ms 补足 1 秒 | 惰性再基准化 + 余数结转 |
| TC-CLK-011 | 基准近 2^32,推进跨回绕 0x2000ms → +8s | tick 回绕(无符号差)安全 |
| TC-CLK-012 | 10 天步进 ×9(累计 90 天,含 3 轮再基准)零漂移 | 长期运行无漂移 |
| TC-CLK-013 | get_str 格式化 + 零点补零;未初始化占位串 |
字符串接口 |
| TC-CLK-014 | 2026-10-02 08:09:10 → 年月日时分秒 + week==5(周五) |
日历字段 + 星期 |
| TC-CLK-015 | get_time(NULL) == -1 |
NULL 参数防御 |
| TC-CLK-016 | set 更新基准 + 写回 RTC;写失败仍更新基准 + 打错误 |
set 两条路径 |
| TC-CLK-017 | set(..., NULL) 不碰 RTC |
可选 RTC 写回 |
| TC-CLK-018 | set_unix 写回 RTC,日历字段 + week 正确 |
Unix 时间戳校时 |
| TC-CLK-019 | 16 个已知日期(跨 1~12 月 / 世纪闰 / 闰日 / 范围首尾)星期对照 | Zeller 全域正确性 |
当前结果:100/100 PASSED(failed=0)。
3. 如何扩展用例
- 在
sys_clock_test.c加一个static void tc_0NN_xxx(void); - 用
rtc_set(y,mo,d,h,mi,s)设置阴影 RTC(init用例); - 用
base_at(unix_secs, tick)建立确定基准(get用例,无 RTC 副作用); tick_advance(ms)推进时间,sys_clock_get()/get_time()/get_str()观测;CHECK(cond, "描述")/CHECK_EQ_U32(got, want, "描述")断言;- 在
main()里注册。
⚠ 别把新用例插到
tc_000之前:tc_000依赖"刚上电"的模块静态态 (s_base_unix_secs == UINT32_MAX),一旦有任何init/set先跑过,它就失效。⚠ 惰性再基准化的"正确用法":差值窗口必须恒 < 30 天。一次
tick_advance推进超过 ~49.7 天会让uint32_t diff溢出混叠——那是调用方违约(不是模块 缺陷),测试应按 <30 天步长周期性调用get()。见 TC-CLK-012 的注释。
4. 平台与隔离机制
4.1 为什么不需要 #ifdef 测试钩子(与看门狗的差异)
看门狗的平台层是内联 __weak 函数(wd_platform_feed/start/tick_ms),
测试时必须用 #ifndef TASK_WATCHDOG_UNIT_TEST 把默认实现排除,否则与桩重名。
sys_clock.c 则直接调用外部符号(HAL_GetTick()、sd2506_get_time()),
这些符号在主机侧本来就不存在(只有声明),测试单元给出定义即可——无需任何
生产代码钩子。这是更干净的做法,也是本单测"零侵入生产代码"的原因。
4.2 隔离:如何在不改生产代码的前提下复位模块静态态
s_base_tick / s_base_unix_secs 是文件静态、无公开复位接口。隔离手段:
| 目标状态 | 手段 |
|---|---|
未初始化(UINT32_MAX) |
只能靠 进程首用例(TC-CLK-000,在任何 init/set 之前)观测 |
| 确定的已初始化状态 | sys_clock_set(unix_secs, NULL) —— 更新基准且不碰 RTC,是理想的隔离原语 |
| RTC 阴影态 | rtc_set(...) + clear_obs() 直接重置阴影寄存器与观测计数 |
5. 由本单测发现并修复的生产缺陷(Zeller 星期公式)
5.1 现象
首次运行 TC-CLK-014/019 时,2026-10-02(真实为周五)返回 week == 4;
TC-CLK-019 的 16 个已知日期中,仅 2038-01-19 正确,其余 9 个(3~12 月)
全错。
5.2 根因
rtc_calc_week() / unix_to_sd2506() 内联的所谓 "Zeller 变种"公式为:
(wy % 100 + wy / 100 / 4 - wy / 100 / 15) + 26*(wm+1)/10 + d % 100) % 7
问题有二:
- 世纪项错误:标准 Zeller(Gregorian)的世纪项是
⌊K/4⌋ + ⌊J/4⌋ + 5J(K = wy%100,J = wy/100)。原式写成wy/100/4 - wy/100/15,丢失了K/4与主导性的5J项(-J/15与5J毫无关系)。 - 1/2 月分支用了错的月份基数:原式对
wm <= 2(即 1/2 月)做wm += 12, 但若传入的wm不是 1-based 的月,就无法得到 13/14。unix_to_sd2506()更是直接传入 0-based 的m(循环下标),使公式基准再偏一位。
实测:1970~2099 全部日期的星期皆为错值(无常数偏移,错误率约 78%)。
5.3 影响面
sys_clock_get_time()->week→ 写入 SD2506 RTC 的03H星期寄存器(错值);rtc_calc_week()亦被sys_clock_init()用于构造默认时间的week;App/app/app_main.c:170的自检日志会打印错的星期。
不影响的:
- 时间数值(时/分/秒/年/月/日 / Unix 秒)换算本身正确——只有
week字段错;- FatFs 文件时间戳:
Lib/FatFs/ffsystem.c:get_fattime()只取 年/月/日/时/分/秒(FAT 格式无星期字段),不使用t.week——故文件 mtime 不受影响;sys_log运行时日志只用sys_clock_get()(Unix 秒),与星期无关。综上:该缺陷只污染 RTC 的星期寄存器与一条自检日志,功能影响可控——这正是 它此前从未被台架功能测试发现的原因(功能测试不校验星期)。
5.4 修复
统一为教科书 Zeller,并收敛到单一函数(消除两处重复实现再次发散的风险):
static uint8_t rtc_calc_week(uint16_t y, uint8_t m, uint8_t d) { /* m: 1-based */
uint32_t wy = y, wm = m;
if (wm <= 2u) { wy--; wm += 12u; } /* 1/2 月 → 上年 13/14 月 */
uint32_t K = wy % 100u, J = wy / 100u;
uint32_t h = (uint32_t)(d + (13u*(wm+1u))/5u + K + K/4u + J/4u + 5u*J) % 7u;
return (uint8_t)((h + 6u) % 7u); /* h:0=Sat → 0=Sun 约定 */
}
unix_to_sd2506() 改为调用 rtc_calc_week((uint16_t)y, (uint8_t)(m+1u), (uint8_t)d)
(传 1-based 月)。
注:
5J ≡ -2J (mod 7),取5J保证各项非负(无符号%安全)。 修复后经 Pythondatetime独立核对 1970~2099 全部 47,482 天零错误;@build0 错误 0 警告。
5.5 校验数据来源
TC-CLK-019 的期望星期由 Python datetime.date(...).weekday() 独立生成
((weekday()+1) % 7 → 0=周日),与被测实现算法正交,避免"用错的算法
验证错的实现"。
6. 与板端验证的分工
| 层 | 覆盖 | 状态 |
|---|---|---|
| 本单测(L0.5) | 换算逻辑 / 星期 / 再基准化 / 故障降级 | ✅ 已落地(100 断言点) |
| 板端(L1/L2) | 真机 SD2506 I2C 读写、掉电保持、与 PC 对时联动 | 见 SD2506_Test_Guide.md |
本单测不替代板端验证:I2C 时序、BCD 编解码、掉电保持、温度读等真机行为, 仍需
@test台架闭环确认。
变更记录
| 日期 | 变更 |
|---|---|
| 2026-10-02 | 创建:sys_clock.c 主机侧单元测试(20 用例 / 100 断言点)+ 强符号桩(零侵入生产代码)+ pre-commit 钩子 sys-clock-unit-test。首次落地即抓出 Zeller 星期公式缺陷(1970~2099 全部日期星期皆错)并修复,@build 0/0 |