Files
STM32F4-Base/docs/系统墙钟时间维护说明.md
2026-08-28 16:42:44 +08:00

349 lines
16 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
# 系统墙钟时间维护 设计说明
> 模块:`App/sys_clock.c` / `App/sys_clock.h`
> 依赖SD2506API-G RTC 驱动(`Drivers/BSP/SD2506/`、HAL`HAL_GetTick()`
> 适用平台STM32F407ZGTxCortex-M4168MHzI2C1 接 SD2506
> 作者王建锋 创建日期2026-07-19
---
## 1. 概述
系统需要一个"墙钟时间"wall clock即真实日历时间 `YYYY-MM-DD HH:MM:SS`),用于:
- 日志/调试信息打时间戳;
- 文件系统的文件创建/修改时间FatFS 需要);
- 业务逻辑中基于真实时间的判定(定时、超时、调度等);
- 对外接口(如 FTP `LIST` 返回的文件时间、SNTP/NTP 校时等)。
本模块在 **SD2506 硬件 RTC掉电保持****HAL 系统节拍(运行期计时)** 之间做桥接:
- 启动时从 SD2506 读取一次真实时间,换算为 Unix 时间戳,建立"墙钟基准"
- 运行期间不再频繁访问 I2C RTC而是以 **HAL Tick 流逝量** 推算当前时间,避免每次取时都走 I2C 总线;
- 仅在 `sys_clock_set()` 被调用如用户校时、SNTP 同步)时,才把时间写回 SD2506 实现掉电保持。
---
## 2. 角色与依赖
| 角色 | 提供方 | 说明 |
|------|--------|------|
| 硬件实时钟RTC | SD2506API-G | 温补晶振带备份电池掉电后继续走时I2C1PB6-SCL/PB7-SDA |
| 系统节拍 | `HAL_GetTick()` | 毫秒级计数HAL 时基由 TIM7 提供168MHz 主频派生,精度远高于走时需求) |
| Unix 时间戳 | 本模块计算 | 自 1970-01-01 00:00:00 UTC 起的秒数(`uint32_t` |
| 时间结构 | `sd2506_time_t` | SD2506 驱动定义,`year` 为**完整年份2000~2099**,含 `week` |
**调用顺序(见 `Src/main.c`**
```
app_main_init(); /* 外设/日志等前置初始化 */
sd2506_init(); /* 初始化 RTCI2C 通信、24h 制、充电配置) */
sys_clock_init(); /* 读取 RTC → 建立墙钟基准 */
```
> 必须在 `sd2506_init()` 之后调用 `sys_clock_init()`;其余 `sys_clock_*` 接口必须在 `sys_clock_init()` 之后使用。
---
## 3. 核心思想基准法drift-free 维持)
为什么不每次都读 RTC
- SD2506 走 I2C单次读取有总线开销与阻塞延迟
- RTC 自身已足够精确,无需运行时反复校准;
- 运行期真正变化的只是"过了多少时间",由 HAL Tick 提供。
因此模块只在初始化(`sys_clock_init`)与显式设置(`sys_clock_set`)时接触 RTC其余时刻用以下公式维持时间
```
当前 Unix 秒数 = s_base_unix_secs + (HAL_GetTick() - s_base_tick) / 1000
```
其中 `(now_tick - s_base_tick)`**unsigned 减法**,在 Tick 每约 49 天回绕一次时依然正确(只要两次采样间隔 < 2³² ms ≈ 49 天,单次运行必然满足),所以连续上电不超过 49 天的场景无回绕问题。
---
## 4. 内部数据结构
`App/sys_clock.c` 中定义两个静态变量作为墙钟基准:
```c
static volatile uint32_t s_base_tick = 0; /* HAL_GetTick() 基准(建立基准时的 Tick 值) */
static volatile uint32_t s_base_unix_secs = UINT32_MAX; /* 基准对应的 Unix 秒数;初始为 UINT32_MAX 表示"未建立" */
```
- `s_base_tick``sys_clock_init()` / `sys_clock_set()` 时采样一次。
- `s_base_unix_secs`:与 `s_base_tick` 同一时刻的 Unix 秒数;`sys_clock_get()` 以它为锚推算当前值。
- 二者均带 `volatile`,因为时间是跨任务共享的状态(虽更新在 nets/默认任务中,但读取可能发生在任意任务上下文)。
> 注:当前工程仅 `main.c` 调用了 `sys_clock_init()``sys_clock_get()` / `sys_clock_get_str()` / `sys_clock_set()` 是供应用层(日志、文件时间、校时协议等)调用的 API尚未被其他模块引用。
---
## 5. 对外 API
| 函数 | 功能 | 返回值 | 限定条件 |
|------|------|--------|----------|
| `int sys_clock_init(void)` | 从 SD2506 读取初始时间,建立墙钟基准(含可选校时) | `0`(始终成功) | `sd2506_init()` 已调用 |
| `uint32_t sys_clock_get(void)` | 获取当前 Unix 时间戳(秒) | 当前秒数;未初始化返回 `UINT32_MAX` | `sys_clock_init()` 已调用 |
| `char *sys_clock_get_str(char *buf, size_t buf_size)` | 获取格式化字符串 `YYYY-MM-DD HH:MM:SS` | `buf` 指针;未初始化输出 `----/--/-- --:--:--` | `buf` 有效且 `buf_size ≥ 20` |
| `int sys_clock_set(uint32_t unix_secs, const sd2506_time_t *sd_time)` | 更新基准,可写回 SD2506 | `0`(始终成功) | `unix_secs` 为合法时间戳 |
> 头文件 `sys_clock.h` 中注释的返回值含 `-1` 失败码,但实际实现恒返回 `0`,以"成功"语义对外;调用方按 `0` 成功处理即可。
---
## 6. 初始化流程(`sys_clock_init`
```
Step 1 从 SD2506 读取初始时间 sd2506_get_time(&sd_time)
├─ 注意sd_time.year 已是完整年份(驱动内已 +2000
│ 后续换算【不得】再加 2000否则年份虚增 → Unix 秒数超 uint32 回绕
└─ 可选校时(见第 9 节 RTC_DEFAULT_INIT_* 宏)
Step 2 将 RTC 时间换算为 Unix epoch 秒数
├─ 累计 1970 至当前年份的整年天数(含闰年 366 天判定)
├─ 加上当年 1 月至当前月的累计天数
├─ 加当月已过天数、时/分/秒
└─ 用 int64_t 累加,避免中间溢出;结果截断存入 uint32_t受 2038 限制,见第 12 节)
Step 3 建立基准
s_base_tick = HAL_GetTick();
s_base_unix_secs = (uint32_t)unix_secs;
```
初始化后,所有后续取时都基于 Step 3 的基准推算,不再访问 RTC。
---
## 7. 时间获取与漂移维持(`sys_clock_get`
```c
uint32_t now_tick = HAL_GetTick();
int64_t unix_secs = (int64_t)s_base_unix_secs
+ (int64_t)((uint32_t)(now_tick - s_base_tick) / 1000uL);
if (unix_secs < 0) return UINT32_MAX; /* 基准未建立(理论上不会发生) */
return (uint32_t)unix_secs;
```
要点:
-**整除 1000** 把毫秒 Tick 折算为秒,**不**做每毫秒累加,避免累加误差累积与频繁除法;
- `now_tick - s_base_tick` 为无符号减法,自动正确处理 Tick 回绕;
- 若基准未建立(`s_base_unix_secs` 仍为 `UINT32_MAX` 且未初始化),返回 `UINT32_MAX` 作为"无效"哨兵。
### 7.1 字符串格式化(`sys_clock_get_str`
内部先 `sys_clock_get()`,若无效则输出占位串 `----/--/-- --:--:--`;否则用 `unix_to_sd2506()` 转回 `sd2506_time_t` 后,以 `snprintf(buf, buf_size, "%04d-%02d-%02d %02d:%02d:%02d", ...)` 格式化(年份显示时 `+2000` 还原为完整年份)。使用 `snprintf` 防止缓冲区溢出,调用方需保证 `buf_size ≥ 20`
---
## 8. 时间设置与回写(`sys_clock_set`
```c
s_base_tick = HAL_GetTick();
s_base_unix_secs = unix_secs;
if (sd_time != NULL) {
sd2506_set_time(sd_time); /* 写回 RTC实现掉电保持 */
}
```
- 仅更新"基准",之后 `sys_clock_get()` 即以此为准;
- 若传入 `sd_time != NULL`,同步写回 SD2506使断电后时间不丢失
- `sd_time``NULL` 时只改运行期基准、不动 RTC适用于临时/网络校时尚未落盘的场景)。
典型应用SNTP/NTP 同步得到 Unix 秒数 → 转成 `sd2506_time_t``sys_clock_set(secs, &sd_time)` 一次性更新运行基准与掉电保持。
---
## 9. 开发期校时宏(`sys_clock.c` 顶部)
```c
#define RTC_DEFAULT_INIT_ENABLE 1
#define RTC_DEFAULT_INIT_FORCE 1
#define RTC_DEFAULT_YEAR 2026
#define RTC_DEFAULT_MONTH 8
#define RTC_DEFAULT_DAY 24
#define RTC_DEFAULT_HOUR 0
#define RTC_DEFAULT_MINUTE 0
#define RTC_DEFAULT_SECOND 0
```
行为(`sys_clock_init` 内,`#if defined(RTC_DEFAULT_INIT_ENABLE)` 包裹):
- 判定 RTC 时间是否"无效"`year` 越界(<2000 或 >2099`month` 越界、`day` 越界;
- `RTC_DEFAULT_INIT_FORCE == 1`:强制每次启动都用上述宏写入默认时间;
- 否则仅在 RTC 时间无效时写入;
- 写入后重新 `sd2506_get_time()` 读取校准后的值,再进入 Step 2 换算。
> ⚠️ **使用注意**`FORCE=1` 会**每次开机覆盖用户/网络已设的时间**,仅用于首次烧录或硬件时钟丢失时的开发期校时。校时完成后应将 `RTC_DEFAULT_INIT_FORCE` 置 `0`(或注释 `ENABLE`),否则会抹掉正常运行中通过 SNTP 等写入的正确时间。
---
## 10. 时间换算算法
### 10.1 Unix 秒 ↔ SD2506`unix_to_sd2506`
- 时分秒:对 `unix_secs` 连续 `%60 /60 /24` 拆分;
- 年月日:先算总天数 `unix_secs / 86400`,再从 1970 年起逐年减去平/闰年天数,定位到具体年;再按月累减定位月、日;
- 星期:由 Zeller 公式(见 10.2)计算,结果 `0=Sunday`
- SD2506 年份字段为 0~99+2000故回写时 `year - 2000`
### 10.2 星期计算Zeller 公式)
`rtc_calc_week()``unix_to_sd2506()` 内使用同一套算法:
```
week = (century_term + month_term + day_term) % 7 /* 0=Sunday */
```
- `1、2 月视作上一年的 13、14 月`(公式要求),对应代码里 `wm<=2``wy--, wm+=12`
- 计算与闰年无关,纯日历代数,结果稳定。
---
## 11. 集成与调用示例
`Src/main.c` 中的初始化顺序(节选):
```c
HAL_Delay(100);
app_main_init();
sd2506_init(); /* 先初始化 RTC */
sys_clock_init(); /* 再建立墙钟基准:所有后续日志即带正确时间戳 */
```
应用层取时间示例:
```c
char ts[24];
DBG_INFO("now: %s", sys_clock_get_str(ts, sizeof(ts)));
uint32_t now = sys_clock_get(); /* Unix 秒,可用于 FatFS 文件时间、超时判定等 */
```
---
## 12. 关键设计要点与陷阱
1. **`year` 已经 `+2000`,禁止二次加 2000**
`sd2506_get_time()` 返回的 `year` 是完整年份(如 2026`sys_clock_init` 的 Step 2 直接把它当完整年份参与 Unix 换算;若再 `+2000` 会得到 4026 年,换算出的 Unix 秒数远超 `uint32_t` 上限,发生截断/回绕,时间彻底错乱。代码中相关注释已明确标注。
2. **`uint32_t` Unix 时间戳的 2038 上限**
基准与返回值均为 `uint32_t`,时间范围约为 `1970-01-01 ~ 2038-01-19`。超过 2038 年换算会回绕。当前设备生命周期内可接受;若需长期运行,需改用 `uint64_t``time_t`
3. **Tick 回绕安全**
`(now_tick - s_base_tick)` 为无符号减法Tick 每 ~49 天回绕一次时计算依然正确,无需特殊处理。
4. **运行期不访问 RTC**
`sys_clock_get()` 无任何 I2C 操作,零阻塞、可随时在任意任务/中断上下文安全调用(仅读 `volatile` 基准)。
5. **校时宏的副作用**
见第 9 节 `FORCE=1` 会覆盖既有正确时间,仅限开发期使用。
---
## 13. 已知限制与可扩展方向
| 项目 | 现状 | 可扩展方向 |
|------|------|------------|
| 时间范围 | 受 `uint32_t` 限制至 2038 | 改用 `uint64_t`/`time_t` 扩展 |
| 运行期校准 | 仅初始化时读 RTC | 可定时(如每天)重新 `sd2506_get_time()` 校准,消除 RTC 与 Tick 的微小累积偏差 |
| 网络校时 | 已实现PC 推送 / TCP见第 15 节) | SNTP/NTP 公网校时仍可作为后续扩展 |
| 时区 | 按 RTC 本地时间处理 | 如需 UTC/时区,可在 API 层增加偏移参数 |
---
## 14. 相关文件
| 路径 | 说明 |
|------|------|
| `App/sys_clock.c` | 墙钟维护实现(基准法、换算、校时宏) |
| `App/sys_clock.h` | 对外 API 声明 |
| `Drivers/BSP/SD2506/sd2506.c` / `.h` | SD2506 RTC I2C 驱动(`sd2506_get_time` / `sd2506_set_time` |
| `Src/main.c` | 初始化调用顺序:`sd2506_init()``sys_clock_init()` |
| `App/time_sync.c` / `time_sync.h` | 时间同步实现TCP Server`timeSyncTask` |
| `test/time_sync_server.py` | PC 端时间推送客户端TCP |
---
---
## 15. 时间同步PC 推送 / TCP
除 RTC 自身走时与开发期校时宏外,系统支持由 **PC 通过 TCP 主动推送本地墙钟** 来同步板子时间,使板子时间与开发电脑一致(无需公网 NTP
### 15.1 模块与角色
| 角色 | 提供方 | 说明 |
|------|--------|------|
| 时间同步服务端(板子) | `App/time_sync.c` / `time_sync.h` | TCP Server监听 `TIME_SYNC_PORT`(默认 8888 |
| 同步任务 | `timeSyncTask`FreeRTOS 任务) | 独立任务,由 `Src/freertos.c` 创建(优先级 `osPriorityNormal`,栈 2KB |
| 时间推送端PC | `test/time_sync_server.py` | Python TCP 客户端,周期推送本机本地时间 |
| 落盘接口 | `sys_clock_set_unix()` | 收到时间包后调用,内部转 `sd2506_time_t` 并写回 SD2506 |
### 15.2 协议
PC 每轮发送固定 8 字节帧:
```
字节 0..3 : 魔术字 "TIME"0x54 0x49 0x4D 0x45
字节 4..7 : 本地 Unix 时间戳uint32大端PC 本地时间,含时区,与电脑显示一致)
```
板子 `time_sync_handle_conn()` 读取后校验魔术字,取大端 uint32 调用 `sys_clock_set_unix(epoch)` 更新运行基准并写回 RTC。
### 15.3 任务流程
`timeSyncTask` 启动后延迟 8s等 netTask 完成 `net_init` 与 PHY 协商),进入循环:
```c
for (;;) {
ls = time_sync_open_listener(); /* net_socket + net_bind + net_listen失败重试 */
conn = net_accept(ls, NULL, NULL); /* 阻塞等待 PC 连接(无连接立即返回 -1循环重试 */
time_sync_handle_conn(conn); /* net_recv 读到 8 字节 → sys_clock_set_unix → net_close */
/* conn单连接模式下即 ls被 net_close 释放,下次循环重建监听 */
}
```
> 单连接模式下监听 Socket 收到连接后即转为数据通道,连接关闭后被释放;因此每轮循环都**重新创建监听 Socket**(与 `lftpd` 同模式),与 FTP 不冲突。
### 15.4 为什么用 TCP 而非 UDP
- 所有 socket 操作(`net_socket` / `net_bind` / `net_listen` / `net_accept` / `net_recv` / `net_close`)均走 `net_socket` 的**线程安全 API**,内部经消息队列由 `netTask` 串行执行;
- netTask 因此只需做 `net_poll()` + `net_process_messages()`,不掺杂任何应用层网络逻辑;
- **TCP 的全部 API 已经是基于消息队列的**(已在 `net_socket.c` 实现),而 UDP 的 `net_bind` / `net_sendto` / `net_recvfrom` 绕过队列。选用 TCP 即可让时间同步作为独立任务运行,且**无需改动 `net_socket.c` 的 UDP 路径**。
### 15.5 Socket 分配与冲突
- `net_socket()` 从 0 开始分配第一个空闲 Socket 控制块(`alloc_socket()`),并把硬件 Socket 号设为该下标调用时的第三个参数0仅为协议占位不影响分配。
- 当前启动顺序FTP 任务(延迟 7s先监听 → 占用 **socket 0**(控制)+ **socket 1**(数据);时间同步任务(延迟 8s后监听 → 占用 **socket 1**(或下一个空闲)。二者不冲突。
- 单连接模式允许**任意** Socket 作为"监听 + 数据复用"通道,且总共 8 个 SocketFTP 用 2 个、时间同步用 1 个,余量充足。
### 15.6 客户端实现要点(避坑)
PC 端 `test/time_sync_server.py` 发送 8 字节后**必须保持连接打开**,等待板子读取(或短延时)后再 `close()`
- 若发送后立刻关闭CH395F 在收到 FIN 时会**丢弃接收缓冲里尚未被板子读出的 8 字节**
- 板子 `net_recv` 经消息队列异步执行(有 ~10ms+ 调度延迟),等它去读时连接已 DISCONNECT、缓冲已清空`net_recv` 走 CLOSED 分支返回 0时间包丢失、同步失败
- 客户端改为 `sendall``recv` 直到板子主动关闭EOF或 2s 超时,即可保证板子先读到数据。
### 15.7 验证
板子日志应出现:
```
[TIME_SYNC] time_sync: TCP listen on :8888 (sock=1)
[NET] sock1 standalone accept
[NET] recv sock1 len=8
[CLK] sys_clock_set: wall clock updated to <epoch>
[CLK] sys_clock_set: SD2506 RTC written
[TIME_SYNC] time_sync: wall clock updated (epoch=<epoch>)
```
PC 端 `python test/time_sync_server.py``interval` 秒推送一次epoch 随电脑本地时间递增即表示同步正常。
---
*文档依据 `App/sys_clock.c` / `App/time_sync.c` 实现与《嵌入式C语言代码规范V1.0)》整理。*