a91640dc6e
前置来自 #57 SNTP 调研:旧 net_sendto / net_recvfrom 由调用方直调 SPI 且无限自旋收包,既违约「netTask 是芯片唯一访问者」,又把 IWDG 预算吃在自旋里。 改造面 - 新增 Drivers/BSP/NET/net_udp.c/.h(真机专属,模拟器不编译): bind/set_peer/sendto/recvfrom 四条执行体 + 挂起收包表 + tick; net_msg_type_t 加四型,公开 API 一律「入队 → 睡在通知上」。 - 源地址出参不搭 net_msg_t(按值拷贝,AGENTS 禁加回传字段):改住 net_udp.c 的 per-fd 槽,netTask 就地写回调用方内存,通知值只带 结果码;每条出口都清槽,泄漏 = 该 fd 永久 BUSY。 - 发送复用 net_send_locked,不写第二份分块逻辑;「会话是否已死」 收成一条判据 net_send_state_down()(TCP 看 ESTABLISHED、UDP 看 UDP_OPEN),原先 4 处各写一遍。 - 顺带修掉真缺陷(新条 TRAP-NET-22):旧 sendto 把 DesIP 写成具体 IP,芯片静默切 UDP Client,而旧 recvfrom 无条件剥 8 字节 → SNTP 这类 48 B 应答丢载荷前 8 字节并交出垃圾源地址。加 udp_mode(塞进 原有 padding,sizeof 不变)、bind 恒 SERVER、显式对端 sendto 切 CLIENT、set_peer(fd, NULL) 轻量回 SERVER,仅 SERVER 且 avail >= 8 才剥头。 - 截断定案:一次调用交付一个数据报,buf 装不下的尾巴用 64 B 栈上 scratch 排空(不用 clear_recv_buf——它连后面已到达的包一起丢)。 - 取消点接齐:net_init 重建 cancel_all、net_socket 分配 fd clear、 net_close settle_close + clear、重 bind 前先结算等包者, net_process_messages 第 5 个 tick 位挂 net_udp_tick。 - 收紧两处契约(原实现无生产调用者):UDP 未 bind 一律 -5; net_recv / _nb / _to 拒 DGRAM(绕过剥头就是静默吃 8 字节); NET_ERR_NOT_IN_NETTASK(-12) 退役为历史常量并写明原因。 验证 - 主机单测 test/host/net_socket 全套 616/616 绿(改造前 470):新 分片 ns_s7_udp.c 20 组,改写 440 / 555 / 560 组;覆盖率 net_udp.c 234/236(99.2%)、net_socket.c 956/969(98.7%), scripts/test_coverage_baseline.json 随本提交登记新文件。 - 测试抓到 4 个被测码真 bug:自等守卫误用调用侧谓词、芯片长度被读 两次、槽指针取在 fd 合法性检查之前、重 bind 静默作废等待者。 - Keil ARMCC 0 Error(s) 0 Warning(s);net_udp.o Code 1,358 / ZI 224 (8 fd × 28 B,其余模块零增长),全仓 RW_IRAM1 72.2%;内存台账 受管区按门禁提示用 --write / --update 机器回填,未手改数字。 - pre-commit 全量钩子(含 doc-links、coding-standard、mem-budget、 generated-fresh、bsp-err、registration、nested-comment)全绿。 限度 - 本轮无板子:真机回归 5 条(两端互通、set_peer(NULL) 能否恢复收 广播、截断排空后下一包完整性、挂起 recv 期间 IWDG 余量、bind 写 DesIP 与 open 先后的真芯片效果)已写进覆盖率口径「台架必验」。 - scripts/stack_report.py 退出码 1 是 10-26 起挂在账上的 iec104Task 栈债(margin 1,016 < 1,024,见 docs/代码规范/pre-commit使用说明.md: 122);本轮只让 net_bind 的 UDP 分支多一层静态帧(1,544 → 1,600), netTask margin 1,888 与 timeSyncTask 1,152 不受影响。抬栈要过 mem-budget 台账,不在本分支自行处理。 - 登记簿销项按仓库约定留给主工作树,本分支不动 docs/任务规划/**。
418 lines
24 KiB
Markdown
418 lines
24 KiB
Markdown
# 系统墙钟时间维护 设计说明
|
||
|
||
> 模块:`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` 中定义两个静态变量作为墙钟基准:
|
||
|
||
```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`)
|
||
|
||
```mermaid
|
||
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`)
|
||
|
||
```c
|
||
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`)
|
||
|
||
```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"不可用":`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` 中的初始化顺序(节选):
|
||
|
||
```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 回绕安全与 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,见第 15 节) | SNTP/NTP 公网校时仍可作为后续扩展 |
|
||
| 时区 | **口径已定:内部一律本地钟面,不存时区**(用户裁定 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`) |
|
||
| `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 魔法延时;网络不就绪则一直等待),进入循环:
|
||
|
||
```c
|
||
/* 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 个,余量充足。
|
||
|
||
### 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 随电脑本地时间递增即表示同步正常。
|