引言

ESP32 的深度睡眠(Deep Sleep)模式是低功耗应用的核心,其功耗可低至 10μA 以下。为了在唤醒后快速恢复现场,我们常利用 RTC 内存(RTC Fast Memory)保存关键变量;同时,通过 EXT1 或 EXT0 机制将特定 GPIO 配置为唤醒源。然而,许多开发者发现:当 GPIO 唤醒源与 RTC 内存操作同时使用时,会出现数据随机丢失或无法唤醒的诡异现象。这并非偶然,而是源于 ESP32 内部电源域和引脚复用的设计约束。

冲突根源:RTC 电源域与 GPIO 矩阵

ESP32 的 RTC 外设(包括 RTC 内存、RTC GPIO)在深度睡眠时由 RTC 电源域(RTC Power Domain)供电,而主系统(包括 GPIO 矩阵)则被断电。

  • RTC 内存:位于 RTC 电源域内,深度睡眠期间保持数据,但访问它需要 RTC 控制器处于活动状态。
  • GPIO 唤醒源:EXT0(单引脚)和 EXT1(多引脚)唤醒功能由 RTC 控制器直接管理,但唤醒引脚必须连接到 RTC GPIO(即 GPIO 0, 2, 4, 12-15, 25-27, 32-39)。这些引脚在深度睡眠时仍由 RTC 域供电,且其输入状态由 RTC 控制器采样。

冲突点:当使用 EXT1 唤醒时,RTC 控制器会持续扫描这些引脚的电平变化。若此时 RTC 内存正在被写入或读取,且访问地址恰好与 RTC 控制器的寄存器映射重叠(例如 RTC_CNTL 寄存器),则可能产生总线竞争,导致数据损坏。此外,某些 GPIO 在深度睡眠前被配置为输出高电平,而唤醒后又被用作 RTC 内存的供电参考,也会引发异常。

规避技巧一:分离 RTC 内存访问与唤醒配置时序

最直接的规避方法是在进入深度睡眠前,先完成 RTC 内存的写入,再配置 GPIO 唤醒源,最后调用 esp_deep_sleep_start()。反之,在唤醒后,先读取 RTC 内存,再重新初始化 GPIO。

// 进入深度睡眠前
void enter_deep_sleep_with_rtc(void) {
    // 1. 写入 RTC 内存
    rtc_mem_data.boot_count++;
    esp_sleep_set_wakeup_time(10 * 60 * 1000000ULL); // 定时唤醒作为备用

    // 2. 配置 GPIO 唤醒源(EXT1)
    esp_sleep_enable_ext1_wakeup(WAKEUP_PIN_MASK, ESP_EXT1_WAKEUP_ANYHIGH);

    // 3. 确保所有 GPIO 状态稳定,避免唤醒后误触发
    gpio_deep_sleep_hold_en();

    // 4. 进入深度睡眠
    esp_deep_sleep_start();
}

// 唤醒后
void wakeup_handler(void) {
    // 1. 立即读取 RTC 内存(此时 GPIO 尚未重新初始化)
    int boot_count = rtc_mem_data.boot_count;
    printf("Boot count: %d\n", boot_count);

    // 2. 再初始化 GPIO 外设
    gpio_config_t io_conf = {...};
    gpio_config(&io_conf);
}

要点

  • 使用 esp_sleep_get_wakeup_cause() 区分唤醒源,避免重复处理。
  • esp_deep_sleep_start() 前调用 gpio_deep_sleep_hold_en() 可保持 GPIO 状态,但需注意与唤醒引脚的冲突。

规避技巧二:使用 RTC 内存的专用 API 与原子操作

ESP-IDF 提供了 esp_sleep_get_rtc_fast_memory_retention() 等函数,但更关键的是使用 RTC_NOINIT_ATTR 属性定义变量,确保它们被放置在 RTC 内存段,并避免编译器优化。

RTC_NOINIT_ATTR static uint32_t boot_count;

void save_boot_count(void) {
    boot_count++;
    // 强制内存屏障,确保写入完成
    __sync_synchronize();
}

void load_boot_count(void) {
    uint32_t val = boot_count;
    // 使用 volatile 或原子操作防止缓存不一致
}

注意:在深度睡眠唤醒后,RTC 内存内容保持不变,但若在唤醒过程中有中断访问同一地址,可能导致撕裂。建议在唤醒后立即读取,并禁用中断直到读取完成。

规避技巧三:避免使用与 RTC 内存地址重叠的 GPIO 唤醒引脚

ESP32 的 RTC 内存地址范围是 0x50000000-0x50001FFF,而 RTC 控制器的寄存器映射在 0x3FF48000 附近。虽然物理上不重叠,但某些 GPIO(如 GPIO36-39)是输入专用,且与 RTC 控制器的 ADC 功能复用。若将这些引脚用作唤醒源,同时又在 RTC 内存中存储 ADC 校准数据,则可能因 ADC 采样干扰而损坏数据。

推荐做法

  • 优先使用 GPIO 0, 2, 4, 12-15, 25-27 作为唤醒源,避开 32-39。
  • 若必须使用 32-39,则确保 RTC 内存中不存储易变数据,或使用校验和(CRC)验证。
#define WAKEUP_PIN_MASK  (1ULL << GPIO_NUM_0 | 1ULL << GPIO_NUM_2) // 安全引脚

规避技巧四:利用 RTC 内存的备份与恢复机制

为了增强可靠性,可以在 RTC 内存中存储两份数据,并附加 CRC 校验。唤醒后先校验,若失败则回退到默认值。

typedef struct {
    uint32_t magic;
    uint32_t boot_count;
    uint32_t crc;
} rtc_data_t;

RTC_NOINIT_ATTR static rtc_data_t rtc_data;

void rtc_save(void) {
    rtc_data.magic = 0xA5A5;
    rtc_data.boot_count++;
    rtc_data.crc = crc32(&rtc_data, sizeof(rtc_data) - 4);
}

bool rtc_load(uint32_t *count) {
    if (rtc_data.magic != 0xA5A5) return false;
    uint32_t calc_crc = crc32(&rtc_data, sizeof(rtc_data) - 4);
    if (calc_crc != rtc_data.crc) return false;
    *count = rtc_data.boot_count;
    return true;
}

完整示例:结合定时与 GPIO 唤醒的稳健设计

#include <stdio.h>
#include "esp_sleep.h"
#include "esp_log.h"
#include "driver/gpio.h"
#include "esp32/rom/crc.h"

#define WAKEUP_PIN_0 GPIO_NUM_0
#define WAKEUP_PIN_2 GPIO_NUM_2
#define WAKEUP_MASK (1ULL << WAKEUP_PIN_0 | 1ULL << WAKEUP_PIN_2)

RTC_NOINIT_ATTR static uint32_t boot_count;
RTC_NOINIT_ATTR static uint32_t crc_stored;

static void init_gpio_wakeup(void) {
    gpio_config_t io_conf = {
        .pin_bit_mask = WAKEUP_MASK,
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,
    };
    gpio_config(&io_conf);
    esp_sleep_enable_ext1_wakeup(WAKEUP_MASK, ESP_EXT1_WAKEUP_ANYHIGH);
}

static void save_boot_count(void) {
    boot_count++;
    crc_stored = crc32(&boot_count, sizeof(boot_count));
    __sync_synchronize();
}

static bool load_boot_count(uint32_t *count) {
    uint32_t crc_calc = crc32(&boot_count, sizeof(boot_count));
    if (crc_calc != crc_stored) return false;
    *count = boot_count;
    return true;
}

void app_main(void) {
    // 唤醒后立即读取 RTC 内存
    uint32_t count = 0;
    if (load_boot_count(&count)) {
        ESP_LOGI("MAIN", "Boot count: %lu", (unsigned long)count);
    } else {
        ESP_LOGW("MAIN", "RTC data corrupted, resetting");
        boot_count = 0;
        save_boot_count();
    }

    // 初始化 GPIO 唤醒
    init_gpio_wakeup();

    // 模拟工作
    vTaskDelay(pdMS_TO_TICKS(5000));

    // 保存数据并进入睡眠
    save_boot_count();
    esp_deep_sleep_start();
}

注意事项与调试建议

  • 电源稳定性:深度睡眠期间,若 GPIO 唤醒引脚连接的外部电路有上拉/下拉电阻,需确保其不会在睡眠期间产生额外功耗或干扰。
  • 日志输出:唤醒后立即使用 ESP_EARLY_LOGx 输出调试信息,因为标准日志系统可能尚未初始化。
  • 测量功耗:使用功耗分析仪验证实际电流,确保没有因 GPIO 配置不当导致的漏电。
  • 版本差异:ESP-IDF 不同版本对 RTC 内存和唤醒源的支持略有差异,建议查阅对应版本的 esp_sleep.h 文档。

结语

ESP32 的低功耗设计并非简单调用 API 即可,理解底层电源域和引脚复用是规避冲突的关键。通过分离时序、使用专用内存属性、选择安全引脚以及增加校验机制,你可以构建一个既省电又可靠的嵌入式系统。希望本文的技巧能助你在项目中少走弯路。