做低功耗设备的人应该都有体会,RTC 是绕不开的一个模块——定时唤醒、时间戳记录、日历功能,哪样都离不开它。最近我在一个周期采样小项目里用了STM32F030R8Tx,想直接用HAL 库做RTC 1秒中断来驱动采样节奏,省得再开一个定时器占资源。结果一上手就踩了几个坑:F030 的 RTC 和 F103 那套完全不是一个路数,没有独立的秒中断,只有闹钟中断和唤醒定时器中断;它的内部低速时钟 LSI 也不是 32.768kHz 而是 40kHz,预分频得自己重新算。这篇文章就把我的完整配置过程、踩坑记录和验证方法写出来,给有同样需求的朋友一个能直接抄作业的参考。
1. 思路与方案选型
1.1 秒中断都能拿来干什么
很多朋友一看到“1秒中断”就以为是做 LED 秒闪,其实它的用途比你想的广得多。
- 周期唤醒:低功耗设备在 STOP 模式下睡大觉,RTC 每秒唤醒一次做一次传感器采样,采完继续睡。用定时器做不到这一点,因为 STOP 模式下定时器基本都停了,只有 RTC 这类备份域外设能持续运行。
- 时间基准:秒中断配合日历寄存器,可以搭建一个轻量的调度器,比如让第 5 秒干一件事、第 30 秒干另一件事,比单纯轮询 while 循环可靠得多。
- 通信超时:一秒一跳的计数变量,配合状态机做超时判断,比阻塞式延时灵活。
- 日志时间戳:每次记录事件时读取当前 RTC 时间,配合秒中断校准秒边界,时间戳更精确。
对 F030R8Tx 这颗片子来说,它的主频只有 48MHz,Flash 64KB、RAM 8KB,属于性价比极高的 Cortex-M0 入门型号。很多人觉得它“小”,但做周期采样、低功耗记录、简单定时,它完全够用。RTC 模块也不是缩水版,日历、双闹钟、唤醒定时器、备份寄存器一应俱全,关键是你会不会用。
1.2 F0 的 RTC 和 F1/F4 的 RTC 有多大差别
我最早接触的是 F103 的 RTC,那玩意儿就是一个 32 位计数器,配合中断标志做秒中断,初始化时要搞一堆 BCD 码转换,烦得要命。后来换到 F030 一看,RTC 模块其实是新一代设计,跟 F4 的 RTC 同源:有独立的日历寄存器(时分秒、日、月、年、星期)、两个闹钟(A 和 B)、一个唤醒定时器、一组备份寄存器,时间格式也有二进制模式,HAL 库帮你把 BCD 码转换全包了。
但有几个非常容易踩的差异点:
- F0 的 RTC 没有独立的秒中断。F103 那套“秒中断”是配置成 1Hz 周期后触发 RTC 全局中断,而 F0 的 RTC 事件里只有闹钟事件、唤醒事件、时间戳事件等,你必须用闹钟 A 或者唤醒定时器去构造 1Hz 中断。
- F0 的 RTC 中断向量只有一个。在 startup 文件和中断处理里,你只能看到
RTC_IRQHandler,不存在RTC_Alarm_IRQHandler或RTC_WKUP_IRQHandler。很多人按 F4 的教程写中断服务函数名,结果中断死活进不来,这是高频踩坑点。 - F0 的 LSI 频率是 40kHz,不是 F1/F4 常见的 32kHz 档。这意味着默认的 RTC 预分频配置并不能直接套用,后面我会详细讲。
- F0 的 RTC 有写保护和备份域控制,但 HAL 库在初始化时已经自动处理了,不需要你自己操作 PWR 和 BKP 寄存器。
搞清楚这些差异,你才能理解为什么网上很多代码抄过来不灵。
1.3 实现 1 秒中断的三条路线
我梳理了一下,F030 上做 1 秒中断至少有三种路数:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 闹钟 A 掩码法 | 设置闹钟只匹配“秒”字段,每秒到相同秒值时触发中断 | 直接体现 RTC 闹钟功能,精度由日历保证 | 需要理解掩码位,初次配置稍绕 |
| WakeUp 定时器 | 配置 1Hz 的唤醒时钟,计数器重载值为 1,递减到 0 触发中断 | 配置最简洁,几行代码搞定 | 和闹钟共用 RTC 中断,调用不同的 HAL 处理函数 |
| 外部秒脉冲 | 外部 32.768kHz 分频后接 IO 触发 | 不占用 RTC 任何资源 | 增加硬件成本,没必要 |
我最终选择了闹钟 A 掩码法,因为标题就说“RTC 1 秒中断”,用闹钟 A 最贴切,而且学完掩码逻辑以后,做精准闹钟也会更顺手。WakeUp 定时器作为备选方案放在后面讲,两种你都可以抄。
2. 时钟源与预分频:RTC 走时准不准的根基
2.1 三种时钟源怎么选
F030 的 RTC 时钟源可以在 LSI、LSE、HSE/32 之间选择。很多人根本不去看时钟树,直接用 CubeMX 默认配置,结果发现时间走得离谱,这时候十有八九是时钟源和预分频不匹配。
- LSI:内部低速 RC 振荡器,F0 上典型值约 40kHz,不需要外部晶振,成本最低。但精度很差,数据手册上标的范围很宽(30~50kHz),温度变化也会漂。适合廉价方案和功能演示,不适合做需要长期走时准确的产品。
- LSE:外部 32.768kHz 晶振,精度取决于晶振本身,通常能做到 ±20ppm 甚至更高。F030R8T6 是 LQFP64 封装,有 PC14/PC15 引脚可以接 32.768kHz 晶振。只要板子上焊了晶振,首选 LSE。
- HSE/32:用主晶振(比如 8MHz)经过 32 分频得到 250kHz,精度随主晶振走。如果系统本来就有 HSE,可以省掉 32.768kHz 晶振。但 250kHz 做 RTC 时钟功耗会高一些,而且分频后的抖动比专用 LSE 大,一般不推荐。
我自己的经验是:验证功能用 LSI,产品定型用 LSE。后面实测部分会展示 LSI 到底能偏成什么样。
2.2 预分频到底怎么算
F0 的 RTC 内部有两级预分频:异步预分频PREDIV_A和同步预分频PREDIV_S,两者串联把 RTCCLK 分频到 1Hz 的日历时钟,公式是:
RTCCLK / (PREDIV_A + 1) / (PREDIV_S + 1) = 1Hz最经典的组合来自 32.768kHz 的 LSE:PREDIV_A = 127,PREDIV_S = 255,因为 128 × 256 = 32768。CubeMX 里对应的就是RTC_AUTO_1_SECOND这个宏,它其实就是把 PREDIV_A 设成 127、PREDIV_S 设成 255。
但要注意,这个宏只对 32.768kHz 的 RTCCLK 成立。如果你用的是 LSI,它约 40kHz,那RTC_AUTO_1_SECOND直接套上就会导致日历时钟变成40000 / 32768 ≈ 1.22Hz,也就是 1 秒被压缩到约 0.82 秒,一天下来能快出几个小时。
正确的做法是按实际 RTCCLK 频率反推。比如 LSI 按 40kHz 算,想要精确 1Hz,可以取PREDIV_A = 99、PREDIV_S = 399,因为 100 × 400 = 40000;也可以取PREDIV_A = 127、PREDIV_S = 312,此时频率约 0.9984Hz,偏差也只有 0.16%。CubeMX 里直接改 AsynchPrediv 和 SynchPrediv 这两个参数就行。
2.3 LSI 40kHz 的坑与补救
这里必须再强调一次:F0 系列的 LSI 是 40kHz,不是 32kHz。我刚上手时想当然地按 F103 的习惯把分频配置当成 32.768kHz,结果 RTC 秒中断像机关枪一样狂触发,打印时间一看,一分钟能走出一分半。后来翻参考手册才确认了 LSI 频率范围,真是血泪教训。
如果你因为硬件上没有 32.768kHz 晶振,只能用 LSI,也不是完全没有补救办法。F0 的 RTC 带校准功能,可以通过HAL_RTCEx_SetCalibration写入校准值,对 40kHz 的偏差做一定修正。但 LSI 本身就是 RC 振荡器,温度一变频率就漂,校准只能暂时缓解,没法根治。所以我的建议很简单:要上量、要稳定,老老实实加一颗 32.768kHz 晶振,这才是根治方案。
3. HAL 库从配置到代码:一步步把秒中断跑起来
3.1 CubeMX 里的配置流程
我用的是 STM32CubeMX 生成工程,具体版本不重要,流程是通用的。
- 芯片选型选 STM32F030R8Tx,配置 48MHz 主频。
- 在 Pinout & Configuration 里找到 RCC,把 Low Speed Clock 选为 LSI 或者 LSE。如果选 LSE,一定要确认板子上真的焊了 32.768kHz 晶振,否则初始化会卡在等待 LSE 就绪。
- 打开 RTC 外设,勾选
Activate Clock Source,再勾选Calendar和Alarm A。 - 在 RTC 参数配置里,根据你选的时钟源修改 AsynchPrediv 和 SynchPrediv。如果时钟源是 LSE,直接用默认的
RTC_AUTO_1_SECOND即可;如果是 LSI,按 40kHz 重新计算后填入。 - 在 NVIC Settings 里使能
RTC global interrupt。注意这里只有一个 RTC 中断向量,Alarm 和 WakeUp 共用。 - 生成代码。
CubeMX 生成的MX_RTC_Init会在 HAL 层帮你处理备份域使能、RTC 写保护解锁、时钟源切换这些脏活累活,你不用自己碰 PWR 和 BKP 寄存器,这点比 F1 的裸机操作省心太多。
3.2 初始化与首次时间设置
初始化函数生成之后,还需要在 main 里设置初始时间。F0 的 HAL 库用RTC_TimeTypeDef和RTC_DateTypeDef描述时间日期,格式直接填十进制,HAL 内部会处理 BCD 转换,代码如下:
void Set_RTC_Time(uint16_t year, uint8_t month, uint8_t date, uint8_t hour, uint8_t minute, uint8_t second) { RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; sTime.Hours = hour; sTime.Minutes = minute; sTime.Seconds = second; sTime.TimeFormat = RTC_HOURFORMAT_24; HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN); sDate.Year = year - 2000; // HAL 里年份只存 00~99 sDate.Month = month; sDate.Date = date; sDate.WeekDay = 1; // 1~7 HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN); }有一件事特别重要:不建议每次上电都重新设置时间。F0 的 RTC 在备份域里,只要 VDD 或 VBAT 还在,时间就不会丢。如果你的程序一上电就把时间重置成 12:00:00,那断电保持就毫无意义了。我习惯用一个备份寄存器写“魔法数”:
if (HAL_RTCEx_BKP_Read(&hrtc, RTC_BKP_DR0) != 0x5A5A) { Set_RTC_Time(2026, 1, 1, 12, 0, 0); HAL_RTCEx_BKP_Write(&hrtc, RTC_BKP_DR0, 0x5A5A); }这样只有第一次上电才会初始化时间,之后系统复位、掉电再上电,时间都保持不变。备份寄存器在 VBAT 供电下同样不丢失,这个是 RTC 模块最值钱的地方。
3.3 用闹钟 A 实现秒中断
闹钟 A 实现秒中断的原理不复杂:把闹钟的“秒”字段设成 0,然后把“时、分、日期/星期”这些字段全部掩码掉,意思是“我不关心时、分、日期,只要秒值等于 0 就触发”。因为日历每秒都会经过一次秒值为 0 的时刻,所以中断频率就是 1Hz。
代码是这样的:
void Config_RTC_AlarmA_1s(void) { RTC_AlarmTypeDef sAlarm = {0}; /* 闹钟时间:时、分、秒全部设为 0 */ sAlarm.AlarmTime.Hours = 0; sAlarm.AlarmTime.Minutes = 0; sAlarm.AlarmTime.Seconds = 0; sAlarm.AlarmTime.SubSeconds = 0; /* 日期/星期字段选择,只在闹钟匹配时用到 */ sAlarm.AlarmDateWeekDaySel = RTC_ALARMSELECT_DATEWEEKDAY; sAlarm.AlarmDateWeekDay = 1; /* 关键:掩码掉不关心的字段 */ sAlarm.AlarmMask = RTC_ALARMMASK_DATEWEEKDAY | RTC_ALARMMASK_HOURS | RTC_ALARMMASK_MINUTES; if (HAL_RTC_SetAlarm_IT(&hrtc, &sAlarm, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } }注意RTC_ALARMMASK_SECONDS不要加进掩码,否则连同秒字段也一起忽略,闹钟就会永远匹配不上。这个掩码逻辑刚接触容易搞反:掩码置 1 的字段不被比较,掩码为 0 的字段参与比较。
HAL_RTC_SetAlarm_IT在设置闹钟的同时会使能闹钟中断。有些新版本的 HAL 库把SetAlarm和SetAlarm_IT合并了,实际调用哪个,以你生成代码里的 HAL 头文件为准。
3.4 中断向量名与回调函数
这是整个调试过程里最隐蔽的坑。F030 的中断向量表里,RTC 相关的中断只有RTC_IRQHandler,没有RTC_Alarm_IRQHandler。我见过太多人按 F4 的代码写中断服务函数,编译能过,但中断服务函数永远不会被调用。
正确做法是在stm32f0xx_it.c里找到RTC_IRQHandler(如果你在 CubeMX 里使能了 RTC 中断,这个函数应该是存在的),如果没有就手动补上:
void RTC_IRQHandler(void) { HAL_RTC_AlarmIRQHandler(&hrtc); }然后在用户代码里重写回调函数。HAL 库的中断处理函数会自动判断是闹钟 A 还是闹钟 B 的事件,并调用对应的弱函数:
void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { g_sec_flag = 1; g_sec_counter++; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); }这里我只做置标志、翻转 LED,不干任何耗时的活。1 秒钟只有 1 次中断,偶尔干点重活看起来没事,但万一你在回调里放了个 200ms 的阻塞操作,下个中断就被挤掉,时间计数就开始不准了。秒中断回调里尽量别直接做耗时的业务逻辑,置个标志位让主循环去处理,才是稳妥的做法。
3.5 WakeUp 定时器的替代实现
如果不想和闹钟掩码较劲,F0 还提供了一个更直观的途径:WakeUp 定时器。它的用法简单得多:
void Config_RTC_WUT_1s(void) { /* 使用 1Hz 的日历时钟,重载值 = 1,即 1 秒唤醒一次 */ if (HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 1, RTC_WAKEUPCLOCK_CK_SPRE_16BITS) != HAL_OK) { Error_Handler(); } }RTC_WAKEUPCLOCK_CK_SPRE_16BITS的意思是让唤醒定时器使用已经分频好的 1Hz 时钟,这样重载值直接就是秒数。想要 60 秒唤醒一次,就写 60;想要 1 秒,就写 1,非常人性化。中断服务函数里对应要调用HAL_RTCEx_WakeUpTimerIRQHandler,回调则是:
void HAL_RTCEx_WakeUpTimerEventCallback(RTC_HandleTypeDef *hrtc) { g_sec_flag = 1; g_sec_counter++; HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); }如果你同时用了闹钟 A 和 WakeUp 定时器,RTC_IRQHandler里最好判断一下标志再分发,免得回调被重复触发:
void RTC_IRQHandler(void) { if (__HAL_RTC_ALARM_GET_FLAG(&hrtc, RTC_FLAG_ALRAF) != RESET) { HAL_RTC_AlarmIRQHandler(&hrtc); } if (__HAL_RTC_WAKEUPTIMER_GET_FLAG(&hrtc, RTC_FLAG_WUTF) != RESET) { HAL_RTCEx_WakeUpTimerIRQHandler(&hrtc); } }对低功耗设备来说,WakeUp 定时器还有个好处:在 STOP 模式下可以直接把 CPU 唤醒,不需要额外配置 EXTI 线路。闹钟 A 同样可以唤醒 STOP 模式,两条路都走得通,看你的需求。
4. 实测记录与效果验证
4.1 先用 LED 和计数器验证中断频率
代码烧进去以后,我先把 LED 引脚(PA5)接上逻辑分析仪,同时开一个全局变量g_sec_counter。按理论推算,PA5 应该输出 1Hz 的方波,也就是每 500ms 高低电平翻转一次,g_sec_counter每秒加 1。
实测下来波形非常干净,方波周期稳定在 1.000~1.001 秒之间。我特地在时钟源选 LSI 的情况下跑了一晚上,第二天再看时间,偏差差不多到了 20% 量级,基本印证了前面说的 LSI 40kHz 预分频问题。后来把时钟源换成 LSE,同样的代码跑了一周,累计误差也只有一两秒,完全是另一个水平的精度。
如果你想快速验证 RTC 是否在走,不用非得写代码。F0 的 RTC 可以把秒脉冲直接输出到 PC13(RTC_OUTPUT功能),用示波器量一下 PC13 就有稳定的方波,这一步甚至不需要配置闹钟中断。调试阶段强烈建议试试,能帮你快速区分“RTC 没走”和“中断没触发”这两种完全不同的问题。
4.2 读时间、掉电保持、低功耗唤醒的实测
秒中断稳定之后,我加了时间读取逻辑,每秒读一次并打印:
RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; HAL_RTC_GetTime(&hrtc, &sTime, RTC_FORMAT_BIN); HAL_RTC_GetDate(&hrtc, &sDate, RTC_FORMAT_BIN); printf("%02d:%02d:%02d - %d/%d/%d\r\n", sTime.Hours, sTime.Minutes, sTime.Seconds, sDate.Year + 2000, sDate.Month, sDate.Date);这里有个顺序细节推荐遵守:先调HAL_RTC_GetTime,再调HAL_RTC_GetDate。HAL 内部在读取时间时会锁定日历寄存器,如果先读日期再读时间,有可能出现时间和日期不同步的边缘情况。顺序对了基本就不会有问题。
掉电保持测试我直接断开 VDD,只留 VBAT 接一个法拉电容,等几秒再上电。程序因为备份寄存器里的魔法数 0x5A5A 还在,所以没有重新初始化时间,串口打印的时间连续,没有跳变,说明 RTC 在掉电期间确实依靠 VBAT 域继续走了下去。
低功耗唤醒我用了 STOP 模式,进入前调用:
HAL_SuspendTick(); HAL_PWR_EnterSTOPMode(PWR_MAINREGULATOR_ON, PWR_STOPENTRY_WFI);唤醒后第一件事是重新配置系统时钟,然后继续主循环。实测在 STOP + RTC 秒中断唤醒的场景下,整板电流能做到微安级,这对电池供电设备来说完全是可用状态。RTC 秒中断在这里的定位不是“闪灯”,而是“最低功耗下的定时心跳”。
5. 常见问题与排查技巧实录
5.1 问题速查表
我把自己遇到的、以及帮别人排查过的问题整理成了一张表,按“现象 -> 原因 -> 解决”来写:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 中断完全不触发 | 中断服务函数名写成了 RTC_Alarm_IRQHandler | 改成 F0 的 RTC_IRQHandler |
| 中断触发频率远高于 1Hz | RTC 时钟源是 LSI 40kHz,预分频还按 32.768kHz 配置 | 按 40kHz 重新计算 PREDIV_A / PREDIV_S |
| 时间有快有慢,一天偏差巨大 | 用了 LSI 且未做校准 | 换 LSE 晶振,或用校准寄存器修正 |
| 程序卡在 HAL_RTC_Init 里出不来 | 选择 LSE 但板子上没有 LSE 晶振 | 改用 LSI,或焊接 32.768kHz 晶振 |
| 重新烧录后时间总是被重置 | 代码每次上电都调用了 SetTime/SetDate | 用备份寄存器魔法数做首次初始化判断 |
| 秒中断回调一执行就进 HardFault | 回调里做了阻塞操作或操作了大数组 | 回调只置标志,业务逻辑放主循环 |
| 唤醒后系统时钟不对 | F0 从 STOP 唤醒后需要重新配置时钟 | 唤醒后调用 SystemClock_Config |
这张表不完整但覆盖了绝大多数新手问题。如果你遇到不在表里的现象,先打开调试器看寄存器:RTC_ISR的ALRAF有没有置位、RTC_CR的ALRAIE有没有使能、NVIC 里 RTC 中断有没有被挂起。寄存器不会说谎。
5.2 四个亲测有效的避坑技巧
第一个技巧是魔法数初始化的位置。不要把它放在MX_RTC_Init之前,因为备份寄存器读写需要 RTC 时钟已经启用,放在MX_RTC_Init之后、主循环之前最合适。我一开始放在初始化前,结果读到的全是 0,折腾了半天才发现顺序反了。
第二个技巧是秒中断回调里千万不要写printf。哪怕你用串口重定向,printf 的耗时也足以在低速时钟下引发各种诡异问题。我在调试时犯过这个毛病,加了个带\r\n的打印后,时间偶尔会跳变,去掉打印就恢复正常。想打印时间,置标志位到主循环再打印。
第三个技巧是确认 RTC 库的版本。不同版本的 HAL 库,闹钟函数可能是HAL_RTC_SetAlarm_IT,也可能合并成了HAL_RTC_SetAlarm,回调函数也可能自动帮你调用。拿到一份新工程,先看一眼 HAL 头文件里有哪些函数,别照着老代码硬套。
第四个技巧是善用 PC13 的 RTC_OUTPUT 功能。调试初期不用配置任何中断,直接在初始化里设置OutPut = RTC_OUTPUT_WAKEUP或者闹钟输出,用示波器看 PC13,就能判断 RTC 硬件本身是否在正常工作。这个技巧能帮你把问题范围缩小一半,省去在软件里猜。
最后再分享一点我个人的体会。做这个项目之前,我也嫌 LSE 晶振碍事,觉得 LSI 省两颗电容多省事。但当实测数据摆在面前,LSI 的日误差大到无法忍受时,我才明白 LSE 晶振那几毛钱的成本换来的精度完全值得。如果你只是做学习验证,用 LSI 走通逻辑就够了;如果你的产品要长期运行、要记录真实时间,请务必在设计阶段就把 32.768kHz 晶振留出来。RTC 秒中断本身不是什么高深技术,但把时钟源选对、把中断向量填对,剩下的路就很顺了。