ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32 RTC例程深度解析:从解压配置到掉电保持与校准

STM32 RTC例程深度解析:从解压配置到掉电保持与校准 简介STM32例程Example_RTC.7z是一份基于STM32F10x标准外设库的RTC实时时钟开发示例工程适合STM32初学者快速入门可掌握RTC时钟配置、日历读写与低功耗唤醒等常见开发方法。压缩包共54个文件主体为14个C源码与15个头文件另含hex、bin、elf等可烧录调试的固件及工程配置文件整体约187KB。代码覆盖RTC初始化、LSE/LSI时钟源选择、日历时间设置、闹钟与唤醒中断、备份寄存器保存关键数据等核心知识点并附有系统启动文件、串口printf重定向和延时函数可直接编译烧录便于在串口终端观察时间输出。目前已有249人学习适合希望快速跑通STM32 RTC功能并加深低功耗时间同步理解的开发者参考。1. Example_RTC.7z 不止是一个时钟例程拿到Example_RTC.7z这份 STM32 例程第一反应多半是解压、编译、下载看到串口打印出时间就关掉。但 RTC实时时钟恰恰是 STM32 里最容易被低估的外设主电源断开后它能依靠 VBAT 备份电池继续走时还拥有独立备份域、BCD 寄存器、闹钟和自动唤醒中断。许多人在跑完例程后只记住HAL_RTC_GetTime()的调用却忽略了 LSE 晶振、预分频、备份域使能这些前置条件代码一搬到自己的 PCB 上时间要么不走要么每次上电都从头开始。这里要把它从压缩包到可交付的过程拆开复盘解压、RTC 初始化、中断回调、掉电保持、校准验证每一步的配置边界和验证方法都摆在明处。2. 解压、重建与移植让 RTC 例程跑在指定型号上先说结论大多数情况下这套例程拿到的不是“开箱即用的固件”而是“针对性很强的参考代码”。它的工程文件、芯片型号、时钟配置都是按某一颗具体 MCU 生成的直接打开可能提示找不到芯片支持包或 Device 型号不一致。因此在看 RTC 代码之前先做三件事校验压缩包完整性、按自己的开发环境重建工程、替换启动文件与器件宏。三步做完剩下才是 RTC 业务逻辑的事。2.1 用 7z 命令行解压并校验例程完整性Windows 开发机上不一定装了 7-Zip 图形界面但命令行7z.exe通常就在 PATH 里。先执行测试命令7z t Example_RTC.7zt只做完整性测试不解压返回码 0 表示压缩文件没有损坏非 0 则建议重新获取文件。很多从网盘、邮件附件下载的压缩包会因传输中断造成结构损坏直接解压虽不报错但某些源文件内容已被截断编译时才报出奇怪的语法错误。测试通过后再执行7z x Example_RTC.7z -oD:\stm32_rtc\Example_RTC -yx保留目录结构解压-o后紧跟目标目录冒号与路径之间不能有空格-y在覆盖已存在文件时不再询问。若压缩包内的文件名或目录名出现乱码常见原因是压缩时使用 UTF-8 编码、而当前 Windows 控制台代码页不是 UTF-8可加上-scsUTF-8参数重新解压。在 Linux 环境里解压 7z 文件依赖 p7zip 工具包sudo apt install p7zip-full 7z x Example_RTC.7z -o./Example_RTC这里额外做一个动作解压后查看文件列表确认是否包含Example_RTC.ioc、Drivers/STM32F1xx_HAL_Driver、MDK-ARM/Example_RTC.uvprojx这类典型文件。如果只有零散的.c和.h说明压缩包作者只提供了核心源码需要自己在 STM32CubeMX 里重建外围框架。命令参数作用使用场景t仅测试压缩包完整性下载完成后先校验避免解压出“假完整”的工程x -o目录解压到指定目录统一工程工作目录防止路径中文或空格带来的工具链问题-scsUTF-8指定解压时的字符集Windows 下解决文件名乱码-y自动覆盖同名文件重复解压时免交互再补一个容易被忽略的细节很多例程包为了减小体积并没有把整个 HAL 驱动目录打包进去只保留了应用层代码。判断方法是在解压后的Drivers目录下找stm32f1xx_hal_rtc.c如果找不到就需要在原厂固件包或 CubeMX 生成工程时补齐驱动文件。2.2 STM32CubeMX 重建工程的时钟树配置用 CubeMX 重建的常见做法是新建一个自己的工程在Pinout Configuration中选择 RTC再打开Clock Configuration页把 LSE 设为Crystal/Ceramic Resonator。如果板子上没有焊接 32.768kHz 晶振而是由外部有源晶振提供时钟则选择Bypass Clock。这两种方式在 HAL 初始化后生成的hrtc.Init结构体里有直接体现外部晶振时HAL 库会在 RCC 部分使能 LSEON 位并等待 LSERDYBypass 模式下则跳过内部振荡驱动只把外部时钟直接引入 RTC 时钟域。RTC 配置页的关键参数见下表参数推荐值原因Hour Format24 hour避免 12 小时制下 AM/PM 位处理简化闹钟比对AsynchPrediv127输入 32768Hz 时与同步分频值共同得到 1HzSynchPrediv255同上(1271)*(2551)32768Activate Internal WakeUp根据需要使用用于周期唤醒时再使能平时关闭省电OutPutDisable校准调试时可临时打开量产默认关闭这里要解释预分频的计算逻辑RTC 内核时钟经过异步预分频器和同步预分频器两级分频后得到用于秒计数的 1Hz 信号。异步分频器输出频率 输入频率 / (AsynchPrediv1)同步分频器再除以 (SynchPrediv1)。两个分频器的作用不同异步分频器功耗更低同步分频器提供更高分辨率的亚秒值但只在需要读取亚秒计数器时才体现出价值。如果例程里没有读亚秒保持默认即可。提示不是所有 STM32 系列都支持任意组合的AsynchPrediv和SynchPrediv。F1 系列的 RTC 与 F4 不同BKP 寄存器和写保护流程差异较大。例程若来自 F4直接换到 F1 上时必须核对RTC_InitTypeDef中的字段名两个系列的 HAL 结构体并不完全一致。2.3 不同芯片型号间的启动文件与宏定义切换工程重建后还有一道坎是芯片型号切换。例程包若是针对STM32F103VET6而开发板是STM32F103C8T6至少要做三处修改MDK 的 Device 选择、启动文件、器件宏定义。MDK 里通过魔术棒Options for Target的 Device 页选择具体芯片这一步决定芯片 Flash/RAM 大小与烧录算法。芯片启动文件STM32F103C8T6startup_stm32f103xb.sSTM32F103VET6startup_stm32f103xe.sSTM32F407VGT6startup_stm32f40xx.s启动文件切换后需要在源码中同步修改器件宏。HAL 工程的头文件顺序通常是#define STM32F103xB #include stm32f1xx.h #include stm32f1xx_hal_conf.h第一行告诉stm32f1xx.h当前芯片属于哪个子系列决定存储器基址、外设寄存器地址和中断号映射第二行引入系列头文件第三行再根据宏决定启用哪些外设驱动模块。如果宏与启动文件不匹配编译阶段不一定报错但链接时可能出现中断向量表偏移或外设地址访问异常。例如启动文件是startup_stm32f103xe.s而宏定义写成STM32F103xBRTC 中断号恰好同一个向量编号暂时不会出问题但如果换到 F2/F4 系列中断向量表就完全不同了必须同步修改中断服务函数名和向量表。若是用 CubeMX 直接选择目标芯片重新生成工程上述文件都可由工具自动生成但例程中的用户代码需要手工拷贝。比较稳妥的做法是保留原.ioc文件作为配置参考再打开 CubeMX 修改芯片型号让工具去处理寄存器地址和启动文件然后把用户业务代码按函数边界复制到新工程。这个流程适合绝大多数“更换主控芯片型号”的 STM32 开发场景。3. 读懂 RTC 例程的寄存器路径与中断回调例程里“能跑”只是表象。RTC 时间基准从哪来、时间怎么读、闹钟中断怎么汇聚到用户回调这三条主线可以在任何 STM32 系列的例程里复用。3.1 LSE 与 LSI 的选型32.768kHz 晶振为什么不能随意换RTC 时间基准的决定性因素是时钟源选型。LSELow Speed External是外部低速晶振通常接 32.768kHz 晶振LSILow Speed Internal是芯片内部的 RC 振荡器频率约 32kHz误差范围较大。二者在成本、精度、适用场景上的差异明显参数LSE 32.768kHz 晶振LSI 内部 RC典型精度±20 ppm 左右±1% ~ ±5%温度漂移小受晶振特性影响大随温度明显变化外部元件晶振、两颗负载电容无启动时间1~2 秒量级很短适用场景日历、闹钟、定时唤醒低功耗唤醒、粗略计时很多例程把 LSI 作为默认配置只是为了让代码在无晶振开发板上也能跑通演示。真正做产品时用 LSI 做日历会有明显问题以 1% 误差计算一天就是 86400 × 1% 864 秒的时间偏差这已经远超可接受范围。所以细看例程你会发现工程普遍默认使能 LSE只有 LSE 启动失败时才回退到 LSI。LSE 启动的关键是外部晶振需要两颗负载电容。负载电容的值取决于晶振规格常见公式是CL (C1 * C2) / (C1 C2) CstrayC1、C2 为晶振两脚对地电容Cstray 为 PCB 分布电容经验值 3~6 pF。例如选用 CL 为 12.5pF 的晶振PCB 杂散电容按 4pF 估算可取 C1 C2 18pF若晶体规格是 6pFC1 C2 8pF 更合适。电容配得不合适最直接的表现是 RTC 初始化卡在等待 LSE 就绪上因为RCC_LSE_ON一直没置位。检查代码通常是while (__HAL_RCC_GET_FLAG(RCC_FLAG_LSERDY) RESET) { /* 等待 LSE 就绪若一直卡在这里先查晶振、负载电容和焊接 */ }这段循环不加超时调试时能快速发现问题点。量产代码里建议加一个超时计数例如等待 500 毫秒仍未就绪就报错并回退到 LSI避免整机卡死在启动阶段。提示部分开发板为省掉两颗电容仅靠芯片引脚寄生电容起振。这种做法能工作但抗干扰能力差在电磁环境恶劣的场合 RTC 可能偶发停振。出现“时间偶尔不走但重新上电又恢复”的现象时优先查 LSE 起振条件。3.2 时间读写、BCD 转换与影子寄存器无论例程界面怎么封装RTC 最终落点是时间寄存器RTC_TR和日期寄存器RTC_DR。直接读这两个寄存器可能读到正在更新的中间值因此硬件上用“影子寄存器”做缓冲软件上 HAL 提供了成对接口RTC_TimeTypeDef sTime {0}; RTC_DateTypeDef sDate {0}; /* 必须先读时间再读日期顺序不能反 */ if (HAL_RTC_GetTime(hrtc, sTime, RTC_FORMAT_BIN) HAL_OK) { HAL_RTC_GetDate(hrtc, sDate, RTC_FORMAT_BIN); }这里两句调用的顺序不是随意写的。HAL_RTC_GetTime会触发寄存器锁存把时间与日期的影子寄存器内容同时复制到内部缓冲区随后再调HAL_RTC_GetDate读到的是同一时刻的日期值。反过来先读日期时间寄存器在后台仍会继续刷新两者可能不属于同一个“时刻”在跨天23:59:59.999 到 00:00:00.000边界处会带来误差。然后是 BCD 问题。RTC_FORMAT_BIN让 HAL 帮我们完成 BCD 到二进制的转换输出sTime.Hours就是可直接打印的 0~23 数值。若例程里用的是RTC_FORMAT_BCD读出来的是0x23这样的值printf 打印时需要手动转换。HAL 内部提供了转换宏/* BCD 转二进制0x23 - 23 */ uint8_t hour __HAL_RTC_BCD2BIN(sTimeBCD.Hours); /* 二进制转 BCD23 - 0x23 */ sTimeBCD.Hours __HAL_RTC_BIN2BCD(23);__HAL_RTC_BCD2BIN本质是(((bcd) 0xF0) 4) * 10 ((bcd) 0x0F)用宏可读性更好。但要特别注意RTC 寄存器都是 BCD 存储不是二进制存储。写时间时如果直接塞入十进制数例如向寄存器写0x12硬件会把它当作 BCD 的 12 点而不是十进制的 12显示结果就错了。3.3 闹钟、唤醒定时器与时间戳中断怎么分工RTC 例程里经常同时出现“闹钟、唤醒定时器、时间戳”三个名词它们共用了一部分中断路径但使用场景完全不同RTC Alarm A/B比较当前时间与设定时间到点产生中断适合“每天 8 点响一次”的时钟闹钟。唤醒定时器WakeUp Timer本质是倒计时定时器递减到 0 触发事件适合周期唤醒。时间戳Timestamp检测 RTC 引脚的边沿信号在事件发生时记录完整时间适合记录外部触发时刻。对应到例程代码Alarm A 中断的使能路径是RTC_AlarmTypeDef sAlarm {0}; sAlarm.AlarmTime.Hours 8; sAlarm.AlarmTime.Minutes 0; sAlarm.AlarmTime.Seconds 0; sAlarm.AlarmMask RTC_ALARMMASK_DATEWEEKDAY; /* 忽略日期每天触发 */ if (HAL_RTC_SetAlarm_IT(hrtc, sAlarm, RTC_ALARM_A) ! HAL_OK) { Error_Handler(); }AlarmMask决定哪些字段参与比较。RTC_ALARMMASK_DATEWEEKDAY表示日期、月份、星期等都不比较只比较时:分:秒这样闹钟每天都会响。若需要“只在某月某日触发”就应保留日期比较位。中断触发后执行流进入 RTC 中断向量HAL 库再将事件分发到弱回调函数void RTC_Alarm_IRQHandler(void) { HAL_RTC_AlarmIRQHandler(hrtc); } void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { /* 用户闹钟业务翻转 LED、触发蜂鸣器、记录日志 */ HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); }RTC_Alarm_IRQHandler是中断向量入口CubeMX 生成工程时已经存在它内部必须调用HAL_RTC_AlarmIRQHandler之后 HAL 才会去检查是 Alarm A 还是 Alarm B 触发再回调对应的弱函数。很多人在裸机上自己写中断函数却忘了调用HAL_RTC_AlarmIRQHandler结果回调永远不执行。正确路径是中断向量 → HAL 中断入口 → 事件回调 → 用户代码。这条链路是 RTC 例程排查中断问题的主线索。4. 把 Example_RTC 改成可用的日历与闹钟应用例程里通常已经包含了 RTC 初始化和读取函数但从“能打印时间”到“能当闹钟用”中间还差串口输出、闹钟验证、掉电保持这几步。4.1 实现串口打印当前时间的最小代码RTC 例程的常用验证手段是串口输出。一段最小可运行的实现长这样#include rtc.h #include usart.h #include stdio.h void RTC_PrintCurrentTime(void) { 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(%04d-%02d-%02d %02d:%02d:%02d\r\n, 2000 sDate.Year, sDate.Month, sDate.Date, sTime.Hours, sTime.Minutes, sTime.Seconds); }sDate.Year在 HAL 里是相对 2000 年的偏移例如 2024 年存入的是 24打印时必须加 2000。sDate.Month的取值从 1 开始sDate.Date就是几号不需要再偏移。RTC_FORMAT_BIN保证这些字段都是普通整数直接用%d输出即可。如果例程里使用RTC_FORMAT_BCD或直接读寄存器printf 里需要先做转换否则显示的是0x23这类非十进制字符。printf重定向部分不属于 RTC 例程但调试时绕不开。MDK 中常见做法是重写fputcint fputc(int ch, FILE *f) { while (HAL_UART_Transmit(huart1, (uint8_t *)ch, 1, 0xFFFF) ! HAL_OK); return ch; }这个函数把标准库的printf输出重定向到串口 1。HAL_UART_Transmit的阻塞等待时间应与波特率匹配115200 波特率下传输一个字节约 87 微秒一般不会成为瓶颈。若在中断回调里调用printf应改用非阻塞方式否则长时间占用 CPU 会影响其他实时任务。4.2 设一个闹钟中断并验证回调触发第 3.3 节已经给出 Alarm A 的初始化代码这里补充验证步骤。第一步在 CubeMX 的 NVIC 设置里勾选 RTC global interrupt把中断优先级设为合适的抢占优先级第二步确认stm32f1xx_it.c中存在RTC_Alarm_IRQHandler函数第三步写回调函数并加一个调试计数器。volatile uint32_t alarm_counter 0; void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc) { alarm_counter; printf(alarm triggered, count%lu\r\n, alarm_counter); }验证时把闹钟时间设为“当前时间 10 秒”例如当前 12:00:30就设成 12:00:40编译下载后观察串口输出。如果 10 秒后没有输出按下面的顺序检查现象检查点到点无中断NVIC 是否勾选 RTC Alarm 中断回调未执行RTC_Alarm_IRQHandler是否调用了HAL_RTC_AlarmIRQHandler中断反复进入Alarm 标志是否一直在 pending是否被 HAL 正确清除与 SysTick 不同RTC 中断不会默认开启CubeMX 里不勾选 NVIC任何中断回调都是空谈。F1 系列 RTC 的 Alarm 中断标志在进入中断函数后需要被清除HAL 库在HAL_RTC_AlarmIRQHandler内部会读取并清除标志但如果用户以直接操作寄存器方式实现未清除中断标志会导致中断反复进入、主循环无法执行。例程若来自标准外设库这一点尤其常见。提示把闹钟时间设置为当前时间后再调用HAL_RTC_SetAlarm_IT不少系列会立即触发一次中断这是正常行为不是代码 bug。产线测试时要注意这一点避免被误判为“闹钟提前响了”。4.3 掉电场景下的 VBAT 供电与备份域保持RTC 在整机断电后继续走时依靠的是 VBAT 引脚上的备份电池。典型接法是把纽扣电池和一节二极管隔离的 VDD 同时接到 VBAT系统上电时由 VDD 供电掉电后由电池供电。例程代码里通常只处理了“使能备份域访问”和“写 RTC”没有体现硬件上的电源切换因为这是原理图层面的事。软件层面要做两件事。第一步在初始化时打开备份域写访问权限。HAL 例程一般在MX_RTC_Init()之前就由 CubeMX 生成的HAL_PWR_EnableBkUpAccess()完成__HAL_RCC_RTC_ENABLE(); HAL_PWR_EnableBkUpAccess(); /* 允许访问备份域和 RTC 寄存器 */调用顺序不能反先使能 RTC 时钟再打开备份域访问然后才能写 RTC 的初始化寄存器。若缺少HAL_PWR_EnableBkUpAccess()HAL_RTC_SetTime等写操作会静默失败因为 RTC 寄存器写保护仍然生效。第二步是把需要掉电后保存的用户数据放到备份寄存器里。RTC 例程的报警时间、闹钟使能标志、系统启动次数都可以存到这里。以 F1 为例读写备份寄存器的 HAL 接口是HAL_RTCEx_BKUPWrite(hrtc, RTC_BKP_DR1, 0xA5A5); uint32_t bak HAL_RTCEx_BKUPRead(hrtc, RTC_BKP_DR1);RTC_BKP_DR1是备份数据寄存器的索引不同系列数量不同。备份寄存器的写操作同样受备份域访问门槛控制必须在HAL_PWR_EnableBkUpAccess()之后写入。量产设计里通常会在 RTC 上电时检查备份寄存器中的“初始化标志”若标志丢失说明电池掉过电或 RTC 曾失去供电此时强制重新初始化时间。这套逻辑在很多 STM32 项目里都有需求可以把它并进例程让系统在“首次上电”和“备份域丢失”两个场景下都能正确引导。5. 校准与 LSE 停振检测让 RTC 例程从“能走”到“走得准”例程能跑通只代表 RTC 在走走得多准是另一件事。这部分的两个技巧建议直接写进量产代码。5.1 用秒脉冲测量日误差再反向计算校准值校准的核心是先测出实际频率再补偿。测量方法把 RTC 的校准输出引脚配置为 1Hz 信号或者让秒中断翻转一个 GPIO用频率计或逻辑分析仪测 10 分钟内的脉冲数。理想值是 600 个。假设实测 599 个说明时钟慢了约 1.67‰。日误差可以由公式估算日误差(秒) (实测频率 - 1) × 86400对于 599/600 的结果日误差约 -144 秒也就是一天慢 2 分 24 秒。若例程所在的系列支持平滑校准可以按每档 1ppm 左右补偿。校准参数不像预分频值那样直观需要对照所用芯片参考手册查补偿窗口长度与脉冲含义。对多数 LSE 晶振而言校准的主要目标是补偿晶体初始频偏和 PCB 负载电容偏差一般调整一次即可不需要每次上电都校准。5.2 使能 LSE 停振检测把现场故障定位变成“读故障标记”在支持 LSE 时钟安全系统Clock Security System的型号上建议把例程里的 LSE 监测打开。它的作用是当 LSE 因晶振损坏、接触不良或强干扰停止振荡时硬件产生安全事件在中断里切换到 LSI 并记录错误标记。__HAL_RTC_LSECSS_ENABLE();宏名称以所移植的 HAL 版本为准但思路一致。使能代码就这一行但必须放在 RTC 初始化且确认 LSE 已经就绪之后。一旦 LSE 停振程序会进入 NMI 或 RTC 中断各系列实现位置不同在回调里至少应做三件事停止依赖 RTC 时间的业务流程、置一个故障标志位、视产品策略决定是否软复位。若什么都不做RTC 时间会停留在停振时刻上位机读到的是一个“看起来正常、实际已冻结”的时间隐蔽性很强。这个检测功能是 RTC 例程里最容易被删掉的代码因为它让中断向量表复杂化但对无人值守的嵌入式设备来说它是保证可维护性的关键一步。量产固件里可以把 LSE 停振故障码写入备份寄存器调试时直接用串口或调试器读出现场就能判断是晶振问题还是代码跑偏而不是反复“重新上电试试”。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进