3. Sleep模式详解:进入、退出与唤醒源实战

各位同学,今天我们来啃Sleep模式这块硬骨头。说实话,在STM32L4的所有低功耗模式里,Sleep模式是最容易上手、也是我项目里用得最多的一个。为什么?因为它简单、可靠,而且唤醒延迟极低。

我记得刚接手一个手持设备项目时,电池续航总差那么一口气。后来把主循环里大部分空闲时间切到Sleep模式,续航直接提升了40%。嗯,今天就把这些经验掰开揉碎了讲给你听。

3.1 Sleep模式的核心机制

Sleep模式说白了,就是只停了CPU的时钟,其他外设该跑还跑。CPU睡大觉去了,但SRAM、寄存器内容全保留,醒来就能接着干活。

关键特征:

  • CPU时钟停止,但系统时钟(SYSCLK)继续运行
  • 所有外设(定时器、USART、SPI、ADC等)正常工作
  • SRAM和寄存器内容完全保持
  • 唤醒时间:最快,约几个CPU周期
  • 电流消耗:典型值约1-2 mA(取决于外设开启数量)

你想想看,这种模式最适合什么场景?就是那种需要频繁响应外部事件,但又不想让CPU空转的情况。比如按键扫描、短间隔数据采集。

3.2 进入Sleep模式的正确姿势

进入Sleep模式其实就两条指令的事。但我在项目中踩过坑——直接调用WFI,结果中断来了没反应。后来才发现,是忘了配置SLEEPONEXIT位。

两种进入方式,看表格对比:

方式 指令 行为特点 适用场景
WFI (Wait For Interrupt) __WFI() 立即进入Sleep,等待任意中断唤醒 主循环空闲时主动休眠
WFE (Wait For Event) __WFE() 等待事件唤醒,可配合SEV指令 多核通信、事件同步场景

我的习惯:绝大多数场景用WFI就够了。WFE那套事件机制,说实话,在单核MCU上用得不多。除非你在做多核或者需要和DMA配合的特殊场景。

代码实现很简单,但要注意顺序:

// 进入Sleep模式的推荐写法
void Enter_Sleep_Mode(void)
{
    // 1. 清除唤醒标志(防止一睡就醒)
    __HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU);
    
    // 2. 选择Sleep模式(默认就是Sleep,但显式设置更安全)
    HAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI);
    
    // 3. 醒来后从这里继续执行
    // 注意:中断服务函数执行完后才回到这里
}

我曾经踩过的坑:第一次用HAL库的HAL_PWR_EnterSLEEPMode时,没注意第二个参数。默认是PWR_SLEEPENTRY_WFI,但如果你不小心传了PWR_SLEEPENTRY_WFE,而中断又没配成事件,那CPU就永远睡过去了。嗯,那次debug花了我整整一个下午。

3.3 唤醒源详解:谁能把CPU叫醒?

Sleep模式下,所有能产生中断的外设都可以作为唤醒源。说白了,只要这个外设的中断能正常响应,它就能把CPU从睡梦中拉起来。

常用的唤醒源有这几类:

  • EXTI(外部中断):最常用,GPIO引脚电平变化就能唤醒。按键、传感器信号都走这条路。
  • RTC(实时时钟):定时唤醒,适合周期性任务。比如每秒钟采集一次数据。
  • 定时器(TIM):精度高,适合微秒级定时唤醒。
  • USART/I2C/SPI:通信接口收到数据时唤醒,适合通信从机场景。
  • ADC:转换完成中断唤醒,适合采样间隔控制。

这里我重点讲EXTI和RTC,因为这两个我在项目里用得最多。

3.4 代码实战:按键唤醒Sleep

好,理论说完了,咱们直接上代码。这个实战我做了不下十次,每次教新人都用这个例子——简单、直观、一跑就通。

硬件连接:

  • PA0 接一个按键,按下为低电平
  • PB0 接一个LED,用来指示状态

完整代码:

#include "stm32l4xx_hal.h"

// 按键引脚配置
#define KEY_PIN       GPIO_PIN_0
#define KEY_PORT      GPIOA
#define KEY_EXTI_LINE EXTI_LINE_0

// LED引脚配置
#define LED_PIN       GPIO_PIN_0
#define LED_PORT      GPIOB

void SystemClock_Config(void);
void GPIO_Init(void);
void EXTI_Init(void);

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    GPIO_Init();
    EXTI_Init();
    
    // 初始状态:LED灭
    HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET);
    
    while (1)
    {
        // 点亮LED,表示CPU在工作
        HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET);
        HAL_Delay(100);  // 亮100ms
        
        // 熄灭LED,准备进入Sleep
        HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET);
        
        // 进入Sleep模式
        // 注意:进入前要确保没有pending的中断
        __HAL_PWR_CLEAR_FLAG(PWR_FLAG_WU);
        HAL_PWR_EnterSLEEPMode(PWR_MAINREGULATOR_ON, PWR_SLEEPENTRY_WFI);
        
        // 醒来后,LED会再次点亮
        // 实际项目中,这里可以放唤醒后的处理逻辑
    }
}

// EXTI中断服务函数
void EXTI0_IRQHandler(void)
{
    // 检查中断标志
    if(__HAL_GPIO_EXTI_GET_IT(KEY_PIN) != RESET)
    {
        // 清除中断标志(必须!否则会一直触发)
        __HAL_GPIO_EXTI_CLEAR_IT(KEY_PIN);
        
        // 这里可以加按键消抖处理
        // 简单起见,我们只做唤醒,不做其他处理
    }
}

void GPIO_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能GPIO时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_GPIOB_CLK_ENABLE();
    
    // 配置PA0为输入,上拉(按键按下为低)
    GPIO_InitStruct.Pin = KEY_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;  // 下降沿触发
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(KEY_PORT, &GPIO_InitStruct);
    
    // 配置PB0为推挽输出
    GPIO_InitStruct.Pin = LED_PIN;
    GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
    GPIO_InitStruct.Pull = GPIO_NOPULL;
    GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
    HAL_GPIO_Init(LED_PORT, &GPIO_InitStruct);
}

void EXTI_Init(void)
{
    // 使能SYSCFG时钟(EXTI配置需要)
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    
    // 设置PA0连接到EXTI0
    // 注意:EXTI0只能对应PA0/PB0/PC0/PD0/PE0/PF0/PG0中的一个
    SYSCFG->EXTICR[0] &= ~SYSCFG_EXTICR1_EXTI0;
    SYSCFG->EXTICR[0] |= SYSCFG_EXTICR1_EXTI0_PA;
    
    // 配置NVIC,使能EXTI0中断
    HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

几个容易忽略的细节:

  • 进入Sleep前一定要清除PWR_FLAG_WU,否则可能一睡就醒
  • EXTI中断标志必须在中断服务函数里手动清除
  • 如果用了HAL库的HAL_GPIO_EXTI_IRQHandler,它会自动帮你清标志,但自己写的话别忘了

3.5 多唤醒源同时配置

实际项目中,往往不止一个唤醒源。比如既要按键唤醒,又要RTC定时唤醒。这时候怎么配?

其实很简单——把多个中断都使能就行了。谁先来,谁就把CPU叫醒。但要注意优先级,别让高优先级的频繁打断低优先级的任务。

我画了一张图,帮你理清多唤醒源的工作流程:

Sleep模式多唤醒源工作流程 CPU进入Sleep EXTI 按键按下 RTC 定时到达 定时器溢出 CPU被唤醒,执行中断服务函数 判断 任务完成,继续Sleep 有紧急任务,保持运行 说明: 多个唤醒源同时使能时,谁先触发谁唤醒。中断服务函数执行完后,CPU回到主循环继续执行。

3.6 避坑指南与性能调优

最后,分享几个我在实际项目中积累的经验:

我曾经踩过的坑:

  • 坑1:进入Sleep前没关全局中断。结果中断在进入Sleep的瞬间触发,导致WFI永远等不到下一个中断。解决方案:进入前用__disable_irq()保护,设置好后再开中断。
  • 坑2:EXTI引脚配置了上下拉,但没注意外部电路。有一次按键外部已经拉了上拉,我又在内部配了上拉,结果电平被拉高,按键永远触发不了。记住:外部电路和内部配置要一致。
  • 坑3:RTC唤醒后没清除RTC标志,导致一醒来又进中断,然后又Sleep,形成死循环。每次唤醒后,记得把所有相关中断标志清干净。

调优建议:

  • 如果唤醒频率很高(比如1ms一次),Sleep模式可能不如直接跑空循环省电——因为进出Sleep也有开销
  • 测量实际功耗时,别用万用表,用示波器看电流波形。我见过有人用万用表测出0.1mA,结果示波器一看,峰值电流每100ms跳一次,平均功耗其实很高
  • 如果多个外设都要唤醒,考虑用EXTI的OR功能,把多个信号合并到一个中断线上

好了,Sleep模式就讲到这里。代码已经给了,建议你直接烧到开发板上跑一跑。按键按下去,LED亮起来,那种感觉——嗯,比看十遍文档都管用。