
先講結論:ESP32 的時間是「借來的」
如果你只寫過讓 LED 閃爍的程式,大概不會注意到這件事:ESP32 沒有板載 RTC,也沒有電池備援。它一斷電,時間就回到原點;重新上電之後,它知道的「現在」跟真實世界一點關係都沒有。
這件事在玩具專案裡無所謂,但只要你的裝置要記錄資料、定時動作、驗證憑證,時間就是必需品。而網路時鐘(NTP)是取得時間最便宜的方式 —— 不用加任何硬體,用你本來就有的 WiFi 就好。
這篇的順序是:先搞懂 NTP 在幹什麼(四個時間戳、48 bytes 的封包),再看接線,然後三種寫法由淺入深,最後談精度取捨。想直接抄程式的話,第二種寫法(configTime())就能動了。
為什麼需要 NTP?先問你的專案要多準
我習慣先問「這個時間戳要拿來做什麼」,因為不同的用途對精度的要求差很多:
- 感測器數據記錄:知道資料是何時採集的,誤差幾秒通常無所謂
- 排程控制:定時開關裝置、鬧鐘功能,秒級就夠
- 安全認證:TLS 憑證驗證、Token 時效,這裡時間錯了會直接連不上
- 分散式同步:多個 ESP32 節點時間要一致,否則日誌順序會亂掉
NTP(Network Time Protocol)是目前最廣泛使用的網路時間同步協定,區域網路內可以做到 sub-millisecond 等級,走網際網路也有數毫秒的精度 —— 對絕大多數 IoT 專案來說,這已經過剩了。

NTP 的四個時間戳:一趟往返就把時間算出來
NTP 的核心其實只是一個很聰明的想法:我不知道現在幾點,但我知道「我送出去到收回來」花了多久,也知道對方是什麼時候回我的。
做法是用用戶端-伺服器架構,透過 UDP port 123 交換時間戳記。整個過程會在四個時間點蓋章:

ESP32 發送 NTP 請求時記錄 T1;NTP 伺服器收到請求記錄 T2,準備回覆時記錄 T3;ESP32 收到回覆時記錄 T4。四個時間戳湊齊,就能算出誤差:
偏移量 θ = (T2 − T1 + T3 − T4) / 2
往返延遲 δ = (T4 − T1) − (T3 − T2)
校正後時間 = T1 + θ
這個演算法有一個前提:假設網路延遲是對稱的(去程與回程花的時間相等)。實務上這個假設不會完全成立,但誤差通常小於 50 ms,對時鐘顯示來說完全看不出來。
封包只有 48 bytes,但每個欄位都有事要做
NTP v4 的封包固定 48 bytes —— 以今天的標準來說小到不可思議。但別被大小騙了,裡面每個欄位都有明確用途:

- LI(Leap Indicator,2b):宣告閏秒(最後一分鐘可能會是 59 或 61 秒)
- VN(Version Number,3b):NTP 版本,v4 寫成
100 - Mode(3b):3 代表用戶端,4 代表伺服器
- Stratum(8b):0 是未同步、1 是主時鐘(原子鐘/GPS)、2–15 是往下層級
- Root Delay/Dispersion:整條同步路徑的總延遲與最大誤差
- Reference Timestamp:最後一次同步的時間
- Originate/Receive/Transmit Timestamp:就是前面那四個時間戳裡的 T1、T2、T3
值得一提的還有時間戳的格式:64-bit 定點數,前 32 bit 是秒數(從 1900-01-01 起算),後 32 bit 是小數。這個「從 1900 年算起」的設計,就是等等寫程式時要減掉 2208988800 的原因。

老實說,真正花掉一半以上空間的是三個時間戳(各 8 bytes)。這也說明了 NTP 的設計重心:欄位可以簡化,時間精度不能妥協。
接線:四條線,就只是 I²C
硬體部分沒什麼好擔心的,OLED 跟 ESP32 之間只走 I²C,四條線接完就能動:
| OLED SSD1306 | ESP32 | 說明 |
|---|---|---|
| VCC | 3.3V | 電源 (3.3V ~ 5V 皆可) |
| GND | GND | 共地 |
| SCL | GPIO 22 | I²C 時脈 |
| SDA | GPIO 21 | I²C 資料 |
SSD1306 使用 I²C 通訊,預設位址是 0x3C(有些模組是 0x3D)。它不需要你一個個畫點:你送出指令設定好頁面與欄位位置,然後一次寫入 128×64 的顯示緩衝區,它自己會把畫面顯示出來。

我自己踩過的坑是位址:買到 0x3D 的模組卻用 0x3C 去初始化,畫面當然全黑,而且 display.begin() 只會回傳 false,不會告訴你為什麼。
方法一:自己組封包送出去(想懂協定就寫這段)
第一種寫法最原始:自己開一個 UDP socket、自己把 48 bytes 的封包填好、自己從回覆的第 40 到 43 bytes 把秒數挖出來。多數教學不會叫你這樣做,但我覺得寫過一次,後面所有 API 都變得很好懂。
// ESP32 NTP 時間同步(使用 WiFiUDP)
#include
#include
const char* ssid = "YourSSID";
const char* password = "YourPassword";
WiFiUDP udp;
const char* ntpServer = "pool.ntp.org"; // NTP 伺服器池
const int localPort = 8888; // 本地 UDP 埠
// NTP 封包(48 bytes)
byte packetBuffer[48];
void setup() {
Serial.begin(115200);
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
udp.begin(localPort);
sendNTPpacket(ntpServer);
}
void loop() {
int size = udp.parsePacket();
if (size >= 48) {
udp.read(packetBuffer, 48);
// 提取 T4 時間戳(第 40-43 bytes = 秒數,第 44-47 = 小數)
unsigned long secsSince1900 = 0;
secsSince1900 |= packetBuffer[40] << 24;
secsSince1900 |= packetBuffer[41] << 16;
secsSince1900 |= packetBuffer[42] << 8; secsSince1900 |= packetBuffer[43]; // NTP 紀元 1900 → Unix 紀元 1970 const unsigned long seventyYears = 2208988800UL; unsigned long timestamp = secsSince1900 - seventyYears; Serial.printf("NTP 時間: %lu\n", timestamp); Serial.printf("本地時間: %s", ctime((time_t*)×tamp)); delay(10000); // 每 10 秒同步一次 } } void sendNTPpacket(const char* server) { memset(packetBuffer, 0, 48); // LI=0, VN=4, Mode=3 (client) packetBuffer[0] = 0b11100011; // 0xE3 // 填入 T1(發送時間) unsigned long t1 = millis(); packetBuffer[40] = (t1 >> 24) & 0xFF;
packetBuffer[41] = (t1 >> 16) & 0xFF;
packetBuffer[42] = (t1 >> 8) & 0xFF;
packetBuffer[43] = t1 & 0xFF;
udp.beginPacket(server, 123); // NTP port
udp.write(packetBuffer, 48);
udp.endPacket();
}
注意幾個細節:packetBuffer[0] = 0b11100011 這一個 byte 就講完了 LI、VN 與 Mode(也就是上面那張表的前三個欄位);而回覆封包裡的時間戳是「從 1900 年算起的秒數」,所以要減掉 2208988800(1900 到 1970 的 70 年)才會變成 Unix 紀元。
這段程式我把它當成教具:它會動,但你得自己處理逾時、重試與時區,而且它每 10 秒就同步一次,實際上太頻繁了。
方法二:configTime() 一行就好(推薦)
Arduino-ESP32 其實已經封裝好 SNTP 客戶端了。上面那一大段,用標準 C 的 time() 與 localtime() 就能取代:
// ESP32 SNTP — 最簡方式同步時間
#include
#include
const char* ntpServer = "pool.ntp.org";
const long gmtOffset_sec = 28800; // UTC+8 (台灣/北京)
const int daylightOffset_sec = 0; // 不考慮夏令時間
void setup() {
Serial.begin(115200);
WiFi.begin("YourSSID", "YourPassword");
while (WiFi.status() != WL_CONNECTED) delay(500);
// 初始化 SNTP
configTime(gmtOffset_sec, daylightOffset_sec, ntpServer);
// 等待時間同步完成
struct tm timeinfo;
while (!getLocalTime(&timeinfo)) {
Serial.println("等待 NTP 同步...");
delay(1000);
}
Serial.println("時間同步完成!");
}
void loop() {
struct tm timeinfo;
if (getLocalTime(&timeinfo)) {
char buf[64];
strftime(buf, sizeof(buf), "%F %T (%A)", &timeinfo);
Serial.println(buf);
}
delay(1000);
}
這是我在正式專案裡的預設做法。你只要告訴它時區偏移(台灣是 28800 秒 = UTC+8)與伺服器位址,剩下的事它會處理:開機後背景同步、之後 getLocalTime() 直接給你本地時間。
唯一要記得的是:同步不是瞬間完成的。你必須先等 getLocalTime() 回傳成功,否則拿到的是未同步的假時間。
方法三:把它變成真正的時鐘(OLED 完整範例)
把 SNTP 加上 SSD1306,就是一台真的網路時鐘了。整個流程長這樣:

// ESP32 NTP 網路時鐘 + OLED 顯示
#include
#include
#include
#include
#include
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
const char* ssid = "YourSSID";
const char* password = "YourPassword";
const char* ntpServer = "pool.ntp.org";
const long gmtOffset = 28800; // UTC+8
void setup() {
Serial.begin(115200);
// 初始化 OLED
if (!display.begin(SSD1306_SWITCHCAPVCC, 0x3C)) {
Serial.println("OLED 初始化失敗");
while (1) delay(10);
}
display.clearDisplay();
display.setTextSize(1);
display.setTextColor(SSD1306_WHITE);
// 連 WiFi
display.setCursor(0, 0);
display.println("Connecting WiFi...");
display.display();
WiFi.begin(ssid, password);
while (WiFi.status() != WL_CONNECTED) delay(500);
display.println("WiFi OK");
display.display();
// NTP 同步
display.println("Syncing NTP...");
display.display();
configTime(gmtOffset, 0, ntpServer);
struct tm timeinfo;
while (!getLocalTime(&timeinfo)) {
delay(500);
display.print(".");
display.display();
}
display.println("\nTime synced!");
display.display();
delay(1000);
}
void loop() {
struct tm timeinfo;
if (!getLocalTime(&timeinfo)) {
display.clearDisplay();
display.setCursor(0, 0);
display.println("NTP Error!");
display.display();
delay(5000);
return;
}
display.clearDisplay();
// ---- 第 1 行:日期(置中) ----
display.setTextSize(1);
char dateBuf[32];
strftime(dateBuf, sizeof(dateBuf), "%Y-%m-%d (%a)", &timeinfo);
int16_t x1, y1;
uint16_t w, h;
display.getTextBounds(dateBuf, 0, 0, &x1, &y1, &w, &h);
display.setCursor((SCREEN_WIDTH - w) / 2, 4);
display.println(dateBuf);
// ---- 第 2 行:時間(大字) ----
display.setTextSize(3);
char timeBuf[16];
strftime(timeBuf, sizeof(timeBuf), "%H:%M:%S", &timeinfo);
display.getTextBounds(timeBuf, 0, 20, &x1, &y1, &w, &h);
display.setCursor((SCREEN_WIDTH - w) / 2, 22);
display.println(timeBuf);
// ---- 第 3 行:WiFi 狀態 + 運行時間 ----
display.setTextSize(1);
char wifiStr[32];
snprintf(wifiStr, sizeof(wifiStr), "RSSI: %d dBm UP: %lus",
WiFi.RSSI(), millis() / 1000);
display.getTextBounds(wifiStr, 0, 50, &x1, &y1, &w, &h);
display.setCursor((SCREEN_WIDTH - w) / 2, 52);
display.println(wifiStr);
display.display();
delay(500); // 每 0.5 秒更新一次
}
畫面的排版刻意分成三行:日期置中、時間用大字(textSize 3)、最下面放 RSSI 與運行時間。置中的做法是先 getTextBounds() 量出文字寬度,再用 (128 - 寬度) / 2 算出起點 —— 這比憑感覺猜 x 座標可靠得多。
更新頻率是每 0.5 秒一次。老實說這有點浪費:秒數一秒才跳一次,但 OLED 每次重畫都要重寫整塊緩衝區。如果你之後要做低功耗版本,這裡就是第一個該調的地方。
進階:夏令時間不要自己算
有些地區有夏令時間(DST)。千萬不要用 if (月份 == 3) 這種寫法自己判斷,因為各國規則不只常改,還會為了政治理由臨時改。
標準做法是餵它 POSIX timezone string,讓函式庫自己去算:
// ESP32 自動偵測 DST(以歐洲/北美為例)
void setupTimeWithDST() {
// 方法 1:使用 posix timezone string
// 北美東部: EST5EDT,M3.2.0,M11.1.0
// 歐洲中部: CET-1CEST,M3.5.0,M10.5.0/3
// 台灣/中國: CST-8 (無 DST)
setenv("TZ", "CST-8", 1); // 台灣
tzset();
configTime(0, 0, "pool.ntp.org");
}
台灣與中國不實施夏令時間,所以直接用 CST-8 就好。註解裡那兩組(北美 EST5EDT,M3.2.0,M11.1.0、歐洲 CET-1CEST,M3.5.0,M10.5.0/3)是我最常查的兩個,先抄起來省得以後再找。
精度實測:NTP 到底值不值得?
如果你還在猶豫要不要為時間多做一點事,這張表大概是最實用的一段:
| 計時方式 | 每日誤差 | 優點 | 缺點 |
|---|---|---|---|
| ESP32 internal millis() | ~10-30 秒 | 不需網路 | 隨溫度飄移 |
| NTP 同步(每小時) | < 10ms | 精度極高 | 需 WiFi |
| DS3231 RTC 模組 | ~1 秒/年 | 斷電保留 | 額外硬體成本 |
| GPS PPS | ~1μs | 原子鐘級 | 需 GPS 模組 + 天線 |
四種方式的差距不是「好一點點」,而是好幾個數量級。不過要注意單位不一樣:millis() 是每日漂移、DS3231 是每年誤差,而 GPS PPS 的 1 µs 是即時精度。

實務上的均衡解是:NTP 每小時同步一次,其餘時間用 ESP32 內建的 micros() 或 esp_timer 維持計時。這樣既不會一直吃網路,也不會讓時間飄到無法接受。
NTP 伺服器要選哪一台?
不用想太多,這幾個就夠了:
- pool.ntp.org:全球 DNS 輪詢,自動導到離你最近的伺服器(預設選這個)
- time.google.com:Google 公共 NTP,底層是 GPS 參考
- time.nist.gov:美國 NIST 官方,高精度
- ntp.aliyun.com:阿里雲,中國大陸連線速度快
- 自建 NTP Server:樹莓派加 4G/GPS 模組,適合完全不想依賴外部的場合
怕單一伺服器掛掉的話,configTime() 可以一次塞三台:
// 多伺服器備援
const char* ntpServers[] = {
"pool.ntp.org",
"time.google.com",
"time.nist.gov"
};
configTime(gmtOffset, 0, ntpServers[0], ntpServers[1], ntpServers[2]);
加分題:用網頁改 WiFi 與時區
最後這個我最喜歡。網路時鐘最大的麻煩是「換一個場地就要改 SSID 密碼,然後重新燒錄」—— 既然它本來就有 WiFi,不如讓它自己開一個設定頁。
搭配之前寫過的 ESP32 Web Server,把 WiFi 設定與時區選擇做成一頁表單:
// Web Server + NTP 時鐘配置
#include
WebServer server(80);
String html = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<title>NTP 時鐘設定</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
</head>
<body>
<h1>NTP 網路時鐘設定</h1>
<form action="/config" method="POST">
SSID: <input name="ssid"><br>
密碼: <input name="pass" type="password"><br>
時區: <select name="tz">
<option value="0">UTC</option>
<option value="28800" selected>Asia/Taipei (UTC+8)</option>
<option value="32400">Japan (UTC+9)</option>
</select><br>
<input type="submit" value="儲存">
</form>
<p id="clock"></p>
<script>
function updateClock() {
fetch('/api/time')
.then(r => r.json())
.then(d => document.getElementById('clock').innerText = d.time);
}
setInterval(updateClock, 1000);
updateClock();
</script>
</body></html>
)rawliteral";
void handleConfig() {
String ssid = server.arg("ssid");
String pass = server.arg("pass");
long tz = server.arg("tz").toInt();
// 儲存到 Preferences / LittleFS
// 重啟後套用新設定
server.send(200, "text/plain", "設定已儲存,重新啟動中...");
delay(1000);
ESP.restart();
}
void setup() {
server.on("/", handleRoot);
server.on("/config", HTTP_POST, handleConfig);
server.begin();
}
頁面上的時區是用 UTC 偏移秒數傳的(UTC+8 就是 28800),收到後存進 Preferences 或 LittleFS,重啟套用。這樣一支程式就能帶著跑,不需要為了換時區重新編譯。
總結
NTP 是 IoT 專案裡最划算的基礎服務之一:不用加硬體、不用加成本,一行 configTime() 就能拿到夠準的時間。ESP32 的 SNTP 客戶端已經把整套 NTP v4 協定(包含那四個時間戳的計算)包在裡面了。
我的建議順序是這樣:
- 先動手:用
configTime()讓時間跑起來,別一開始就自己刻封包 - 再理解:回頭看那四個時間戳與 48 bytes 的封包,之後遇到問題才知道去哪裡找答案
- 要顯示就加 OLED:四條線的 I²C,幾十行程式就有一台網路時鐘
- 要精度就分工:NTP 每小時校時,中間交給
esp_timer;真的要求 µs 等級再上 GPS PPS
文章評論