0x6A Logbook

0x6A Logbook
Shi6a的筆記本
  1. 首頁
  2. 程式開發
  3. 正文

ESP32 NTP 網路時鐘 + OLED 顯示完整教學:從 NTP 協定到 Arduino 實作

2026 年 6 月 11 日 583點熱度 0人點贊 0條評論

ESP32 NTP 網路時鐘 + OLED 顯示完整教學封面

先講結論:ESP32 的時間是「借來的」

如果你只寫過讓 LED 閃爍的程式,大概不會注意到這件事:ESP32 沒有板載 RTC,也沒有電池備援。它一斷電,時間就回到原點;重新上電之後,它知道的「現在」跟真實世界一點關係都沒有。

這件事在玩具專案裡無所謂,但只要你的裝置要記錄資料、定時動作、驗證憑證,時間就是必需品。而網路時鐘(NTP)是取得時間最便宜的方式 —— 不用加任何硬體,用你本來就有的 WiFi 就好。

這篇的順序是:先搞懂 NTP 在幹什麼(四個時間戳、48 bytes 的封包),再看接線,然後三種寫法由淺入深,最後談精度取捨。想直接抄程式的話,第二種寫法(configTime())就能動了。

為什麼需要 NTP?先問你的專案要多準

我習慣先問「這個時間戳要拿來做什麼」,因為不同的用途對精度的要求差很多:

  • 感測器數據記錄:知道資料是何時採集的,誤差幾秒通常無所謂
  • 排程控制:定時開關裝置、鬧鐘功能,秒級就夠
  • 安全認證:TLS 憑證驗證、Token 時效,這裡時間錯了會直接連不上
  • 分散式同步:多個 ESP32 節點時間要一致,否則日誌順序會亂掉

NTP(Network Time Protocol)是目前最廣泛使用的網路時間同步協定,區域網路內可以做到 sub-millisecond 等級,走網際網路也有數毫秒的精度 —— 對絕大多數 IoT 專案來說,這已經過剩了。

ESP32 NTP 網路時鐘系統架構

NTP 的四個時間戳:一趟往返就把時間算出來

NTP 的核心其實只是一個很聰明的想法:我不知道現在幾點,但我知道「我送出去到收回來」花了多久,也知道對方是什麼時候回我的。

做法是用用戶端-伺服器架構,透過 UDP port 123 交換時間戳記。整個過程會在四個時間點蓋章:

NTP 協定時序圖:四時間戳偏移計算

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 —— 以今天的標準來說小到不可思議。但別被大小騙了,裡面每個欄位都有明確用途:

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 的原因。

NTP v4 封包 48 bytes 的欄位預算

老實說,真正花掉一半以上空間的是三個時間戳(各 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 的顯示緩衝區,它自己會把畫面顯示出來。

OLED SSD1306 與 ESP32 的 I²C 接線表

我自己踩過的坑是位址:買到 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*)&timestamp)); 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,就是一台真的網路時鐘了。整個流程長這樣:

OLED 網路時鐘的程式流程

// 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
標籤: 教學
最後更新:2026 年 9 月 30 日

shi6a

這個人很懶,什麼都沒留下

點贊
< 上一篇
下一篇 >

文章評論

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
取消回覆

COPYRIGHT © 2026 0x6A Logbook. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang