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

256 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 系统时钟单元测试说明(主机侧)
> **模块**: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` 台架闭环确认。