简介:STM32 RTC万年历工程包围绕STM32实时时钟模块,面向需要实现日期管理、闹钟唤醒与掉电保持的嵌入式开发者。工程基于标准外设库完成RTC初始化、LSE/LSI时钟源选择、时间日期BCD格式读写、闹钟中断及备份寄存器应用,并已编译生成可执行文件,可直接烧录验证。资源共109个文件,以h头文件、c源文件为主,辅以uvproj工程文件、map映射文件、htm文档及编译中间产物,压缩包仅786KB,目录结构清晰。已有545人学习下载。通过该工程可掌握RTC万年历完整写法,包括闰年二月天数计算、二进制转换、掉电保存用户设置、每日定时闹钟等关键技巧;源码同时涵盖定时器、ADC、I2C、USART、RCC、FSMC等外设驱动,便于在更复杂系统中二次开发。适合入门至中级工程师直接移植复用,也可以作为学习STM32时钟系统的参考模板。
1. 用 STM32 RTC 做万年历,先别急着写代码:你真正要解决的三个问题
接手过带实时时钟功能的 STM32 项目的人,大概率都经历过这个场景:硬件焊好了,代码也能跑,但断电重启后时间回到了 2000 年 1 月 1 日——如果恰好闰年逻辑没写好,日历还会在 2 月 29 日前后给你“惊喜”。STM32 RTC 万年历这个标题背后,其实是三个独立问题的叠加:RTC 外设本身怎么正确初始化、日历算法怎么处理大小月和闰年、以及掉电后时间怎么保住。三者缺一个,做出来的都只是“能走秒的时钟”,不是“万年历”。
这篇文章要讲的,就是用 STM32 的内置 RTC(无论你是用标准库还是 HAL 库)实现一个能正确处理 2099 年前日期换算的万年历方案。适用对象是正在做嵌入式时钟、数据记录仪、工控面板或者毕业设计里带时间戳功能的开发者。我会把寄存器层面的配置和上层日历算法拆开讲,因为项目里绝大多数诡异 Bug,都出在这两层互相“甩锅”的时候。
2. STM32 RTC 万年历的核心矛盾:秒计数器如何变成人类可读的日期
2.1 RTC 的本质是一个会“数数”的计数器,万年历是软件算法补上去的
STM32 全系列内置的 RTC,本质上就是一个 32 位的二进制计数器——它在配置好的时钟源驱动下,每个秒脉冲到来时自增一次。硬件只负责两件事:保证计数器的累加精度,以及在达到预设的闹钟值或唤醒值时产生中断。至于这个计数器的值对应哪年哪月哪日,硬件完全不关心,这是万年历软件层需要解决的问题。
这个设计意味着一个非常重要的结论:日期换算的精度取决于算法,而时间的走时精度取决于 RTC 的时钟源。很多人调试万年历时发现“每天慢 3 秒”,这不是日历算法的问题,是 RTC 的晶振频偏没有校准。后面会专门讲校准方向,这里先记住这个职责划分。
2.2 万年历算法的“坐标系”:以 2000 年 1 月 1 日为零点的秒数
用 STM32 做万年历,最常见的实现思路是:把 RTC 计数器里存放的秒数,换算成自 2000-01-01 00:00:00(这个零点在 RTC epoch 里是 0)以来的天数,再做日期分解。选择 2000 年而不是 1970 年作为基准,是因为 STM32 的 RTC 计数器一般从 0 开始,且在 BKP 寄存器中保存的备份值也以这个零点为参考,2000 年后的日期覆盖了绝大多数产品的使用周期。
换算的基本公式是:天数 = 秒数 / 86400,余数是当天已经过去的秒数。有了总天数,再按“年、月、日”逐层剥离。这里的核心算法要注意两个陷阱。第一个是年份范围:STM32 的 RTC 用 32 位计数器,秒数上限能到 2106 年,所以万年历做 2099 年之前是安全的。第二个是闰年规则:世纪年(如 2100 年)不是闰年,但普通能被 4 整除的年份是闰年。很多网上流传的代码只判断了year % 4 == 0,这在 2000 年到 2099 年间恰好不会出错,但 2100 年一过就破功。
2.2.1 闰年判断的完整写法与日期分解步骤
闰年判断建议写成函数,便于日后再工程里调用:
uint8_t rtc_is_leap_year(uint16_t year) { // 规则:能被4整除且不能被100整除,或能被400整除 return ((year % 4 == 0) && (year % 100 != 0)) || (year % 400 == 0); }日期分解时,按“年 → 月 → 日”的次序剥离。每年天数要么 365,要么 366。月份天数建议用查表法预先处理二月的平闰差异:
const uint8_t month_days[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; uint16_t days_in_month(uint16_t year, uint8_t month) { if (month == 2 && rtc_is_leap_year(year)) return 29; return month_days[month - 1]; }这里要强调一个容易被忽略的细节:查表数组下标从 0 开始,但 month 传入的是 1 到 12。如果代码里写成month_days[month],会在 month=12 时越界读取到数组下一个字节,轻则日期错乱,重则 HardFault。我在实际项目中见过因为这个越界导致 RTC 初始化函数把相邻变量冲掉的情况,排查了整整两天。
2.3 RTC 的时钟树与计数器的“心跳”:LSI、LSE 还是 HSE/RTCCLK
万年历长时间走时的精度,取决于给 RTC 提供时钟的来源。STM32 的 RTC 可以从 LSI、LSE 或者经预分频后的 HSE 中选择时钟,具体看芯片型号。常用的三种选择如下表:
| 时钟源 | 典型频率 | 精度表现 | 适用场景 |
|---|---|---|---|
| LSI(内部低速时钟) | 约 32 kHz | 受温度影响大,频偏可达 5% | 要求不高的休眠计时、简单唤醒 |
| LSE(外部低速晶振) | 32.768 kHz | 可校准后达到月误差秒级 | 万年历、实时时钟产品标准做法 |
| HSE 分频 | 通常 1 MHz 或 4 MHz | 精度高但耗电大,且依赖系统主时钟 | 对功耗不敏感且有稳定主时钟的场景 |
LSE 是 32.768 kHz 的原因很直接:2 的 15 次方等于 32768,用 15 位二进制分频器正好可以除出 1 Hz。这个频率还不是随便选的——32.768 kHz 晶振是目前体积最小、成本最低的晶振类型,几乎所有实时时钟芯片和 MCU 的 RTC 都围绕它设计。所以做万年历产品,优先把 LSE 接上,不要省这颗晶振。
2.3.1 LSE 起振失败时的检测与回退策略
LSE 起振慢是 STM32 RTC 项目里翻车率最高的环节。晶振负载电容配错、PCB 走线过长、芯片在低温环境上电,都可能导致 LSE 一直不 Ready。开发阶段最稳妥的办法是:初始化 LSE 时设置超时,超时后回退到 LSI,同时在调试串口打印当前使用的时钟源。
uint8_t rtc_select_clock_source(void) { // 先尝试 LSE,等待就绪 uint32_t timeout = 10000; RCC_LSEConfig(RCC_LSE_ON); while ((RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) && (timeout > 0)) { timeout--; } if (timeout > 0) { RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); printf("[RTC] LSE OK\r\n"); return 1; } // LSE 起振失败,回退 LSI RCC_LSICmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) == RESET); RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI); printf("[RTC] LSE FAIL, fallback LSI\r\n"); return 0; }这段代码的判断逻辑是:等待 LSE 就绪标志,超时则走备用路径。这里要补充一个实际经验:产品量产时不要依赖 LSI 长期跑万年历。LSI 的频偏在温漂大的环境里可达 1% 以上,换算成走时就是每天误差超过 800 秒。LSI 只适合做“RTC 功能演示”或者断电时间很短的临时替代,正式产品必须用 LSE,且出厂前要做频偏校准。
3. 基于标准库的 STM32 RTC 万年历初始化与读写完整代码
3.1 为什么这一节用标准库示例,而 HAL 库的差异在哪
现在用 STM32CubeMX 生成工程已经是主流,但讲 RTC 万年历我却坚持用标准库的寄存器结构来展示。原因是:RTC 的操作本质就是几个寄存器,标准库代码能直接看到RTC->CRL、RTC->CNTH这些寄存器位的读写顺序;而 HAL 库把这一切封装成了HAL_RTC_SetTime()和HAL_RTC_GetTime(),看起来简单,但出了问题时你反而不知道它内部到底做了什么。对理解原理和排错来说,标准库风格的代码是更好的教材。
HAL 库和标准库在 RTC 上的操作差异,主要体现在四个方面,这也是你从标准库移植到 HAL 库时的关注点:
| 功能点 | 标准库做法 | HAL 库做法 | 易错点 |
|---|---|---|---|
| RTC 配置解锁 | `RTC->CRL | = RTC_CRL_CNF;` | __HAL_RTC_WRITEPROTECTION_DISABLE() |
| 进入配置模式 | 读取 RTOFF 位确认空闲 | HAL_RTC_WaitForSynchro() | 必须先等同步再操作 |
| 设置时间 | 直接写 CNTH/CNTL | HAL_RTC_SetTime()+HAL_RTC_SetDate() | HAL 库的日期与时间分两个结构体 |
| 读时间 | 读 CNTH/CNTL 再转换 | HAL_RTC_GetTime()+HAL_RTC_GetDate() | 必须先读时间再读日期 |
标准库读写 RTC 的核心是三个寄存器。RTC->CRL控制配置使能和标志位;RTC->CNTH和RTC->CNTL分别存秒计数的高 16 位和低 16 位。读的时候要连续读两次高低位并校验,防止在读取过程中秒值进位导致日期跳变。
3.2 时间结构体与日期转换的完整工具函数
设计万年历的第一步是定义时间结构体。这里不应只定义年月日时分秒,还要带星期几的计算,因为万年历产品几乎都要显示星期。
typedef struct { uint16_t year; // 完整年份,如 2025 uint8_t month; // 1-12 uint8_t day; // 1-31 uint8_t week; // 1-7,1 表示周一 uint8_t hour; // 0-23 uint8_t minute; // 0-59 uint8_t second; // 0-59 } rtc_datetime_t;从 RTC 计数器的秒数还原出这个结构体的完整函数如下。这段代码可以直接抄进工程,但注意看注释里的边界检查,这是网上大多数例程缺失的部分。
void rtc_seconds_to_datetime(uint32_t seconds, rtc_datetime_t *dt) { uint32_t days = seconds / 86400; uint32_t day_secs = seconds % 86400; uint16_t year = 2000; uint16_t days_in_year; // 剥离年份 while (1) { days_in_year = rtc_is_leap_year(year) ? 366 : 365; if (days >= days_in_year) { days -= days_in_year; year++; } else { break; } } // 剥离月份 uint8_t month = 1; while (1) { uint16_t dim = days_in_month(year, month); if (days >= dim) { days -= dim; month++; } else { break; } } dt->year = year; dt->month = month; dt->day = (uint8_t)(days + 1); dt->hour = (uint8_t)(day_secs / 3600); dt->minute = (uint8_t)((day_secs % 3600) / 60); dt->second = (uint8_t)(day_secs % 60); // 星期计算:2000-01-01 是星期六,按 1=周一 到 7=周日 dt->week = (uint8_t)(((days + 5) % 7) + 1); }星期计算的基准很关键:2000 年 1 月 1 日实际是星期六。如果按 1 表示周一到 7 表示周日,那这一天应该是第 6(周五为第 5,周六为第 6)。公式(total_days + 5) % 7 + 1的推导过程是:total_days是自 2000-01-01 起累计的天数,第 0 天是周六,即序号 6;第 1 天是周日,序号 7。那么第n天的序号是(n + 6 - 1) % 7 + 1,简化后就是(n + 5) % 7 + 1。注意如果你用 HAL 库的HAL_RTC_GetDate(),它内部已经带星期计算,不需要自己写,但如果你做的是低功耗唤醒后读秒数转换,就得用上面这段。
3.3 反向转换:把年月日转回秒数用于设置 RTC 初始值
设置时间的过程是日期转秒数的逆运算。这个函数用于上电后用户通过按键或串口设定时间。
uint32_t rtc_datetime_to_seconds(rtc_datetime_t *dt) { uint32_t days = 0; uint16_t y; // 年份累积 for (y = 2000; y < dt->year; y++) { days += rtc_is_leap_year(y) ? 366 : 365; } // 月份累积 for (uint8_t m = 1; m < dt->month; m++) { days += days_in_month(dt->year, m); } days += (dt->day - 1); return days * 86400 + dt->hour * 3600 + dt->minute * 60 + dt->second; }这里的时间校验逻辑要在调用此函数前做:month 必须 1 到 12,day 不能超过当月的最大天数,hour 不能超过 23。不加校验的话,seconds 转回日期时会复现错误,比如设了 2025 年 2 月 30 日,seconds 转回来会变成 3 月 2 日,用户会看到日期“自己变了”,实际是自己输入的非法值。
3.4 标准库下 RTC 的完整初始化与时间写入函数
时间写入 RTC 硬件时要注意操作顺序:先开配置模式,再写计数器,最后关配置模式。标准库的寄存器操作顺序如下。
void rtc_set_counter(uint32_t seconds) { // 等待上一次写操作完成 while ((RTC->CRL & RTC_CRL_RTOFF) == 0); // 进入配置模式 RTC->CRL |= RTC_CRL_CNF; // 写入高 16 位和低 16 位 RTC->CNTH = seconds >> 16; RTC->CNTL = seconds & 0xFFFF; // 退出配置模式 RTC->CRL &= ~RTC_CRL_CNF; // 等待写入生效 while ((RTC->CRL & RTC_CRL_RTOFF) == 0); }这里连续两个 while 等待 RTOFF 是有讲究的:第一次等待确保之前没有未完成的写操作,第二次是为本次写操作收尾。很多例程只在开头等待一次,如果前一次写入还没完成就进入配置模式,数据会丢失。RTC 是低速外设,它的写周期相对 APB 总线慢得多,这个等待步骤不能省。
初始化函数要在系统上电后做一次,逻辑是:检查 RTC 是否已经初始化过——通过 BKP 寄存器里保存的标志位来判断,而不是盲目地每次都重设时间。否则每次复位都会把时间重置回编译时刻,这是新手常犯的错误。
void rtc_init(void) { // 使能 PWR 和 BKP 时钟(访问备份域的前提) RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); // 已经初始化过则跳过重新配置 if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { rtc_select_clock_source(); RCC_RTCCLKCmd(ENABLE); // 等待 RTC 同步 RTC_WaitForSynchro(); // 预分频:32768 Hz / 32767 + 1 = 1 Hz RTC_SetPrescaler(32767); // 设置初始时间:2025-01-01 00:00:00 rtc_datetime_t init = {2025, 1, 1, 0, 0, 0, 0}; uint32_t secs = rtc_datetime_to_seconds(&init); rtc_set_counter(secs); // 写入初始化标志 BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); } else { // 已初始化,只需等待同步 RTC_WaitForSynchro(); } }这段代码有几个关键设计点:
第一,BKP 寄存器内容依靠 VBAT 引脚供电维持。如果你的板子没有接备份电池或超级电容,掉电后 BKP 清零,每次上电都会重新初始化时间。排查“为什么我设了时间但断电重启又恢复出厂”时,先量 VBAT 电压。
第二,预分频器的写入值是 32767,不是 32768。因为 RTC 预分频器的实际分频系数等于寄存器值加 1。写成 32767 时 32768 / (32767+1) = 1 Hz;写成 32768 时输出 0.99997 Hz,每天累积误差缩短约 2.6 秒,方向是走快。这个是网上代码最常见的隐藏错误。
第三,RTC_SetPrescaler()内部其实已经包含了等待 RTOFF 与进入配置模式的操作,所以不需要再手动调用rtc_set_counter()里类似的逻辑。但如果你的标准库版本比较古老,函数内部没有带等待,就要在调用前查看 datasheet 确认。稳妥起见,可以在RTC_SetPrescaler(32767);后加一个while ((RTC->CRL & RTC_CRL_RTOFF) == 0);。
4. 调试 STM32 RTC 万年历:晶振不起振、日期错乱、功耗异常排查手册
4.1 LSE 不起振的硬件排查与软件补偿
LSE 不起振是 RTC 万年历项目里占比第一的问题。典型现象是:程序在while ((RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET))死循环里出不来。软件上如果加了超时回退逻辑,则表现为输出日志显示 “LSE FAIL”,而后走时误差极大。
硬件排查顺序建议是:先量晶振两个引脚的对地电阻和负载电容排布。STM32 的 LSE 引脚对地寄生电容尽量控制在 5 pF 以内,外部负载电容通常选 6 pF 到 12.5 pF 配合晶振规格书。其次是 PCB 上 LSE 走线不要跨越数字信号线,晶振下方不要铺铜。这些都是老生常谈,但真正严格执行的项目并不多。
更实用的排查手段是看时钟输出。STM32 的 MCO 引脚可以输出 LSE 时钟:
// 通过 PA8 (MCO) 输出 LSE 时钟,用示波器看 32.768kHz 是否稳定 RCC_MCOConfig(RCC_MCO_SYSCLK); // 改参数以选择 LSE不过这个测试方法有个局限:MCO 输出会额外增加引脚负载,对本身起振就很勉强的晶振可能是压死骆驼的最后一根稻草。如果 MCO 一使能 LSE 就停振,基本可以确认是晶振电路的驱动余量不足,要改负载电容或换晶振。
软件上能做的补偿分两步。第一步是在 RTC 初始化时对 LSE 做“起振稳定延迟”——即使 LSERDY 标志置位,也建议再延时 500 ms 到 1 s 再开始操作 RTC。因为 LSERDY 只代表振荡器起振了,不代表幅度达到稳定工作点。第二步是运行时做数字校准:测量参考时钟下 RTC 的走时偏差,写入 RTC 的校准寄存器(需要芯片型号支持,例如 STM32F4 系列有 RTC 校准寄存器)。校准值计算方式是:每 32 秒窗口内,通过加脉冲或减脉冲微调,能达到 0.119 ppm 的调整精度。
4.2 闰年边界与日期回拨的“最后一秒”问题
万年历日期错乱的另一个高发区是闰年与跨日边界。典型场景:设置 2024 年 2 月 28 日 23:59:59,等待一秒后,显示 2024 年 3 月 1 日——跳过了 2 月 29 日。这个问题的根源几乎不在 RTC 硬件,而在读取时间时使用了分步读取:先读了RTC->CNTH,然后读RTC->CNTL,但 CNTL 读取时刚好发生了秒进位,导致高低位属于不同秒。
正确做法是读两次并进行一致性校验:
uint32_t rtc_read_counter_safe(void) { uint32_t high1, low, high2; do { high1 = RTC->CNTH; low = RTC->CNTL; high2 = RTC->CNTH; } while (high1 != high2); return (high1 << 16) | low; }这个循环在绝大多数情况下只执行一次,只在秒进位瞬间恰好命中high1 != high2时才会重读。类似的情况在 HAL 库中已经被封装,但你自己写读取函数时要记得。连续两次读之间要保证第二条指令不能被打断——这里更稳妥的是在中断里读取,或者临时关闭全局中断。
还有一类日期错乱与日期回拨有关:用户为了测试时间跳变,手动把时间从 2025 年回拨到 2023 年。如果代码里没有对传入时间做单调性检查,而依赖 RTC 计数器的差值判断闹钟触发,就会出现“刚设完闹钟就触发”的误动作。解决方法是闹钟比较时用绝对时间比较日历,而不是比较秒差值,具体做法在下一节会涉及。
4.3 RTC 进入低功耗模式后的唤醒与时钟源保持
做低功耗产品的场景里,RTC 往往承担着周期唤醒的功能:MCU 进入 Stop 模式,RTC 走时,闹钟或唤醒定时器到达后拉高唤醒引脚。这个问题常被忽略的坑点是:进入 Stop 模式前,RTC 时钟源如果选的是 LSI,那么 Stop 模式下时钟继续工作没问题;但如果依赖外部晶振 LSE,要注意 Stop 模式下 LSE 默认是继续工作的,但某些型号的 LSE 驱动能力在低功耗模式下会减弱。
不要踩的坑是:量测功耗发现 Stop 模式电流偏大,逐项排查后发现是 RTC 闹钟中断没有正确关闭外部中断线。RTC 唤醒可以从 RTC 的 ALRAF 或 ALRBF 事件出,也可以从 RTC 唤醒定时器出,两者对应的 EXTI 中断线不同,需要分别使能。用 HAL 库时容易漏掉HAL_RTCEx_SetWakeUpTimer_IT()之后还必须在 EXTI 层配置。标准库中对应的代码如下:
// 唤醒定时器配置:周期 1 秒 void rtc_wakeup_config(uint32_t seconds) { // 唤醒定时器时钟选择:1 Hz(RTCCLK 除以 32767 + 1 后再分频) RTC->CR |= RTC_CR_WUCKSEL_0; // 选择 1 Hz 时钟 RTC->WPR = 0xCA; RTC->WPR = 0x53; RTC->CR |= RTC_CR_WUTE; RTC->WUTR = seconds - 1; RTC->CR &= ~RTC_CR_WUTE; RTC->CR |= RTC_CR_WUTE; }注意唤醒定时器计数值从 0 开始,WUTR = seconds - 1。如果你直接写成WUTR = seconds,实际唤醒间隔是 seconds + 1 秒,日积月累就会多出可感知的误差。这个减一操作是 RTC 外设最常见的“少一或多一”陷阱,和预分频值 32767 的加一逻辑正好相反。
4.4 校准 RTC 走时误差的两种实用方法
万年历产品出厂前一定会做走时精度校准。有两种方法在项目里最常用。
第一种是软校准:利用 MCU 的另一个定时器输入捕获功能,捕获 1 PPS 信号(可以由 GPS 模块或高精度信号源提供),对比 RTC 每秒中断输出的时刻偏差。记录 N 秒内的累计偏差,然后计算秒误差值。把这个误差值通过 RTC 校准寄存器写入,可以修正到月误差小于 1 秒。
第二种是硬件校准:在 PCB 上预留一颗可变电容与 LSE 晶振的负载电容并联。出厂时用频率计或示波器量测 32.768 kHz 输出的实际频率,调整可变电容使之为标称值。这个方法不需要写代码,适合产线批量操作。
软件校准寄存器的配置代码如下:
void rtc_apply_calibration(int8_t ppm) { // 具体寄存器位因型号而异,这是 STM32F4 的写法 uint32_t reg = RTC->CR; reg &= ~RTC_CR_COSEL; // 清除校准配置位 reg |= RTC_CR_CAL; // 使能校准 // 根据 ppm 设置校准脉冲个数 // 假设每个校准脉冲等效 512 个时钟周期 // 某型号支持正负校准范围 -63 ~ 63 RTC->CALR = (uint16_t)(ppm & 0x7F); RTC->CR = reg; }这个函数的参数 ppm 需要由之前的测量结果推算。测量方法:通过 MCO 引脚输出 RTCCLK(实际是 1 Hz 或分频后的时钟),用频率计测 1000 秒内的计数,公式是ppm = (实际秒数 - 1000) * 1000 / 1000。测得 +5 ppm 表示走快,写入正校准值让 RTC 每 32 秒跳过 5 个脉冲周期(具体方向以芯片参考手册为准)。
5. 扩展:用 RTC 万年历做多路闹钟与定时任务的边界检查
5.1 不要把闹钟直接挂在 RTC 硬件比较器上
STM32 的 RTC 硬件带有闹钟寄存器(ALRAF、ALRBF),其比较逻辑是“秒计数器完全相等”时触发中断。这意味着你可以用它做单次闹钟,但做“每天固定时间响”的万年历闹钟时,硬件帮不上忙——因为日期不同,秒计数器的值每天都不同。
我建议的做法是:RTC 每秒产生一个中断(或每 500 ms 查询一次时间),在中断或主循环里做软件比较。比较对象是rtc_datetime_t的小时和分钟字段。这样设计虽然占用了一点 CPU 时间,但灵活度最高,而且能顺便做“多个闹钟同时匹配”的检测。
typedef struct { uint8_t hour; uint8_t minute; uint8_t enabled; uint8_t matched; // 防止同一分钟重复触发 } alarm_item_t; alarm_item_t alarms[4]; void rtc_check_alarms(rtc_datetime_t *now) { for (int i = 0; i < 4; i++) { if (!alarms[i].enabled) continue; if (alarms[i].hour == now->hour && alarms[i].minute == now->minute && !alarms[i].matched) { alarms[i].matched = 1; rtc_alarm_trigger(i); } // 分钟变化时重置 matched 状态 if (alarms[i].matched && now->second > 1) { alarms[i].matched = (alarms[i].hour == now->hour && alarms[i].minute == now->minute) ? 1 : 0; } } }这段代码的核心是matched标志,它避免了同一分钟内每秒中断都触发一次闹钟。注意now->second > 1这个判断:当秒数大于 1 时,说明已经离开了该分钟的起始时刻,此时可以安全地重置标志。
有一个边界要处理:用户把闹钟设为 23:59,而程序在处理时先触发了一次,然后跨天。由于matched标志在第二天 00:00 后分钟变化会被下一轮判断清除,所以不会漏掉第二天的同一时刻。但如果用户修改了时间,把时间拨回同一分钟,可能造成重复触发。这种情况下可以额外记录上次触发时的日期(year/month/day),只有日期和分钟都变化时才允许再次触发。
5.2 定时任务的“宽限期”设计:处理 RTC 查询偏移
软件闹钟还有一个隐蔽问题:如果 RTC 秒中断本身有抖动(比如系统中断优先级配置不当,导致秒中断被其他中断长时间阻塞),那时间查询会偶尔跳秒。极端情况下,某一秒中断晚来 50 ms,而主循环里rtc_check_alarms()在中断发生前已经执行了一次,就会跳过该分钟的匹配。
应对办法是做“宽限期”比较:不要求minute完全相等,而是允许 1 秒的偏移窗口。实践中的改进方案是:比较now的分钟与闹钟分钟相同,或now的分钟比闹钟分钟大 1 且now->second小于 2。这能覆盖由于查询延迟导致的 1 秒窗口遗漏,也不会造成重复触发。
5.3 带温度补偿的 RTC 万年历进阶思路
如果你的产品使用环境温度变化大(比如户外设备,冬夏温差超过 40 摄氏度),RTC 的走时误差会随温度明显漂移。LSE 晶振的温频特性曲线近似抛物线,在 25 摄氏度附近斜率最小,偏离后误差变大。
进阶做法是让 MCU 定期通过内部温度传感器(STM32 全系均有)采样温度,查表获得当前温度下的补偿系数,动态写入 RTC 校准寄存器。温度采样周期不用太频繁——温度变化是慢变量,每 10 分钟采样一次足够,不会明显增加功耗。查表的各项数据在晶振数据手册中可查,典型的 32.768 kHz 晶振在 -20 到 +60 摄氏度的频偏范围约 ±20 ppm。
实现这个功能需要沿着“温度采集 → 查表 → 写校准寄存器”的链路走一遍,其中温度传感器要选择与 RTC 晶振尽可能靠近的位置。这是 RTC 万年历走向产品级精度的必然一步,但不要在项目第一版做——先把晶振选好、负载电容配好、基础校准做完,再考虑温度补偿,否则多个变量搅在一起,调试会非常痛苦。
本文还有配套的精品资源,点击获取