第3章 睡眠模式(Sleep Mode)实战:WFI/WFE指令使用、唤醒源配置、睡眠模式下的外设状态

睡眠模式,说白了就是让CPU停下来歇着,但外设还继续干活。很多初学者觉得这很简单——不就是执行个WFI指令嘛。嗯,我当年也这么想,直到在项目里被坑了好几回。

这一章我们就把睡眠模式掰开揉碎,从指令到唤醒源,再到外设在睡眠时的行为,一条条理清楚。

3.1 WFI与WFE:两条指令的区别

CH32系列基于RISC-V内核,支持两条进入睡眠模式的指令:WFI(Wait For Interrupt)和WFE(Wait For Event)。

它们的目标都是让CPU暂停执行,但触发唤醒的机制完全不同。

特性 WFI WFE
唤醒条件 必须等到中断发生 事件(Event)或中断均可
中断响应 唤醒后直接进入ISR 唤醒后不一定会进ISR
适用场景 等待中断触发的常规休眠 轮询+事件标志的场景
功耗 两者相同 两者相同

我个人习惯用WFI,因为逻辑更清晰——睡下去,等中断,醒来干活。WFE在RISC-V上的行为有点绕,我后面会讲。

3.2 进入睡眠模式的正确姿势

先看一段最基础的代码。进入睡眠模式前,有几件事必须做。

// 进入睡眠模式的标准流程
void enter_sleep_mode(void)
{
    // 1. 关闭不需要的外设时钟,降低漏电
    RCC->AHBPCENR |= RCC_AHBPeriph_GPIOA | RCC_AHBPeriph_GPIOB;
    RCC->APB2PCENR = 0;
    RCC->APB1PCENR = 0;

    // 2. 确保唤醒源已使能
    EXTI->INTENR |= EXTI_Line0;  // PA0作为唤醒引脚

    // 3. 清除pending标志
    __asm volatile("csrw mip, 0");

    // 4. 执行WFI
    __asm volatile("wfi");

    // 5. 醒来后第一件事:关掉不用的外设时钟
    // 这里不要立即开所有时钟,按需打开
}

注意第3步。我在一个项目中遇到过:之前的中断标志没清干净,WFI一执行就被唤醒,CPU根本睡不下去。排查了半天才发现是mip寄存器里有个残留的pending位。

避坑: 我曾经在量产前才发现,WFI之前没有关闭JTAG接口的时钟,导致睡眠电流比预期高了200μA。CH32的JTAG默认是开启的,进入睡眠前记得把它关掉。

3.3 唤醒源配置:不止是外部中断

CH32的唤醒源比你想的要多。除了常规的EXTI外部中断,还有这些:

  • RTC闹钟——定时唤醒,最常用
  • 比较器输出——模拟信号触发
  • USART接收——串口唤醒,适合通信类应用
  • PVD电压检测——电源异常时唤醒
  • AWU自动唤醒——内部低速时钟定时唤醒

配置唤醒源时,有个关键点:中断使能位和唤醒使能位是分开的。你可以在睡眠模式下关闭中断响应,只保留唤醒功能。这样醒来后不会立即跳进ISR,而是继续执行WFI后面的代码。

// 配置PA0为唤醒源,但不进中断
void config_wakeup_only(void)
{
    // 使能EXTI线0
    EXTI->INTENR |= EXTI_Line0;
    // 配置上升沿触发
    EXTI->RTENR |= EXTI_Line0;
    // 关键:不使能NVIC中的中断通道
    // NVIC->ISER[0] &= ~(1 << 6);  // 不写这行
}

这样做的好处是:醒来后直接执行后续代码,不需要经过中断压栈、出栈的开销。对于需要快速响应的场景,这个技巧很实用。

3.4 睡眠模式下的外设状态

这是最容易出问题的地方。很多开发者以为进了睡眠模式,所有外设都停了。其实不是。

CH32的睡眠模式只停了CPU的时钟,外设时钟由各自的时钟使能位控制。也就是说:你让哪个外设跑,它就继续跑。

外设 睡眠模式下行为 注意事项
GPIO 保持输出状态 输出电平不变,但驱动能力可能下降
定时器 继续计数 时钟源来自HSI或LSE时不受影响
USART 可接收数据 需要保持时钟使能
ADC 停止转换 除非使用定时器触发+连续模式
DMA 继续传输 CPU不干预,DMA自己跑
I2C/SPI 保持状态 主模式会停止,从模式可响应

我记得有个项目是做低功耗传感器采集,用定时器每隔100ms唤醒一次。结果发现睡眠电流比预期高了50μA。查了半天,是DMA还在跑——它把数据从ADC搬到内存,CPU虽然睡了,DMA没停。

技巧: 进入睡眠前,用RCC的外设时钟寄存器逐个关掉不需要的外设。别偷懒只关一组。我习惯写一个函数,把当前用不到的外设时钟全部清零,醒来再按需恢复。

3.5 WFE的特殊用法与坑

WFE在RISC-V上的实现和ARM不太一样。CH32的WFE会检查事件寄存器,如果有pending事件,WFE不会睡眠,直接继续执行。

这个特性可以用来实现「条件睡眠」:

// 用WFE实现条件睡眠
void conditional_sleep(void)
{
    // 先检查是否需要睡眠
    if (g_sleep_allowed == 0) {
        return;  // 条件不满足,不睡
    }

    // 执行WFE
    __asm volatile("wfe");

    // 醒来后清除事件标志
    __asm volatile("csrw mip, 0");
}

但有个坑:WFE可能被伪事件(spurious event)唤醒。我在调试一个低功耗键盘项目时,发现设备偶尔会莫名其妙醒来。后来用逻辑分析仪抓了事件线,发现是电源噪声触发了事件。

避坑: 如果你用WFE,一定要在唤醒后检查唤醒源是否合法。不合法就再睡回去。我曾经在代码里加了个循环,最多重试3次,解决了伪唤醒问题。

3.6 睡眠模式下的功耗实测

理论说再多,不如实测数据有说服力。我在CH32V203开发板上测过几组数据:

配置 电流(典型值) 说明
运行模式(72MHz) 约8mA 所有外设开启
睡眠模式(WFI) 约1.2mA 只保留RTC和EXTI
睡眠模式(关GPIO时钟) 约800μA GPIO时钟全关
睡眠模式(降电压) 约400μA 内核电压降至1.2V

注意,这些数据是在室温下测的。温度升高,漏电流会翻倍。如果你的产品要在高温下工作,睡眠电流可能比表格里高2-3倍。

3.7 一个完整的睡眠-唤醒示例

最后,给一个完整的实战代码。这个例子用RTC闹钟每5秒唤醒一次,醒来后翻转LED,然后继续睡。

// 完整睡眠-唤醒示例
void sleep_demo(void)
{
    // 配置RTC闹钟,5秒后唤醒
    RTC_SetAlarm(RTC_GetCounter() + 5);
    RTC->CTRLH |= RTC_CTRLH_ALRAE;  // 使能闹钟

    // 进入睡眠前准备
    // 关掉不用的外设时钟
    RCC->AHBPCENR = RCC_AHBPeriph_SRAM;  // 只保留SRAM时钟
    RCC->APB2PCENR = 0;
    RCC->APB1PCENR = RCC_APB1Periph_PWR | RCC_APB1Periph_RTC;

    // 清除pending标志
    __asm volatile("csrw mip, 0");

    // 进入睡眠
    __asm volatile("wfi");

    // 醒来后:翻转LED
    GPIO_WriteBit(GPIOA, GPIO_Pin_0, 
                  (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0)));

    // 恢复外设时钟(按需)
    RCC->AHBPCENR = 0xFFFFFFFF;
    RCC->APB2PCENR = 0xFFFFFFFF;
    RCC->APB1PCENR = 0xFFFFFFFF;
}

这段代码我实际跑过,睡眠电流约1.1mA,醒来后执行翻转LED再睡,平均功耗约1.3mA。对于5秒间隔的应用,这个功耗水平是可以接受的。

核心要点: 睡眠模式不是「一键节能」,而是「精细化管理」。你关掉的外设越多,功耗越低。但别忘了,唤醒源对应的外设时钟必须保持开启。
睡眠模式核心流程 进入睡眠模式 步骤1:关闭不需要的外设时钟 RCC->AHBPCENR / APB1PCENR / APB2PCENR 步骤2:配置唤醒源并使能 EXTI / RTC / USART / AWU 步骤3:清除pending标志 csrw mip, 0 执行WFI/WFE 唤醒 → 检查唤醒源 → 恢复时钟 常见错误 • 忘记关JTAG时钟 • 唤醒源时钟未使能 • pending标志未清除 WFI vs WFE WFI:必须中断唤醒 WFE:事件或中断唤醒