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

> 模块：`App/sys_clock.c` / `App/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 实现掉电保持。

---

## 2. 角色与依赖

| 角色 | 提供方 | 说明 |
|------|--------|------|
| 硬件实时钟（RTC） | SD2506API-G | 温补晶振，带备份电池，掉电后继续走时；I2C1（PB6-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();        /* 初始化 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 天回绕一次时依然正确（只要两次采样间隔 < 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 个 Socket，FTP 用 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）》整理。*
