第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位。
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没停。
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)唤醒。我在调试一个低功耗键盘项目时,发现设备偶尔会莫名其妙醒来。后来用逻辑分析仪抓了事件线,发现是电源噪声触发了事件。
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秒间隔的应用,这个功耗水平是可以接受的。