Files
STM32F4-Base/docs/子系统设计/系统墙钟时间维护说明.md
T
super_agent_bot fff961d803
CI / 静态门禁 (pre-commit) (push) Failing after 49s
CI / 主机侧单测 (MSVC) (push) Failing after 30s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 45s
CI / 主机侧单测 (MSVC) (pull_request) Failing after 44s
feat(net/udp): UDP 数据面并入 netMsgQueue 串行执行(#73)
前置来自 #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/任务规划/**。
2026-11-03 18:44:01 +08:00

418 lines
24 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.
# 系统墙钟时间维护 设计说明
> 模块:`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 随电脑本地时间递增即表示同步正常。