
前言
如果有人問我「電池供電的 IoT 裝置,最關鍵的一行程式碼是什麼」,我大概會回答 esp_deep_sleep_start()。這行看起來什麼都沒做的函式,決定了你的感測器節點是一天就沒電,還是能撐好幾個月。
數字攤開來很有感:一顆 2000 mAh 的電池,讓裝置 24/7 全速跑,大概一天就見底;但只要讓它在 99% 的時間裡睡覺,續航力就是幾個月、甚至幾年的事。同一顆電池、同一塊電路,差別只在「什麼時候醒、醒多久」。
這篇我打算從硬體架構講起,把 ESP32 與 STM32 的多級低功耗模式拆開來看,再一路寫到 RTC Timer 定時喚醒、GPIO 外部喚醒、Touch 喚醒的程式實作。最後補上電池續航的估算方式,還有我自己踩過的幾個坑。

一、為何需要 Deep Sleep?
先講一件常被忽略的事:IoT 裝置幾乎都不需要「一直運作」。真實的工作內容長這樣:
- 感測器節點:每分鐘讀一次溫度,其他 59 秒都在等
- 智慧門鎖:平時待命,門磁被觸發或有人開鎖才醒
- 追蹤器:每小時回報一次 GPS 座標
- 氣象站:每 15 分鐘上傳一次資料
看出模式了嗎?裝置有 99% 以上的時間是無事可做的。既然這樣,就讓它無事可做的時候進入 μA 等級的睡眠,要做事再醒過來,這是最直接的省電法。
至於能省多少,我喜歡用數字算一遍。這張表是用原文的三個數字換出來的:2000 mAh 的電池、醒著時 80 mA、睡著時 5 μA,每次醒來做事算 1 秒。

每小時只醒 1 秒的話,算出來大約 8.4 年,原文自己的估算則是 7.9 年 —— 同一個數量級。最上面那條「完全不睡」是 25 小時,對照起來就很有感了。
二、ESP32 低功耗模式
ESP32 的功耗模式可以想成一條階梯,從全速運轉一路往下走到完全斷電:Active 大約 260 mA,Deep-sleep 掉到 5 μA 左右,最後斷電是 0 μA。原文那張圖說寫得很直白 —— 六種模式,橫跨的就是這五個數量級。

這張圖我把兩顆晶片放在一起比。要注意的是它用對數刻度,因為線性刻度根本畫不出來 —— 260 mA 跟 2 μA 放在同一根軸上,小的一邊會直接消失。看得懂這張圖,你就懂為什麼大家寧可多寫幾十行程式,也要把裝置推進睡眠。
2.1 ESP32 喚醒來源
睡著之後要怎麼醒,ESP32 給了五種來源:
| 喚醒來源 | 說明 | 典型用途 |
|---|---|---|
| Timer | RTC 定時器到達警報值 | 定期感測器讀取 |
| EXT0 | RTC_GPIO 指定電平 | 按鈕開機、警報觸發 |
| EXT1 | 多個 RTC_GPIO 任意組合 | 門磁、PIR 感測器 |
| Touch | 觸碰感應值低於閾值 | 觸控開關、接近感測 |
| ULP Coprocessor | ULP 協處理器在睡眠中 執行自訂程式後喚醒 |
ADC 監控、I2C 感測器輪詢 |
這張表是「用途」的視角,但寫程式時你真正要記的是另一面:每種喚醒來源,在程式裡對應一個常數與一行設定。我把原文程式碼裡出現的那幾組整理成下面這張圖,之後在 switch 裡看到它們就不會陌生。

2.2 ESP32 Timer 定時喚醒
最常用、也最好用的就是定時喚醒。整個骨架是:進 setup 之後先問「我為什麼醒來」,做完事、設好下一次鬧鐘,再睡回去。
// esp32_deep_sleep_timer.ino — 定時喚醒範例
#include <WiFi.h>
#include <HTTPClient.h>
// 定義 RTC_DATA_ATTR 變數:Deep Sleep 期間保留
RTC_DATA_ATTR int boot_count = 0;
void setup() {
Serial.begin(115200);
delay(1000); // 等待序列埠穩定
boot_count++;
Serial.printf("開機次數: %d\n", boot_count);
// 取得喚醒原因
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
switch (cause) {
case ESP_SLEEP_WAKEUP_TIMER:
Serial.println("喚醒原因: RTC Timer");
break;
case ESP_SLEEP_WAKEUP_EXT0:
Serial.println("喚醒原因: RTC_GPIO (EXT0)");
break;
case ESP_SLEEP_WAKEUP_EXT1:
Serial.println("喚醒原因: RTC_GPIO (EXT1)");
break;
case ESP_SLEEP_WAKEUP_TOUCHPAD:
Serial.println("喚醒原因: Touch");
break;
case ESP_SLEEP_WAKEUP_ULP:
Serial.println("喚醒原因: ULP Coprocessor");
break;
default:
Serial.println("喚醒原因: 電源開機或復位");
}
// === 執行感測器讀取 ===
float temp = read_temperature(); // 自訂感測器函式
float hum = read_humidity();
Serial.printf("溫度: %.1f°C 濕度: %.1f%%\n", temp, hum);
// === 透過 WiFi 上傳資料 ===
WiFi.begin("SSID", "PASSWORD");
if (WiFi.waitForConnectResult() == WL_CONNECTED) {
HTTPClient http;
http.begin("https://api.example.com/sensor");
http.addHeader("Content-Type", "application/json");
char payload[64];
snprintf(payload, sizeof(payload), "{\"temp\":%.1f,\"hum\":%.1f}", temp, hum);
int code = http.POST(payload);
Serial.printf("HTTP %d\n", code);
http.end();
WiFi.disconnect(true);
WiFi.mode(WIFI_OFF);
}
// === 設定下次喚醒時間(60 秒後) ===
esp_sleep_enable_timer_wakeup(60 * 1000000); // 微秒單位
Serial.println("進入 Deep Sleep...");
// 清除喚醒原因旗標
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_ON);
esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_ON);
esp_deep_sleep_start(); // 進入睡眠(不會返回此處)
}
void loop() {
// 永遠不會執行到這裡
}
這段有兩個地方我第一次看的時候卡了一下。第一,RTC_DATA_ATTR 這個修飾詞代表「這個變數放在 RTC RAM,睡眠期間不會被清掉」—— 所以才數得出來開機次數。第二,設定的時間單位是微秒,60 * 1000000 才是 60 秒,寫成 60 的話裝置會醒得比你想像中勤快非常多。
還有一個小陷阱:esp_deep_sleep_start() 之後程式不會返回,loop() 永遠執行不到。醒來是從頭跑一次 setup(),這也是為什麼整個邏輯都擠在 setup 裡。

2.3 ESP32 GPIO 外部喚醒(EXT1)
需要「有事件才醒」的場合 —— 按鈕、門磁、PIR —— 就靠 EXT1。它允許你把多個 RTC_GPIO 綁成一組,任何一個腳位達到指定電平就喚醒。
// esp32_deep_sleep_ext1.ino — 多 GPIO 喚醒範例
#include <esp_sleep.h>
#define BUTTON_PIN GPIO_NUM_4 // GPIO4 喚醒(外部下拉)
#define DOOR_PIN GPIO_NUM_33 // GPIO33 門磁開關
void setup() {
Serial.begin(115200);
delay(1000);
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
if (cause == ESP_SLEEP_WAKEUP_EXT1) {
uint64_t gpio_mask = esp_sleep_get_ext1_wakeup_status();
if (gpio_mask & (1ULL << BUTTON_PIN)) {
Serial.println("喚醒原因: 按鈕按下");
}
if (gpio_mask & (1ULL << DOOR_PIN)) {
Serial.println("喚醒原因: 門磁觸發");
}
}
// 設定 EXT1 喚醒:GPIO4 和 GPIO33,任意一個高電平喚醒
const uint64_t ext1_mask = (1ULL << BUTTON_PIN) | (1ULL << DOOR_PIN);
esp_sleep_enable_ext1_wakeup(ext1_mask, ESP_EXT1_WAKEUP_ANY_HIGH);
Serial.println("進入 Deep Sleep(等待外部觸發...)");
esp_deep_sleep_start();
}
void loop() {}
這裡的關鍵是 esp_sleep_get_ext1_wakeup_status(),它回傳一個 bit mask,讓你分辨是哪一個腳位把裝置叫醒的。專案裡同時接按鈕和門磁的時候,這個 mask 就是唯一的判斷依據。另外 ESP_EXT1_WAKEUP_ANY_HIGH 的意思是「任一腳位為高就醒」,要改成全部為高才醒也可以,只差一個常數。
2.4 ESP32 Touch 喚醒
觸控喚醒是 ESP32 很討喜的一個功能:不用按鈕、不用機構,手指靠近就醒。
// esp32_deep_sleep_touch.ino — Touch 喚醒範例
#include <esp_sleep.h>
#include <driver/touch_pad.h>
// T0 = GPIO4 (Touch pad 0)
#define TOUCH_THRESHOLD 40 // 觸碰閾值(越低越靈敏)
void setup() {
Serial.begin(115200);
delay(1000);
if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_TOUCHPAD) {
int touch_pin = esp_sleep_get_touchpad_wakeup_status();
Serial.printf("Touch pad %d 觸發喚醒\n", touch_pin);
}
// 設定 Touch pad 0 喚醒(需先讀取基準值)
touch_pad_init();
touch_pad_config(TOUCH_PAD_GPIO4_CHANNEL, TOUCH_THRESHOLD);
esp_sleep_enable_touchpad_wakeup();
Serial.println("進入 Deep Sleep(Touch 喚醒模式)...");
esp_deep_sleep_start();
}
void loop() {}
要注意的是閾值的方向。原文寫得很清楚:數值越低越靈敏,TOUCH_THRESHOLD 40 就是靈敏端的設定。實務上建議先讀一次沒被觸碰時的基準值,再拿它去減一個安全距離,不然溫濕度一變化就自己醒來。
三、ESP32 ULP 協處理器(進階)
ULP(Ultra Low Power)協處理器是 ESP32 獨有的東西:一顆可以在 Deep Sleep 期間獨立運作的極低功耗處理器,能跑自訂的組合語言程式做 ADC 採樣、I2C 讀取或 GPIO 控制,只在真的需要的時候才把主 CPU 叫醒。
它的價值在於「判斷這件事本身就不該叫醒主 CPU」。以電池電壓監控為例,每分鐘叫醒主 CPU 量一次電壓再睡回去,光開機的固定開銷就吃掉不少電;交給 ULP 的話,主 CPU 可以一路睡到電壓真的低於門檻。
// esp32_ulp_adc.S — ULP 組合語言範例(ADC 監控)
// ULP 在 Deep Sleep 中定期讀取 ADC,超過閾值時喚醒主 CPU
/* 定義 ULP 變數存放在 RTC_SLOW_MEM */
.bss
.global sample
.global threshold
sample: .long 0
threshold: .long 1800 // 3.0V × 4095 / 3.3V ≈ 1800
/* ULP 主程式入口 */
.text
.global entry
entry:
/* ADC 初始化(channel 0, GPIO36) */
adc_init 0
/* 開始 ADC 轉換 */
adc_start 0
adc_wait 0
adc_read 0, sample, 12 // 讀取 12-bit ADC 值
/* 檢查是否超過閾值 */
move r0, sample
lsh r0, r0, 16 // load high 16 bits
move r1, threshold
lsh r1, r1, 16
sub r0, r0, r1 // sample - threshold
jumpr gt, wakeup // 若 sample > threshold → 喚醒
/* 未超過:繼續睡眠 */
halt
wakeup:
/* 喚醒主 CPU */
wake
halt
這段組合語言裡最值得看的是門檻 1800 這個數字:它是 3.0 V 乘上 4095 再除 3.3 V 算出來的 12-bit ADC 值。也就是說,ULP 每次採樣後就是拿這個數字在做比較,超過就 wake,沒超過就 halt 繼續睡。
四、STM32 低功耗模式
STM32 這邊比較好記,就三種模式,本文以 STM32F1/F4 為例。三者其實是「越省電、越慢醒、保留越少」的取捨:
| 模式 | CPU 狀態 | SRAM | 喚醒源 | 喚醒時間 | 典型電流 |
|---|---|---|---|---|---|
| Sleep | 停止 | 保留 | 任何中斷 | ~5 μs | ~3 mA |
| Stop | 停止 | 保留 | EXTI (GPIO/RTC/USB) | ~10 μs | ~50 μA |
| Standby | 斷電 | 遺失 | WKUP Pin/RTC/NRST | ~500 μs | ~2 μA |
我的選擇邏輯很簡單:要快醒就用 Sleep,要平衡用 Stop,要極致省電又不怕重開就用 Standby。下面兩個範例分別是 Stop 與 Standby,差別一定要親眼看過一次才會記住 —— Standby 醒來等於復位,程式是從頭跑的。
4.1 STM32 RTC 喚醒(Stop 模式)
Stop 模式是實務上最常用的一級:SRAM 全部保留,靠 RTC 或 EXTI 醒來,醒來之後從原本的位置繼續往下執行。
// stm32_rtc_stop.c — STM32 RTC 定時喚醒(Stop 模式)
#include "stm32f1xx_hal.h"
RTC_HandleTypeDef hrtc;
void HAL_MspInit(void)
{
HAL_PWR_EnableBkUpAccess(); // 啟用備份域存取
}
void enter_stop_mode(void)
{
// 設定 RTC 喚醒:20 秒後
HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 20000, RTC_WAKEUPCLOCK_CK_SPRE_16BITS);
// 進入 Stop 模式
HAL_PWR_EnterSTOPMode(PWR_LOWPOWERREGULATOR_ON, PWR_STOPENTRY_WFI);
// === 從此處繼續(喚醒後) ===
// 重新配置系統時鐘(HSE/PLL)
SystemClock_Config();
}
// RTC 喚醒中斷回呼
void HAL_RTCEx_WakeUpTimerEventCallback(RTC_HandleTypeDef *hrtc)
{
// Stop 模式喚醒後,此中斷被觸發
// 通常不需要在此做任何事,直接返回 main()
}
這段有兩件事要記得。第一,HAL_PWR_EnableBkUpAccess() 要先開,不然備份域的暫存器動不了。第二,醒來之後系統時鐘會回到預設值,所以那行 SystemClock_Config() 不是裝飾,少了它後面的 UART 與計時全部會歪掉。
4.2 STM32 Standby 模式(最低功耗)
Standby 是省電的極限:SRAM 不保留、CPU 斷電,電流掉到 2 μA 左右。
// stm32_standby.c — STM32 Standby 模式
#include "stm32f1xx_hal.h"
void enter_standby_mode(void)
{
// 清除 Wake-up 旗標
__HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU);
// 啟用 RTC 喚醒(40 秒)
HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 40000, RTC_WAKEUPCLOCK_CK_SPRE_16BITS);
// 啟用 WKUP Pin (PA0) 外部喚醒
HAL_PWR_EnableWakeUpPin(PWR_WAKEUP_PIN1);
// 進入 Standby 模式
HAL_PWR_EnterSTANDBYMode();
// === 不會到達此處 ===
// Standby 喚醒等同於復位,程式從頭開始執行
}
// 檢查復位原因
void check_reset_cause(void)
{
if (__HAL_RCC_GET_FLAG(RCC_FLAG_SFTRST)) {
printf("軟體復位\n");
}
if (__HAL_PWR_GET_FLAG(PWR_FLAG_SB)) {
printf("Standby 喚醒復位\n");
}
// 清除旗標
__HAL_RCC_CLEAR_RESET_FLAGS();
__HAL_PWR_CLEAR_FLAG(PWR_FLAG_SB);
}
代價是「醒來等於重新開機」。所以判斷邏輯要改成看復位原因:PWR_FLAG_SB 有被設起來,就代表這次開機是 Standby 喚醒,而不是真的斷電重來。把狀態存在備份域暫存器,是 Standby 架構裡唯一能跨過復位的方法。
五、ESP32 vs STM32 低功耗比較
兩顆晶片的省電能力其實很接近,真正拉開距離的是「醒來之後要花多少代價做事」。下面這張表是原文的整理:
| 項目 | ESP32 | STM32 (F1/F4) |
|---|---|---|
| 最小睡眠電流 | ~5 μA (Deep-sleep) | ~2 μA (Standby) |
| RTC 保留電流 | ~2.5 μA (Hibernation) | ~1.4 μA (VBAT backup) |
| Wake-up 時間 | ~5 ms (Deep-sleep → Active) | ~500 μs (Standby) |
| SRAM 保留 | Deep-sleep: RTC RAM (8KB) 保留 | Stop: 全部保留 / Standby: 不保留 |
| 喚醒來源 | Timer/GPIO/Touch/ULP | RTC/EXTI/WKUP Pin |
| 睡眠中運算 | ULP 協處理器可執行自訂程式 | 無(可選 RTC 或 LPTIM) |
| WiFi 連線時間 | ~1~3 秒 (連接 AP) | 需外接 WiFi 模組 (ESP8266/AT) |
| 最佳應用 | WiFi 感測器、閘道器 | 超低功耗感測器節點 |
我自己讀這張表的重點只有兩行:喚醒時間差了一個數量級(5 ms 對 500 μs),WiFi 連線要 1~3 秒。前者說明 STM32 適合高頻喚醒,後者說明 ESP32 每次醒來都得為連線付一筆固定成本 —— 這也是下一篇要談「合併上傳」的原因。
六、生產級 Deep Sleep 設計指南
6.1 上傳資料策略
每次喚醒後上傳資料,是最耗電的一步。能省的地方大概就這幾處:
- 合併資料:記錄多次讀值再一次上傳(例如每小時上傳 60 筆每分鐘的資料)
- 使用快速連接:ESP32 用 ESP-NOW 可以省掉連 AP 的時間
- 條件式上傳:只有變化超過閾值才上傳
- 批次上傳:用 RTC RAM(ESP32)或外部 EEPROM 先把資料快取起來
這四招的精神都一樣:把「醒著的時間」壓到最短,而不是想辦法讓睡眠更省電。
6.2 RTC_DATA_ATTR 變數設計
要跨過一次次睡眠,資料就得放在 RTC RAM 裡。原文的做法是把相關欄位包成一個結構:
// 保留在 RTC RAM 中的資料結構
RTC_DATA_ATTR struct {
uint32_t boot_count; // 總開機次數
uint32_t error_count; // 錯誤計數
float last_temperature; // 上次測量的溫度
uint8_t data_buffer[64]; // 資料快取區
uint32_t crc32; // 資料完整性檢查
} rtc_data;
// 每次喚醒後更新 RTC 資料
void save_to_rtc_mem(float temp, float hum) {
rtc_data.boot_count++;
rtc_data.last_temperature = temp;
rtc_data.data_buffer[rtc_data.boot_count % 64] = (uint8_t)(temp * 10);
rtc_data.crc32 = esp_crc32_le(0, (uint8_t*)&rtc_data, sizeof(rtc_data) - 4);
}
這個結構裡我特別想提兩個欄位。data_buffer[64] 是拿來當環狀快取用的,配合 boot_count % 64 就能一直覆寫最舊的資料;crc32 則是在每次寫入後重算,醒來時先驗一次就知道這包資料還能不能信。

至於要留哪一塊 RTC 記憶體,原文的建議很務實:只把必要的資料留在 RTC_SLOW_MEM,RTC_FAST_MEM 可以關掉省電。對照 STM32 的 Stop 模式 SRAM 全保留、Standby 完全不保留,就會發現「資料要放哪裡」其實是選完模式之後的第一個決定。
6.3 電源管理最佳實踐
魔鬼都在這些細節裡,原文列的六件事我照抄如下:
- 關閉不必要的週邊:進睡眠前關掉 ADC、I2C、SPI 等週邊時鐘
- GPIO 狀態管理:睡前把所有 GPIO 設成高阻抗輸入或固定電平,避免漏電
- 電壓調節器:ESP32 可選 RTC 調節器(效率高但啟動慢)或數位調節器(相反)
- Flash 斷電:ESP32 在 Deep Sleep 前會自動斷開 Flash 電源,外接 Flash 則要自己控制
- WiFi 完全關閉:
esp_deep_sleep_start()前先呼叫WiFi.disconnect(true)與WiFi.mode(WIFI_OFF) - RTC 記憶體選擇:只在 RTC_SLOW_MEM 保留必要資料,RTC_FAST_MEM 可關閉節電
把這些對應到「出問題時會看到什麼症狀」,就變成下面這張排查表:

我的排查順序是:先確認喚醒來源對不對,再看資料有沒有留下來,最後才懷疑無線品質。順序反過來的話,很容易在 WiFi 上繞半天,結果問題出在一個忘記設定的 GPIO。
七、總結
Deep Sleep 是 IoT 裝置從「桌上原型」走到「實際部署」的關鍵一步。這篇涵蓋了 ESP32 的五種喚醒來源與對應程式碼、ULP 協處理器的判斷邏輯、STM32 三級低功耗模式的 HAL 實作,以及電池續航的估算方法。
整篇其實就是一句話:每次喚醒都在做事,每次睡眠都在省電。把喚醒週期、上傳策略與 GPIO 狀態這三件事設計好,一顆電池讓感測器節點跑上幾年,就不是什麼神奇的事了。
文章評論