
做嵌入式开发这几年几乎每个带时钟功能的产品都会被用户问一个问题“断电之后再上电时间还准不准”早期我做过一个设备断电后时间全部归零每次重启用户都要重新校准体验非常差。后来改用STM32的RTC加VBAT电池方案才彻底解决这个问题。这篇文章我就把完整思路、硬件连接方式、软件配置和核心代码全部整理出来照着做你的板子掉电之后时钟照样走不用再背“时间丢失”的锅。如果你正在做带实时时钟的产品或者想给自己的项目加一个掉电不丢时间的功能这篇文章正好适合你。我会以最常见的STM32F1系列为主兼顾F4系列从原理讲到硬件电路再从寄存器级配置讲到标准库和HAL库的实现最后把容易踩的坑也列出来尽量一次说透。1. 项目概述为什么VBAT电池能让时钟掉电不中断1.1 RTC不只是个“定时器”STM32内部有一个独立的实时时钟外设叫RTCReal-Time Clock。很多人第一次接触它会觉得这不就是个定时器吗确实RTC本质上是靠计数器累加实现计时但它和普通定时器有本质区别普通定时器的供电和时钟都来自主电源主电源一断它立刻停止而RTC在STM32内部被单独划分在一个“备份域”里这个区域可以脱离主电源由VBAT引脚独立供电。也就是说当你的系统主电源断电时芯片内CPU、Flash、SRAM全部停止工作但备份域内的RTC计数器、备份寄存器和部分配置寄存器仍然可以由VBAT引脚上的电池供电继续跑下去。这就是“掉电时钟不中断”的硬件基础。1.2 VBAT引脚到底是什么VBAT是STM32芯片上一个独立电源引脚它的作用就是给备份域供电。在正常工作时你可以把VBAT直接接VDD也可以接一个电池或超级电容。当VDD主电源掉电后芯片内部会自动切换到VBAT通路保证RTC和备份寄存器不掉电。需要注意的一点是VBAT的供电电压范围通常在1.8V到3.6V之间。最常见的做法是接一个CR2032纽扣电池电压3V非常适合。也可以使用超级电容比如5.5V/1F的超级电容在断电后能维持RTC工作很长时间成本还低不需要更换电池。1.3 掉电“走时”和掉电“保存时间”是两码事很多初学者会把“掉电保存时间”和“掉电时钟不中断”搞混。掉电保存时间是指每次断电前把当前时间写入Flash或EEPROM上电后读回来恢复显示。这种做法依赖断电检测而且恢复的时间是“最后保存那一刻”的时间断电期间时间并没有继续走。而掉电时钟不中断是RTC计数器在VBAT供电下一直累加断电期间时间依然在流逝重新上电后你读到的时间就是当前真实时间不用保存、不用恢复。这才是这项技术的核心价值。2. 硬件设计VBAT供电电路与RTC时钟源选型2.1 VBAT供电电路怎么搭硬件上VBAT电路并不复杂但有三个细节一定要处理好。我自己做过的板子上最先踩的坑就是电池电压倒灌到VDD导致整板通过电池供电设备关不掉。第一个细节是防倒灌。如果直接用电池正极接VBAT电池电压偶尔会高于VDD这时电流会从VBAT倒灌到VDD导致断电后VDD上仍有电压系统无法彻底关机。正确做法是在VDD到VBAT之间串联一个二极管或者在VBAT通路上用双二极管隔离。实际项目中很多人直接用BAT54S这种双二极管芯片一正一负非常省事。第二个细节是去耦。VBAT引脚旁边必须放一个100nF陶瓷电容最好再并一个1uF到10uF的电容。RTC内部电路在工作时会有电流跳变去耦电容能稳定VBAT电压防止晶振停振。第三个细节是电池座的可靠性。CR2032电池座时间长了会氧化接触不良如果用在工业产品里建议优先选弹簧压接式电池座并且打上三防漆。如果只是自己打板调试用普通贴片电池座问题不大。下面是一个我常用的VBAT参考电路不需要画图文字描述也能讲清楚VDD过来先接一个0欧电阻或者肖特基二极管SS14的正极负极接到VBAT节点CR2032电池座正极也接到VBAT节点电池座负极直接接GNDVBAT节点上放100nF电容和1uF电容到GND。需要注意如果VDD掉电后还想让电池给RTC供电SS14的正极要接VDD负极接VBAT这样VDD存在时通过二极管给VBAT供压VDD消失后二极管反向截止不会倒灌。2.2 RTC时钟源LSE还是LSISTM32的RTC时钟源有两个选择一个是外部低速晶振LSE频率32.768kHz另一个是内部低速RC振荡器LSIF1系列典型值约40kHz。很多人刚上手时图省事直接选LSI代码能跑但后面会出问题。LSE的32.768kHz是32768Hz正好是2的15次方经过15级分频后能得到精确的1Hz秒脉冲长期走时精度取决于晶振本身一般能做到每天几秒到几十秒的偏差。而LSI是芯片内部RC振荡器频率受温度和电压影响很大掉电时VBAT电压从3.3V慢慢降到2VLSI频率可能会漂移很大走时误差会非常夸张一天差几分钟甚至更多都有可能。所以做带RTC的产品我强烈建议直接用LSE外部晶振。这也是大多数开发板的标准配置32.768kHz晶振加上两个6pF到12.5pF的负载电容并联一个1M到10M的反馈电阻就组成了RTC最可靠的时钟源电路。在选择负载电容时要以晶振规格书为准比如晶振标称负载电容是6pF两只对地电容可以取6pF到10pF。实际频率偏差可以用示波器观测RTC秒脉冲来修正这块后面再说。2.3 掉电检测与备份域复位有了VBAT电池RTC在掉电后能继续走但你还需要知道系统是否经历了一次掉电。STM32内部有一个PVD可编程电压检测器可以设定一个阈值电压当VDD跌落到阈值以下时会触发PVD中断。在中断里你可以做一些收尾工作比如把当前状态写入备份寄存器。再说说备份域复位的问题。STM32的复位分好几种普通系统复位不会影响RTC但备份域复位BKP Reset会把RTC配置和备份寄存器全部清零。以下几种情况会触发备份域复位一是软件复位备份域通过BKP_DeInit操作二是VDD和VBAT同时掉电备份域彻底失电三是某些芯片在上电时序异常时也会导致备份域复位。所以代码里判断RTC是否曾经被初始化过不能只看RTC有没有使能最好用一个备份寄存器存一个魔数做标志位。3. 软件配置RTC初始化核心步骤与寄存器级原理解读3.1 初始化流程整体认知在写代码之前先搞清楚STM32F1系列RTC的软件访问限制。RTC和备份寄存器都在备份域里复位后默认是禁止访问的。你要先打开PWR和BKP外设的时钟然后调用PWR_BackupAccessCmd(ENABLE)解除备份域写保护接下来才能操作备份域寄存器。这是所有RTC代码的第一步很多人忘记解除写保护结果RTC配置写不进去时间怎么都不走。初始化RTC的完整流程分几个阶段开启LSE并等待起振选择LSE作为RTC时钟源并使能RTC等待RTC同步设置预分频系数然后首次上电时写入初始时间。如果备份寄存器里的魔数已经存在说明RTC之前已经初始化过那就跳过初次配置只需要等待同步后直接读取计数即可。F1系列的RTC本质上是一个32位的向上计数器时钟源经过预分频后每秒计数加一。预分频器是20位的寄存器分频系数等于预分频寄存器值加1。当使用32.768kHz的LSE时要让RTC每秒走一步需要设置预分频值为32768减1也就是32767。如果你用LSI因为LSI频率不固定这个值就得根据实测频率来算这也是为什么用LSI做RTC麻烦的原因之一。3.2 标准外设库方式初始化RTCSTM32F1系列最简单常用的代码是标准外设库版本。所有操作步骤都用库函数封装好了逻辑很清晰。初始化函数可以这样写void MyRTC_Init(void) { /* 使能PWR和BKP时钟 */ RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); /* 解除备份域访问保护 */ PWR_BackupAccessCmd(ENABLE); /* 检查备份寄存器标志判断是否已初始化 */ if (BKP_ReadBackupRegister(BKP_DR1) 0xA5A5) { /* 已初始化过等待RTC同步后直接返回 */ RTC_WaitForSynchro(); return; } /* 备份域复位清空所有备份寄存器 */ BKP_DeInit(); /* 开启LSE外部低速晶振 */ RCC_LSEConfig(RCC_LSE_ON); /* 等待LSE就绪注意要加超时 */ while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) RESET) { } /* 选择LSE作为RTC时钟源并使能RTC */ RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); RCC_RTCCLKCmd(ENABLE); /* 等待RTC同步确保可以访问 */ RTC_WaitForSynchro(); RTC_WaitForLastTask(); /* 设置预分频值32768分频到1Hz */ RTC_SetPrescaler(32767); RTC_WaitForLastTask(); /* 首次初始化写入初始时间戳例如2023-11-14 22:13:20 */ RTC_SetCounter(1700000000); RTC_WaitForLastTask(); /* 写入魔数标志表示RTC已初始化 */ BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); }这段代码里的几个点要说明一下。RTC_WaitForSynchro是等RTC寄存器与APB总线同步因为RTC时钟和APB时钟不是同一个时钟域直接读有可能读到旧值。RTC_WaitForLastTask是等上一次写入完成每次对RTC寄存器写入前都要调用。很多网上代码不写这两个等待就会出现偶发性的时间不走、配置写失败。关于等待LSE就绪的while循环在正式产品里一定要加超时机制。LSE起振本来就需要一两秒但如果晶振没焊好、电容不对、或者晶振质量有问题这个循环就会永远等下去程序卡死在初始化。实际产品中可以做一个超时计数超过一定时间就主动跳过走备用逻辑。3.3 HAL库方式初始化RTC如果你用的是STM32CubeMX加HAL库初始化RTC的方式会相对高层一些。CubeMX里勾选RTC启用Calendar选择LSE作为时钟源然后配置异步预分频和同步预分频。代码生成后主要配置集中在MX_RTC_Init函数里。HAL库下RTC的时间读取和设置用RTC_TimeTypeDef和RTC_DateTypeDef结构体操作起来更直观。基础读写代码如下RTC_TimeTypeDef sTime {0}; RTC_DateTypeDef sDate {0}; /* 设置时间BCD格式或二进制格式 */ sTime.Hours 22; sTime.Minutes 30; sTime.Seconds 0; HAL_RTC_SetTime(hrtc, sTime, RTC_FORMAT_BIN); sDate.Year 23; sDate.Month 11; sDate.Date 14; sDate.WeekDay 2; HAL_RTC_SetDate(hrtc, sDate, RTC_FORMAT_BIN); /* 读取时间先读时间再读日期注意HAL库的坑 */ HAL_RTC_GetTime(hrtc, sTime, RTC_FORMAT_BIN); HAL_RTC_GetDate(hrtc, sDate, RTC_FORMAT_BIN);这里有一个HAL库特有的坑读取时间时必须先调用HAL_RTC_GetTime再调用HAL_RTC_GetDate。因为GetTime函数内部会锁存日期寄存器如果你只读日期不先读时间日期数据可能是上一次的旧值。很多人用HAL库发现日期一直不变就是这个问题。3.4 时间读写API设计F1系列RTC没有日历寄存器只有一个32位计数器。所以你需要自己定义“时间”的格式。最方便的是使用Unix时间戳也就是从1970年1月1日0时0分0秒到当前的秒数。这样在STM32里你只需要维护一个uint32_t变量RTC的计数就是当前时刻的时间戳。需要显示年月日时分秒时再用标准库的gmtime函数或者自己写转换函数解析。在32位范围内Unix时间戳在2038年会溢出。对普通嵌入式产品来说一般够用了。如果你做的是一个使用寿命非常长的设备可以改用自己定义的基准时间比如从2020年开始计算秒数或者直接用BCD格式存年月日时分秒。我这边提供了一个最简单的读写封装/* 设置RTC时间传入Unix时间戳 */ void MyRTC_SetUnixTime(uint32_t timestamp) { RTC_WaitForLastTask(); RTC_SetCounter(timestamp); RTC_WaitForLastTask(); } /* 获取RTC当前时间返回Unix时间戳 */ uint32_t MyRTC_GetUnixTime(void) { return RTC_GetCounter(); }上层应用拿到时间戳后用C库函数转换成本地时间结构体就能得到实时的年月日时分秒。注意在STM32上使用time.h的gmtime等函数前要确保编译器支持并且时区偏移自己处理。Keil MDK自带的ARM Compiler是做得到的GCC交叉编译环境也没问题。4. 核心代码解析掉电不掉时间的完整实现4.1 判断是否首次上电的设计思路前面初始化代码里用了一个备份寄存器BKP_DR1来存魔数0xA5A5这个思路是整个“掉电不掉时间”方案的关键。因为系统掉电时备份域有VBAT供电所以BKP_DR1里的值也会一直在。重新上电后代码检查这个寄存器发现还是0xA5A5就知道RTC已经被配置过了不应该再去重置时间只需要恢复正常访问即可。如果你是第一次烧录程序后上电BKP_DR1是空的或者通过BKP_DeInit清空过代码会进入首次初始化分支写入初始时间戳并设置魔数。这样区分了“生产首次运行”和“日常掉电重启”两种场景。这个做法的好处是不需要外部Flash或EEPROM保存RTC初始化状态完全依赖备份域不丢失的特性非常可靠。当然生产烧录时如果需要强制重置RTC可以写一个专门触发备份域复位的操作指令比如在串口命令里调用BKP_DeInit再重新初始化。4.2 完整工程下的读取与显示示例假设你要在OLED上显示当前时间最简单的方式是循环里调用MyRTC_GetUnixTime再转成时分秒。这里给一个完整示例#include time.h void Display_RTC_Time(void) { uint32_t timestamp MyRTC_GetUnixTime(); time_t rawtime (time_t)timestamp; struct tm *timeinfo gmtime(rawtime); char timeStr[20]; sprintf(timeStr, %04d-%02d-%02d %02d:%02d:%02d, timeinfo-tm_year 1900, timeinfo-tm_mon 1, timeinfo-tm_mday, timeinfo-tm_hour, timeinfo-tm_min, timeinfo-tm_sec); OLED_ShowString(0, 0, timeStr); }注意gmtime返回的时间是UTC时间没有加时区偏移。如果你在北京时区需要加上8小时或者直接对时间戳加8 * 3600秒之后再转换。如果不想依赖C库的结构体转换也可以自己写一个简单的秒数转时分秒逻辑。因为RTC每秒钟才走一步低功耗设备通常不需要高频率调用转换函数在定时器里每500ms或者1s刷新一次显示就够了。4.3 掉电实验实测验证VBAT效果程序写完、板子焊好后一定要做掉电实验验证效果不要以为接个电池就万事大吉。我的测试方法是板子正常上电设置当前时间精确到秒等待RTC稳定跑起来然后直接把主电源断开。观察VBAT电压是否维持在3V左右。大约等一分钟再重新上电通过串口打印读到的时间戳转换结果与之前设置的时间比较看看是不是增加了约60秒。如果时间正确增加说明VBAT供电和RTC走时都正常。这个实验里最容易遇到的异常是重新上电后时间变成了初始值说明RTC被重置了。常见原因有VBAT没接对、电池没电、或者初始化逻辑有BUG每次上电都走了首次初始化分支。还有一个很容易被忽略的现象用调试器烧录程序时如果调试器给目标板供电并且在复位后立刻执行了BKP_DeInit备份寄存器就被清了下一次上电自然又从头开始。所以测试的时候最好用独立电源不要依赖调试器供电。5. 常见问题与排查技巧实录5.1 掉电后时间被重置问题到底出在哪里这是RTC项目里最高频的问题。排查思路按优先级来先用万用表量VBAT电压断电状态下必须稳定在1.8V以上。如果是CR2032电池新电池通常在3V左右低于2V直接换掉。然后量VBAT到GND之间有没有短路很多电池座贴片焊接时会连锡。再检查软件确认初始化代码在每次上电时都会执行PWR_BackupAccessCmd(ENABLE)并且先读取备份寄存器魔数再决定要不要重置时间。如果魔数判断放在RCC配置之后、BKP_DeInit之前顺序错了每次都会清备份域。最后别忘了看“外部复位”和“备份域复位”的区别。如果程序里其他模块误调用了复位相关代码或者开启看门狗后复位了备份域RTC也会被清。可以通过调试器在初始化函数里打断点单步走一遍观察BKP_DR1的值变化。5.2 32.768kHz晶振不起振怎么排查RTC常见的一个启动难题就是外部晶振不起振。故障现象是程序卡死在等待LSE就绪的while循环或者RTC时间完全不更新。排查手段有几个。首先看硬件晶振的两个引脚和芯片OSC32_IN、OSC32_OUT之间的走线尽量短两边不要走高频信号线。负载电容的值要和晶振标称匹配两个电容对地焊好。有些廉价晶振质量差直接换一颗正规品牌再试。然后看软件F1系列有一个LSE驱动能力配置在RCC寄存器里可以调整振荡器增益。标准库默认不调但有些晶振需要更高的驱动电流才能起振。F4系列在RCC_CR寄存器的LSEON后面还有LSEDRV位可以尝试修改驱动强度。具体方法是在初始化RCC_LSEConfig之前直接修改RCC-CR相关位。还有一种非常常见的坑测量时使用示波器探头去点晶振引脚探头电容会造成停振这不是电路问题。正确方式是测量RTC秒脉冲是否正常输出或者直接看程序有没有走出等待循环。5.3 RTC秒中断不触发时钟却正常走这种情况通常是中断使能配错了。F1系列的RTC中断包括秒中断、闹钟中断和溢出中断。秒中断需要设置RTC_ITConfig(RTC_IT_SEC, ENABLE)同时在NVIC里使能RTC_IRQn并且写RTC_IRQHandler中断服务函数。一个容易忽略的细节是RTC中断标志在读取后会自动清除但F1系列对RTC中断的处理顺序有讲究。中断函数里要先调用RTC_GetITStatus判断是不是秒中断再RTC_ClearITPendingBit清除标志。如果清了标志又去读状态第二次就进不了中断逻辑上要避免。还有一个影响秒中断的隐患是禁止了RTC中断写保护后没有重新使能。就是初始化时调用了PWR_BackupAccessCmd(ENABLE)后面某个地方又调用了PWR_BackupAccessCmd(DISABLE)之后RTC中断配置就写不进去秒中断自然不工作。5.4 快速排查表把常见问题的现象、原因和解决方向整理成一张表按表排查会快很多。问题现象可能原因解决方向重新上电时间回到初始值VBAT电压不足或未接通量VBAT电压检查电池座和二极管重新上电时间回到初始值每次上电都执行了BKP_DeInit用备份寄存器魔数判断初始化状态程序卡死在LSE等待循环晶振未起振或电容不匹配检查晶振电路换晶振调整负载电容修改LSE驱动能力RTC时间不走未选择RTC时钟源或未使能RTC检查RCC_RTCCLKConfig和RCC_RTCCLKCmdRTC时间走得不准使用了内部LSI或外部晶振偏差换LSE必要时校准或调负载电容秒中断不触发中断未使能或NVIC未配置RTC_ITConfig NVIC_EnableIRQ读取时间一直是旧值读RTC前缺少同步等待调用RTC_WaitForSynchro5.5 实际生产中的几个补充经验最后分享几个我实际踩坑后总结的经验。第一VBAT引脚不宜直接接普通电解电容漏电太大的电容要选低漏电流的陶瓷电容或钽电容否则电池会很快被耗干。第二用超级电容时要注意充电电路不能直接超过电压等级的电源接到超级电容上最好加限流电阻。第三RTC走时精度受温度影响明显如果产品工作环境温差大想保证每天误差几秒以内可以考虑使用带温度补偿的RTC芯片比如DS3231把STM32的RTC作为备用。我记得有一次量产时发现部分板子在断电后时间重置排查了很久最后发现是PCB上VBAT走线过孔太多导致电阻偏高加上电池座弹片氧化接触电阻变大VBAT电压在临界值附近波动。后来把VBAT走线加宽减少过孔问题就解决了。这种问题在样板阶段很难复现批量后才会暴露所以原理图评审和PCB布局阶段就要重视VBAT电源质量。