12 KiB
开机时钟小时偏差问题修复说明
1. 提交信息
- 提交:
f33ad70 fix(time): apply timezone before lcd startup - 现象:设备每次开机后,LCD 显示的分钟正确,但小时错误;网络连接并完成时间服务初始化后,小时恢复正确。
- 复现场景:普通重启、Deep Sleep 唤醒、开机后暂时没有网络。
2. 问题原因
ESP32 的系统时间和 RTC 时间使用 UTC 时间戳保存绝对时间。LCD 将时间戳转换成年、月、日、小时和分钟时,会通过 localtime_r() 使用当前进程的 TZ 环境变量进行本地时区换算。
旧实现只在 Wi-Fi 连接成功、准备启动 SNTP 时执行:
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 时间并不等于保留本地时区配置。
修复前的启动顺序为:
存储准备
-> 音频初始化
-> LCD 初始化及时间渲染
-> Wi-Fi 连接
-> 运行时任务启动
-> SNTP 启动并设置时区
问题本质不是 RTC 或 SNTP 保存了错误的小时,而是本地时区设置得太晚。
3. 解决方式
将“应用本地时区”和“通过网络同步绝对时间”拆分为两个可以独立执行的操作:
- 新增不依赖网络的时区设置接口,只负责设置
TZ并调用tzset()。 - 按照现有分层,通过
platform -> domain -> control_plane -> app_composition逐层暴露该能力。 - 在存储准备完成后、LCD 初始化之前增加
wall_clock启动阶段,立即应用配置项CONFIG_TQ_TIME_POSIX_TZ。 - SNTP 仍在网络连接成功后启动,用于校准 UTC 绝对时间;启动 SNTP 时再次应用时区,作为后续保护。
- 如果开机阶段设置时区失败,记录警告并继续启动,避免时区异常阻断整个设备启动流程。
修复后的启动顺序为:
存储准备
-> 本地应用 CST-8 时区
-> 电池启动保护及音频初始化
-> LCD 初始化及时间渲染
-> Wi-Fi 连接
-> SNTP 校准绝对时间
其中,CST-8 是 POSIX 时区字符串。POSIX 的符号约定与通常书写习惯相反,CST-8 表示 UTC+8。
4. 具体代码
4.1 Platform 层:新增独立时区设置接口
公开接口声明:
/**
* @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);
接口实现:
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 启动复用时区接口
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 层:桥接时区能力
公开接口声明:
/**
* @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);
桥接实现:
esp_err_t domain_runtime_time_set_timezone(const char *posix_tz)
{
return platform_time_set_timezone(posix_tz);
}
4.4 Control Plane 层:使用项目配置准备本地时钟
时区配置缺省值:
#ifndef CONFIG_TQ_TIME_POSIX_TZ
#define CONFIG_TQ_TIME_POSIX_TZ "CST-8"
#endif
公开接口声明:
/** Apply the configured local time zone before any local time is rendered. */
esp_err_t control_plane_prepare_wall_clock(void);
接口实现:
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:
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 和网络初始化之前调用:
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 继续负责绝对时间校准。改动不新增常驻内存资源,主要影响仅为新增启动日志、启动阶段编号顺延和全局本地时区提前生效。