Files
STM32F4-Base/docs/子系统设计/系统墙钟时间维护说明.md
T

390 lines
21 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 实现掉电保持。
---
## 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.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 公网校时仍可作为后续扩展 |
| 时区 | 按 RTC 本地时间处理 | 如需 UTC/时区,可在 API 层增加偏移参数 |
---
## 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()` → `sys_clock_init()` |
| `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` 绕过队列。选用 TCP 即可让时间同步作为独立任务运行,且**无需改动 `net_socket.c` 的 UDP 路径**。
### 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 随电脑本地时间递增即表示同步正常。
---
## 变更记录
| 日期 | 变更 |
|------|------|
| 2026-09-15 | v1.3:① `sys_clock_get` 30 天惰性再基准化——修复 49.7 天墙钟回绕(§3/§7/§12);② `sys_clock_init` 检查 RTC 读返回值/写回复验,失败降级为默认时间基准并返回 -1——激活"时钟芯片异常"自检(§5/§6/§9);③ 修正未初始化哨兵判断(§12-6);④ 时间同步任务启动/Socket 占用描述同步为事件屏障 + 单连接实现(§15);⑤ 新增日志串用 ASCII——本机 ARMCC 按 CP936 解读无 BOM 源文件,奇数汉字字符串会吞掉结束引号致编译失败(见 AGENTS.md 编码铁律) |
*文档依据 `App/util/sys_clock.c` / `App/sync/time_sync.c` 实现与《嵌入式C语言代码规范_V1.0》整理。*