4. 运行模式(Run Mode)调优:降低主频、关闭未使用的外设时钟、优化Flash等待周期、使用Sleep-on-Exit特性

说到低功耗,很多工程师第一反应就是「赶紧进睡眠模式」。但说实话,我见过不少项目,连运行模式都没优化好,就急着去折腾各种深度睡眠。这就像开车不关空调,却想着怎么省油——方向错了。

运行模式是MCU最耗电的状态,但也是我们最能主动控制的状态。调好了,功耗能降30%以上,而且不影响功能。今天我就把这四个核心手段掰开揉碎了讲给你听。

4.1 降低主频:最直接的降功耗手段

功耗和频率的关系,说白了就是线性关系。频率降一半,动态功耗差不多也降一半。这个道理很简单,但实际项目里很多人不敢降——怕性能不够。

我个人习惯的做法是:先测,再降。

拿一个实际项目举例。我之前做一款便携式数据采集器,主控是STM32L4系列。最初主频跑80MHz,整机电流12mA。后来我仔细分析了任务时序:

  • 传感器读取:每100ms一次,耗时2ms
  • 数据处理:每次5ms
  • LCD刷新:每500ms一次,耗时10ms
  • 其余时间:CPU在空转

你看,CPU真正干活的时间不到20%。剩下的80%时间都在跑while(1)空循环。这时候把主频从80MHz降到40MHz,任务耗时翻倍,但依然能覆盖所有时序要求。电流直接从12mA降到了7mA。

关键原则:主频只要能满足最坏情况下的任务截止时间,越低越好。

具体怎么操作?用HAL库的话,调整系统时钟很简单:

// 以STM32L4为例,切换到MSI振荡器,设为4MHz
HAL_RCC_DeInit();
RCC_OscInitTypeDef RCC_OscInitStruct = {0};
RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_MSI;
RCC_OscInitStruct.MSIState = RCC_MSI_ON;
RCC_OscInitStruct.MSICalibrationValue = 0;
RCC_OscInitStruct.MSIClockRange = RCC_MSIRANGE_5;  // 约4MHz
RCC_OscInitStruct.PLL.PLLState = RCC_PLL_NONE;
HAL_RCC_OscConfig(&RCC_OscInitStruct);

RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK | RCC_CLOCKTYPE_SYSCLK 
                            | RCC_CLOCKTYPE_PCLK1 | RCC_CLOCKTYPE_PCLK2;
RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_MSI;
RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV1;
RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0);

小技巧:如果任务有突发性高计算需求,可以动态切换频率。平时跑低频率,需要时临时提频。我做过一个项目,平时跑2MHz,数据压缩时临时切到48MHz,平均功耗降低了60%。

4.2 关闭未使用的外设时钟

这个问题,说大不大,说小不小。很多外设即使不工作,只要时钟还开着,就会持续耗电。我见过一个案例,工程师把USART、SPI、I2C、ADC、DAC、定时器全部使能了,结果光外设就吃了3mA。

为什么会这样?因为每个外设都有独立的时钟门控。时钟一开,外设的寄存器就能响应读写,内部状态机也在待命。这些待命电流虽然单个不大,但积少成多。

我的做法是:用多少,开多少。用完就关。

以STM32L4为例,外设时钟控制通过AHB和APB总线上的外设时钟使能寄存器实现:

// 关闭不需要的外设时钟
__HAL_RCC_GPIOB_CLK_DISABLE();
__HAL_RCC_GPIOC_CLK_DISABLE();
__HAL_RCC_USART2_CLK_DISABLE();
__HAL_RCC_SPI1_CLK_DISABLE();
__HAL_RCC_TIM2_CLK_DISABLE();
__HAL_RCC_ADC_CLK_DISABLE();
__HAL_RCC_DAC_CLK_DISABLE();

// 只保留必需的外设
__HAL_RCC_GPIOA_CLK_ENABLE();   // 按键输入
__HAL_RCC_USART1_CLK_ENABLE();  // 串口通信
__HAL_RCC_TIM6_CLK_ENABLE();    // 系统滴答定时器

这里有个坑——GPIO的时钟也要关。很多人只关外设时钟,忘了GPIO。实际上每个GPIO端口都有自己的时钟门控,不用的端口关掉能省不少电。

我曾经踩过的坑:在关外设时钟前,一定要确保该外设已经处于空闲状态。比如USART还在发送数据时就把时钟关了,会导致发送中断,数据丢失。正确的顺序是:先DeInit外设,再关时钟。

我整理了一个外设时钟管理的检查清单,你可以对照着用:

外设类型 典型待机电流 关闭方法 注意事项
USART/UART ~200μA __HAL_RCC_USARTx_CLK_DISABLE() 先HAL_UART_DeInit()
SPI ~150μA __HAL_RCC_SPIx_CLK_DISABLE() 确保CS拉高
I2C ~180μA __HAL_RCC_I2Cx_CLK_DISABLE() 释放总线
ADC ~300μA __HAL_RCC_ADC_CLK_DISABLE() 先停止转换
DAC ~250μA __HAL_RCC_DAC_CLK_DISABLE() 输出缓冲也要关
定时器 ~100μA __HAL_RCC_TIMx_CLK_DISABLE() 先停止计数
GPIO端口 ~50μA/端口 __HAL_RCC_GPIOx_CLK_DISABLE() 引脚先配置为模拟模式

4.3 优化Flash等待周期

这个点容易被忽略,但它对功耗的影响不小。Flash等待周期(Latency)决定了CPU读Flash时需要等几个时钟周期。频率越高,需要的等待周期越多。

关键来了:等待周期越多,每次读Flash消耗的能量就越大。因为Flash读取电路要工作更长时间。

STM32的Flash等待周期配置规则一般是:

  • 0等待周期:0MHz ~ 16MHz(具体看型号)
  • 1等待周期:16MHz ~ 32MHz
  • 2等待周期:32MHz ~ 48MHz
  • 3等待周期:48MHz ~ 72MHz
  • 4等待周期:72MHz以上

所以,如果你把主频降到16MHz以下,就可以用0等待周期。这不仅能省电,还能提升代码执行效率——因为不用等了。

配置方法很简单:

// 设置Flash等待周期为0(适用于≤16MHz)
__HAL_FLASH_SET_LATENCY(FLASH_LATENCY_0);

// 或者用HAL库的封装
HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_0);

我的经验:降频后一定要同步调整Flash等待周期。我见过有人把主频从80MHz降到8MHz,但忘了改等待周期,结果Flash还是4个等待周期。功耗没降多少,性能反而更差了——因为每次读Flash都要等4个空时钟周期。

4.4 使用Sleep-on-Exit特性

这个特性是ARM Cortex-M内核自带的,但很多人不知道。它的作用是:每次从异常/中断处理函数返回时,自动进入睡眠模式。

说白了,就是让CPU只在处理中断时工作,处理完立刻睡。非常适合事件驱动的应用——比如按键响应、定时器触发、传感器数据就绪等。

启用方法很简单:

// 在SystemInit或main函数开头设置
SCB->SCR |= SCB_SCR_SLEEPONEXIT_Msk;

配合WFI(Wait For Interrupt)指令使用:

int main(void)
{
    // 初始化...
    HAL_Init();
    SystemClock_Config();
    
    // 启用Sleep-on-Exit
    SCB->SCR |= SCB_SCR_SLEEPONEXIT_Msk;
    
    // 主循环只做初始化,然后进入睡眠
    while(1)
    {
        __WFI();  // 等待中断
    }
}

这样,每次中断处理完,CPU自动进入睡眠模式,直到下一个中断到来。中间没有任何空转。

对比一下:

  • 传统方式:主循环轮询标志位,CPU一直在跑
  • Sleep-on-Exit:中断处理完自动睡,醒来只处理中断
  • 功耗差异:在事件间隔100ms的场景下,后者功耗只有前者的1/10

不过要注意,Sleep-on-Exit只适用于纯中断驱动的应用。如果你的主循环里还有需要持续执行的任务(比如数据滤波、状态机轮询),那就不适合用这个特性。

我曾经踩过的坑:在一个项目里用了Sleep-on-Exit,但忘了配置NVIC的中断优先级分组。结果所有中断都是相同优先级,导致中断嵌套时系统卡死。排查了两天才发现是优先级分组没设对。所以记得在初始化时加上:HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4);

4.5 四种手段的综合运用

这四种手段不是孤立的,要组合使用才能达到最佳效果。我画了一张流程图,帮你理清思路:

运行模式调优决策流程 开始调优 步骤1:分析任务时序与最坏情况 确定最低可接受主频 步骤2:降低主频至最低可用频率 同步调整Flash等待周期 步骤3:关闭所有未使用外设时钟 包括GPIO端口时钟 判断:是否纯事件驱动应用? 主循环是否只等待中断? 否 是 保持轮询模式 主循环中执行WFI 启用Sleep-on-Exit 中断处理完自动睡眠 提示:每次调整后务必测量实际功耗,理论计算和实测往往有差距

这张图的逻辑很清晰:先分析任务,再降频,然后关外设,最后根据应用场景决定是否启用Sleep-on-Exit。每一步都有明确的判断依据。

最后说一句:调优不是一次性的。我习惯在项目不同阶段反复做这几步——原型阶段、功能稳定后、量产前,每次都能发现新的优化空间。功耗优化,说白了就是个持续迭代的过程。