# 系统时钟单元测试说明(主机侧) > **模块**:System Clock(墙钟时间维护,`App/util/sys_clock.c`) > **测试类型**:**主机侧纯逻辑单元测试**——在 PC 上离线验证时间换算与 > 墙钟基准逻辑,零硬件、零烧录、秒级、不占 MCU 的 RAM。 > **状态**:✅ 已落地(20 用例 / 100 断言点,`/keil-build` 0/0,已挂 pre-commit 钩子) > **日期**:2026-10-02 > **关联文档**: > [`../../../AGENTS.md`](../../../AGENTS.md)(平台约束)、 > [`测试开发标准流程.md`](../流程与规范/测试开发标准流程.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`](../../../test/host/common/unit_test_harness.py), 本模块 `run_unit_test.py` 只**声明** `TestBuild`(源文件 / 桩目录 / 汇总行标记)。 ### 1.4 运行方式 ```powershell # 仓库根目录(统一用 .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 变种"公式为: ```c (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,并**收敛到单一函数**(消除两处重复实现再次发散的风险): ```c 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`](../板级测试/SD2506_Test_Guide.md) | > 本单测**不替代**板端验证:I2C 时序、BCD 编解码、掉电保持、温度读等真机行为, > 仍需 `/serial-boot-test` 台架闭环确认。