低功耗与深度睡眠:电池供电必修
- 说清 ESP32-S3 的几个功耗档位,知道正常工作、WiFi 发射、深度睡眠各自吃多少电流
- 能用 esp_sleep_enable_timer_wakeup / esp_sleep_enable_ext1_wakeup 写出定时唤醒和引脚唤醒两种深睡代码
- 用 RTC_DATA_ATTR 把跨睡眠的变量保住,用 esp_sleep_get_wakeup_cause() 判断这次是上电还是睡醒
- 会用平均电流估算电池续航,知道哪些坑会让续航打骨折
你做了个电池供电的温湿度计:一节 18650 锂电、一颗 ESP32-S3、一个 DHT22,每隔几分钟读一次温湿度发到服务器。插着 USB 测的时候一切正常。拔了 USB 换电池,第二天早上一看——没电了。
不是你电路接错,是 ESP32-S3 默认就一直全速跑、WiFi 一直开着。这种状态下它平均要吃几十毫安,一节 2500mAh 的电池撑一天算它客气。要让这种设备跑几个月,核心只有一招:让它绝大部分时间睡着,只在需要干活的那几秒醒过来。这一节就把这招用 ESP-IDF 讲透。
开始前你应该已经会让 ESP32-S3 连 WiFi 上报数据(见 /guide/l3-wifi/),也知道锂电池供电的基本安全(见 /principle/lithium-safety/)。本篇的核心 API——esp_deep_sleep_start()、esp_sleep_enable_*——本就是 ESP-IDF 原生的,Arduino 也是直接调它们;真正要换的是外壳:把 setup/loop 换成 app_main、Serial 换成 ESP_LOGI。这点我说在前头,免得你以为有什么魔法。
先认清功耗档位:你的电流花在哪
谈省电之前,得先有数量级的概念。ESP32-S3 不是"开"和"关"两档,而是好几个档位,电流差了上万倍:
| 状态 | 典型电流 | 在干什么 |
|---|---|---|
| 正常工作(CPU 全速,WiFi 关) | 约 30~50mA | 跑你的代码、读传感器 |
| 正常工作 + WiFi 已连接空闲 | 约 80~120mA | WiFi 维持连接 |
| WiFi 发射峰值 | 瞬时 240~500mA | 正在发数据包 |
| Modem-sleep | 约 20~30mA | CPU 跑,射频按需关 |
| Light-sleep | 约 0.8mA | CPU 暂停,RAM 保留,可快速恢复 |
| Deep-sleep | 约 7~10μA | 几乎全关,只留 RTC |
注意单位:正常工作是毫安(mA),深度睡眠是微安(μA),差了一万倍。深睡 10μA 是什么概念?一节 2500mAh 的电池,光按这个电流算能放上百年(实际会被电池自放电和稳压器静态电流拖死,后面讲)。
所以省电的本质不是"把工作电流降一点",而是尽量缩短工作时间,把剩下时间全扔进深度睡眠。这是电池设备的第一性原理。
三种睡眠模式,该用哪个
ESP-IDF 给你三档睡眠,给你我的取舍建议:
- Modem-sleep:只关射频、CPU 照跑。适合"必须秒级响应但又想省点"的设备,省得有限,电池设备基本看不上。
- Light-sleep(
esp_light_sleep_start()):CPU 暂停、外设和 RAM 都保留,来个中断就毫秒级恢复,接着上次的位置继续跑。适合"要保持响应、又能频繁打盹"的场景,比如一个要随时接按键的遥控器。 - Deep-sleep(
esp_deep_sleep_start()):除了 RTC 控制器和一小块 RTC 内存,几乎全断电。功耗压到约 10μA,代价是醒来等于重启——RAM 清空,从app_main从头跑,不会接着上次的位置继续。
这是 Light-sleep 和 Deep-sleep 最大的行为差异:浅睡是"暂停/继续",深睡是"关机/重新开机"。电池供电的"间歇上报"类设备(温湿度计、土壤传感器、水表),九成场景用 Deep-sleep。下面重点讲它。
第一步:先把定时唤醒这段跑通
最常见的需求:每隔一段时间醒一次,测个数发出去,再睡下。因为深睡醒来从 app_main 重跑,所以整个工作逻辑都写在 app_main 里,跑完就睡,没有 while(1)——这跟点灯那种常驻循环的程序完全不同,是深睡设备特有的"做完就睡"结构。
把下面这段放进工程的 main/main.c,idf.py set-target esp32s3 后直接 idf.py build flash monitor:
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_sleep.h"
#include "esp_log.h"
#define uS_PER_SECOND 1000000ULL // 微秒到秒的换算,ULL 防溢出
#define SLEEP_MINUTES 10 // 每 10 分钟醒一次
static const char *TAG = "deepsleep";
// RTC_DATA_ATTR:变量存进 RTC 慢速内存,深睡期间持续供电,醒来不丢
RTC_DATA_ATTR static int boot_count = 0;
void app_main(void) {
boot_count++;
ESP_LOGI(TAG, "第 %d 次醒来", boot_count);
// 判断这次是被谁叫醒的(首次上电 / 定时醒)
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
if (cause == ESP_SLEEP_WAKEUP_TIMER) {
ESP_LOGI(TAG, "是定时器把我叫醒的");
} else {
ESP_LOGI(TAG, "首次上电或复位"); // ESP_SLEEP_WAKEUP_UNDEFINED
}
// —— 这里是你每次醒来要干的活 ——
// 1. 读传感器(尽量快,工作越快越省电)
// float temp = read_temperature(); // 你自己的读取函数
ESP_LOGI(TAG, "读传感器、连 WiFi 上报...(略,见 l3-wifi 那套骨架)");
vTaskDelay(pdMS_TO_TICKS(200)); // 占位:模拟干活耗时
// 2. 配定时唤醒:参数单位是微秒
esp_sleep_enable_timer_wakeup(SLEEP_MINUTES * 60 * uS_PER_SECOND);
// 3. 进深睡。这行之后的代码永远不会执行——醒来是从 app_main 顶部重跑
ESP_LOGI(TAG, "睡 %d 分钟,晚安", SLEEP_MINUTES);
esp_deep_sleep_start();
}
注意 uS_PER_SECOND 后缀是 ULL(无符号长长整型)。定时唤醒的参数单位是微秒,10 分钟 = 600 秒 = 6 亿微秒,普通 int 装不下会溢出——这是新手最常踩的坑,Arduino 时代是这坑,ESP-IDF 也是这坑,API 没变。
还要注意:深睡设备的 app_main 不放 while(1)。普通常驻程序最后是个死循环不让 app_main 返回;深睡程序则相反,做完事直接 esp_deep_sleep_start(),这个函数不返回(它把芯片关了),所以你不用担心 app_main 跑到结尾——它根本到不了结尾。
你应该看到什么
idf.py monitor 里你应该看到这样的循环节奏:
I (312) deepsleep: 第 1 次醒来
I (315) deepsleep: 首次上电或复位
I (318) deepsleep: 读传感器、连 WiFi 上报...
I (520) deepsleep: 睡 10 分钟,晚安
(串口安静 10 分钟,期间只有进入深睡前系统打的几行底层日志)
ets ... rst:0x5 (DEEPSLEEP_RESET) ← 这行是醒来时 bootloader 打的
I (312) deepsleep: 第 2 次醒来
I (315) deepsleep: 是定时器把我叫醒的
...
两个关键信号:一是"晚安"之后串口彻底安静——芯片没在跑任何代码,这正是深睡的标志;二是醒来时 bootloader 会打一行 rst:0x5 (DEEPSLEEP_RESET),这是"从深睡复位"的铁证,和上电复位(POWERON_RESET)区分得清清楚楚。如果"晚安"之后串口立刻又冒出"第 N 次醒来",说明它根本没睡进去或立刻被唤醒了,去查唤醒源设置。
有条件的话串个电流表(万用表选 mA/μA 档,串进电源回路)。工作那几秒你会看到几十毫安、连 WiFi 时跳到上百毫安甚至峰值更高;"晚安"之后,读数会骤降到几十微安。这个肉眼可见的暴跌,就是省电真正生效的证据。注意:多数开发板上有 USB 转串口芯片、电源 LED、稳压器,它们自己就吃几毫安,会盖住 10μA 的真实功耗——要测准得用裸模组或砍掉这些外围(见后面的坑)。
把这段讲透:三个 ESP-IDF 概念
RTC_DATA_ATTR:醒来后怎么记住上次的事
代码里那个 RTC_DATA_ATTR static int boot_count 是关键。深睡醒来 RAM 全清空,普通全局变量每次都回到初始值。但带 RTC_DATA_ATTR 修饰的变量,编译器会把它放进 RTC 慢速内存(RTC slow memory)——这块内存在深睡期间持续供电,所以醒来后值还在。
ESP32-S3 的 RTC 慢速内存有约 8KB,够存计数器、累计值、上次状态这类小数据。比如累计上报次数、滑动平均、"上次是不是连上了 WiFi"的标志,都该放这里:
RTC_DATA_ATTR static int boot_count = 0;
RTC_DATA_ATTR static float last_temp = 0;
RTC_DATA_ATTR static bool wifi_failed_last = false;
别往里塞大数组或字符串缓冲——RTC 内存小,只放真正需要跨睡眠保留的轻量状态。要存几 KB 以上、或者要掉电也保住的数据,该用 NVS(flash 上的键值存储,见 l3-wifi 里讲的那块),而不是 RTC 内存。
esp_sleep_get_wakeup_cause():这次是上电还是睡醒
因为深睡醒来 = 从 app_main 重跑,你常常需要在开头判断:"这是第一次上电,还是从深睡里醒来的?是定时醒的还是被引脚叫醒的?"这就靠 esp_sleep_get_wakeup_cause(),它返回一个枚举:
ESP_SLEEP_WAKEUP_UNDEFINED:不是从深睡醒的(首次上电、按复位键、看门狗复位等)。ESP_SLEEP_WAKEUP_TIMER:定时器到点叫醒的。ESP_SLEEP_WAKEUP_EXT0/ESP_SLEEP_WAKEUP_EXT1:引脚电平叫醒的(下一节讲)。ESP_SLEEP_WAKEUP_GPIO/ESP_SLEEP_WAKEUP_TOUCHPAD:GPIO/触摸唤醒。
有了它,你可以"首次上电做一次完整初始化(比如配网、对时),之后每次定时醒只干轻活"——省下重复初始化的时间和电。
esp_deep_sleep_start():之后不返回
esp_deep_sleep_start() 调用后不会返回:它把 CPU、RAM、大部分外设断电,芯片进入深睡。醒来时不是接着这行往下走,而是整颗芯片复位、从 app_main 顶部重新开始。所以这行后面写任何代码都是死代码,别在它后面放清理逻辑——清理要放在它前面。
第二步:引脚唤醒——按钮或 PIR 触发醒来
除了定时,还能让某个引脚的电平变化把 ESP32-S3 叫醒。这适合事件驱动的设备——门磁、按钮、PIR 人体感应,平时睡死,有人动了才醒。
这里有个 ESP32-S3 和老 ESP32 的重要差异,必须讲清楚:老 ESP32 常用的 esp_sleep_enable_ext0_wakeup()(单引脚 ext0)在 ESP32-S3 上不支持——S3 没有 ext0,只有 ext1(可多引脚)。所以在 S3 上做引脚唤醒,用 esp_sleep_enable_ext1_wakeup():
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_sleep.h"
#include "esp_log.h"
#define WAKE_GPIO GPIO_NUM_4 // 必须是 RTC GPIO(S3:GPIO0~21)
#define WAKE_MASK (1ULL << WAKE_GPIO) // ext1 用位掩码指定引脚
static const char *TAG = "wake";
RTC_DATA_ATTR static int boot_count = 0;
void app_main(void) {
boot_count++;
// 判断这次是被谁叫醒的
esp_sleep_wakeup_cause_t cause = esp_sleep_get_wakeup_cause();
if (cause == ESP_SLEEP_WAKEUP_EXT1) {
// ext1 还能告诉你具体是哪个引脚触发的(多引脚场景有用)
uint64_t pins = esp_sleep_get_ext1_wakeup_status();
ESP_LOGI(TAG, "引脚触发醒的!触发引脚掩码=0x%llx", (unsigned long long)pins);
// 干你的活:开灯、拍照、报警...
} else if (cause == ESP_SLEEP_WAKEUP_TIMER) {
ESP_LOGI(TAG, "定时醒的");
} else {
ESP_LOGI(TAG, "首次上电(第 %d 次)", boot_count);
}
ESP_LOGI(TAG, "干完活,准备睡,等下次触发...");
vTaskDelay(pdMS_TO_TICKS(200));
// 配 ext1:WAKE_GPIO 变成高电平就唤醒
// ESP_EXT1_WAKEUP_ANY_HIGH = 掩码里任一引脚拉高就醒;ANY_LOW = 任一拉低就醒
esp_sleep_enable_ext1_wakeup(WAKE_MASK, ESP_EXT1_WAKEUP_ANY_HIGH);
// 也可以同时配定时唤醒:做"有人触发立刻醒 + 没人触发每小时自检一次"的混合策略
// esp_sleep_enable_timer_wakeup(60ULL * 60 * 1000000ULL);
esp_deep_sleep_start();
}
注意三点:
一是唤醒引脚必须是 RTC GPIO。ESP32-S3 上 GPIO0~GPIO21 是 RTC GPIO,可以用来唤醒;GPIO22 以上不行。普通 GPIO 在深睡时整个 IO 子系统都断电了,叫不醒。具体哪些脚能用,以你手上开发板的 datasheet 为准——选脚前查一眼。
二是 ext1 用位掩码指定引脚(1ULL << gpio),触发模式选 ESP_EXT1_WAKEUP_ANY_HIGH(掩码里任一脚拉高就醒)或 ESP_EXT1_WAKEUP_ANY_LOW(任一拉低就醒),按你的传感器输出极性选。多个引脚可以"或"进同一个掩码,任意一个触发都醒,醒来用 esp_sleep_get_ext1_wakeup_status() 看是哪个。
三是按钮/传感器唤醒记得加硬件上下拉电阻。悬空的 RTC 引脚会被干扰乱触发,反复把芯片叫醒反而更费电。配 ANY_HIGH 就接下拉(平时拉低,触发时拉高),配 ANY_LOW 就接上拉。
如果你之前用过老 ESP32 的 esp_sleep_enable_ext0_wakeup(GPIO_NUM_33, 1) 写法,在 S3 上会编译不过或运行报错——这是芯片能力差异,不是你写错。S3 把单引脚 ext0 砍了,统一用更灵活的 ext1。另外 S3 还有 esp_deep_sleep_enable_gpio_wakeup() 这条路,行为类似,挑一种用即可。
续航估算:一个简单乘法
知道续航能不能撑几个月,靠一个核心公式——平均电流 = (工作电流 × 工作时长 + 睡眠电流 × 睡眠时长) ÷ 总时长。
举个实在例子:每 10 分钟醒一次,醒来读数 + 连 WiFi 上报大约花 6 秒,然后睡 600 秒。
- 工作那 6 秒:平均电流粗估 100mA(连 WiFi 比较费)
- 睡的 600 秒:约 0.05mA(算上开发板外围漏电,比裸模组 10μA 高得多)
平均电流 ≈ (100mA × 6s + 0.05mA × 600s) ÷ 606s ≈ 1.04mA
一节 2500mAh 的电池:2500mAh ÷ 1.04mA ≈ 2400 小时 ≈ 100 天。从"撑一天"变成"撑三个月",全靠把那 600 秒压到 μA 级。
反过来看,如果你不睡、一直连着 WiFi 跑(平均 100mA),2500mAh ÷ 100mA = 25 小时——一天就没了,和开头那个翻车的温湿度计完全对上。
想算自己的设备:量出一次工作真正花多少秒、那几秒平均吃多少 mA(电流表看),代进公式即可。锂电池的容量、放电特性见 /principle/lithium-safety/,供电稳压器的静态电流也会偷电,见 /principle/power-regulator/。
几个会让续航打骨折的坑
- 深睡醒来程序从头跑:很多人下意识以为会接着上次的位置继续(那是 Light-sleep 的行为),结果状态全丢。记住:Deep-sleep 醒来 = 复位重启,逻辑写
app_main、跨睡眠的值放RTC_DATA_ATTR,开头用esp_sleep_get_wakeup_cause()判断来路。 - WiFi 每次重连又慢又耗电:从睡醒到 WiFi 连上常要 3~8 秒,这几秒是高电流大头。能少连就少连——比如攒够 6 次读数再一次性连一次上报,或改用更省的协议。连不上一定要设超时(见 l3-wifi 里那套带重连上限的事件骨架),别让它干等几十秒把电耗光。
- 只有 RTC GPIO 能唤醒:用引脚唤醒时若选了非 RTC 引脚(S3 上 GPIO22 以上),深睡时那个 IO 已断电,叫不醒。先查 datasheet 确认你的唤醒脚在 RTC GPIO 范围内。
- 外设漏电:DHT22、屏幕、SD 卡这些在深睡时如果还接着电,照样偷电。讲究的做法是用一个 GPIO 控制 MOSFET 给外设上电,睡前切断。
- 开发板外围吃电:USB 转串口芯片、电源指示 LED、低效稳压器(AMS1117 静态电流就有几个 mA)能轻松吃掉几毫安,让你的 10μA 深睡毫无意义。量产省电设备得用裸 ESP32-S3 模组 + 高效低静态电流稳压器。
- GPIO 悬空乱唤醒:用 ext1 唤醒时,唤醒脚没接上下拉电阻,会被干扰反复叫醒,反而更费电。
故障排查表
| 现象 | 可能原因 | 怎么查 |
|---|---|---|
| "晚安"后立刻又醒 | 定时器参数溢出(没用 ULL)/ 唤醒脚被触发 | 检查微秒计算用了 ULL;唤醒脚加上下拉电阻 |
boot_count 永远是 1 |
变量没加 RTC_DATA_ATTR |
把跨睡眠的变量加上该修饰符 |
esp_sleep_enable_ext0_wakeup 编译/运行报错 |
在 ESP32-S3 上用了 ext0(S3 不支持) | 改用 esp_sleep_enable_ext1_wakeup + 位掩码 |
| 引脚唤醒不生效 | 用了非 RTC GPIO(S3 GPIO22 以上)/ 极性反了 | 换成 GPIO0~21;核对 ANY_HIGH/ANY_LOW 和上下拉 |
| 电流表测不到 μA 级骤降 | 开发板外围(USB 芯片/LED/稳压器)在吃电 | 换裸模组测,或砍掉外围;先确认是不是测错档位 |
| 续航远不如估算 | WiFi 连接太频繁太久 / 外设没断电 | 量实际工作电流和秒数;睡前用 MOSFET 切外设 |
| 醒来后传感器读数是 0 或乱码 | 外设刚上电还没稳定 | 上电后 vTaskDelay 等传感器初始化完成再读 |
变体:换个唤醒方式
PIR 低功耗人体感应:把 PIR 模块(见 /sensor/pir/)的输出接到 RTC GPIO 唤醒脚,用 ext1 + ESP_EXT1_WAKEUP_ANY_HIGH 高电平触发。平时 ESP32-S3 睡死、整机几十微安;有人经过 PIR 拉高电平,芯片秒醒,拍照或上报"有人来了",干完再睡。这是做电池供电安防/感应灯最省电的架构。
混合唤醒的手持设备:把按钮接 RTC 唤醒脚,同时配 esp_sleep_enable_timer_wakeup——做"按一下立刻醒 + 没人按也每小时自检一次"的混合策略。两个唤醒源可以同时设,醒来用 esp_sleep_get_wakeup_cause() 区分这次是按钮叫的还是定时叫的,分别处理。读完显示几秒、几秒无操作自动睡回去。
Light-sleep 保状态变体:如果你的设备需要"睡着也别丢内存状态、来个中断毫秒级接着跑"(比如低功耗遥控器),换 esp_light_sleep_start()——它醒来是从调用处往下继续,不是重启,RAM 全保留。代价是功耗比深睡高(约 0.8mA vs 10μA)。按"要不要保状态/响应速度"在浅睡和深睡之间选。
动手挑战
做一个每 15 分钟上报一次温湿度的电池设备,要求:
- 用 DHT22(见 /sensor/dht22/)读温湿度,逻辑全写在
app_main,做完就睡。 - 用
esp_sleep_enable_timer_wakeup设 15 分钟定时唤醒,工作完进 Deep-sleep。 - 用
RTC_DATA_ATTR记录累计上报次数,每次开头ESP_LOGI打印出来。 - 开头用
esp_sleep_get_wakeup_cause()判断:首次上电时做一次完整初始化,定时醒时跳过初始化只读数上报。 - WiFi 连接复用 l3-wifi 那套带重连上限的事件骨架,连不上就跳过这次、照常睡下。
- 估算续航:量出一次工作实际花几秒、平均吃多少 mA,代进平均电流公式,算出一节 2000mAh 电池能撑多少天。
- 进阶:用一个 GPIO + MOSFET 在睡前切断 DHT22 供电,对比切与不切的平均电流差多少。
把你算出来的续航天数和电流表实测的深睡电流记下来——这两个数字就是你这台设备的"省电成绩单"。
本篇代码为参考实现,需结合你所用的最新 ESP-IDF 文档自校——尤其是
esp_sleep的唤醒源 API、ESP32-S3 的 RTC GPIO 范围和 ext1 用法随版本/芯片型号可能微调,以官方 Sleep Modes 文档 为准。
小结与下一步
深度睡眠不是某个高级技巧,而是电池供电设备的默认架构:平时睡死(μA 级)、需要时秒醒干活(mA 级)、干完立刻睡回去。掌握了 esp_sleep_enable_timer_wakeup 定时唤醒 + esp_sleep_enable_ext1_wakeup 引脚唤醒 + RTC_DATA_ATTR 跨睡眠保值这三件套,再用 esp_sleep_get_wakeup_cause() 判断来路、用一个乘法估出续航,你就能把"撑一天"的玩具做成"撑几个月"的真设备。
要让多台这样的低功耗节点实时收发数据,下一步可以看长连接通信:/guide/l3-websocket/。如果你对前置的 WiFi 上报还不够熟,先回头巩固 /guide/l3-wifi/。想看整条学习路线,去 /roadmap/;更多实战项目在 /guide/。