NTP 时间同步与定时任务
- 用 ESP-IDF 的 esp_netif_sntp 让 ESP32-S3 联网后从 NTP 服务器拿到准确时间
- 搞懂为什么用 TZ 字符串("CST-8")设时区,而不是手填秒数偏移
- 用标准 C 的 time() / localtime_r() / strftime() 取并格式化本地时间
- 区分"绝对时钟定时"和"vTaskDelay 周期定时"该用哪个,自己做一个每天定时触发的小功能
你想做个定时开关:每天早上 8 点自动开灯,晚上 11 点关。或者想给上报的传感器数据打个时间戳,这样云端才知道这条数据是几点测的。但你很快撞上一个尴尬的事——板子一断电,再上电,它根本不知道现在是几点。靠 esp_timer_get_time() 或 FreeRTOS 的 tick 计数只会从 0 重新数起,它告诉你的是"开机后过了多久",不是"现在几点"。
问题出在硬件上:ESP32-S3 芯片里虽然有个 RTC(实时时钟),但它没有独立的纽扣电池供电。普通台式机主板上那颗 CR2032 纽扣电池,作用就是关机后还给 RTC 供电,让它继续走时——ESP32-S3 默认没有。所以一断电,时间归零,重新上电它只能从某个默认时刻(POSIX 纪元起点 1970 年 1 月 1 日)开始。
好在 ESP32-S3 有个杀手锏:它能联网。只要连上 WiFi,它就能去问网络上的时间服务器"现在几点",几秒钟就把自己的系统时钟校准到准确时间。这个过程叫 NTP(或 SNTP,简化版)对时。读这篇前,你得先跑通过让 ESP32-S3 连上 WiFi——拿到 IP 是 NTP 的前提,连不上网就别谈对时。本篇假设你已经有那套 wifi_init_sta() 骨架,连上后再启动 SNTP。
为什么非要"准确"时间
先想清楚你到底在哪些场景需要绝对时间,而不是相对时间:
- 日志打时间戳:
ESP_LOGI默认前缀是"开机后毫秒数"(I (1234) ...),排查问题时你想知道"这条是几点几分发生的",就得有绝对时间。 - 定时开关:每天固定时刻开关灯、浇花、喂食,靠的是"现在是不是 8 点",这是绝对时钟。
- 数据上报:传感器数据传到云端,带上准确时间戳,云端才能画出正确的趋势曲线。
- 显示时钟:做个 OLED 桌面时钟,总不能显示"开机后 3742 秒"吧。
这些都需要一个和现实世界对齐的绝对时间。开机计时器给不了——它只知道开机多久。NTP 能给。
NTP 是怎么对时的
NTP 全称 Network Time Protocol(ESP-IDF 里用的是它的简化版 SNTP,Simple NTP,单片机场景足够)。原理不复杂:网络上有一批专门的时间服务器,它们的时钟由原子钟或 GPS 授时,极其准确。你的 ESP32-S3 联网后,向其中一台发一个请求"现在的 UTC 时间是多少",服务器把当前时间回给它,ESP32-S3 拿到后设置自己的系统时钟。底层走的是 UDP 123 端口——这点记一下,万一在严格的防火墙/企业网里对不上时,先怀疑 UDP 123 被挡了。
注意服务器给的是 UTC 时间(世界协调时,就是格林尼治时间),不带时区。你在中国,需要在 UTC 基础上加 8 小时才是北京时间。但在 ESP-IDF 里你不手填这个 8 小时——你设一个 TZ 时区字符串,让标准 C 库帮你换算(后面专门讲,这是它和 Arduino configTime 最大的写法差别)。
常用的 NTP 服务器:
ntp.aliyun.com(阿里云,国内访问快,推荐)cn.pool.ntp.org(国内节点池,国内备选)pool.ntp.org(国际公共服务器池,国内有时慢)
国内优先填阿里云,连得稳。
第一步:先把整段代码跑通
下面这段是完整可烧录的程序——它复用上一篇的 wifi_init_sta() 连上 WiFi,再用 ESP-IDF 5.x 推荐的 esp_netif_sntp 一套 API 向阿里云对时,对上后每秒打印一次当前的年月日时分秒。把 wifi_init_sta()(连同 WiFi 名字密码)从上一篇拿过来,放进同一个 main/main.c:
#include <time.h>
#include <sys/time.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
#include "esp_netif_sntp.h" // ESP-IDF 5.x 的 SNTP 封装
#include "esp_sntp.h"
#include "nvs_flash.h"
static const char *TAG = "ntp";
// 上一篇 l3-wifi 的 wifi_init_sta() 直接复用,这里省略,连上后函数才返回
static void obtain_time(void) {
// 1. 配置 SNTP:服务器填阿里云,国内对得快
esp_sntp_config_t cfg = ESP_NETIF_SNTP_DEFAULT_CONFIG("ntp.aliyun.com");
esp_netif_sntp_init(&cfg);
// 2. 阻塞等同步完成,最多等 10 秒;超时了 ret 不是 ESP_OK
if (esp_netif_sntp_sync_wait(pdMS_TO_TICKS(10000)) != ESP_OK) {
ESP_LOGW(TAG, "10 秒内没对上时,检查网络/服务器/UDP123");
} else {
ESP_LOGI(TAG, "NTP 对时成功");
}
// 3. 设时区:中国东八区用 TZ 字符串 "CST-8",再 tzset() 生效
setenv("TZ", "CST-8", 1);
tzset();
}
static void print_local_time(void) {
time_t now;
struct tm timeinfo;
char buf[64];
time(&now); // 拿当前 UTC 秒数(自 1970 起)
localtime_r(&now, &timeinfo); // 按 TZ 换算成本地时间,填进 timeinfo
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S 星期%w", &timeinfo);
ESP_LOGI(TAG, "%s", buf);
}
void app_main(void) {
ESP_ERROR_CHECK(nvs_flash_init());
wifi_init_sta(); // 上一篇的骨架:连上 WiFi 拿到 IP 后才返回
obtain_time(); // 联网后才能对时
while (1) {
print_local_time();
vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒打印一次
}
}
在工程目录下一条命令编译、烧录、看日志:
idf.py build flash monitor
(第一次用要先 idf.py set-target esp32s3 选好芯片型号。按 Ctrl + ] 退出监视。)
关键就三段:esp_netif_sntp_init() 把服务器配好并在后台起一个 SNTP 客户端;esp_netif_sntp_sync_wait() 阻塞等到首次对上时(或超时),这一步是它比 Arduino 省心的地方——你不用自己轮询"对上没";setenv("TZ", ...) + tzset() 设好时区,之后 localtime_r 才会给你本地时间。取时间则是三件套标准 C:time() 拿 UTC 秒数 → localtime_r() 按 TZ 换算成 struct tm → strftime() 格式化成字符串。
你应该看到什么
idf.py monitor 的日志里(中间夹着一堆系统自己打的 wifi:、sntp: 开头的底层日志,正常)会滚出这样几条:
I (3210) ntp: NTP 对时成功
I (3220) ntp: 2026-06-20 14:32:07 星期5
I (4220) ntp: 2026-06-20 14:32:08 星期5
I (5220) ntp: 2026-06-20 14:32:09 星期5
对上时之前那一两秒,如果你提前读了一次,会打出 1970-01-01 08:00:00(注意:是 08:00 不是 00:00,因为 TZ 已经把 0 秒的 UTC 加了 8 小时)——这说明还没对上时,等几秒就会刷出准确时间,并且每秒跳一下。如果你等了十几秒还是 1970,说明对时失败了,往下看故障排查表。
strftime 里 %Y 是四位年、%m 是 01-12 的月、%w 是星期几(0=周日)。它直接给你算好对齐的字符串,不用你操心 struct tm 那几个反人类的偏移(下面"为什么"里讲)。想单独取某一项再去读 timeinfo.tm_hour 这些字段。
第二步:把这套写法讲透——和 Arduino 的差别在哪
如果你之前用过 Arduino 的 NTP,会发现这套写法少了"手填时区秒数"、多了"TZ 字符串"和"显式 sync_wait"。这不是 ESP-IDF 啰嗦,是它把对时这件事拆得更清楚、更产品级。一块块拆。
esp_netif_sntp:5.x 的推荐写法,对照旧式三连
上面用的 esp_netif_sntp_init / esp_netif_sntp_sync_wait 是 ESP-IDF 5.x 封装好的"高层"API,一句话起客户端、一句话等同步,最省事。在这之前(以及很多老例子里)是这样的旧式三连,效果一样,理解原理用:
// 旧式底层写法(对照用,新工程推荐用上面的 esp_netif_sntp)
esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL); // 轮询模式:客户端定期去问
esp_sntp_setservername(0, "ntp.aliyun.com"); // 第 0 个服务器
esp_sntp_init(); // 启动,但不阻塞等同步
旧式 esp_sntp_init() 起完就返回了,它不帮你等对上时——你得自己循环查 sntp_get_sync_status() 是不是 SNTP_SYNC_STATUS_COMPLETED,或者查年份是不是 >2020。新式 esp_netif_sntp_sync_wait() 把这段"等"封装进去了,所以新工程直接用它,少写一段轮询。
为什么用 TZ 字符串 "CST-8",而不是手填 28800 秒
这是和 Arduino configTime(28800, 0, ...) 最大的差别。ESP-IDF 走的是标准 POSIX 时区机制:你设一个环境变量 TZ,标准 C 库的 localtime_r 会读它来把 UTC 换算成本地时间。
"CST-8" 拆开看:
CST是时区名(China Standard Time,只是个标签,叫啥不影响计算)。-8才是关键——POSIX 的偏移符号和直觉相反:东八区(比 UTC 快 8 小时)写成-8,不是+8。记法:"本地时间 = UTC 减去这个数",东八区是 UTC+8,所以这里填-8。这是最容易填反的地方。
用 TZ 字符串而不是手填秒数,好处是:夏令时、跨时区一并交给标准库处理,不用你自己算偏移。中国不用夏令时,"CST-8" 就够。要支持夏令时的地区(如美东),TZ 字符串能带规则,比如 "EST5EDT,M3.2.0,M11.1.0"——这种复杂规则手填秒数根本表达不了,TZ 字符串一行搞定。这就是 ESP-IDF 选 TZ 机制的原因。
time / localtime_r / strftime:标准 C 三件套
对上时之后,ESP32-S3 的系统时钟就是准的了,取时间用的全是标准 C 库函数,和你在电脑上写 C 一模一样:
time(&now):拿当前时间,是个time_t(自 1970-01-01 00:00:00 UTC 起的秒数)。localtime_r(&now, &timeinfo):把这个 UTC 秒数,按 TZ 换算成本地时间,拆进struct tm(带年月日时分秒字段)。注意是localtime_r(带_r,可重入、线程安全版)——多任务环境别用不带_r的localtime,它用静态缓冲会被别的任务踩。strftime(buf, ..., "格式", &timeinfo):把struct tm按格式串渲染成字符串。
如果你手动读 struct tm 的字段而不用 strftime,有两个祖传坑:tm_year 是从 1900 算起的,读出来要 +1900;tm_mon 是 0~11,1 月是 0,读出来要 +1。用 strftime 的 %Y/%m 则库帮你处理好了,看不到这俩坑。能用 strftime 就别手算。
怎么判断"到底对上时没有"
esp_netif_sntp_sync_wait() 返回 ESP_OK 就是对上了,最直接。如果你用旧式 init、或想在别处再确认一次,有两招:
// 招一:查 SNTP 同步状态
if (sntp_get_sync_status() == SNTP_SYNC_STATUS_COMPLETED) { /* 对上了 */ }
// 招二:查年份(最朴素,没对上时年份是 1970)
time_t now; struct tm ti;
time(&now); localtime_r(&now, &ti);
if (ti.tm_year + 1900 > 2020) { /* 年份合理,说明对上了 */ }
招二是"穷人版"判断,胜在简单直观——只要年份还停在 1970,就是没对上,别拿这个 1970 去打时间戳。
第三步:定时任务——绝对时钟 vs 周期延时
拿到准确时间后,做"每天 8 点开灯"就顺理成章了——判断当前的"时"和"分"是不是到点:
while (1) {
time_t now; struct tm ti;
time(&now);
localtime_r(&now, &ti);
int hour = ti.tm_hour; // 0~23
int minute = ti.tm_min; // 0~59
// 每天 8:00 开灯(这一分钟内会反复触发,需配合"是否已执行"标志位防重复)
if (hour == 8 && minute == 0) {
gpio_set_level(LED_GPIO, 1);
}
// 每天 23:00 关灯
if (hour == 23 && minute == 0) {
gpio_set_level(LED_GPIO, 0);
}
vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒查一次
}
这里要和你之前用的 vTaskDelay 周期任务分清楚,它俩解决的是两类问题:
vTaskDelay / 周期延时 |
NTP 绝对时钟 | |
|---|---|---|
| 它告诉你/控制 | 距上次过了固定一段 | 现实世界现在几点 |
| 适合做 | "每隔 5 秒采一次样" | "每天 8 点开灯" |
| 断电后 | 照常按节奏跑 | 联网后自动恢复准确 |
| 要不要联网 | 不要 | 要 |
"每隔 30 秒上报一次数据"这种周期性任务,用 vTaskDelay(pdMS_TO_TICKS(30000)) 就够了,不依赖现实时间,断网也能跑。"每天固定时刻"这种对齐现实日历的任务,必须用 NTP 给的绝对时间。实际项目里两者常一起用:vTaskDelay 控制采样节奏,NTP 给每条数据打时间戳。
注意上面 hour == 8 && minute == 0 的写法,在 8:00 这一整分钟里每秒都会判断为真、反复执行。真正用的时候要加一个"今天是否已经开过"的标志位,触发一次后置位,过了这一分钟再清零,避免重复触发。
断网/断电了怎么办
如果 WiFi 突然断了,ESP32-S3 还会继续走时吗?会,但要打折扣。对时成功后,系统时钟拿着这个准确时间靠内部 RTC 继续走,短时间内(几分钟到几小时)够用,所以偶尔断网几分钟不影响定时任务。但内部 RTC 的晶振精度一般,走久了会慢慢漂,几天下来可能偏差好几秒甚至更多,所以要定期重新对时。
esp_netif_sntp 默认就会周期性自动重对时(轮询模式下隔一段时间再问一次服务器,默认约一小时),这点比手动 Arduino 写法省心——你不用自己定时调对时函数。但最根本的一条还是绕不开:一断电,系统时钟彻底丢了,重新上电只能靠重新联网对时。要做断电后还记得时间的产品级设备,得外挂一颗带纽扣电池的 RTC 芯片(如 DS3231)。
补充一个思路:如果你的设备装在没 WiFi 的野外,又要准确时间,可以用 GPS 模块(NEO-6M)——GPS 卫星信号里本身就带极准的时间,模块解析出来同样能给单片机授时,原理和 NTP 类似,只是信源从网络换成了卫星。
故障排查表
对不上时间是最常见的卡点,照这张表一项项查:
| 现象 | 大概率原因 | 怎么解决 |
|---|---|---|
| 时间一直显示 1970-01-01 | 还没对上时(开机头几秒)或对时失败 | 等几秒;sync_wait 返回非 ESP_OK 看下面几条 |
| 一直 1970,等再久也不变 | NTP 服务器连不上 | 换成 ntp.aliyun.com;先确认 WiFi 真连上了(上一篇打印过 IP) |
| WiFi 连上了但还是对不上 | UDP 123 被防火墙/企业网挡了 | 换个网络试;NTP 走 UDP 123,被挡就过不去 |
| 时间快/慢正好 8 小时 | TZ 没设或填反 | setenv("TZ","CST-8",1); tzset();,注意东八区是 -8 不是 +8 |
| 时间莫名多 1 小时 | TZ 字符串里误带了夏令时规则 | 国内就用纯 "CST-8",别加 DST 段 |
| 年份不对(差 1900) | 手动读 tm_year 没加 1900 |
用 strftime,或读字段时 +1900 |
| 月份差 1 | tm_mon 是 0~11 |
用 strftime,或读字段时 +1 |
| 走着走着偏了几秒 | 内部 RTC 晶振漂移 | 让 esp_netif_sntp 周期自动重对时(默认就会) |
最高频的就两个:要么填了 pool.ntp.org 在国内连不稳,换阿里云就好;要么 TZ 填反或没设,整体差 8 小时,设成 "CST-8" 就对了。
变体玩法
把对时这块跑通后,能直接长出几个好玩的东西:
- OLED 桌面时钟:把
strftime渲染好的时分秒字符串,配合 OLED 屏幕 显示出来,就是个永不跑偏的网络时钟,每秒刷新。比买的电子钟还准,因为它一直在和 NTP 对齐。 - 定时浇花/喂食:把上面"每天 8 点开灯"里的
gpio_set_level换成驱动继电器,控制水泵或喂食电机,就是个定时浇花器、定时喂食器。设定每天几点、浇几秒,逻辑都一样。 - 带时间戳的数据记录仪:每隔 1 分钟(
vTaskDelay控制节奏)读一次温湿度,每条都用 NTP 时间strftime打戳,攒成一份带准确时间的记录。
动手挑战
做一个**"每天定时触发"的小功能**,自己挑一个场景:
- 跑通上面的完整代码,确认
idf.py monitor里能稳定打印当前准确时间(年份是 2026 而不是 1970)。 - 设一个你方便验证的时刻(比如就读出当前时间、设成"往后 2 分钟",别傻等到明天 8 点)。
- 到点时触发一个动作——最简单是点亮板载 LED(
gpio_set_level),或者ESP_LOGI打一行"叮!到点了"。 - 加上防重复触发:用一个标志位记住"这次已经触发过了",确保它只响一次,不会在这一整分钟里疯狂刷屏。
进阶一点:把对时前后的时间都打出来对比——对上时之前 time() 给的是 1970,对上之后跳成 2026,亲眼看一眼这个"跳变",你就彻底理解 NTP 干了什么。
做完你就掌握了所有定时类项目的核心套路:拿准确时间 → 判断是否到点 → 触发一次 → 防重复。后面无论是定时开关、定时上报还是闹钟,都是这套逻辑的变形。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校,尤其是
esp_netif_sntp/esp_sntp的 API 细节随版本可能微调,以官方 System Time 文档为准。
小结与下一步
这一节你给 ESP32-S3 装上了"时间感":
- 明白了它为什么断电就忘了几点(没带电池的 RTC),重新上电只能靠联网重对。
- 学会了用 ESP-IDF 5.x 的
esp_netif_sntp_init+esp_netif_sntp_sync_wait联网秒级对时,也知道了它对照的旧式esp_sntp三连。 - 搞懂了为什么用 TZ 字符串
"CST-8"(注意东八区是-8)让标准库换算时区,而不是手填秒数偏移。 - 会用标准 C 三件套
time()/localtime_r()/strftime()取并格式化本地时间,避开了tm_year/tm_mon的偏移坑。 - 分清了"绝对时钟定时"(NTP)和"周期延时"(
vTaskDelay)各管什么。
时间搞定后,下一个绕不开的话题是省电——很多定时设备靠电池跑,不能一直全速通电。下一节深度睡眠(Deep Sleep)教 ESP32-S3 在没事干的时候睡过去、定时醒来干活,配合这一节的时间能力,做出能用电池跑上几个月的低功耗设备。