1. 为什么断电后还要走时钟?先把需求和坑位铺开
先说说这件东西的实际用途。STM32的RTC(Real-Time Clock)就是一颗内置的实时时钟,它能在主电源断电之后,靠着VBAT引脚上外接的纽扣电池或者超级电容继续走时。这个功能在很多产品里是刚需,最常见的场景就是电表、水表、考勤机、数据记录仪,还有各类带时间戳的传感器节点。你想一下,一个野外部署的采集设备,主电源一断,时间全部归零,重新上电之后所有数据的时间戳全乱掉,那这套系统的可用性就直接归零了。所以断电不停钟这个需求,本质上不是“加个电池”这么简单,而是要保证系统的“时间基准”在任何情况下都不丢失。
做这个功能,通常会面临几个让人头大的问题。一是VBAT电路设计不当,电池在正常供电时被白白消耗;二是RTC时钟源选择不对,LSE外部晶振不起振或者频率偏差大,导致一天能差出几十秒;三是备份域寄存器在系统复位时被意外清零,时间直接回退到2000年;还有更隐蔽的问题,比如读时间的时候把“进位”那一瞬间读到旧值,导致出现过一分钟时间倒退、跳变之类的诡异现象。这些坑我在项目里基本都踩过一遍,这篇就把硬件设计、代码配置、调试方法和实测心得都整理出来,给大家一条能直接走通的路。
如果你手上已经有了一块STM32的开发板,或者正在做一个小型的数据记录类产品,接下来这套东西可以直接抄作业。代码以STM32F103系列和HAL库为例,但你只要理解了原理,换到F4、L4、G0系列都只是改改时钟配置的问题。
2. 电路层的事没做好,代码写得再漂亮也是白搭
很多人在VBAT这块栽跟头,不是代码问题,而是硬件电路设计的时候就埋了雷。先说清楚VBAT引脚的作用:它专门给RTC和备份寄存器供电,在VDD主电源掉电后,VBAT的电压可以维持RTC继续走时,同时保证备份寄存器里存的数据不丢失。注意,VBAT不是给整个芯片供电的,它的负载非常小,在微安级别,所以一颗小电池能用好几年,这是设计这套机制的前提。
2.1 VBAT引脚的供电切换逻辑
STM32内部的电源结构是这样的:正常情况下,芯片由VDD供电,RTC和备份域的电源来自VDD;一旦VDD跌落到一定阈值以下,芯片内部的电源开关会自动切换到VBAT,由外部电池继续给备份域供电。这个切换是硬件自动完成的,不需要代码参与,但前提是VBAT引脚上必须有一个有效的备用电源。
这里有个常见的错误设计:有些人直接把VBAT悬空不接,觉得不用RTC无所谓。结果就是RTC走时不稳定,或者复位后备份寄存器数据全丢。实际上如果你不打算用RTC和备份寄存器,STM32官方建议把VBAT直接接到VDD上;如果你要用断电不停钟,就必须接电池或者法拉电容。
2.2 电池选型和放电回路保护
VBAT电池最常用的是CR1220纽扣电池和超级电容。CR1220容量一般在40mAh左右,工作电压3V,RTC走时电流大约1~2uA,算下来理论上能走两三年。实际应用中还要考虑电池自放电和温度影响,保守估计一年半左右就要定期检查电压。超级电容的好处是可充电,可以配合主电源做浮充,但容量和漏电流要仔细算,适合那种断电时间不会太长(几天以内)的场景。
一个必须做的设计是电池防倒灌。VBAT和VDD之间有一个内部切换电路,但外部如果走线不合理,或者电池正极和VDD之间没有隔离,正常供电时电池会被持续充电或者被电路反向消耗。常见的做法是在VBAT外部串联一个低压降二极管(比如1N5819肖特基),阳极端接电池,阴极端接VBAT引脚,防止主电源正常工作时电流倒灌进电池。不过要注意二极管本身的漏电流会影响电池寿命,选型时优先选反向漏电流小的型号。
2.3 锂电池和法拉电容的不同玩法
如果你做的是充电型产品,比如手持设备或者带USB供电的记录仪,VBAT用可充电法拉电容是很好的方案。法拉电容容值大,常见的是1F或更大的,充满之后能维持RTC走时几天到几周。充电电路可以用二极管加限流电阻直接浮充,简单粗暴,但要注意法拉电容的自漏电和充电电流冲击,限流电阻别省。
如果使用充电锂电池,比如那种小型的3.7V锂电加充电管理芯片,就需要一个稳压器和合适的电压匹配,因为STM32的VBAT最大允许电压是3.6V左右,直接接3.7V满电的锂电池会超压。这个问题有些人会忽略,结果芯片的备份域长期在超压状态下工作,非常影响可靠性。
3V纽扣电池需要三到五s,用二极管连接、VBAT引脚对地加一个0.1uF瓷片电容去耦,这个电容可以滤掉电源切换瞬间的毛刺,对防止备份域数据错乱有实实在在的好处。PCB走线建议VBAT线路尽量短,避免从大电流或高频信号线旁边穿过,因为RTC走时是微安级的微弱电流,线路上的任何干扰都可能影响时钟稳定性。
3. RTC的底层原理搞不懂,后面全是玄学
RTC模块本身不复杂,但牵扯到“时钟源”“分频”“备份寄存器”“复位域”这些概念,如果不搞清楚,配置时会一直处于一种半懂不懂的状态。我尽量把底层逻辑讲透,把这些概念用大白话解释清楚。
3.1 RTC的核心:分频计数
RTC的本质就是一个不断累加的计数器。STM32的RTC由两个分频器组成:一个异步预分频器和一个同步预分频器,常见配置是异步分频127、同步分频255,两个分频器级联后把输入的32.768kHz时钟分频到1Hz,也就是计数器每秒钟加1。
为什么是32.768kHz?因为它是2的15次方,用15位分频器可以很均匀地分频,误差可以做到标称值。分频之后,RTC维护一组时间寄存器,包括秒、分、时、日、星期、月、年,还自动处理闰年和月份天数。你只需要把当前时间写入寄存器,剩下的进位和日历计算全部由硬件完成。
分频参数的选择影响时钟精度。32.768kHz的晶振通常是20ppm精度,也就是每天约1.7秒的偏差。如果使用LSE外部晶振,分频误差主要来自晶振本身的精度;如果使用LSI内部RC振荡器,频率受温度影响很大,精度远差于外部晶振,走一天偏几分钟都很正常。
3.2 备份域和它的“特权机制”
RTC、备份寄存器和电源控制相关的少量寄存器,在STM32中被称为“备份域”。这个区域由VBAT供电,在主电源掉电后依然可以保持数据和走时。备份域寄存器的访问有一个解锁机制,防止程序跑飞时意外改写。你需要先在PWR寄存器中打开备份域访问权限,然后才能操作RTC和备份寄存器。这个步骤漏了,写操作会直接被忽略,很多人调代码时发现寄存器写不进去,问题多半出在这里。
备份域寄存器的复位源也比较特殊。它不跟随系统复位、不跟随外部引脚复位,只有以下情况才会清空:备份域掉电、软件主动触发备份域复位,以及芯片发生侵入检测事件。这意味着,你按复位键重新启动程序,RTC的时间和备份数据是保留的。这就需要特别注意你的代码里不能一启动就“重置RTC时间”,否则用户只要复位一次,时间就变成了你代码里写死的初始值,这是非常常见的实现错误。
3.3 RTC时钟源选型:LSE、LSI还是HSE
STM32的RTC时钟源有三类,选错等于给后面的精度挖坑。
LSE(外部低速晶振,通常是32.768kHz)精度最高,功耗也最低,是绝大多数需要“长时间准”的项目的首选,但代价是需要在PCB上放一颗晶振,并且要正确配置晶振的驱动能力和负载电容。LSI(内部低速RC振荡器,F1系列大约40kHz,F4/L4系列一般是32kHz)不需要外部元件,但精度很差,适合对时间精度要求不高的场合。HSE经过分频后也可以做RTC时钟源,因为HSE本身精度较高,但有一个严重问题:主电源掉电后HSE停振,RTC也就没有时钟了,所以HSE方案根本不支持“断电不停钟”,只能用在不需要VBAT保持的场景。
在实际项目中,需要断电保持时间精度的,直接选LSE;只是需要一个计时功能、断电后重新上电时间不重要的,才考虑LSI。我之前做过一个低功耗采集器,主控是STM32L4,为了省一颗晶振的成本和时间,用LSI做RTC,结果发现环境温度一变化,一天能偏出几分钟,最后只能老老实实改回LSE。
3.4 一个重要的细节:RTC校准
LSE晶振本身的精度是固定的,但晶振的负载电容误差、PCB走线寄生电容、芯片内部的晶振驱动电路等因素,会导致最终的RTC频率和标称值有一点偏差。STM32提供了RTC校准功能,可以通过调整同步分频器的参数或者使用校准寄存器,在软件层面对频率偏差做补偿。
校准的测量方法是用一个高精度的参考时钟(比如GPS模块的PPS秒脉冲)来对比RTC的走时误差,然后反推校准值。对于一般产品,如果每天误差在1~2秒以内,用软件校准完全可以做到一个月误差不超过几秒。基础校准方法是在一个固定时间窗口内统计误差秒数,计算出每日偏差,再换算成校准寄存器的步进值。
4. 从零配置RTC的完整过程,照着做就行
这一节把完整的RTC配置流程走一遍,代码可以直接用,每一步我都解释为什么这么写。以STM32F103C8T6最小系统板加HAL库为例,IDE用Keil MDK。
4.1 开启备份域访问权限
这是操作RTC的第一步,很多人漏掉,导致后面所有写操作都不生效。
__HAL_RCC_PWR_CLK_ENABLE(); __HAL_RCC_BKP_CLK_ENABLE(); // F1系列需要开启BKP时钟 HAL_PWR_EnableBkUpAccess(); // 解除备份域写保护在F1系列上,PWR和BKP的时钟默认是关闭的,必须先开启才能访问备份域寄存器。HAL_PWR_EnableBkUpAccess()函数的作用是把PWR寄存器里的DBP位置1,这个位置1之后,RTC和备份域寄存器的写操作才被允许。在F4及以上系列,没有单独的BKP时钟,但同样需要调用这个函数。
这个步骤之后,还有一个容易忽略的操作:如果启用了RTC,并且不希望系统复位时RTC配置被重置,需要设置RTC的“无备份域复位”属性,在HAL中通常通过RTC初始化结构体相关配置完成。
4.2 配置RTC时钟源为LSE
选择LSE作为RTC时钟源,并等待LSE起振稳定。LSE起振是个慢过程,一般需要几百毫秒到一两秒,代码里必须等待就绪标志,否则后续操作全废。
RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_LSE; RCC_OscInitStruct.LSEState = RCC_LSE_ON; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); }如果LSE始终无法起振,大概率是硬件问题:晶振没焊好、负载电容不匹配、PCB走线太长、晶振旁边有干扰源。这时要先把硬件修好,不能指望代码绕过。
这里补充一个排查技巧:先用示波器测量晶振两个引脚,正常起振时能看到明显的正弦波形。如果完全测不到波形,优先检查晶振是否虚焊、负载电容是否焊反、芯片的OSC_IN/OSC_OUT引脚是否连对。有些人为了省成本用无源晶振却忘记加负载电容,也会导致无法起振或起振后频偏很大。
4.3 初始化RTC时间和日期
RTC时钟源配置好后,进入RTC初始化:
RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; hrtc.Instance = RTC; hrtc.Init.HourFormat = RTC_HOURFORMAT_24; hrtc.Init.AsynchPrediv = 127; hrtc.Init.SynchPrediv = 255; hrtc.Init.OutPut = RTC_OUTPUT_DISABLE; if (HAL_RTC_Init(&hrtc) != HAL_OK) { Error_Handler(); } sTime.Hours = 23; sTime.Minutes = 59; sTime.Seconds = 50; sTime.DayLightSaving = RTC_DAYLIGHTSAVING_NONE; sTime.StoreOperation = RTC_STOREOPERATION_RESET; if (HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } sDate.WeekDay = RTC_WEEKDAY_MONDAY; sDate.Month = RTC_MONTH_JANUARY; sDate.Date = 1; sDate.Year = 24; // 注意是24,不是2024 if (HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); }几个值得注意的点:AsynchPrediv和SynchPrediv的组合要保证分频后的频率正好是1Hz,对于32.768kHz的LSE,这两组值通常就是127和255。有时看到别人代码里用的是127和255,也有人用127和255之外的值,比如某些库或者某些系列芯片默认不同,但核心公式是:异步分频值加1乘以同步分频值加1,要等于输入时钟频率。比如32.768kHz:128 × 256 = 32768。
年月日格式中,Year的存放值是“年减去2000”,所以在2024年,写入24。WeekDay的数值范围是1到7,周一对应1。
4.4 读取时间时的防撕裂读法
直接顺序读RTC的时分秒寄存器有时候会读错,因为在读的过程中发生了秒进位。比如你先读到23:59:59,然后下一瞬间时间变成00:00:00,但你的程序可能先读秒再读小时,结果组合出一个不存在的“00:59:59”。这个问题在HAL库的HAL_RTC_GetTime函数内部会做处理,通过轮询RSF标志和读取影子寄存器来实现。
但如果你是用寄存器操作,就必须自己处理。我的建议是使用HAL的读取函数,不要自己直接读寄存器。如果必须在中断或者主循环里手动读,可以连续读两次时间值,如果两次完全一致,说明这次读取是稳定的;如果两次不一致,重新再读一次。这个方法简单可靠,是嵌入式读取多字节连续数据时常用的一致性读取手段。
4.5 从备份寄存器判断“是否需要重新设置时间”
这是断电不停钟项目中代码层最关键的设计。系统上电后,你不能无条件地重新设置RTC时间,否则每次复位都会把时间覆盖成初始值。正确做法是使用备份寄存器作为标志:
#define RTC_FLAG_ADDR 0x01 // BKP寄存器编号 #define RTC_FLAG_MAGIC 0xA5A5 // 上电后判断是否已经设过时间 if (HAL_RTCEx_BKUPRead(&hrtc, RTC_FLAG_ADDR) != RTC_FLAG_MAGIC) { // 首次运行,需要设置时间 HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN); HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN); HAL_RTCEx_BKUPWrite(&hrtc, RTC_FLAG_ADDR, RTC_FLAG_MAGIC); } else { // 已经有时间了,不要覆盖 }备份寄存器在VBAT不掉电的情况下能一直保存数据,所以这个标志位可以可靠地工作。注意:备份寄存器数量有限,F1系列只有42个16位寄存器,F4有20个32位寄存器,节约使用,通常用几个就够。热词里有人问“复位后备份寄存器为什么丢了”,多半是VBAT电路没接电池,或者代码里触发了备份域复位。备份域复位在RTC_ISR寄存器里有一个INITS位,软件不能直接触发,但RTC初始化过程本身在某些条件下会清备份域,这也是为什么初始化代码要写在“首次设置”分支里。
4.6 中断和闹钟功能
RTC除了走时,还有闹钟和唤醒功能。闹钟可以精确到秒级,在指定时间触发中断,实现定时唤醒之类功能。唤醒定时器可以配置为从几百微秒到几天不等的周期,适合低功耗设备的周期唤醒。
配置闹钟的代码放在HAL_RTC_SetAlarm,里面主要配置闹钟日、时、分、秒和匹配方式,比如只匹配时分秒、不匹配日期。使用闹钟中断要正确配置NVIC,同时使能RTC闹钟中断,且在中断回调函数里清除闹钟标志——这个标志不清除的话,中断会反复触发,CPU被拖死,别问我怎么知道的。
4.7 关于LSI的备选方案
如果因为硬件BUG或者空间限制,暂时只能用LSI,代码层需要改动的有两处:一是时钟源从RCC_LSE_ON改成RCC_LSI_ON,二是分频值要重新算。F103的LSI典型频率是40kHz,分频值就要变成(40kHz / 1Hz)减1,但LSI本身误差太大,建议配合软件校准使用。
5. 调试RTC时一定会踩的坑,我帮你提前踩完了
这一块都是实战经验,不少问题看似代码问题,实际是调试思路和方式的问题。
5.1 调试器复位导致的时间重置
用ST-Link或者J-Link调试时,每按一次复位键,程序从头跑。如果你的代码里没有用备份寄存器做“首次设置”判断,每次复位都会把时间重置为初始值,看起来很像是“断电时间不保持”,实际上问题出在代码逻辑。比较典型的例子是有些教程直接在主程序里调用HAL_RTC_SetTime,完全没做判断,初学者按一次复位就蒙了。解决方案就是上文的备份寄存器标志法。
5.2 断电后时间不跑或者时间清零
断电后时间清零,几乎可以断定VBAT没接好。检查顺序是:量VBAT引脚的电压是否在2.0V到3.6V之间;量电池两端电压是否正常;查二极管方向是否接反;查VBAT的滤波电容是否虚焊。断电后时间还在走但走得慢,大概率是LSE晶振频率偏了,或者备用电容容量不足导致供电不稳。
有一种情况特别坑:VBAT接的电池电压正常,但断电后时间依然清零。这时候要查一下是否是芯片复位引脚影响:有些外部复位电路在电压跌落时会产生复位脉冲,而备份域数据只有在“电源跌落到备份域电压阈值以下”时才会丢失,如果复位电路异常导致芯片还在运行状态但主电源已经大幅波动,可能出现RTC的数据被内部复位。这种情况要仔细测断电瞬间各路电压的变化曲线。
5.3 时间写入后读出来不对
写入时间后读出来不对,先看年月的格式问题。HAL库的RTC_FORMAT_BIN表示二进制格式,RTC_FORMAT_BCD表示BCD格式,如果你写了BCD格式却用二进制方式去解析,读出来的数据自然是一堆看似随机但很规律的数字。这个错误很隐蔽,因为读出来可能是“0x23”代表23点,也可能是“0x17”代表BCD的17,必须统一格式。全部使用RTC_FORMAT_BIN可以避免多数混乱。
另外一个时间是写入后马上读取会读到旧值,因为RTC寄存器存在影子寄存器,写入后有个同步延迟,一般延迟几百微秒再读就正常了。HAL的RTC_GetTime中间有这个等待逻辑,但如果你用寄存器操作,要注意在RSF位被设置后再读取。
5.4 LSE起振失败或精度不够的检测手段
LSE起振失败的典型症状是HAL_RTC_Init返回错误,或者在调试时看到RTC_ISR的LSERDY标志一直为0。检测手段包括:用示波器测量OSC_IN/OSC_OUT引脚,看有没有晶振振荡;检查RCC_OscInitStruct配置里的LSEState是否为RCC_LSE_ON;确认外部晶振的负载电容大小,32.768kHz晶振通常要求6.8pF~12.5pF,不匹配会衰减振荡幅度。
精度不够的问题,需要用长时间统计来发现。最简单的做法是记录一个时间点,和电脑或其他标准时钟对比,跑24小时后看偏差秒数,然后决定是否启用RTC校准。校准参数的写入方法在STM32参考手册的RTC章节有详细说明,不同系列寄存器名不同,但思路一致:在校准窗口内按比例增减时钟计数。
5.5 Keil调试时查看时间和寄存器的技巧
在Keil5的调试界面,打开View菜单下的Watch窗口,添加表达式hrtc.Instance->TR和hrtc.Instance->DR,可以看到原始的二进制时间数据。或者直接在Watch窗口添加HAL_RTC_GetTime的函数调用,但注意函数调用有延迟,不适合频繁断点查看。更推荐的是在程序里加一个定时打印,用串口把当前时间和备份寄存器的标志值周期性输出,调试效率会高很多。
另外,Keil调试时如果发现程序卡在RTC初始化中,先检查RTC中断开关状态和NVIC配置,有时候调试器和外部中断互相影响,会导致HAL_RTC_Init的等待循环超时。视情况可以在初始化前临时屏蔽相关中断,确认问题后再统一开放。
5.6 电流测量法验证低功耗目标
如果你的项目对功耗有要求,断电后整机功耗应该只包含RTC走时电流,可以串一个万用表在电池回路里,测一下实际电流。正常的纽扣电池供电回路电流在2uA以内都算合理,如果测出来是几十微安甚至毫安级别,说明VBAT电路里可能有其他器件在漏电,或者二极管选型不当导致反向漏电过大。这个时候要回头检查电路,不要一味优化代码。
6. 扩展:休眠模式下的RTC应用和产品化建议
RTC除了断电保持时间,还有一个非常常见的应用:让MCU进入低功耗休眠模式,用RTC闹钟或者唤醒定时器周期性唤醒MCU执行任务,然后再次休眠。这种方式可以把待机电流做到微安级别,非常适合电池供电的物联网传感器。典型的流程是:初始化RTC和闹钟,配置EXTI唤醒线,进入STOP模式或STANDBY模式,闹钟中断唤醒后执行任务,然后重新设置下一次闹钟时间。
STOP模式唤醒后,系统时钟需要重新配置,因为从STOP模式唤醒后HSE就关了。STANDBY模式唤醒后,相当于复位,RAM内容丢失,但RTC和备份寄存器因为是由VBAT供能的,所以时间信息不会丢。这两者的区别要在项目设计时提前考虑清楚。
产品化阶段,还有几个建议:第一,出厂时设置一个默认时间,或者通过上位机、手机蓝牙校准时间;第二,考虑时间同步机制,比如产品连接网络后自动校时;第三,记录备份电池电压,在电池快没电时提前告警,避免用户突然发现时间停了;第四,恶劣环境下,备份电池最好用座子固定,方便后期更换。这些都是实际项目中非常实用的小细节,在开发初期考虑进去,之后能省掉大量售后问题。
7. 一段可以拿去直接用的完整示例代码
最后给一个可以直接编译运行的完整例子,基于STM32F103C8T6、HAL库,使用LSE时钟,包含首次设置、备份标志、读取时间、串口打印。初始化时串口输出当前时间,然后周期性每秒打印一次。
#include "main.h" RTC_HandleTypeDef hrtc; UART_HandleTypeDef huart1; #define RTC_FLAG_ADDR 0x01 #define RTC_FLAG_MAGIC 0xA5A5 static void SystemClock_Config(void); static void MX_GPIO_Init(void); static void MX_USART1_UART_Init(void); static void MX_RTC_Init(void); static void PrintTime(void); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_RTC_Init(); while (1) { PrintTime(); HAL_Delay(1000); } } static void MX_RTC_Init(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; __HAL_RCC_PWR_CLK_ENABLE(); __HAL_RCC_BKP_CLK_ENABLE(); HAL_PWR_EnableBkUpAccess(); RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_LSE; RCC_OscInitStruct.LSEState = RCC_LSE_ON; if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); } hrtc.Instance = RTC; hrtc.Init.HourFormat = RTC_HOURFORMAT_24; hrtc.Init.AsynchPrediv = 127; hrtc.Init.SynchPrediv = 255; hrtc.Init.OutPut = RTC_OUTPUT_DISABLE; if (HAL_RTC_Init(&hrtc) != HAL_OK) { Error_Handler(); } if (HAL_RTCEx_BKUPRead(&hrtc, RTC_FLAG_ADDR) != RTC_FLAG_MAGIC) { sTime.Hours = 12; sTime.Minutes = 30; sTime.Seconds = 0; sTime.DayLightSaving = RTC_DAYLIGHTSAVING_NONE; sTime.StoreOperation = RTC_STOREOPERATION_RESET; if (HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } sDate.WeekDay = RTC_WEEKDAY_MONDAY; sDate.Month = RTC_MONTH_JANUARY; sDate.Date = 1; sDate.Year = 24; if (HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } HAL_RTCEx_BKUPWrite(&hrtc, RTC_FLAG_ADDR, RTC_FLAG_MAGIC); } } static void PrintTime(void) { RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; char buf[64]; HAL_RTC_GetTime(&hrtc, &sTime, RTC_FORMAT_BIN); HAL_RTC_GetDate(&hrtc, &sDate, RTC_FORMAT_BIN); snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d\r\n", sDate.Year + 2000, sDate.Month, sDate.Date, sTime.Hours, sTime.Minutes, sTime.Seconds); HAL_UART_Transmit(&huart1, (uint8_t *)buf, strlen(buf), 1000); }这段代码的最核心逻辑就是背板寄存器标志判断:如果第一次运行,写入默认时间并标记;如果已经设置过,保留现有时间,不做覆盖。只要VBAT供电正常,无论你断电多久、复位多少次,时间都会正确保留。
看到的年份保存到寄存器时存的是“年减2000”,读取时再减回去,这是HAL库的固定规则,别自己改动,否则所有年份都会差2000年。串口打印部分如果缺少字符串处理支持,用最原始的循环发送方式也可以,不影响RTC功能本身。
8. 最后再聊两句调试时的实用心得
调试RTC功能时,我喜欢先把串口打印弄好,让RTC时间每秒输出一次,观察它是否连续稳定。这样的好处是:掉电重启后只要看串口的上一次时间和这一次时间是否连续,就能立刻判断断电保持是否成功。这个方法比用调试器打断点高效得多,特别是断电、上电这个操作场景,调试器本身也会重置芯片,干扰判断。
我还在开发板上面飞了一颗电容到VBAT,模拟法拉电容的供电场景。实测主电源拔掉后,只要电容电压还能维持在2V以上,RTC就一直在走。这个方法也可以用来粗略估算你的备用电源能撑多久:看电压跌落的速度,推算出维持时间。
如果你做的是量产产品,一定要在出厂前做断电老化测试。很多RTC问题是在断电切换那个瞬间暴露的,比如切换瞬间电压抖动导致备份域数据错乱,这种问题在常温常压下可能几十次才复现一次,不专门去测根本发现不了。测试时可以在电源输入端加一个开关反复通断,配合串口打印和自动比对脚本,跑几百次看有没有时间跳变。
RTC本身不复杂,但“断电保持”这个需求考验的是对整个供电架构和复位机制的理解。硬件电路上把VBAT接好、选对电池、做好保护;软件上用好备份寄存器、正确判断首次运行;调试时用对方法、注意观察细节——这套组合拳做下来,基本就不会有太多折腾了。