最近调试一个小项目,要用 STM32F030R8Tx 这颗芯片做周期采样,需求很朴素:RTC 每秒钟产生一次中断,主程序在这个节奏里去读传感器、刷新状态。我一开始以为这事特别简单,毕竟 RTC 嘛,读时间、开中断,老套路了。结果等我把 F103 那套 RTC 秒中断的思路搬过来,编译一跑,中断完全不来,时间也写得乱七八糟。翻手册才发现,F030 这系列虽然也叫 RTC,但内部结构和 F1 的经典 RTC 差别非常大,它压根没有独立的秒中断通道,想实现“1 秒中断”必须换一种思路。这篇文章就把我用 HAL 库在 STM32F030R8Tx 上实现 RTC 1 秒中断的完整过程写出来,重点覆盖 CubeMX 配置、预分频计算、闹钟掩码的理解、中断回调代码,以及几个实际调试时最容易卡住的坑。如果你正好也在调 F0 系列,这篇文章应该能帮你省下不少时间。
1. 为什么 F030 的“1 秒中断”不能照抄 F1 的例程
1.1 F0 和 F1 的 RTC 完全是两代设计
很多人对 RTC 的印象还停留在 F103 时代:RTC 本质上是一个独立计数器,闹钟中断只能指定一个绝对时间点,秒中断则是计数器溢出或者每秒翻转产生。但在 STM32F0 系列里,RTC 外设被重新设计过,它不再是一个简单的计数器,而是一个带日历功能的外设,内部包含完整的年、月、日、时、分、秒寄存器,同时提供了多个闹钟和唤醒定时器。
STM32F030R8Tx 属于 F0 系列中不带外部 VBAT 引脚的型号,日历靠 VDD 供电维持。这意味着整机掉电之后,RTC 时间是不会保存的,这点和带 VBAT 的型号有本质区别。很多人调 F030 时发现“断电再上电时间就回到初始值”,以为是代码问题,其实这是硬件特性,不是 bug。
更关键的差异在于中断源。F030 的 RTC 模块没有 F1 那种“秒中断”寄存器,它提供的中断事件主要是:
- 闹钟 A 事件
- 唤醒定时器事件
- 时间戳事件(部分型号)
- 入侵检测事件(部分型号)
所以要在 F030 上做“每秒中断”,本质上是把闹钟 A 改造成一个周期事件,或者使用唤醒定时器。这篇文章的核心思路就是利用闹钟 A 的掩码机制,让它每个秒边界都触发一次中断。把原本“一天只响一次的闹钟”,变成一个“每秒钟都在响的节拍器”。
1.2 闹钟怎么变成节拍器
闹钟 A 的工作原理是:RTC 每走一秒,硬件就把当前日历值跟闹钟寄存器里设定好的值做一次比较,匹配上了就拉高事件标志、触发中断。关键点在于比较规则是可以配置的,闹钟寄存器有四个可独立屏蔽的比较字段:日期/星期、小时、分钟、秒。
如果把四个字段全部设置为“不参与比较”,那么无论 RTC 当前时间是多少,比较结果都是匹配。这样闹钟事件就从“某个时间点触发一次”变成了“每个秒边界都触发一次”。这种用法也是 F0 系列实现秒中断最直接的方法。
这里要特别提醒一下 HAL 库宏定义的反直觉之处:RTC_ALARMMASK_NONE表示“没有掩码”,也就是四个字段全部参与比较;RTC_ALARMMASK_SECONDS表示“屏蔽秒字段”,秒字段不参与比较。想达到每秒触发,需要把日期/星期、小时、分钟、秒四个字段全部用掩码屏蔽掉,而不是用RTC_ALARMMASK_NONE。
而这个“掩码”恰恰是很多人第一次写时候最容易混淆的地方,直接把sAlarm.AlarmMask = RTC_ALARMMASK_NONE交上去,结果闹钟永远不触发,或者只在某一个特定时间点触发,然后就开始怀疑代码哪里写错了。
2. CubeMX 配置里藏着三处陷阱,我逐一拆开
2.1 时钟源选 LSE 还是 LSI
RTC 必须有自己的输入时钟,F030 的 RTC 时钟源可以选择 LSE、LSI 或 HSE 分频。CubeMX 里在RCC配置页打开 LSE,或者在Clock Configuration里把 RTC 时钟源选成 LSI。
我的建议很直接:如果 PCB 上已经放了 32.768kHz 晶振,优先用 LSE,计时精度高,分频也整巧。如果板子空间紧张,没放低速晶振,就用 LSI 内部 RC。LSI 的频率标称是 40kHz 左右,但它是一颗无校准的 RC 振荡器,不同芯片之间、不同温度下频差都不小,用于普通秒级心跳没问题,拿去做时钟就等着误差累积吧。
如果使用 LSI,要注意一件事:CubeMX 在生成代码时,不一定能自动帮你把 RTC 预分频值适配到 LSI 的真实频率。很多版本生成的AsynchPrediv = 0x7F、SynchPrediv = 0xFF,这组值是按住 32.768kHz LSE 计算的。如果 RTC 时钟实际是 40kHz 的 LSI,这个分频结果会让 RTC 秒周期变成约 0.8 秒,走时明显偏快。所以在 MspInit 里看到默认分频值时,最好自己算一遍。
2.2 预分频计算是第一个硬门槛
F030 RTC 的时钟分频链由两个寄存器组成:异步预分频PREDIV_A和同步预分频PREDIV_S。RTC 输入时钟先除以PREDIV_A + 1,再除以PREDIV_S + 1,最终得到 1Hz 的日历秒时钟。公式就是:
RTC输入频率 / ((PREDIV_A + 1) * (PREDIV_S + 1)) = 1Hz针对不同时钟源,我实际用过的几组配置如下:
| RTC 时钟源 | 典型频率 | AsynchPrediv | SynchPrediv | 实际分频结果 |
|---|---|---|---|---|
| LSE 外部晶振 | 32768 Hz | 127 | 255 | 32768 / (128 * 256) = 1Hz |
| LSI 内部 RC | 约 40000 Hz | 39 | 999 | 40000 / (40 * 1000) = 1Hz |
| HSE/128(8MHz 晶振) | 62500 Hz | 49 | 1249 | 62500 / (50 * 1250) = 1Hz |
这里要注意PREDIV_A是 7 位寄存器,最大值 127;PREDIV_S是 15 位寄存器,最大值 32767。如果芯片实际使用 LSI,最好用示波器或秒表先粗略测一下周期,再回头微调分频值。不少人在 F030 上做周期定时,发现自己定义的 1 秒中断实际变成 0.8 秒,问题就出在这里。
2.3 勾选哪些选项,生成的代码长什么样
在 CubeMX 的RTC配置页里,需要打开三件套:
- Activate Clock Source:使能 RTC 时钟源
- Activate Calendar:使能日历功能
- Activate Alarm A:使能闹钟 A
然后在 NVIC 设置里勾选RTC global interrupt。这块生成的代码通常长这样:
void MX_RTC_Init(void) { rtcHandle.Instance = RTC; rtcHandle.Init.HourFormat = RTC_HOURFORMAT_24; rtcHandle.Init.AsynchPrediv = 127; rtcHandle.Init.SynchPrediv = 255; rtcHandle.Init.OutPut = RTC_OUTPUT_DISABLE; rtcHandle.Init.OutPutPolarity = RTC_OUTPUT_POLARITY_HIGH; rtcHandle.Init.OutPutType = RTC_OUTPUT_TYPE_OPENDRAIN; if (HAL_RTC_Init(&rtcHandle) != HAL_OK) { Error_Handler(); } }如果你选的是 LSE,这个默认分频没毛病;如果你用的是 LSI,一定要把AsynchPrediv和SynchPrediv按上面的表格改掉。生成代码后不要直接编译跑,先把这两行确认好。
2.4 中断向量名别对错号
F1 的 RTC 闹钟中断在启动文件里叫RTCAlarm_IRQHandler,而 F0 系列的中断向量名是RTC_IRQHandler。有些同学把 F1 的中断服务函数名字直接复制过来,编译不报错但中断永远进不去,因为向量表根本对不上。正确写法是:
void RTC_IRQHandler(void) { HAL_RTC_AlarmIRQHandler(&hrtc); }这个hrtc是 CubeMX 生成的外部变量,在main.c里有定义,如果中断服务函数写在其他文件里,记得加extern RTC_HandleTypeDef hrtc;。
3. 闹钟掩码是最容易被误解的地方,我详细拆一遍
3.1 先搞懂 RTC_AlarmTypeDef
在 HAL 库里,闹钟结构体的关键字段有两个:AlarmTime和AlarmMask。AlarmTime用来填写期望闹钟响的时间,比如你希望每天 8 点 30 分 15 秒响铃,就把时、分、秒填进去。AlarmMask则决定哪些字段要被忽略,掩码为 1 的字段不参与比较,掩码为 0 的字段必须完全匹配。
我用一个直白的类比解释:普通手机闹钟默认会记住“早上 7:00”这个时刻,但你如果把手表调成“每天任何一秒都响一次”,那这种闹钟就变成节拍器了。AlarmMask就是用来设置“哪些项我不关心”的开关。把日期、小时、分钟、秒全部设成不关心,任何时刻都会匹配成功,中断自然变成每秒一次。
各掩码组合的实际效果如下表:
| 掩码设置 | 触发行为 |
|---|---|
RTC_ALARMMASK_NONE | 日期、时、分、秒全部要匹配,一年可能只触发一次 |
RTC_ALARMMASK_DATE_WEEKDAY | 每天固定的时分秒触发一次 |
| `RTC_ALARMMASK_DATE_WEEKDAY | RTC_ALARMMASK_HOURS` |
| `RTC_ALARMMASK_DATE_WEEKDAY | RTC_ALARMMASK_HOURS |
| `RTC_ALARMMASK_DATE_WEEKDAY | RTC_ALARMMASK_HOURS |
注意最后一行的“全部掩码”才是我们要的秒中断,其他组合各自对应不同的周期场景。
3.2 初始化时间并启动秒级闹钟
设置闹钟之前,最好先把日历初始化到一个确定的时间点。如果只开闹钟但日历从来没有写进去,RTC 内部的时间一直在跑,也能触发,只是你可能观察不到规律。下面的代码把时间设到 12:00:00,日期设到 2001-01-01。
#include "stm32f0xx_hal.h" extern RTC_HandleTypeDef hrtc; volatile uint8_t g_one_second_flag = 0; void RTC_SetDemoTime(void) { RTC_TimeTypeDef sTime = {0}; RTC_DateTypeDef sDate = {0}; sTime.Hours = 12; sTime.Minutes = 0; sTime.Seconds = 0; sTime.TimeFormat = RTC_HOURFORMAT_24; 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 = 1; /* 2001年 */ if (HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } }接下来是启动秒级闹钟的核心代码:
void RTC_StartOneSecondAlarm(void) { RTC_AlarmTypeDef sAlarm = {0}; sAlarm.AlarmTime.Hours = 1; sAlarm.AlarmTime.Minutes = 2; sAlarm.AlarmTime.Seconds = 3; sAlarm.AlarmDateWeekDay = 1; sAlarm.AlarmDateWeekDaySel = RTC_ALARMDATEWEEKDAYSEL_DATE; sAlarm.AlarmMask = RTC_ALARMMASK_DATE_WEEKDAY | RTC_ALARMMASK_HOURS | RTC_ALARMMASK_MINUTES | RTC_ALARMMASK_SECONDS; if (HAL_RTC_SetAlarm_IT(&hrtc, &sAlarm, RTC_FORMAT_BIN) != HAL_OK) { Error_Handler(); } }这里的AlarmTime填什么其实已经无所谓了,因为四个字段全部被掩码忽略。但建议还是填一组非零值,避免某些库版本的边界判断产生怪异行为。HAL_RTC_SetAlarm_IT会同时完成三件事:把闹钟配置写入寄存器、使能闹钟 A 中断、使能 NVIC 里对应的 RTC 中断(具体取决于 CubeMX 是否帮你配置了中断优先级)。所以整个初始化流程中,NVIC 的开与不开都要确认,否则即便事件产生也进不了中断函数。
3.3 中断服务函数与回调
F0 的 RTC 中断服务函数统一收口到RTC_IRQHandler,HAL 库在里面自动判断是闹钟事件还是唤醒事件,并把对应回调函数调用出来。我们只需要处理自己的回调即可:
void RTC_IRQHandler(void) { HAL_RTC_AlarmIRQHandler(&hrtc); } void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { g_one_second_flag = 1; }主循环里只需要不断查询这个标志位:
while (1) { if (g_one_second_flag) { g_one_second_flag = 0; RTC_TimeTypeDef now = {0}; HAL_RTC_GetTime(&hrtc, &now, RTC_FORMAT_BIN); printf("tick: %02d:%02d:%02d\r\n", now.Hours, now.Minutes, now.Seconds); } }HAL_RTC_GetTime内部会等待日历同步标志,所以读出来的时间一定是完整的。不要在中断回调里调用打印函数,会拖长时间影响后续中断;更不要在中断里大量操作 GPIO,秒中断一密集起来很容易造成主循环卡顿。
3.4 是否需要在回调里重新设置闹钟
这是一个很多人问过的问题。如果你使用的是“全部字段掩码”的方式,理论上每个秒边界都会触发,不需要再重新写入闹钟寄存器。但如果你的需求是更通用的“每分钟第 30 秒触发”,或者单片机的库版本比较老、行为不够稳定,可以在回调里重新设置一次闹钟,让闹钟指向下一秒钟,这样最保险。
不过 F030 的 HAL 库实测下来,全掩码方式的连续触发是稳定的。我从一个靠近实际产品的角度建议:如果你的项目里只需要秒级节拍,全掩码更省事;如果后续要扩展成“每小时整点触发”或“每天固定时间触发”,那就把掩码和AlarmTime重新整理一下,不要在一个代码里混用两种逻辑。
4. 实测中遇到的连环坑,逐个排雷
4.1 中断完全不触发,排查链路是什么
我在第一次跑通代码时也遇到了中断不触发的问题,当时把 NVIC、使能函数都翻了一遍。这里把排查顺序整理成一条可复现的链路,避免你在同一个坑里转圈:
- 先看 RTC 时钟是否真在走。在主循环里只做
HAL_RTC_GetTime打印时间,如果秒值变化,说明日历和时钟链路正常;如果时间不动,说明时钟源没起振或分频有问题。 - 检查 NVIC。CubeMX 勾选了 RTC 中断后,会生成
HAL_NVIC_EnableIRQ(RTC_IRQn),如果手写工程很容易漏掉。 - 检查闹钟掩码。把
AlarmMask全部打上,而不是用RTC_ALARMMASK_NONE。 - 检查中断处理函数名字。F0 是
RTC_IRQHandler,不是 F1 的RTCAlarm_IRQHandler。 - 如果前四项都正常,用调试器看看
RTC->ISR的ALRAF标志位,确认硬件事件是否真的产生过。
按这个顺序走完,绝大多数“中断不来”的问题都能定位。
4.2 时间写不进去,或者每次复位后回到初始状态
F030 没有独立的 VBAT 供电引脚,所以整机断电后 RTC 时间丢失是正常现象。如果你只是在调试阶段频繁按复位键,系统复位并不会清掉 RTC 的日历数据,时间应该能保留。但如果你调用过HAL_RTC_DeInit(),或者执行了 RTC 备份域复位,时间就会被清空。
另外,HAL_RTC_SetTime偶尔会返回错误,原因是 RTC 没有正确进入初始化模式。这种情况多见于你在 RTC 已经跑起来之后强行改时间。实际上 HAL 内部会处理写保护,只要你调用顺序对,不会有问题。如果确实碰到写不进去的情况,可以手动把 RTC 置入初始化模式再写:
RTC->ISR |= RTC_ISR_INIT; while ((RTC->ISR & RTC_ISR_INITF) == 0) { /* 等待进入初始化模式 */ } HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN); RTC->ISR &= ~RTC_ISR_INIT;不过这种底层操作不建议常规使用,先检查是不是把hrtc的处理放错了位置。
4.3 秒中断频率明显偏快或偏慢
这个问题基本就是预分频算错了。我见过一个典型场景:选了 LSI 当 RTC 时钟,但 CubeMX 生成代码里的AsynchPrediv还是默认的 127,SynchPrediv是 255,导致秒周期明显小于 1 秒,主循环里的计数飞快。用示波器测 RTC 秒脉冲或者每秒翻转一次 GPIO,就能直观看到频率误差。
还有一种情况是 LSI 本身频率漂移。即便是把分频设成理想值,LSI 在温度变化时也会偏,这不是配置能解决的,只能靠 LSE 晶振或者外接精确时钟源。
4.4 STOP 模式下无法唤醒,跟 EXTI 有关
如果后续把系统进入 STOP 低功耗模式,RTC 闹钟是常用的唤醒源。F0 的 RTC 闹钟事件会连接到 EXTI 线 17,CubeMX 在配置 Alarm A 时会自动生成 EXTI 相关初始化。但如果你是自己手写的工程,这套初始化很容易漏掉。
一旦漏掉 EXTI 配置,就会出现这种情况:正常运行中断正常,一旦进入 STOP 模式,闹钟标志照样置位,CPU 却醒不过来。解决办法是在 RTC 初始化里补上:
EXTI_InitTypeDef EXTI_InitStruct = {0}; EXTI_InitStruct.Line = EXTI_LINE_17; EXTI_InitStruct.Mode = EXTI_MODE_INTERRUPT; EXTI_InitStruct.Trigger = EXTI_TRIGGER_RISING; HAL_EXTI_SetEXTI(&EXTI_InitStruct, RTC_EXTI_LINE_17);不同 HAL 库版本的 API 名称可能会有小差异,但思路是一致的。如果你用 CubeMX 勾选了 Alarm A,可以打开生成的 MspInit 确认 EXTI 是否已配置,没有的话手动补上。
4.5 回调里直接读时间,偶尔读到不变的值
RTC 日历为了保持一致性,在 APB 时钟没同步时可能会返回旧数据。HAL_RTC_GetTime内部有等待逻辑,理论上不会出问题。但如果你在回调里读完时间又立刻做浮点运算或者格式化字符串,会占用大量中断时间,导致下一个秒中断被延后。更稳妥的做法是回调里只置标志位,时间留在主循环读。
如果你需要高精度的秒间间隔,还要注意:闹钟事件的时刻不一定正好落在秒值跳变的瞬间,它可能落在同步预分频计数器的某个阶段,并且从事件产生到进入中断服务函数还有一点延迟。对绝大多数逻辑来说,这个延迟可以忽略;但用来做高精度时间戳采集时,需要把这个固定延迟估算进去。
5. 扩展:想周期唤醒的话,WakeUp Timer 可能是更好的选择
5.1 两种方案怎么选
闹钟 A 的强项是“指定时刻唤醒”和“复杂掩码组合”,弱点是每次触发后如果掩码不全屏蔽,就得重新配置,代码逻辑相对绕。而 F0 的 RTC 还有一个专门的唤醒定时器 WakeUp Timer,它本质是一个自动重装载的倒数计数器,用 RTC 的秒时钟或者 kHz 时钟驱动,到期后自己重新装载,天然就是周期触发的结构。
如果你的需求只是“每隔 N 秒做一次事”,我会建议优先考虑 WakeUp Timer,而不是闹钟改造成秒中断。闹钟更适合类似“每天 8 点整做一次备份”“每周一早上 9 点启动某流程”这种绝对时间任务;WakeUp Timer 则专注于固定周期任务,代码更短,语义更清楚。
5.2 WakeUp Timer 的 1 秒周期配置
在 CubeMX 里勾选 WakeUp Timer,配置唤醒时钟为RTC_WAKEUPCLOCK_CK_SPRE_1HZ,WakeUpCounter 设成 0,就表示每隔0 + 1 = 1秒触发一次。代码层面只需要一句:
HAL_RTCEx_SetWakeUpTimer_IT(&hrtc, 0, RTC_WAKEUPCLOCK_CK_SPRE_1HZ);中断服务函数还是进RTC_IRQHandler,但改调HAL_RTCEx_WakeUpTimerIRQHandler:
void RTC_IRQHandler(void) { HAL_RTCEx_WakeUpTimerIRQHandler(&hrtc); } void HAL_RTCEx_WakeUpTimerEventCallback(RTC_HandleTypeDef *hrtc) { g_one_second_flag = 1; }WakeUp Timer 的计数器会在每一次触发后自动重载,不需要你再动手写回,这是它比闹钟更省心的地方。若需要 2 秒周期,把0改成1即可,周期等于WakeUpCounter + 1秒;如果需要分钟级,也可以启用 1kHz 时钟再做更多预分频,灵活度很高。
5.3 结合 STOP 模式的低功耗设计建议
很多低功耗产品的方案是这样的:主循环操作完传感器后进入 STOP 模式,再由 RTC 周期事件唤醒,做下一次采集。此时 WakeUp Timer 的优势更明显,因为它是自动重载的,即使系统唤醒后重新配置时钟,也不担心回调里漏掉重设闹钟。
但要注意,STOP 模式下 RTC 必须保持运转,时钟源的选择决定了能不能唤醒。LSE 晶振天然适合低速持续运行,LSI 也能在许多型号的 STOP 模式下继续工作,但具体行为建议以对应的数据手册为准。我的习惯是:做产品用 LSE,做实验用 LSI,并且一定要在整板进入了 STOP 模式后实测一次唤醒,别只在上电全速状态测试中断,否则真到了低功耗阶段再来查唤醒问题会更难。
我在实际使用 F030R8Tx 的过程中,最终把秒级节拍任务从闹钟方案改成了 WakeUp Timer。并不是说闹钟方案不行,而是在“固定周期触发”这个场景下,WakeUp Timer 的结构更匹配需求,代码也少。如果你只是想在 RTC 里练手、验证中断链路,那用闹钟全掩码方式跑一遍秒中断会很有助于理解 RTC 的比较机制。两种方案各有适合的位置,掌握好区别,后面做低功耗和周期任务时就能顺手选型了。