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

16 KiB
Raw Blame History

系统墙钟时间维护 设计说明

模块:App/sys_clock.c / App/sys_clock.h 依赖SD2506API-G RTC 驱动(Drivers/BSP/SD2506/、HALHAL_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 中定义两个静态变量作为墙钟基准:

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_ticksys_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

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

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_timeNULL 时只改运行期基准、不动 RTC适用于临时/网络校时尚未落盘的场景)。

典型应用SNTP/NTP 同步得到 Unix 秒数 → 转成 sd2506_time_tsys_clock_set(secs, &sd_time) 一次性更新运行基准与掉电保持。


9. 开发期校时宏(sys_clock.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 或 >2099month 越界、day 越界;
  • RTC_DEFAULT_INIT_FORCE == 1:强制每次启动都用上述宏写入默认时间;
  • 否则仅在 RTC 时间无效时写入;
  • 写入后重新 sd2506_get_time() 读取校准后的值,再进入 Step 2 换算。

⚠️ 使用注意FORCE=1每次开机覆盖用户/网络已设的时间,仅用于首次烧录或硬件时钟丢失时的开发期校时。校时完成后应将 RTC_DEFAULT_INIT_FORCE0(或注释 ENABLE),否则会抹掉正常运行中通过 SNTP 等写入的正确时间。


10. 时间换算算法

10.1 Unix 秒 ↔ SD2506unix_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<=2wy--, wm+=12
  • 计算与闰年无关,纯日历代数,结果稳定。

11. 集成与调用示例

Src/main.c 中的初始化顺序(节选):

HAL_Delay(100);
app_main_init();
sd2506_init();      /* 先初始化 RTC */
sys_clock_init();   /* 再建立墙钟基准:所有后续日志即带正确时间戳 */

应用层取时间示例:

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 是完整年份(如 2026sys_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_ttime_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 ServertimeSyncTask
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
同步任务 timeSyncTaskFreeRTOS 任务) 独立任务,由 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 协商),进入循环:

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时间包丢失、同步失败
  • 客户端改为 sendallrecv 直到板子主动关闭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.pyinterval 秒推送一次epoch 随电脑本地时间递增即表示同步正常。


文档依据 App/sys_clock.c / App/time_sync.c 实现与《嵌入式C语言代码规范V1.0)》整理。