ESP32-C3 低功耗模式下 RTC 内存保持与 ULP 协处理器唤醒延迟的精确测量

引言

在电池供电的IoT设备中,低功耗设计至关重要。ESP32-C3 提供了深度睡眠(Deep Sleep)模式,其中 RTC 内存(RTC Fast Memory)可在睡眠期间保持数据,而 ULP 协处理器(Ultra-Low-Power Processor)能在主 CPU 休眠时执行简单任务,如传感器轮询。然而,唤醒延迟直接影响系统实时性,本文聚焦于如何精确测量 ULP 唤醒主 CPU 的延迟,并验证 RTC 内存数据的可靠性。

原理讲解

RTC 内存保持

ESP32-C3 的 RTC 快速内存(RTC Fast Memory)大小为 8KB,位于 RTC 电源域,在深度睡眠时保持供电。数据可存于此区域,但需注意:

  • 使用 RTC_NOINIT_ATTR 属性将变量放置于 RTC 内存,避免初始化清零。
  • 深度睡眠唤醒后,主 CPU 可正常访问这些变量。

ULP 协处理器与唤醒延迟

ULP 协处理器可独立于主 CPU 运行,通过 RTC 定时器或 GPIO 触发。当 ULP 检测到条件满足(如 ADC 值超阈值),可调用 WAKE 指令唤醒主 CPU。唤醒延迟定义为:ULP 发出唤醒信号到主 CPU 开始执行用户代码的时间。该延迟包括:

  • ULP 指令执行时间(微秒级)
  • 系统从深度睡眠恢复时间(约 1-2 ms)
  • 引导加载器执行时间(若未配置快速启动)

精确测量需借助内置计数器,如 esp_timer 或 RTC 定时器,但注意在唤醒初期系统时钟尚未稳定,建议使用 RTC 时钟(如 rtc_time_get())或读取 esp_timer_get_time()(在系统初始化后)。

配置步骤

1. 项目环境

使用 ESP-IDF v5.x,创建新项目,目标芯片为 ESP32-C3。

2. 定义 RTC 内存变量

在代码中声明变量,并放置于 RTC 内存:

#include "esp_attr.h"

RTC_NOINIT_ATTR uint32_t rtc_data_counter;
RTC_NOINIT_ATTR uint64_t rtc_timestamp;

3. 编写 ULP 程序

ULP 程序使用汇编或 C 语言(通过 ulp_riscv 工具链)。以下示例使用汇编,实现简单的计数并唤醒:

// ulp_program.S
#include "soc/rtc_cntl_reg.h"
#include "soc/rtc_io_reg.h"
#include "soc/soc_ulp.h"

  .data
  .global ulp_entry
ulp_entry:
  // 递增 RTC 内存中的计数器(地址需映射)
  move r3, rtc_data_counter_addr
  ld r2, r3, 0
  add r2, r2, 1
  st r2, r3, 0

  // 唤醒主 CPU
  wake

  // 停止 ULP
  halt

注意:ULP 访问 RTC 内存需使用绝对地址,可通过 &rtc_data_counter 获取,但需在编译时映射。更简单的方式是使用 ulp_riscv 宏,但为清晰起见,此处用汇编。实际项目中,建议使用 C 语言编写 ULP 程序,通过 ulp_riscv 工具链编译。

4. 主程序配置

主程序负责初始化 ULP、设置深度睡眠,并测量唤醒延迟。

#include <stdio.h>
#include "esp_sleep.h"
#include "ulp.h"
#include "esp_timer.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

extern const uint8_t ulp_program_start[] asm("_binary_ulp_program_start");
extern const uint8_t ulp_program_end[] asm("_binary_ulp_program_end");

// RTC 内存变量地址(需与 ULP 中一致)
extern uint32_t rtc_data_counter;

void app_main(void) {
    // 初始化 ULP
    esp_err_t err = ulp_load_binary(0, ulp_program_start, (ulp_program_end - ulp_program_start) / sizeof(uint32_t));
    if (err != ESP_OK) {
        printf("ULP load failed: %s\n", esp_err_to_name(err));
        return;
    }

    // 启动 ULP
    ulp_run(0);

    // 记录唤醒前时间(使用 RTC 时钟)
    uint64_t wake_time_before = esp_timer_get_time(); // 注意:深度睡眠前调用,esp_timer 在睡眠时停止,但此处仅作参考

    // 进入深度睡眠,设置唤醒源为 ULP
    esp_sleep_enable_ulp_wakeup();
    esp_deep_sleep_start();

    // 唤醒后从这里继续执行
    uint64_t wake_time_after = esp_timer_get_time(); // 系统时钟已恢复
    printf("ULP wakeup! Counter = %u\n", rtc_data_counter);
    printf("Wakeup delay (esp_timer) = %llu us\n", wake_time_after - wake_time_before);

    // 更精确的测量:使用 RTC 定时器(在唤醒后立即读取)
    // 注意:esp_timer 在唤醒后需要初始化,但 RTC 时钟一直运行
    // 我们可以在 ULP 唤醒前记录 RTC 时间,但 ULP 无法直接访问 RTC 寄存器,需通过主 CPU 在睡眠前设置一个标记
    // 此处简化,使用 esp_timer 差值,但实际延迟应使用 RTC 计数器

    // 重新配置 ULP 继续运行,或进入睡眠
    // 示例:再次启动 ULP 并睡眠
    ulp_run(0);
    esp_deep_sleep_start();
}

5. 精确测量唤醒延迟

上述 esp_timer 差值包含了深度睡眠前的准备时间(如 esp_deep_sleep_start 内部操作),并非纯唤醒延迟。更精确的方法:

  • 在 ULP 唤醒主 CPU 前,ULP 可写入一个 RTC 内存变量记录时间戳(使用 RTC 定时器计数)。
  • 主 CPU 唤醒后立即读取该时间戳,并与当前 RTC 时间比较。

但 ULP 无法直接读取 RTC 定时器,需通过 RTC_CNTL 寄存器。可在 ULP 中读取 RTC_CNTL_TIME0_REGRTC_CNTL_TIME1_REG,但需注意这些寄存器在 ULP 中可访问。以下为改进方案:

// ULP 程序(汇编)中读取 RTC 时间寄存器
// 假设 RTC_CNTL_TIME0_REG 地址为 0x3f408000 + 0x10
#define RTC_CNTL_TIME0_REG 0x3f408010
#define RTC_CNTL_TIME1_REG 0x3f408014

  // 读取时间戳
  move r3, RTC_CNTL_TIME0_REG
  ld r2, r3, 0
  move r4, rtc_timestamp_addr
  st r2, r4, 0
  move r3, RTC_CNTL_TIME1_REG
  ld r2, r3, 0
  move r4, rtc_timestamp_addr + 4
  st r2, r4, 0

主程序唤醒后,读取 rtc_timestamp,并与当前 RTC 时间比较,差值即为唤醒延迟。RTC 时钟频率为 150 kHz(可配置),需转换为微秒。

完整代码示例

以下为整合后的主程序,使用 RTC 时间戳测量延迟:

#include <stdio.h>
#include "esp_sleep.h"
#include "ulp.h"
#include "esp_timer.h"
#include "soc/rtc_cntl_reg.h"
#include "soc/rtc.h"

// RTC 内存变量
extern uint32_t rtc_data_counter;
extern uint64_t rtc_timestamp; // 由 ULP 写入

// ULP 程序二进制
extern const uint8_t ulp_program_start[] asm("_binary_ulp_program_start");
extern const uint8_t ulp_program_end[] asm("_binary_ulp_program_end");

void app_main(void) {
    // 初始化 ULP
    esp_err_t err = ulp_load_binary(0, ulp_program_start, (ulp_program_end - ulp_program_start) / sizeof(uint32_t));
    if (err != ESP_OK) {
        printf("ULP load failed: %s\n", esp_err_to_name(err));
        return;
    }

    // 启动 ULP
    ulp_run(0);

    // 进入深度睡眠,等待 ULP 唤醒
    esp_sleep_enable_ulp_wakeup();
    esp_deep_sleep_start();

    // 唤醒后执行
    // 读取 ULP 写入的时间戳(RTC 计数)
    uint64_t ulp_timestamp = rtc_timestamp;
    // 获取当前 RTC 时间(微秒)
    uint64_t current_time = rtc_time_get(); // 返回微秒
    // 计算延迟(注意:ULP 写入的时间戳是 RTC 计数,需转换为微秒)
    // RTC 时钟频率为 150kHz,周期约 6.67us,但 rtc_time_get 返回微秒,需统一
    // 更简单:ULP 写入的时间戳也是通过 rtc_time_get 类似的函数?不,ULP 直接读寄存器,需转换
    // 此处假设 rtc_timestamp 存储的是微秒值(由 ULP 计算)
    // 实际中,ULP 可调用 rtc_time_get 的汇编版本,但复杂。此处简化,使用 esp_timer 差值

    // 使用 esp_timer 差值(包含准备时间,但可接受)
    uint64_t wake_time = esp_timer_get_time();
    // 由于我们无法获得睡眠前时间,此处仅打印计数器
    printf("ULP wakeup! Counter = %u\n", rtc_data_counter);
    printf("Current time = %llu us\n", wake_time);

    // 重新进入睡眠
    ulp_run(0);
    esp_deep_sleep_start();
}

注意:上述代码中,rtc_time_get() 在唤醒后调用,但 ULP 写入的时间戳是原始寄存器值,需转换。为简化,建议在 ULP 中直接使用 RTC_CNTL 寄存器读取,并在主程序中转换。

注意事项

  • RTC 内存访问:ULP 访问 RTC 内存时,地址必须使用物理地址,且需确保变量未优化掉。使用 RTC_NOINIT_ATTR 可防止初始化。
  • 唤醒延迟测量esp_timer 在深度睡眠期间停止,因此差值包含了睡眠前的时间,不准确。建议使用 RTC 定时器,但需注意 RTC 时钟频率和精度。
  • ULP 程序调试:ULP 程序无法直接打印,可通过 RTC 内存传递数据。
  • 电源域:确保 RTC 外设(如 ULP)的电源域在睡眠时保持开启,可通过 esp_sleep_pd_config 配置。
  • 快速启动:启用 CONFIG_BOOTLOADER_SKIP_VALIDATE_IN_DEEP_SLEEP 可减少唤醒时间。
  • 测量多次:延迟可能因温度、电压波动,建议多次测量取平均值。

结语

通过本文,您应能掌握 ESP32-C3 在深度睡眠下 RTC 内存的使用和 ULP 唤醒延迟的测量方法。实际应用中,精确的延迟测量有助于优化低功耗系统的实时性。建议结合逻辑分析仪或示波器验证测量结果,以获得更可靠的时序数据。