Files
STM32F4-Base/docs/子系统设计/系统墙钟时间维护说明.md
T
super_agent_bot ad7338365c
CI / 静态门禁 (pre-commit) (push) Failing after 6m14s
CI / 主机侧单测 (MSVC) (push) Successful in 54s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m35s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 47s
feat(sntp): 板端 SNTP 校时接入——coreSNTP 序列化器 + [sntp] 段 + 设置页(#73)
- vendored coreSNTP v2.0.0 序列化器(MIT,仅 serializer+LICENSE,零堆),
  48 字节报文编解码在库,节奏与准入判据在本方(RFC 4330 §10 调研口径)
- App/sync/time_sntp.c 挂 timeSyncTask 的 8888 事件循环,等待切片 ≤50 ms,
  不饿任务狗与 IWDG;懒建 UDP socket,代际变化/禁用/死错误码即关闭归还
- 防伪 = OriginTimestamp 逐位等于发出时 T1(含低 16 位随机小数位);
  mode/stratum/闰秒指示/KoD 按 RFC 拒收;DENY·RSTR 换服务器、RATE 只退避
- 主备轮换 + 失败指数退避(5 s 起、60 s 封顶)+ 首轮 1~5 分钟随机错峰;
  成功恢复 poll_min 周期(1~1440 分,出厂默认 60,默认不启用不发包)
- 钟面 = 本地时间:UTC+8 固定偏移落 sys_clock_set_unix(§1.1 时基裁定,
  第三个写入口,装置仍不存时区);|offset|<2 s 只记日志不步进,冷启动或
  更大偏差步进并回写 SD2506,留痕 [SNTP] step 与 syslog OPER
- 首个校验通过的同步置 SYS_EVT_TIMESYNC_READY(就绪位,消费方按需 NoClear)
- [sntp] 配置段(enable/server1/server2/poll_min)属主 sntp_cfg.c,
  注册在 net_cfg_load();UI 页 31「SNTP 设置」(密码叶):启用开关 +
  主/备服务器 octet 编辑 + 周期(分) + 保存,复用 net_cfg_save_request 链
- 测试与门禁:test/host/sntp 新套件(报文/防伪/KoD/退避周期/阶跃域/配置
  注册表)、net_cfg 与 time_sync 套件补 fake、黄金参考 66→74 张零漂移、
  字符集 228 字、Keil 0 错 0 警(RO +5,848 B 含字体)、覆盖率基线回填
- 文档:墙钟维护说明 §16、网络配置说明 [sntp] 节、配置文件设计说明变更记录、
  产品需求 FR-TIME-3 与 8.3/9.1.2 台账、UI 补齐规划/UI补齐 31 叶口径、
  登记簿销项并登记「SNTP 校时真机判据」等台架(PC 时间源 192.168.31.161/.13)
2026-11-04 16:20:22 +08:00

27 KiB
Raw Blame History

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

模块:App/util/sys_clock.c / App/util/sys_clock.h 依赖:SD2506API-G RTC 驱动(Drivers/BSP/SD2506/)、HAL(HAL_GetTick()) 适用平台:STM32F407ZGTx(Cortex-M4,168MHz),I2C1 接 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 实现掉电保持。

1.1 时基口径:内部一律本地钟面(用户裁定 2026-10-27)

裁定原文:「时基先本地为准」。 定的是冲突时谁赢:面板时钟页与 8888 对时把「本地钟面当 epoch」写入, 而 104 的 C_CS NA(对时命令)携的是 UTC,两条写入口后写者赢 ⇒ 装置墙钟与主机本地时刻恒差 8 小时。 本裁定结束「后写者赢」:内部真源 = 本地钟面,任何来源写入前都要先换算成本地钟面再落库。

这条同时也纠正了本文自己的矛盾(这才是 8 小时的根):§2 曾写 epoch = 「自 1970-01-01 00:00:00 UTC 起的秒数」,而 §15 的网络校时协议字段写的是「本地 Unix 时间戳,与电脑显示一致」——两处口径不同,代码 按后者走。现在 §2 已改成「本地时间戳」口径。

为什么选本地:日志时标、UI 显示、FAT 文件时间三者都是给现场电气工程师读的,跟装置所在地钟面一致 才没有解释成本;装置不存时区、不做夏令时,也就没有「时区参数配错」这一类故障。

⚠ 本裁定没有回答的部分(送检前必须单独定):104 上行时标 CP56Time2a 按标准应发 UTC,而现在 iec104_epoch_ms_to_cp56() 是把内部值直接按 UTC 分解发出(即发出去的是本地钟面值)。两条出路: ① 上行前 −时区偏移转 UTC(标准符合,但要给装置一个时区常量,与「不存时区」相冲);② 维持现状发本地 值(现场与主站同时区时看着正确,第三方主站按 UTC 解读会差 8 小时)。在送检/第三方主站验收方向定下来 之前,本仓不改 iec104_asdu.c 的分解逻辑——它不是 bug 修复,是产品对外语义决定。


2. 角色与依赖

角色 提供方 说明
硬件实时钟(RTC) SD2506API-G 温补晶振,带备份电池,掉电后继续走时;I2C1(PB6-SCL/PB7-SDA)
系统节拍 HAL_GetTick() 毫秒级计数,HAL 时基由 TIM7 提供(168MHz 主频派生,精度远高于走时需求)
Unix 时间戳 本模块计算 本地时间戳:把本地钟面按 UTC 记法数出来的秒数(uint32_t),内部不加任何时区偏移(口径见 §1.1)
时间结构 sd2506_time_t SD2506 驱动定义,year 为完整年份(2000~2099),含 week

调用顺序(见 Src/main.c):

app_main_init();      /* 外设/日志等前置初始化 */
sd2506_init();        /* 初始化 RTC(I2C 通信、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.7 天(2³² ms)回绕一次时,单次回绕本身被模运算无缝吸收。但差值窗口必须始终小于一整圈——否则连续运行超过 49.7 天后差值混叠,"过了 2.5 天" 与 "过了一整圈 + 2.5 天" 无法区分,墙钟整体倒退一圈。因此 sys_clock_get() 内置惰性再基准化(§7):窗口达到 30 天时把基准向前平移(墙钟值不变、窗口清零,亚秒余数结转,无损),窗口恒 < 49.7 天,任意长连续运行均无回绕。


4. 内部数据结构

App/util/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_tick:sys_clock_init() / sys_clock_set() 时采样;运行期另由 sys_clock_get() 的惰性再基准化(§7)每 30 天平移一次。
  • s_base_unix_secs:与 s_base_tick 同一时刻的 Unix 秒数;sys_clock_get() 以它为锚推算当前值。
  • 二者均带 volatile(跨任务共享:更新只发生在 sys_clock_init / sys_clock_set / 再基准化临界区,读取可能发生在任意任务/中断上下文)。

注:app_main.c(app_main_init)调用 sys_clock_init();使用方含 dbg_log(日志时间戳)、ffsystem(文件时间戳)、time_sync(对时)等。


5. 对外 API

函数 功能 返回值 限定条件
int sys_clock_init(void) 从 SD2506 读取初始时间,建立墙钟基准(含可选校时) 0 - 成功;-1 - RTC 不可用(启用默认值宏时降级为默认时间基准,墙钟仍可用) 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_init() 返回 -1 表示 RTC 芯片不可用:RTC_DEFAULT_INIT_ENABLE=1 时墙钟降级为编译期默认时间基准(日志时间戳仍有效),app_main 记录"时钟芯片异常"自检;禁用时墙钟保持"未初始化"(各取时接口返回无效哨兵)。sys_clock_set() 只更新基准(写回 RTC 尽力而为),恒返回 0。


6. 初始化流程(sys_clock_init)

sequenceDiagram
    autonumber
    participant SC as sys_clock_init
    participant RTC as SD2506 RTC
    participant HAL as HAL Tick

    Note over SC,RTC: Step 1  从 SD2506 读取初始时间(检查返回值)
    SC->>RTC: sd2506_get_time(sd_time)
    RTC-->>SC: sd_time(year 已是完整年份)+ 返回值
    Note right of SC: 后续换算【不得】再加 2000<br>否则年份虚增 → Unix 秒数超 uint32 回绕
    Note right of SC: 可选校时(见第 9 节):读失败/越界 →<br>写默认时间 → 重读(检查返回值)→ 复验范围
    alt RTC 仍不可用
        Note over SC: 降级:ENABLE=1 → 以编译期默认时间建基准(仅 RAM)并返回 -1;<br>ENABLE=0 → 不建基准(墙钟保持"未初始化")并返回 -1<br>app_main 记录"时钟芯片异常"自检
    end

    Note over SC: Step 2  将 RTC 时间换算为 Unix epoch 秒数
    SC->>SC: 累计 1970 至当前年份的整年天数(含闰年 366 天判定)
    SC->>SC: 加上当年 1 月至当前月的累计天数
    SC->>SC: 加当月已过天数、时/分/秒
    SC->>SC: 用 int64_t 累加,避免中间溢出;结果截断存入 uint32_t(受 2038 限制,见第 12 节)

    Note over SC,HAL: Step 3  建立基准
    SC->>HAL: HAL_GetTick()
    HAL-->>SC: 当前 tick
    Note right of SC: s_base_tick = HAL_GetTick()<br>s_base_unix_secs = (uint32_t)unix_secs

初始化后,所有后续取时都基于 Step 3 的基准推算,不再访问 RTC。


7. 时间获取与漂移维持(sys_clock_get)

uint32_t now_tick = HAL_GetTick();
uint32_t diff = now_tick - s_base_tick;      /* 32 位模减,跨 Tick 回绕无缝 */

if (s_base_unix_secs != UINT32_MAX && diff > SYS_CLOCK_REBASE_MS) {
    /* 惰性再基准化:窗口达 30 天,基准前移(墙钟值不变),余数结转 */
    taskENTER_CRITICAL();
    diff = now_tick - s_base_tick;           /* 复核:防双任务重复累加 */
    if (diff > SYS_CLOCK_REBASE_MS) {
        s_base_unix_secs += diff / 1000u;
        s_base_tick = now_tick - (diff % 1000u);  /* 新窗自 (now − 余数) 起算 */
        diff = diff % 1000u;
    }
    taskEXIT_CRITICAL();
}

if (s_base_unix_secs == UINT32_MAX) return UINT32_MAX;  /* 未初始化(显式哨兵判断) */
return (uint32_t)((int64_t)s_base_unix_secs + diff / 1000u);

要点:

  • 用 整除 1000 把毫秒 Tick 折算为秒,不做每毫秒累加,避免累加误差累积与频繁除法;
  • now_tick - s_base_tick 为模差:Tick 每 ~49.7 天回绕被无缝吸收,但窗口必须恒 < 一整圈(见下方再基准化);
  • 惰性再基准化(30 天):差值窗口达到 SYS_CLOCK_REBASE_MS 时,基准前移流逝秒数并清零窗口——墙钟值逐位不变,亚秒余数结转入新窗(base_tick = now − rem),与"从不重基"数学等价,零损失;
  • 再基准化为自我参照:纯内存操作、零 I2C、非阻塞——不重读 RTC 重锚(RTC 漂移 ±20ppm ≈ ±1.7 s/天,远大于 Tick 漂移,重锚反而降低精度;RTC 只承担冷启动锚点 + 掉电保持);
  • 触发为惰性:墙钟读取高频(每条日志/文件操作/待机屏),阈值越过后的下一次读取即完成再基准化,无需定时器任务;30 天阈值留 19.7 天余量,设备完全静默也不会越界;
  • 再基准化在临界区 + 区内复核中执行,防双任务并发重复累加;
  • 基准未建立(s_base_unix_secs 仍为 UINT32_MAX 哨兵)时返回 UINT32_MAX 作为"无效"(显式哨兵判断,替代旧版对正 int64 永不成立的 < 0 检查)。

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

典型应用:SNTP/NTP 同步得到 Unix 秒数 → 转成 sd2506_time_t → sys_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"不可用":sd2506_get_time() 返回值非 OK(I2C 读失败)、year 越界(<2000 或 >2099)、month/day 越界;
  • RTC_DEFAULT_INIT_FORCE == 1:强制每次启动都用上述宏写入默认时间;
  • 否则仅在 RTC 不可用时写入;
  • 写入后重新 sd2506_get_time() 读取校准后的值(同样检查返回值)并复验范围,再进入 Step 2 换算;
  • RTC 仍不可用时:ENABLE=1 则以编译期默认时间建基准(仅 RAM 的降级基准,墙钟仍可用)且 sys_clock_init 返回 -1;ENABLE=0 则不建基准,返回 -1。

⚠️ 使用注意: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 中的初始化顺序(节选):

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 是完整年份(如 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 回绕安全与 30 天再基准化 (now_tick - s_base_tick) 为无符号模差:Tick 每 ~49.7 天(2³² ms)回绕被模运算无缝吸收,但差值窗口必须恒小于一整圈——否则连续上电超过 49.7 天后差值混叠,墙钟"倒退"一整圈(49.7 天)。因此 sys_clock_get() 在窗口达 30 天时惰性再基准化(基准前移 + 窗口清零,亚秒余数结转、无损、纯内存操作、零 I2C),详见 §7。

  4. 运行期不访问 RTC sys_clock_get() 无任何 I2C 操作,零阻塞、可随时在任意任务/中断上下文安全调用(仅读 volatile 基准)。

  5. 校时宏的副作用 见第 9 节 FORCE=1 会覆盖既有正确时间,仅限开发期使用。

  6. 未初始化哨兵必须显式判断 "基准未建立"以 s_base_unix_secs == UINT32_MAX 表示,sys_clock_get() 须先显式判哨兵再推算。旧实现以"推算结果 < 0"判断,但哨兵转 int64 后是正数(4294967295),条件永不成立、会返回垃圾小值而非 UINT32_MAX(使所有消费方的降级路径失效)——v1.3(2026-09-15)已修正。


13. 已知限制与可扩展方向

项目 现状 可扩展方向
时间范围 受 uint32_t 限制至 2038 改用 uint64_t/time_t 扩展
运行期校准 30 天惰性再基准化已实现(§7):纯自我参照、零 I2C;有意不周期性重读 RTC 重锚——RTC 漂移(±20ppm ≈ ±1.7 s/天)远大于 Tick 漂移,重锚反而降低精度;时间纠偏由时间同步(§15)负责
网络校时 已实现两条:PC 推送 / TCP 8888(§15);SNTP 客户端 / UDP 123(§16,2026-11-04 落地) NTP 公网源需现场路由,口径不变;104 C_CS 写入口径见 §1.1 末待决项
时区 口径已定:内部一律本地钟面,不存时区(用户裁定 2026-10-27,见 §1.1) 「API 层加偏移参数」这条旧建议作废;唯一待决的是 104 上行是否要转 UTC(§1.1 末,送检前定)

13.1 用户 SRAM 的占用分配(共享资源,用之前先在这里登记)

SD2506 有 70 字节电池保持用户 SRAM(寄存器 2CH~71H,Drivers/BSP/SD2506/sd2506.h 的 SD2506_REG_SRAM_START/END)。它是全仓唯一"零 NAND 磨损、零卷锁、跨掉电保持"的 小介质,但没有任何机制防止两个消费者写同一段——所以占用只能靠本表登记, 新占用请在此加一行再动代码(偏移从低往高排,已用段永不复用)。

偏移(SRAM 内) 寄存器 长度 属主与内容 失效判读
0..7 2CH~33H 8 B App/util/boot_stats.c:上电次数账本(magic 2B + 计数 4B + 格式版本 1B + 校验 1B) 换电池 / 电池欠压 ⇒ 整段丢失属正常降级,故口径是「自 RTC 电池周期起」,不是终身次数
8..69 34H~71H 62 B 空闲(日后新占用从这里起,别在 0..7 里挤) —
  • 为什么这一格归 RTC 而不是文件系统:上电次数每上电才变一次,落 NAND 要么每次开机 一次 FTL 写、要么做成周期近似写;两者都不如 8 字节 I2C 写干净。写点在内核启动前 (App/app/app_main.c 紧接 sd2506_init()),所以既不占喂狗点、也不碰卷锁。
  • ⚠ 同一条 I2C1 总线还挂着别的器件:不要把这里的读写搬进任何关键链或 ISR, 本表的属主全部只在启动段单线程访问一次。
  • 机理与四态判读(不可信 / 首次启用 / 延续 / 电池域掉过电)的唯一出处是 App/util/boot_stats.h 的文件头与函数头,本文不复制第二份。

14. 相关文件

路径 说明
App/util/sys_clock.c 墙钟维护实现(基准法、换算、校时宏)
App/util/sys_clock.h 对外 API 声明
Drivers/BSP/SD2506/sd2506.c / .h SD2506 RTC I2C 驱动(sd2506_get_time / sd2506_set_time)
App/app/app_main.c 初始化调用顺序:sd2506_init() → boot_stats_init()(用户 SRAM 上电账本,§13.1)→ sys_clock_init()
App/util/boot_stats.c / .h 用户 SRAM 上电账本 + 本次开机复位原因快照(占用见 §13.1;单测 test/host/boot_stats/)
App/sync/time_sync.c / time_sync.h 时间同步实现(TCP Server,timeSyncTask)
App/sync/time_sntp.c / time_sntp.h SNTP 校时客户端(§16,同一任务内挂 8888 事件循环);单测 test/host/sntp/
Drivers/BSP/NETCFG/sntp_cfg.c / .h [sntp] 配置段属主(注册在 net_cfg_load())
Lib/coreSNTP/ vendored coreSNTP v2.0.0 序列化器(MIT,仅 serializer + LICENSE;48 字节报文编解码)
test/host/time_sync_server.py PC 端时间推送客户端(TCP)

15. 时间同步(PC 推送 / TCP)

除 RTC 自身走时与开发期校时宏外,系统支持由 PC 通过 TCP 主动推送本地墙钟 来同步板子时间,使板子时间与开发电脑一致(无需公网 NTP)。

15.1 模块与角色

角色 提供方 说明
时间同步服务端(板子) App/sync/time_sync.c / time_sync.h TCP Server,监听 TIME_SYNC_PORT(默认 8888)
同步任务 timeSyncTask(FreeRTOS 任务) 独立任务,由 Src/freertos.c 创建(优先级 osPriorityNormal,栈 2KB)
时间推送端(PC) test/host/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 启动后先等待 SYS_EVT_NET_READY 事件标志(取代旧版 8s 魔法延时;网络不就绪则一直等待),进入循环:

/* osFlagsNoClear:就绪位是"状态"不是"事件",任何消费者都不得把它清掉
 * (否则其它等待者永不唤醒——2026-09-21 FTP 永不监听事故的根因) */
osEventFlagsWait(sys_evtHandle, SYS_EVT_NET_READY, osFlagsWaitAny | osFlagsNoClear, osWaitForever);

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 曾经绕过队列,2026-11-03 已并入队列(改造事实见 docs/网络/BSD_Socket_API_使用指南.md §9.0)。也就是说:"UDP 不安全"不再是本节选 TCP 的理由——本节结论不变(现有 TCP:8888 时间同步通道继续有效),但若日后要换成 UDP(#57 SNTP 方向),不再需要先改 net 层。

15.5 Socket 分配与冲突

  • net_socket() 从 0 开始分配第一个空闲 Socket 控制块(alloc_socket()),并把硬件 Socket 号设为该下标;调用时的第三个参数(0)仅为协议占位,不影响分配。
  • 当前启动顺序:FTP 任务(NET_READY + STORAGE_READY 屏障满足后,无魔法延时)先监听 → 占用一个 Socket(单连接模式下监听通道复用为数据通道);时间同步任务(NET_READY 后)后监听 → 占用另一个空闲 Socket。二者不冲突。
  • 单连接模式允许任意 Socket 作为"监听 + 数据复用"通道,且总共 8 个 Socket,FTP 与时间同步各用 1 个,余量充足。
  • SNTP 客户端(§16)另占 1 个 UDP Socket:懒创建(首次到点才 net_socket+net_bind 本地口 0 = 芯片动态分配),断网代际变化、enable=0 或内核错误码时关闭归还;不监听、不参与端口台账。

15.6 客户端实现要点(避坑)

PC 端 test/host/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/host/time_sync_server.py 每 interval 秒推送一次,epoch 随电脑本地时间递增即表示同步正常。


16. SNTP 校时(客户端,2026-11-04 落地)

  • 模块:App/sync/time_sntp.c/.h(挂在 timeSyncTask 的 8888 事件循环里,等待切片 ≤50 ms,不饿任务狗); 48 字节报文编解码 = vendored coreSNTP 序列化器(Lib/coreSNTP/,MIT,零堆),节奏与准入判据在本方。
  • 配置:/config.ini [sntp] 段(enable / server1 / server2 / poll_min,属主 Drivers/BSP/NETCFG/sntp_cfg.c, 注册在 net_cfg_load());现场改法 = UI「通讯参数 → SNTP 设置」页(密码叶)保存后随下一轮生效,或 FTP 改文件(4 s 落定)。 出厂默认不启用、服务器为空——不配不发任何 UDP 包。
  • 时钟面换算:SNTP 给的是 UTC,落库前加固定东八区偏移(SNTP_TZ_LOCAL_OFFSET_SECS 28800)写 sys_clock_set_unix() (连带回写 SD2506)——这正是 §1.1 裁定「任何来源写入前先换算成本地钟面」的第三个写入口;装置仍不存时区。
  • 判据(RFC 4330 §10 口径):OriginTimestamp 逐位等于发出时的 T1(含低 16 位随机小数位,防伪造/防重放); mode=4、stratum 1~15、闰秒指示 ≠3、服务器 Kiss-o'-Death 按 DENY/RSTR(换服务器)与 RATE(退避)分流; 响应源地址必须等于请求目的地址。失败:超时指数退避(5 s 起、60 s 封顶)+ 主备轮换;成功:恢复 poll_min 周期。
  • 步进策略:|offset| < 2 s 只记日志不步进;≥ 2 s 或冷启动直接步进并留痕 [SNTP] step offset=、syslog OPER 「clock set by SNTP」;首个校验通过的同步置 SYS_EVT_TIMESYNC_READY(就绪位,不覆盖后续步进语义)。
  • 与 8888 的关系:同一任务、同一 sys_clock_set_unix 收口,后写者赢维持现状(两路并存是部署选择,不是冲突缺陷)。
  • 主机单测:test/host/sntp/(报文格式、防伪拒收、KoD、退避与周期、阶跃域守卫、配置注册表)。