Files
STM32F4-Base/docs/测试说明/单元测试/系统时钟单元测试说明.md
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

14 KiB
Raw Blame History

系统时钟单元测试说明(主机侧)

模块:System Clock(墙钟时间维护,App/util/sys_clock.c) 测试类型:主机侧纯逻辑单元测试——在 PC 上离线验证时间换算与 墙钟基准逻辑,零硬件、零烧录、秒级、不占 MCU 的 RAM。 状态:✅ 已落地(20 用例 / 100 断言点,/keil-build 0/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 变动
门禁层级 主机侧纯逻辑单测(介于静态门禁与板端测试套件之间):主机侧、秒级、纯逻辑
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-SYSCLK-000 未初始化时 get()==UINT32_MAX、get_time()==-1、get_str() 输出占位串 "未初始化"哨兵语义(必须最先跑)
TC-SYSCLK-001 RTC 合法 → init()==0,基准 = 换算 Unix 秒;不写回 RTC 正常初始化路径
TC-SYSCLK-002 2024-02-29 / 2000-02-29(世纪闰)换算正确;2/29→3/1 间隔恰 86400 s 闰年规则(含世纪闰)
TC-SYSCLK-003 1/31、2/28、4/30 精确值;平年 2/28→3/1 间隔 86400 s 月末边界
TC-SYSCLK-004 2000-01-01 换算;2038-01-19 03:14:07 → 0x7FFFFFFF;set(0) → 1970-01-01 世纪边界 / uint32 上限
TC-SYSCLK-005 RTC 读失败 → init()==-1,降级为编译期默认时间,打 DBG_ERROR RTC 读故障降级
TC-SYSCLK-006 年/月/日越界 → 自愈写回默认时间,init()==0 越界自愈(启用默认值时)
TC-SYSCLK-020 越界 + 写回失败 → 降级默认时间,init()==-1 组合故障降级
TC-SYSCLK-007 越界 → 写回 RTC 一次,阴影 RTC 被修正,init()==0 自愈写回 + 复验
TC-SYSCLK-008 999ms 不变 / 1000ms +1s / 再 +2500ms 共 +3s 正常推进 + 亚秒截断
TC-SYSCLK-009 +29 天线性推进(未达 30 天阈值,不触发再基准化) 阈值下不触发
TC-SYSCLK-010 30 天 + 1500ms → 值无损(+30 天 1 秒);再 +500ms 补足 1 秒 惰性再基准化 + 余数结转
TC-SYSCLK-011 基准近 2^32,推进跨回绕 0x2000ms → +8s tick 回绕(无符号差)安全
TC-SYSCLK-012 10 天步进 ×9(累计 90 天,含 3 轮再基准)零漂移 长期运行无漂移
TC-SYSCLK-013 get_str 格式化 + 零点补零;未初始化占位串 字符串接口
TC-SYSCLK-014 2026-10-02 08:09:10 → 年月日时分秒 + week==5(周五) 日历字段 + 星期
TC-SYSCLK-015 get_time(NULL) == -1 NULL 参数防御
TC-SYSCLK-016 set 更新基准 + 写回 RTC;写失败仍更新基准 + 打错误 set 两条路径
TC-SYSCLK-017 set(..., NULL) 不碰 RTC 可选 RTC 写回
TC-SYSCLK-018 set_unix 写回 RTC,日历字段 + week 正确 Unix 时间戳校时
TC-SYSCLK-019 16 个已知日期(跨 1~12 月 / 世纪闰 / 闰日 / 范围首尾)星期对照 Zeller 全域正确性

当前结果:100/100 PASSED(failed=0)。


3. 如何扩展用例

  1. 在 sys_clock_test.c 加一个 static void tc_0NN_xxx(void);
  2. 用 rtc_set(y,mo,d,h,mi,s) 设置阴影 RTC(init 用例);
  3. 用 base_at(unix_secs, tick) 建立确定基准(get 用例,无 RTC 副作用);
  4. tick_advance(ms) 推进时间,sys_clock_get() / get_time() / get_str() 观测;
  5. CHECK(cond, "描述") / CHECK_EQ_U32(got, want, "描述") 断言;
  6. 在 main() 里注册。

⚠ 别把新用例插到 tc_000 之前:tc_000 依赖"刚上电"的模块静态态 (s_base_unix_secs == UINT32_MAX),一旦有任何 init/set 先跑过,它就失效。

⚠ 惰性再基准化的"正确用法":差值窗口必须恒 < 30 天。一次 tick_advance 推进超过 ~49.7 天会让 uint32_t diff 溢出混叠——那是调用方违约(不是模块 缺陷),测试应按 <30 天步长周期性调用 get()。见 TC-SYSCLK-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-SYSCLK-000,在任何 init/set 之前)观测
确定的已初始化状态 sys_clock_set(unix_secs, NULL) —— 更新基准且不碰 RTC,是理想的隔离原语
RTC 阴影态 rtc_set(...) + clear_obs() 直接重置阴影寄存器与观测计数

5. 由本单测发现并修复的生产缺陷(Zeller 星期公式)

5.1 现象

首次运行 TC-SYSCLK-014/019 时,2026-10-02(真实为周五)返回 week == 4; TC-SYSCLK-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

问题有二:

  1. 世纪项错误:标准 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 毫无关系)。
  2. 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 的自检日志会打印错的星期。

不影响的:

  1. 时间数值(时/分/秒/年/月/日 / Unix 秒)换算本身正确——只有 week 字段错;
  2. FatFs 文件时间戳:Lib/FatFs/ffsystem.c:get_fattime() 只取 年/月/日/时/分/秒(FAT 格式无星期字段),不使用 t.week——故文件 mtime 不受影响;
  3. 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 保证各项非负(无符号 % 安全)。 修复后经 Python datetime 独立核对 1970~2099 全部 47,482 天零错误; /keil-build 0 错误 0 警告。

5.5 校验数据来源

TC-SYSCLK-019 的期望星期由 Python datetime.date(...).weekday() 独立生成 ((weekday()+1) % 7 → 0=周日),与被测实现算法正交,避免"用错的算法 验证错的实现"。


6. 与板端验证的分工

层 覆盖 状态
本单测(主机侧纯逻辑单测) 换算逻辑 / 星期 / 再基准化 / 故障降级 ✅ 已落地(100 断言点)
板端测试套件 / 主机侧回归 真机 SD2506 I2C 读写、掉电保持、与 PC 对时联动 见 SD2506_Test_Guide.md

本单测不替代板端验证:I2C 时序、BCD 编解码、掉电保持、温度读等真机行为, 仍需 /serial-boot-test 台架闭环确认。