290 lines
12 KiB
Markdown
290 lines
12 KiB
Markdown
# 开机时钟小时偏差问题修复说明
|
||
|
||
## 1. 提交信息
|
||
|
||
- 提交:`f33ad70 fix(time): apply timezone before lcd startup`
|
||
- 现象:设备每次开机后,LCD 显示的分钟正确,但小时错误;网络连接并完成时间服务初始化后,小时恢复正确。
|
||
- 复现场景:普通重启、Deep Sleep 唤醒、开机后暂时没有网络。
|
||
|
||
## 2. 问题原因
|
||
|
||
ESP32 的系统时间和 RTC 时间使用 UTC 时间戳保存绝对时间。LCD 将时间戳转换成年、月、日、小时和分钟时,会通过 `localtime_r()` 使用当前进程的 `TZ` 环境变量进行本地时区换算。
|
||
|
||
旧实现只在 Wi-Fi 连接成功、准备启动 SNTP 时执行:
|
||
|
||
```c
|
||
setenv("TZ", posix_tz, 1);
|
||
tzset();
|
||
```
|
||
|
||
但 LCD 的初始化和渲染发生在 Wi-Fi 连接及 SNTP 启动之前。因此,设备刚启动时虽然 RTC 中保留的 UTC 时间戳仍然有效,但当前进程尚未设置中国标准时间时区,`localtime_r()` 会按 UTC 解释时间。这会产生以下特征:
|
||
|
||
- 分钟不受 UTC 与中国标准时间时差影响,所以分钟显示正确。
|
||
- 小时少 8 小时,因此小时显示错误。
|
||
- Wi-Fi 连接成功后,SNTP 启动流程设置 `TZ=CST-8`,小时随即恢复正确。
|
||
|
||
Deep Sleep 也会复现该问题,是因为 RTC 可以继续保留绝对时间,而应用进程重新启动后,进程级的 `TZ` 环境状态需要重新设置。也就是说,保留 RTC 时间并不等于保留本地时区配置。
|
||
|
||
修复前的启动顺序为:
|
||
|
||
```text
|
||
存储准备
|
||
-> 音频初始化
|
||
-> LCD 初始化及时间渲染
|
||
-> Wi-Fi 连接
|
||
-> 运行时任务启动
|
||
-> SNTP 启动并设置时区
|
||
```
|
||
|
||
问题本质不是 RTC 或 SNTP 保存了错误的小时,而是本地时区设置得太晚。
|
||
|
||
## 3. 解决方式
|
||
|
||
将“应用本地时区”和“通过网络同步绝对时间”拆分为两个可以独立执行的操作:
|
||
|
||
1. 新增不依赖网络的时区设置接口,只负责设置 `TZ` 并调用 `tzset()`。
|
||
2. 按照现有分层,通过 `platform -> domain -> control_plane -> app_composition` 逐层暴露该能力。
|
||
3. 在存储准备完成后、LCD 初始化之前增加 `wall_clock` 启动阶段,立即应用配置项 `CONFIG_TQ_TIME_POSIX_TZ`。
|
||
4. SNTP 仍在网络连接成功后启动,用于校准 UTC 绝对时间;启动 SNTP 时再次应用时区,作为后续保护。
|
||
5. 如果开机阶段设置时区失败,记录警告并继续启动,避免时区异常阻断整个设备启动流程。
|
||
|
||
修复后的启动顺序为:
|
||
|
||
```text
|
||
存储准备
|
||
-> 本地应用 CST-8 时区
|
||
-> 电池启动保护及音频初始化
|
||
-> LCD 初始化及时间渲染
|
||
-> Wi-Fi 连接
|
||
-> SNTP 校准绝对时间
|
||
```
|
||
|
||
其中,`CST-8` 是 POSIX 时区字符串。POSIX 的符号约定与通常书写习惯相反,`CST-8` 表示 UTC+8。
|
||
|
||
## 4. 具体代码
|
||
|
||
### 4.1 Platform 层:新增独立时区设置接口
|
||
|
||
公开接口声明:
|
||
|
||
```c
|
||
/**
|
||
* @brief Wall-clock time synchronization (SNTP) primitives.
|
||
*
|
||
* - `platform_time_set_timezone()` applies the process-local POSIX time zone
|
||
* without starting SNTP. Call it during boot before local time is rendered.
|
||
* - `platform_time_sync_start()` is idempotent: it sets the process time zone
|
||
* (`posix_tz`, e.g. "CST-8"; ignored when NULL/empty) on every call and
|
||
* starts the SNTP client once. Must be called after the network is up.
|
||
* - `platform_time_is_synced()` reports whether the system clock has advanced
|
||
* past a sane epoch lower bound (i.e. SNTP has set the time at least once).
|
||
*/
|
||
esp_err_t platform_time_set_timezone(const char *posix_tz);
|
||
esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz);
|
||
bool platform_time_is_synced(void);
|
||
```
|
||
|
||
接口实现:
|
||
|
||
```c
|
||
esp_err_t platform_time_set_timezone(const char *posix_tz)
|
||
{
|
||
if (posix_tz == NULL || posix_tz[0] == '\0') {
|
||
return ESP_ERR_INVALID_ARG;
|
||
}
|
||
if (setenv("TZ", posix_tz, 1) != 0) {
|
||
ESP_LOGE(TAG, "Set time zone failed (tz=%s)", posix_tz);
|
||
return ESP_FAIL;
|
||
}
|
||
tzset();
|
||
ESP_LOGI(TAG, "Local time zone applied (tz=%s)", posix_tz);
|
||
return ESP_OK;
|
||
}
|
||
```
|
||
|
||
该实现先校验参数,再更新进程级 `TZ` 环境变量并刷新 C 运行库的时区换算规则。此过程不访问网络,也不修改 RTC 中的 UTC 时间戳。
|
||
|
||
### 4.2 Platform 层:SNTP 启动复用时区接口
|
||
|
||
```c
|
||
esp_err_t platform_time_sync_start(const char *sntp_server, const char *posix_tz)
|
||
{
|
||
if (posix_tz != NULL && posix_tz[0] != '\0') {
|
||
esp_err_t timezone_err = platform_time_set_timezone(posix_tz);
|
||
if (timezone_err != ESP_OK) {
|
||
return timezone_err;
|
||
}
|
||
}
|
||
|
||
portENTER_CRITICAL(&s_time_lock);
|
||
if (s_sntp_started) {
|
||
portEXIT_CRITICAL(&s_time_lock);
|
||
return ESP_OK;
|
||
}
|
||
s_sntp_started = true;
|
||
portEXIT_CRITICAL(&s_time_lock);
|
||
|
||
const char *server = (sntp_server != NULL && sntp_server[0] != '\0')
|
||
? sntp_server
|
||
: "pool.ntp.org";
|
||
|
||
esp_sntp_config_t config = ESP_NETIF_SNTP_DEFAULT_CONFIG(server);
|
||
config.start = true;
|
||
config.server_from_dhcp = false;
|
||
|
||
esp_err_t err = esp_netif_sntp_init(&config);
|
||
if (err != ESP_OK && err != ESP_ERR_INVALID_STATE) {
|
||
portENTER_CRITICAL(&s_time_lock);
|
||
s_sntp_started = false;
|
||
portEXIT_CRITICAL(&s_time_lock);
|
||
ESP_LOGE(TAG, "SNTP init failed: %s", esp_err_to_name(err));
|
||
return err;
|
||
}
|
||
|
||
ESP_LOGI(TAG, "SNTP started (server=%s, tz=%s)",
|
||
server, (posix_tz != NULL && posix_tz[0] != '\0') ? posix_tz : "<unset>");
|
||
return ESP_OK;
|
||
}
|
||
```
|
||
|
||
SNTP 启动仍保持幂等。每次调用都会先应用有效的时区配置,但 SNTP 客户端只初始化一次。
|
||
|
||
### 4.3 Domain 层:桥接时区能力
|
||
|
||
公开接口声明:
|
||
|
||
```c
|
||
/**
|
||
* @brief Wall-clock time synchronization, bridged to the platform SNTP
|
||
* primitive. `set_timezone` applies local-time conversion without
|
||
* network access. `start` is idempotent and also applies the process
|
||
* time zone (`posix_tz`, e.g. "CST-8"); `is_synced` reports whether the
|
||
* clock has been set. Call `start` after the network is up.
|
||
*/
|
||
esp_err_t domain_runtime_time_set_timezone(const char *posix_tz);
|
||
esp_err_t domain_runtime_time_sync_start(const char *sntp_server, const char *posix_tz);
|
||
bool domain_runtime_time_is_synced(void);
|
||
```
|
||
|
||
桥接实现:
|
||
|
||
```c
|
||
esp_err_t domain_runtime_time_set_timezone(const char *posix_tz)
|
||
{
|
||
return platform_time_set_timezone(posix_tz);
|
||
}
|
||
```
|
||
|
||
### 4.4 Control Plane 层:使用项目配置准备本地时钟
|
||
|
||
时区配置缺省值:
|
||
|
||
```c
|
||
#ifndef CONFIG_TQ_TIME_POSIX_TZ
|
||
#define CONFIG_TQ_TIME_POSIX_TZ "CST-8"
|
||
#endif
|
||
```
|
||
|
||
公开接口声明:
|
||
|
||
```c
|
||
/** Apply the configured local time zone before any local time is rendered. */
|
||
esp_err_t control_plane_prepare_wall_clock(void);
|
||
```
|
||
|
||
接口实现:
|
||
|
||
```c
|
||
esp_err_t control_plane_prepare_wall_clock(void)
|
||
{
|
||
return domain_runtime_time_set_timezone(CONFIG_TQ_TIME_POSIX_TZ);
|
||
}
|
||
```
|
||
|
||
### 4.5 App Composition 层:增加开机阶段
|
||
|
||
启动阶段枚举中增加 `APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK`:
|
||
|
||
```c
|
||
typedef enum {
|
||
APP_COMPOSITION_BOOT_STAGE_POWER_HOLD = 1,
|
||
APP_COMPOSITION_BOOT_STAGE_STORAGE_PREPARE,
|
||
APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
|
||
APP_COMPOSITION_BOOT_STAGE_AUDIO_INIT,
|
||
APP_COMPOSITION_BOOT_STAGE_BOOT_SOUND,
|
||
APP_COMPOSITION_BOOT_STAGE_AUDIO_READY,
|
||
APP_COMPOSITION_BOOT_STAGE_LCD_DEVICE,
|
||
APP_COMPOSITION_BOOT_STAGE_LCD_RENDER_LOOP,
|
||
APP_COMPOSITION_BOOT_STAGE_PLATFORM_DOMAIN,
|
||
APP_COMPOSITION_BOOT_STAGE_TURNTABLE_CALIBRATION,
|
||
APP_COMPOSITION_BOOT_STAGE_WIFI_BOOTSTRAP,
|
||
APP_COMPOSITION_BOOT_STAGE_RUNTIME_WORKERS,
|
||
APP_COMPOSITION_BOOT_STAGE_NETWORK_SERVICES,
|
||
} app_composition_boot_stage_t;
|
||
```
|
||
|
||
在存储准备完成之后、LCD 和网络初始化之前调用:
|
||
|
||
```c
|
||
app_composition_log_stage(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
|
||
"wall_clock",
|
||
"control_plane_prepare_wall_clock");
|
||
esp_err_t wall_clock_err = control_plane_prepare_wall_clock();
|
||
app_composition_log_stage_result(APP_COMPOSITION_BOOT_STAGE_WALL_CLOCK,
|
||
"wall_clock",
|
||
wall_clock_err);
|
||
if (wall_clock_err != ESP_OK) {
|
||
ESP_LOGW(TAG,
|
||
"Local time zone setup failed, continue boot: %s",
|
||
esp_err_to_name(wall_clock_err));
|
||
}
|
||
```
|
||
|
||
## 5. 解决成果
|
||
|
||
- 代码路径已确保在 LCD 首次渲染时间前应用 `CST-8`;RTC 时间有效时,开机阶段即可显示正确的中国标准时间。
|
||
- 从启动顺序上消除了普通重启和 Deep Sleep 唤醒后等待 Wi-Fi 才能修正小时的问题。
|
||
- 从实现上支持离线启动时根据 RTC 中保留的 UTC 时间换算本地小时。
|
||
- 分钟显示逻辑和原有 LCD 更新时间隔保持不变。
|
||
- SNTP 职责保持不变,网络连接后仍负责校准绝对时间。
|
||
- 已使用 ESP-IDF v5.5.4 完成编译验证,构建成功;普通重启、Deep Sleep 和断网启动仍建议按第 6.6 节进行实机回归。
|
||
- 固件大小为 `0x217f20`,应用分区剩余 `0x1680e0`,约 40%。
|
||
|
||
## 6. 后续是否有其他影响
|
||
|
||
### 6.1 内存和性能影响
|
||
|
||
本次修改没有新增任务、定时器、帧缓冲、动态内存分配或 PSRAM 分配。新增内容主要是函数调用、少量代码和日志字符串,对内部 RAM 的持续占用基本无可感知变化,也不需要为了该修复将内部 RAM 改为 PSRAM。
|
||
|
||
开机时只增加一次 `setenv()`、`tzset()` 和日志输出,执行耗时相对于完整启动流程可以忽略。SNTP 启动时会再次调用时区设置接口,但不会重复启动 SNTP 客户端。
|
||
|
||
### 6.2 全局时区影响
|
||
|
||
`TZ` 是进程级全局配置。设置为 `CST-8` 后,项目内所有使用 `localtime()` 或 `localtime_r()` 的模块都会采用同一时区。这符合设备当前统一使用中国标准时间的预期;如果未来需要同时显示多个地区的时间,不能依赖反复修改全局 `TZ`,需要增加独立的时区换算方案。
|
||
|
||
### 6.3 启动日志和阶段编号
|
||
|
||
启动日志会新增一个 `wall_clock` 阶段以及一条时区应用成功日志。由于枚举中插入了新的阶段,后续启动阶段的数字编号会顺延一位;阶段名称和启动行为不受影响。如果外部日志分析程序依赖数字编号,需要同步调整。
|
||
|
||
### 6.4 异常降级行为
|
||
|
||
若时区字符串为空或设置失败,启动流程不会被中断,而是输出警告后继续。此时 LCD 可能暂时按 UTC 显示;网络连接后 SNTP 启动流程会再次尝试应用时区。
|
||
|
||
### 6.5 RTC 无有效时间的情况
|
||
|
||
本次修复只解决“UTC 时间有效,但本地时区尚未应用”的问题,不负责在完全断电且没有可靠 RTC 时间来源时生成正确绝对时间。如果冷启动时系统时间无效,LCD 应继续显示 `--:--`,直到网络连接并由 SNTP 完成校时。时区设置不能替代 SNTP 校时。
|
||
|
||
### 6.6 建议的硬件回归验证
|
||
|
||
建议在实机上继续覆盖以下场景:
|
||
|
||
- RTC 时间有效时进行普通软件重启,确认首屏小时和分钟均正确。
|
||
- 进入 Deep Sleep 后唤醒,确认联网前的小时正确。
|
||
- 断开网络启动,确认 RTC 有效时仍能按 UTC+8 显示。
|
||
- 网络恢复并完成 SNTP 同步,确认时间连续且没有异常跳变。
|
||
- 完全断电导致 RTC 时间无效时,确认同步前显示 `--:--`,同步后恢复正确时间。
|
||
|
||
## 7. 结论
|
||
|
||
该问题由启动阶段未及时恢复进程级时区引起,与分钟计算、LCD 渲染或 RTC 走时精度无关。修复通过在 LCD 启动前独立应用 `CST-8`,使设备不依赖网络也能将有效的 UTC 时间换算为正确本地时间;网络连接后的 SNTP 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。
|