尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

STM32低功耗实战:RTC闹钟实现30秒定时唤醒与待机模式

STM32低功耗实战:RTC闹钟实现30秒定时唤醒与待机模式 简介针对STM32低功耗定时唤醒需求该工程提供RTC待机模式唤醒的完整实现。主循环中设定闹钟并进入Sys_Enter_StandbyRTC中断自动清中断并定时唤醒程序重头执行逻辑清晰非常适合省电设计、定时采集等场景的嵌入式开发者参考。包内共204个文件以C源码、H头文件、编译中间文件、HEX烧录文件及UVPROJX工程文件为主压缩包仅10.04MB。源码和头文件便于阅读移植HEX可直接烧录验证工程文件适合Keil环境重新编译调试。目前已有6966人学习热度不错。此外工程还包含STM32F10x标准外设库多个驱动模块及Keil配置脚本有助于理解低功耗唤醒机制、外设初始化顺序和待机后程序重启流程适合入门到进阶的系统学习。 做低功耗设备的朋友应该都有体会电池供电的产品最怕的就是“明明睡着了却还在耗电”。STM32的休眠模式配合RTC定时唤醒是解决这类需求非常经典的一套组合拳。这几天正好有同行在问“怎么在主程序里设置30秒后RTC唤醒然后休眠到点自动从头跑”我把实际项目里的做法和踩过的坑完整梳理一遍这篇文章就把方案、代码、排查一次说透新手可以直接照着抄老手也能对照校验一下自己的写法。1. 方案选型为什么用RTC而不是定时器或看门狗先聊一个最基础的问题休眠唤醒这件事可选的路其实不止一条。普通的定时器比如TIM2也能产生中断但大多数型号在停机STOP模式下定时器时钟会停掉根本跑不起来。独立看门狗IWDG倒是可以在待机STANDBY模式下继续计数它用的是LSI内部低速时钟但问题在于它本质上是“看门狗”不是“闹钟”你要精确控制30秒、1分钟这种时间点用IWDG去数非常别扭而且它的超时时间档位是固定的几个值不够灵活。RTC就不一样了。RTC自带日历和闹钟功能时钟源可以选择LSE外部32.768kHz晶振或者LSI内部RC振荡器无论芯片进入STOP还是STANDBY模式只要备份域供电正常VBAT接了电池或者VDD没有断开RTC就会一直走时。到了预设的闹钟时间点RTC会拉高一个唤醒事件把芯片从休眠状态拉起来。这套机制专门就是为低功耗定时唤醒设计的所以选RTC是最稳妥、最标准的路子。再往下细分STM32的休眠模式本身也有好几档Sleep、Stop、Standby。Sleep模式CPU停了但外设还在跑电流大概mA级别基本不适合电池供电的低功耗场景。Stop模式主时钟停了SRAM和寄存器内容保留电流可以做到几十uA甚至更低恢复也快唤醒后继续往下执行代码。Standby模式最狠除了备份域和RTC整个芯片几乎全断电电流能压到uA级别但代价是SRAM和寄存器内容全部丢失唤醒后系统等于重新复位代码从main函数开头重新跑。这个项目需求里明确写了“RTC在30秒后自动唤醒从头开始运行主程序”这句话翻译过来就是唤醒后要执行一次完整的系统复位和初始化流程。那最匹配的就是STANDBY模式因为它唤醒后本来就是从头跑天然满足需求如果你选STOP模式唤醒后是接着休眠前的下一条指令继续跑你还得手动做一次软复位才能实现“从头开始”多此一举。所以后面所有内容都围绕STANDBY RTC闹钟唤醒来展开。2. 休眠准备进入低功耗前的必要配置真正动手写代码之前有几个准备工作必须先做好否则休眠之后要么醒不来要么醒来了程序跑飞。2.1 时钟源选型LSE和LSI怎么选RTC的时钟源有两类外部低速时钟LSE32.768kHz晶振和内部低速时钟LSI约32kHz具体频率因芯片而异一般是32.768kHz或40kHz左右。LSE精度很高温漂也小适合需要精确计时的场合比如日历、闹钟LSI精度就差不少而且不同芯片之间频率离散性比较大如果休眠时间很长误差会积累得很明显。这个项目休眠30秒属于短时间定时用LSI其实也能凑合但如果你的设备是电池供电、需要长时间运行比如几小时甚至几天唤醒一次一定要用LSE配合外部晶振才能保证定时不跑偏。实际做产品时我一般无脑选LSE因为低功耗设备对时间精度的容错率本来就低省一个晶振的成本换来的是用户对产品的不信任不值当。如果选LSE电路上需要在PC14和PC15两个引脚之间接一颗32.768kHz晶振旁边再接两颗6pF到20pF的负载电容具体容值看晶振手册。选LSI就简单了不需要外部器件纯软件配置。关键点在于LSE起振比较慢上电后要等它稳定HAL库里有HAL_RTC_WaitForSynchro这个函数就是干这个的。2.2 进入STANDBY前必须处理的三个坑第一个坑是下载口失联。STANDBY模式下芯片的调试接口默认是断开的这意味着你一旦进入休眠下次想用ST-Link下载程序或者在线调试可能连不上芯片。解决办法有两个一是把复位引脚拉低让芯片复位后再快速连接下载二是在代码里加一个判断比如上电后延时几秒检测某个按键按下了就不进入休眠方便调试。我自己的习惯是调试期保留一个“调试模式”按住某个IO口再上电就跳过休眠逻辑量产固件里再把这个开关去掉。第二个坑是唤醒源标志没清干净。RTC闹钟唤醒后RTC_ISR寄存器里的ALRAF标志位会置1如果不清除下次设置闹钟可能会出问题。HAL库的做法是调用HAL_RTC_AlarmIRQHandler的时候自动清标志但如果你用的是裸机寄存器操作一定要记得手动清。第三个坑是最容易被忽略的进入STANDBY之前要关掉不必要的外设时钟。很多人直接调HAL_PWR_EnterSTANDBYMode就完了结果休眠电流还是大的离谱排查半天发现是某个GPIO没配置成低功耗状态或者某个外设时钟没关。STM32进入STANDBY后大部分外设时钟会自动关闭但GPIO的电平状态是不受控制的如果某个IO口悬空或者输出高电平驱动着外部器件漏电流照样会吃掉你辛苦省下来的电流。所以进入休眠前把所有用不到的GPIO统一配置成模拟输入GPIO_MODE_ANALOG或者下拉输入把外部器件断电再进休眠电流才能压得下去。3. 核心代码实现30秒定时唤醒全流程下面的代码基于STM32 HAL库主控以STM32L151为例其他型号如F103、L476也适用接口一样开发环境是STM32CubeMX Keil MDK。CubeMX里需要配置的内容RTC使能、时钟源选LSE、使能Alarm A、预分频和计数器的值按下方说明填写。3.1 用CubeMX快速生成初始化工程RTC的配置在CubeMX里比较直观。进入Connectivity - RTC勾选Activate Clock Source时钟源选LSE然后在Parameters选项卡里把Alarm A勾上Alarm A Interrupt使能。RTC的Asynch Predivider和Synch Predivider这两个参数根据时钟源来计算公式是时钟频率 LSE频率 / 异步预分频1 / 同步预分频1要求最终得到1Hz的计数时钟。以LSE为32.768kHz为例设异步预分频为127、同步预分频为255则 32768/(1271)/(2551) 1Hz这就是最常用的配置。如果你用的是LSI需要查对应芯片手册确认LSI实际频率再反推预分频值。另外推荐把WakeUp相关的选项先忽略因为本项目用的是Alarm A而不是RTC WakeUp Timer两者容易搞混。RTC WakeUp Timer是另一种唤醒方式它只有向下计数器不支持绝对时间点适合做周期固定的延时唤醒而Alarm A可以指定具体的日期时间时分秒更灵活。3.2 主程序逻辑设置闹钟、进休眠、被唤醒重启直接看代码逻辑比看一堆配置项更直观。整个主程序的结构其实非常简单核心就三步初始化外设、设置30秒后的RTC闹钟、进入STANDBY休眠。int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_RTC_Init(); // 读取复位标志判断本次启动是不是RTC唤醒导致的 if (__HAL_PWR_GET_FLAG(PWR_FLAG_SB) ! RESET) { // 清除待机标志 __HAL_PWR_CLEAR_FLAG(PWR_FLAG_SB); // 这里放唤醒后需要执行的业务逻辑 WakeUp_Handler(); } // 设置RTC闹钟当前时间 30秒 RTC_Alarm_Config(30); // 进入STANDBY模式前把用不到的IO统一处理 EnterLowPower_GPIO_Config(); // 关闭不需要的外设时钟 __HAL_RCC_PWR_CLK_ENABLE(); // 正式进入休眠 HAL_PWR_EnterSTANDBYMode(); // 正常情况下代码永远不会执行到这里因为STANDBY唤醒后是系统复位 while (1) { } }这段代码有几个细节值得展开讲一下。__HAL_PWR_GET_FLAG(PWR_FLAG_SB)是用来判断本次上电是不是因为待机唤醒导致的如果是就说明休眠之前业务已经初始化完成了唤醒后不需要重复初始化一遍外设直接进业务处理函数即可。这个标志非常关键不然每次唤醒都全量初始化一遍不仅浪费时间还可能因为外设状态错乱导致莫名其妙的问题。RTC_Alarm_Config(30)这个函数是核心它负责设置一个30秒后的闹钟点。这里不能直接写死30秒因为RTC的闹钟是基于“绝对时间”的得先读当前RTC时间然后加上30秒再把结果写进闹钟寄存器。3.3 设置30秒后闹钟的完整实现来看闹钟设置函数的具体实现这里需要用HAL_RTC_GetTime读取当前时间再手动加上30秒注意处理秒和分钟、小时的进位。void RTC_Alarm_Config(uint32_t seconds) { RTC_AlarmTypeDef sAlarm {0}; RTC_TimeTypeDef sTime {0}; RTC_DateTypeDef sDate {0}; // 读取当前RTC时间注意HAL库这步之前必须调用 // HAL_RTC_GetTime且要先读时间再读日期顺序反了会出问题 HAL_RTC_GetTime(hrtc, sTime, RTC_FORMAT_BIN); HAL_RTC_GetDate(hrtc, sDate, RTC_FORMAT_BIN); // 计算目标时间 当前时间 30秒 uint32_t target_sec sTime.Seconds seconds; sTime.Minutes target_sec / 60; sTime.Seconds target_sec % 60; uint32_t target_min sTime.Minutes; sTime.Hours target_min / 60; sTime.Minutes target_min % 60; uint32_t target_hour sTime.Hours; // 如果小时超过24需要进位到日期这里简化处理 // 实际项目建议用HAL_RTC_GetTime返回的子秒或直接转为时间戳计算 if (target_hour 24) { target_hour % 24; // 日期加一天的处理略 } sTime.Hours target_hour; // 配置闹钟通道A秒匹配 sAlarm.AlarmTime.Hours sTime.Hours; sAlarm.AlarmTime.Minutes sTime.Minutes; sAlarm.AlarmTime.Seconds sTime.Seconds; sAlarm.AlarmTime.SubSeconds 0; sAlarm.AlarmMask RTC_ALARMMASK_DATEWEEKDAY; // 只匹配时分秒日期不参与匹配 sAlarm.Alarm RTC_ALARM_A; sAlarm.AlarmDateWeekDaySel RTC_ALARMDATEWEEKDAYSEL_DATE; sAlarm.AlarmDateWeekDay 1; // 该字段在Mask了日期后无实际意义随意填 sAlarm.AlarmSubSecondMask RTC_ALARMSUBSECONDMASK_ALL; if (HAL_RTC_SetAlarm_IT(hrtc, sAlarm, RTC_FORMAT_BIN) ! HAL_OK) { Error_Handler(); } }这里最容易翻车的点在于HAL_RTC_GetTime和HAL_RTC_GetDate的调用顺序。HAL库要求必须先把Time读出来再去读Date因为读取时间操作会锁存影子寄存器顺序反了会导致时间数据错乱。新手第一次写很容易忽略这个细节。另外AlarmMask这个参数很关键它决定了闹钟匹配的粒度。这里用RTC_ALARMMASK_DATEWEEKDAY表示忽略日期和星期、只匹配时分秒这样每天到了这个时间点都会触发闹钟。比如现在是10:05:30设置30秒后唤醒那就是10:06:00触发不会管今天是几号。如果项目需要指定某一天某个时刻唤醒比如每天早上8点Mask就要设为只忽略秒甚至不忽略日期具体看需求。3.4 RTC中断服务函数怎么写Alarm A使能了中断自然要有对应的中断服务函数。在STM32CubeMX生成的代码里RTC闹钟中断的IRQHandler已经帮你写好了框架你要做的就是补充用户回调函数。void RTC_Alarm_IRQHandler(void) { HAL_RTC_AlarmIRQHandler(hrtc); }HAL库会在这个函数内部调用HAL_RTC_AlarmAEventCallback这是个弱定义的回调函数你需要自己在用户代码里重写它处理闹钟事件。但这里有个细节需要特别注意在STANDBY模式下唤醒程序执行到中断服务函数的地方其实是唤醒后的重启流程里。因为STANDBY唤醒等于复位所有外设重新初始化中断向量重新走一遍所以在唤醒后重新初始化的过程中如果此时闹钟中断标志还是置位的HAL库的中断处理会把回调再执行一遍。我的建议是回调函数里只做最简单的标志位设置不要放复杂的业务逻辑真正的业务处理放在main函数里检测待机唤醒标志之后再做这样逻辑更清晰。void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { // 简单置个标志位真正的业务处理放到main里做 g_rtc_alarm_flag 1; }顺带提一个真实的bug如果你在回调里放了一个串口打印而串口在唤醒初始化阶段还没来得及配置好那这个打印函数很可能会卡死或者跑飞。别问我怎么知道的这个坑我踩过好几回。3.5 唤醒标志的检测与清除RTC闹钟唤醒和上电复位从CPU的角度看都是“复位”唯一的区别就是复位原因寄存器的值不同。代码里通过读取PWR_CSR寄存器的SBFStandby Flag位来判断本次启动是不是待机唤醒导致的。如果这个位是1说明是RTC闹钟叫醒的直接走唤醒后的业务逻辑如果是0说明是冷启动或者外部复位正常初始化即可。务必在主程序早期就调用__HAL_PWR_CLEAR_FLAG(PWR_FLAG_SB)把待机标志清掉否则下次再进STANDBY再唤醒这个标志永远都是1就分不清到底是冷启动还是唤醒启动了。这个标志位的生命周期非常长不清的话会一直累积。还有一个容易被忽略的问题在STANDBY模式下RTC的备份寄存器Backup Registers是唯一还能保存用户数据的地方。如果你希望在唤醒后知道“上一次睡之前到了哪一步”可以把状态值写进备份寄存器。比如// 进休眠前记录状态 HAL_RTCEx_BKUPWrite(hrtc, RTC_BKP_DR0, 0xA5A5); // 唤醒后读取判断 if (HAL_RTCEx_BKUPRead(hrtc, RTC_BKP_DR0) 0xA5A5) { // 说明是RTC唤醒唤醒走到这里 }这个功能在处理“休眠前是否有未完成的任务”这个场景时特别好用。4. 实测记录下载、调试和电流数据纸上谈兵讲了这么多实际操作中的体验比代码本身更能说明问题。我这次测试用的是一块STM32L151C8T6核心板LSE外部晶振测试环境是Keil MDK 5.36 ST-Link V2。第一件要吐槽的事就是下载。第一次写完代码烧进去之后系统一进STANDBYST-Link立刻失联。之后再点下载Keil直接报“No target connected”那叫一个绝望。原因是STANDBY模式下调试接口断电MCU不响应调试请求。解决办法是按住板子上的复位键让MCU处于复位状态然后点下载在松手的瞬间让IDE抓住下载窗口成功率很高。后来我学乖了在代码里加了调试开关上电后延时3秒检查一个按键是否按下按下就不进休眠调试期特别方便。// 调试期按住KEY1按键再上电跳过休眠逻辑 if (HAL_GPIO_ReadPin(KEY1_GPIO_Port, KEY1_Pin) GPIO_PIN_RESET) { // 用户按下按键进入调试模式不进入休眠 while (1) { // 等待调试 } }第二件事是实测电流数据。整板在48MHz主频运行时电流大概在8mA左右进STANDBY之后电流掉到了1.6uA左右——注意这是整板电流包含了LDO和板载LED的漏电。如果你自己画板子把LDO换成品率更高的低静态电流型号把LED去掉STANDBY电流做到几百nA不是问题。这个量级的功耗差距才是选择STANDBY而不是STOP的核心原因。第三件事是时间精度的验证。LSE晶振起振后我实测连续唤醒10次每次间隔精度在正负0.2秒以内对于30秒的唤醒周期来说是相当准的。如果你发现唤醒时间偏差明显偏大大概率是LSE晶振没起振、代码回退到了LSI或者负载电容选得不对。可以用示波器直接量PC14引脚的波形正常峰峰值至少要有几百毫伏的正弦波。5. 常见问题排查实录5.1 无法进入STANDBY或者休眠电流偏高现象是调用HAL_PWR_EnterSTANDBYMode()之后芯片没有真正断电或者电流高达几百uA。排查优先级从高到低第一检查是否有其他外设还在工作。特别是串口、ADC、DMA这类外设如果有数据在传输芯片可能会拒绝进入STANDBY。第二检查GPIO状态休眠前把不需要的引脚都设为模拟输入或固定电平但要注意别把唤醒引脚如WKUP1也配置错了。第三检查调试器是否还连着ST-Link在调试模式下会强制保持目标芯片的调试时钟导致无法进入真正的低功耗状态——这就是为什么很多人单独跑代码不进调试、电流就正常的原因。// 进入STANDBY前关闭所有不用的外设时钟 __HAL_RCC_GPIOA_CLK_DISABLE(); __HAL_RCC_GPIOB_CLK_DISABLE(); // 配置所有用不到的引脚为模拟输入 GPIO_InitTypeDef GPIO_InitStruct {0}; GPIO_InitStruct.Pin GPIO_PIN_All; GPIO_InitStruct.Mode GPIO_MODE_ANALOG; GPIO_InitStruct.Pull GPIO_NOPULL; HAL_GPIO_Init(GPIOA, GPIO_InitStruct);5.2 RTC唤醒时间不对总是差几个小时这大概率是闹钟掩码配置问题AlarmMask没有把日期掩掉导致闹钟条件包含了日期匹配而日期值不对所以闹钟要等到下次日期匹配才能触发。// 正确写法只匹配时分秒忽略日期 sAlarm.AlarmMask RTC_ALARMMASK_DATEWEEKDAY;还有一个可能是RTC时间还没设置过。很多开发板出厂RTC时间是随机的不一定是1970年1月1日如果你在程序里没有显式初始化RTC时间闹钟设置基于一个奇怪的时间起点那“当前时间30秒”的算法也会跑偏。建议的程序启动时序是第一次上电时检测RTC备份寄存器标志发现RTC没初始化过就设置一个默认时间点之后再正常走闹钟逻辑。5.3 编译报错或者找不到HAL RTC相关函数检查两点。第一CubeMX有没有勾选RTC外设如果没勾选相关源文件不会被编译第二stm32l1xx_hal_conf.h里有没有启用RTC模块这个头文件里面有#define HAL_RTC_MODULE_ENABLED如果被注释掉了所有RTC函数都会被编译器丢弃报undefined reference我遇到过好几个人因为这个卡了半天。5.4 程序唤醒后死机或者跑飞STANDBY唤醒后系统实际上是“复位”了所有外设寄存器值全部回到默认状态。如果你的代码里在休眠前使能了某个外设中断而唤醒后没有重新初始化这个外设中断服务函数又找不到对应的外设句柄就会死机。我的建议是在main函数的开头做一个轻量化的初始化分支用待机唤醒标志判断如果是从STANDBY唤醒只初始化本次业务需要的外设其他外设一律跳过这样既能快速运行又不容易出问题。6. 避坑经验和几个实用技巧最后分享几个从实际项目里攒下来的经验这些内容常规文档里基本不会写但实战中极其重要。第一关于LSE晶振不起振的问题。环境温度低、晶振负载电容不匹配、PCB布局离MCU太远都可能导致LSE无法起振。代码层面的应对方法是在初始化RTC之前先检测LSE是否就绪没就绪就切换到LSI并把切换事件记录到备份寄存器里便于排查。检测代码大致是这样RCC_OscInitTypeDef RCC_OscInitStruct {0}; RCC_OscInitStruct.OscillatorType RCC_OSCILLATORTYPE_LSE; RCC_OscInitStruct.LSEState RCC_LSE_ON; RCC_OscInitStruct.PLL.PLLState RCC_PLL_NONE; if (HAL_RCC_OscConfig(RCC_OscInitStruct) ! HAL_OK) { // LSE起振失败记录错误并回退到LSI HAL_RTCEx_BKUPWrite(hrtc, RTC_BKP_DR1, 0xBAD0); }第二除了Alarm A之外RTC还有一个更简单的唤醒方式RTC WakeUp Timer。虽然这个项目用的是Alarm但偶尔也有只做固定周期延时的需求WakeUp Timer配置起来更简洁一条函数就搞定。建议在代码里把两种方式封装成不同接口留着备用// 周期性唤醒用WakeUp Timer最大周期取决于预分频配置 HAL_RTCEx_SetWakeUpTimer_IT(hrtc, 32700, RTC_WAKEUPCLOCK_CK_SPRE_1); // 以上配置在1Hz的CK_SPRE时钟下32700就是32700秒可以根据实际需求调整第三多闹钟场景。如果你有多个定时任务比如一个30秒唤醒采集数据一个5分钟唤醒上报Alarm A只能支持一个闹钟Alarm B在部分型号上也没有。这时候可以把周期拆成最小公倍数在主程序里判断当前时间是否到了某个任务的执行点从而在一个唤醒周期内处理多个任务。用RTC STANDBY这套方案做完这个30秒定时唤醒项目之后我的体会是它值得作为任何低功耗嵌入式项目的标准开局方案。低成本、无额外器件、代码量小、功耗表现优秀踩过坑之后你会发现这套方案的容错空间其实很大。如果你接下来要做的项目涉及低功耗不妨先把这套组合跑熟后续加无线通信、加传感器采集思路都是通的。希望这篇文章能帮你少踩几个我当年踩过的坑。本文还有配套的精品资源点击获取
返回列表