ESP32 低功耗边界实测:RTC 内存保持与 ULP 协处理器唤醒的工程陷阱

1. 背景与问题定义

ESP32 的 Deep Sleep 模式可将功耗降至 10μA 以下,但代价是主 CPU 停止运行。此时,RTC 内存(RTC_SLOW_MEM)和 ULP 协处理器成为唯一“活着的”计算资源。然而,很多开发者发现:

  • 在 Deep Sleep 中,RTC 内存数据有时会丢失(尤其是使用 RTC_DATA_ATTR 时)。
  • ULP 协处理器唤醒后,读取的 ADC 值或 GPIO 状态与预期不符。
  • 唤醒源配置正确,但系统仍意外复位。

这些问题的根源在于对 ESP32 电源域(Power Domain)和 RTC 内存映射的边界理解不足。本文通过实测,明确这些边界条件。

2. RTC 内存保持的边界条件

2.1 RTC 内存类型与映射

ESP32 的 RTC 内存分为两类:

  • RTC_FAST_MEM:8KB,位于 RTC 电源域,CPU 可快速访问,但 Deep Sleep 时可能被断电(取决于 esp_sleep_pd_config)。
  • RTC_SLOW_MEM:8KB,始终由 RTC 电源供电,Deep Sleep 期间数据保持。

关键点:RTC_DATA_ATTR 变量默认放在 RTC_SLOW_MEM,但若你使用 RTC_FAST_ATTR,则必须确保 RTC_FAST_MEM 的电源域未关闭。

2.2 实测:数据保持的边界

我们设计了一个测试:在 Deep Sleep 前写入一个结构体到 RTC_SLOW_MEM,包含 32 位整数、浮点数和字符串。然后设置不同的 esp_sleep_pd_config 选项,观察唤醒后数据是否完整。

// 定义 RTC 内存变量
RTC_DATA_ATTR struct {
    uint32_t counter;
    float voltage;
    char msg[16];
} rtc_data = {0, 0.0f, "init"};

// 在 Deep Sleep 前写入
void prepare_data() {
    rtc_data.counter++;
    rtc_data.voltage = 3.3f;
    strcpy(rtc_data.msg, "hello");
}

// 配置电源域
void config_power_domains() {
    // 保持 RTC_SLOW_MEM 供电(默认)
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_ON);
    // 关闭 RTC_FAST_MEM(可选,节省功耗)
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF);
}

测试结果

  • 当 RTC_SLOW_MEM 电源域为 ON 时,数据 100% 保持。
  • 当 RTC_SLOW_MEM 设为 OFF(不推荐),数据全部丢失。
  • 若使用 RTC_FAST_ATTR 且将 RTC_FAST_MEM 关闭,唤醒后数据为随机值。

结论:RTC_SLOW_MEM 是唯一可靠的 Deep Sleep 数据存储区,但需注意其容量仅 8KB,且访问速度较慢(通过 RTC 总线)。

3. ULP 协处理器唤醒的边界条件

3.1 ULP 唤醒源类型

ULP 协处理器可以监控 GPIO、ADC、I2C 等,并在满足条件时唤醒主 CPU。但唤醒条件并非“任意变化”,而是基于边沿阈值。常见配置:

  • GPIO 上升/下降沿触发。
  • ADC 连续采样,当值超过/低于阈值时触发。
  • 定时器周期唤醒(但 ULP 定时器精度有限)。

3.2 实测:ADC 阈值唤醒的边界

我们使用 ULP 读取内部温度传感器,设定阈值 40°C,当温度超过时唤醒。代码如下:

// ULP 程序(汇编或 C 语言)
#include "soc/rtc_io_reg.h"
#include "soc/soc_ulp.h"

// 定义变量
ULP_DATA_ATTR uint32_t adc_value;

// ULP 主程序
void ulp_main() {
    // 读取 ADC 通道(如内部温度)
    adc_value = read_adc(ADC_CHANNEL_0);
    // 如果大于阈值,唤醒
    if (adc_value > 4000) { // 假设 4000 对应 40°C
        wake_up();
    }
    // 否则继续睡眠
    halt();
}

实测发现

  • 阈值设置必须留有余量。由于 ADC 噪声,实际唤醒点可能偏移 ±2%。例如,阈值设为 4000,实际唤醒可能在 3920~4080 之间。
  • 唤醒后,主 CPU 读取 adc_value 时,该值可能不是触发唤醒的那次采样,而是最后一次采样(因为 ULP 在唤醒前可能又执行了几条指令)。
  • 若 ULP 程序运行时间过长(如超过 10ms),可能导致 RTC 内存访问冲突,因为主 CPU 唤醒后可能同时访问 RTC 内存。

3.3 边界条件总结

| 边界条件 | 影响 | 建议 | |---------|------|------| | ADC 阈值余量 | 唤醒点偏移 | 设置 ±5% 的滞回区间 | | ULP 运行时间 | 与主 CPU 竞争 RTC 内存 | 保持 ULP 程序 < 5ms | | GPIO 边沿 | 抖动导致误唤醒 | 使用软件去抖(ULP 中延时) | | 唤醒后数据一致性 | 读取的 ULP 变量可能非最新 | 使用双缓冲或标志位 |

4. 完整代码示例:低功耗数据采集与唤醒

以下是一个实际项目中的代码框架,实现每 10 秒采集一次 ADC,并通过 ULP 监控阈值唤醒。

#include <stdio.h>
#include "esp_sleep.h"
#include "esp_attr.h"
#include "soc/rtc_io_reg.h"
#include "soc/soc_ulp.h"
#include "ulp_main.h"

// RTC 内存变量
RTC_DATA_ATTR uint32_t wake_count = 0;
RTC_DATA_ATTR uint32_t last_adc = 0;

// 声明 ULP 程序入口
extern const uint8_t ulp_main_bin_start[] asm("_binary_ulp_main_bin_start");
extern const uint8_t ulp_main_bin_end[] asm("_binary_ulp_main_bin_end");

// 初始化 ULP
void init_ulp() {
    // 加载 ULP 程序
    esp_err_t err = ulp_load_binary(0, ulp_main_bin_start, 
                                    (ulp_main_bin_end - ulp_main_bin_start));
    if (err != ESP_OK) {
        printf("ULP load failed: %d\n", err);
        return;
    }

    // 配置 ADC 通道
    adc_config_t adc_cfg = {
        .mode = ADC_READ_TO_ULP,
        .clk_div = 8,
        .sample_and_hold = ADC_SAMPLE_HOLD_DISABLE,
    };
    adc_init(&adc_cfg);
    adc_channel_t channel = ADC_CHANNEL_0;
    adc_config_channel(channel);

    // 设置 ULP 变量初始值
    ulp_adc_value = 0;
    ulp_wake_threshold = 4000;

    // 启动 ULP
    ulp_run(0);
}

// 主程序
void app_main() {
    // 唤醒后处理
    if (esp_sleep_get_wakeup_cause() == ESP_SLEEP_WAKEUP_ULP) {
        wake_count++;
        last_adc = ulp_adc_value; // 读取 ULP 变量
        printf("Wake from ULP, count=%u, adc=%u\n", wake_count, last_adc);
    }

    // 准备数据
    prepare_data();

    // 配置电源域
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_SLOW_MEM, ESP_PD_OPTION_ON);
    esp_sleep_pd_config(ESP_PD_DOMAIN_RTC_FAST_MEM, ESP_PD_OPTION_OFF);

    // 设置 ULP 唤醒源
    esp_sleep_enable_ulp_wakeup();

    // 进入 Deep Sleep
    esp_deep_sleep_start();
}

ULP 程序(ulp_main.c)

#include "soc/rtc_io_reg.h"
#include "soc/soc_ulp.h"

// ULP 变量(映射到 RTC 内存)
ULP_DATA_ATTR uint32_t adc_value;
ULP_DATA_ATTR uint32_t wake_threshold;

void entry() {
    // 读取 ADC 通道 0
    adc_value = read_adc(0);
    // 比较阈值
    if (adc_value > wake_threshold) {
        // 唤醒主 CPU
        wake_up();
    }
    // 延时去抖(约 1ms)
    wait_us(1000);
    // 继续睡眠
    halt();
}

5. 注意事项与工程建议

  • RTC 内存容量:8KB 有限,建议将大块数据放在 NVS 或 Flash,RTC 内存仅存关键状态。
  • ULP 程序调试:ULP 无法使用 JTAG,建议通过 RTC 内存中的调试变量输出状态。
  • 电源域配置:关闭 RTC_FAST_MEM 可省电,但若 ULP 程序或中断向量在该区域,则必须保持开启。
  • 唤醒延迟:从 ULP 唤醒到主 CPU 运行,实测约 130μs,期间 RTC 内存可访问,但主 CPU 尚未初始化外设。
  • ADC 校准:ULP 的 ADC 精度低于主 CPU,建议在唤醒后重新校准。

6. 结语

ESP32 的低功耗设计并非“配置即用”,RTC 内存的保持和 ULP 唤醒的边界条件直接影响系统可靠性。通过本文的实测,你可以明确:RTC_SLOW_MEM 是数据保持的“安全区”,而 ULP 唤醒需考虑阈值余量和时序竞争。建议在实际项目中,先进行最小系统验证,再逐步增加功能,避免“隐藏”的边界问题。