ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32F1标准库RTC万年历开发:从秒计数到日期转换

STM32F1标准库RTC万年历开发:从秒计数到日期转换 简介一套基于STM32 RTC模块的万年历完整工程面向嵌入式开发与STM32学习者解决RTC初始化、时间日期设置、闹钟触发及万年历推算等实际问题。包内共109个文件包含28个C源文件与26个头文件附有MDK工程配置uvproj、sct以及编译生成的axf、map等压缩包仅786KB结构清晰便于快速定位代码。工程已演示LSE低速晶振作为RTC时钟源、预分频器配置、BCD格式写入时间并通过备份寄存器保存用户数据闹钟部分采用HAL_RTC_SetAlarm_IT中断方式配合日期星期判断可实现每日定时提醒。源码基于STM32标准外设库适用于F1系列等常见型号可直接在开发板上验证万年历显示与闹钟功能。目前已有545人浏览学习适合需要落地RTC应用或扩展日历、定时事件的中高级开发者参考。1. 拆开 Stm32Rtc 工程先看到一个关键差异拿到这套Stm32Rtc.zip文件列表里没有 HAL 库那套stm32f1xx_hal_rtc.c而是一排stm32f10x_tim.c、stm32f10x_rcc.c、stm32f10x_fsmc.c标准外设库源码这是个很直接的信号工程基于标准库 V3.5 构建代码组织方式和现在 CubeMX 生成的习惯不一样。FSMC 出现在这里也顺理成章——万年历要显示日期星期屏幕大概率走并口 8080 时序而标准库 RTC 模块的本质是一个从 2000-01-01 00:00:00 开始累加的 32 位秒计数器。这意味着你不能像调 HAL 库那样直接HAL_RTC_GetTime()读出时分秒中间必须自己写换算逻辑。想跑通这套代码并改造成自己可用的 rtc 万年历关键是把三个点吃透RTC 时钟源与备份域配置、BCD 码与秒计数的转换、以及日期算法里闰年和星期的处理。这篇文章就按这三个层次拆开讲最后补上闹钟和唤醒的边界条件。2. 时钟源和备份域决定万年历能不能走准、断电丢不丢时间2.1 LSE 和 LSI一个管精度一个管兜底STM32F1 的 RTC 可选两个时钟源LSE 外部低速晶振和 LSI 内部低速 RC 振荡器。两者的差距不是一点半点直接看数据参数LSE外部晶振LSI内部 RC典型频率32.768 kHz40 kHz精度20 ppm 左右约 1.7 秒/天1% 级一天误差可达数分钟温漂低跟晶振本身有关明显随温度变化启动时间毫秒级到秒级可能不起振微秒级稳定适用场景RTC 日历、低功耗唤醒看门狗、允许误差的场景万年历项目里 LSE 是唯一合理选择。LSE 选 32.768 kHz 不是巧合它正好等于 2 的 15 次方用 15 级二分频就能得到精确的 1 Hz 秒脉冲硬件上做起来最简单。工程里如果用的是 6 脚 32.768 kHz 晶振务必配上两个 6~12 pF 负载电容有些板子省略了这两个电容结果就是 LSE 起振困难代码卡在等待LSERDY标志位上出不来。标准库使能 LSE 的写法是这样RCC_LSEConfig(RCC_LSE_ON); // 开启 LSE 振荡器 while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) RESET); // 等待起振参数说明RCC_FLAG_LSERDY是 LSE 就绪标志标准库里通过RCC_GetFlagStatus读取如果晶振没焊好或负载电容不对这里会一直死等。工程上常见的做法是加一个超时计数比如循环 2000 次还没就绪就切到 LSI 并置一个错误标志避免上电卡死的现象。调试时用示波器点晶振引脚能看到 32.768 kHz 波形没有波形就先查电容和晶振匹配。2.2 从 32768 Hz 到 1 Hz预分频器的两个数字LSE 输出 32768 Hz 后RTC 内部经过两级分频异步预分频和同步预分频两者单独配置最终分频系数是两者加 1 之后的乘积。要得到 1 Hz 秒计数最常用的一组参数是异步 127、同步 255因为(127 1) × (255 1) 128 × 256 32768正好把 32768 Hz 分到 1 Hz。标准库没有把两级分频分开暴露而是提供了一个直接写装载值的接口而 HAL 库是分开的两个字段两个版本等效配置对照如下// 标准库 V3.5 方式直接写 PRL 预分频装载值 RTC_SetPrescaler(32767); // 32768 分频为 1Hz // HAL 库方式异步 同步打开 RCC_RTCInitTypeDef RCC_RTCInitStruct; RCC_RTCInitStruct.RTCClockSelection RCC_RTCCLKSOURCE_LSE; HAL_RTC_Init(hrtc); // 实际分频值放在 RTC_InitTypeDef 中如 AsynchPrediv127, SynchPrediv255逻辑说明标准库的RTC_SetPrescaler(32767)把分频值设置为 32767计数器从 32767 倒数到 0 正好输出一个 1 Hz 脉冲HAL 库的异步/同步分频只是把同样的总数拆成了两级。异步预分频打开的好处是它独立于同步预分频工作在低功耗模式下不需要启用同步预分频时钟这对电池供电的万年历设备有意义。参数修改时要保证(异步1) × (同步1) 32768否则秒计数器不准一天下来偏移量会变得很难看。2.3 掉电不丢时间靠的是 VBAT 和备份寄存器RTC 属于备份域的一个组成部分在 VDD 断电后由 VBAT 引脚供电继续走时。板子上 VBAT 接一个 3V 纽扣电池或一个大电容主系统断电后秒计数器依然在跑。但寄存器访问权限有个前提条件这也是新手最常见的卡点——必须先打开 PWR 和 BKP 时钟并允许备份域访问否则写 RTC 寄存器毫无反应。// 使能 PWR 和 BKP 时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); // 允许访问备份域 PWR_BackupAccessCmd(ENABLE); // 初始化 RTC 时钟源 RCC_LSEConfig(RCC_LSE_ON); while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) RESET); // 选择 LSE 作为 RTC 时钟源并使能 RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); RCC_RTCCLKCmd(ENABLE); // 等待 RTC 寄存器同步并设置预分频 RTC_WaitForSynchro(); RTC_WaitForLastTask(); RTC_SetPrescaler(32767);这段代码的每一步都有讲究。PWR_BackupAccessCmd(ENABLE)打开备份域写保护等效于 HAL 库里的HAL_PWR_EnableBkUpAccess()RCC_RTCCLKConfig指定 RTC 时钟源后RCC_RTCCLKCmd(ENABLE)才真正把时钟切换到 RTC 模块。RTC_WaitForSynchro()等待 RTC 寄存器与 APB 总线同步标准库要求写任何 RTC 寄存器之前都先调用RTC_WaitForLastTask()这是标准库比 HAL 库繁琐但更安全的地方。备份寄存器 BKP_DR1 之后会被用来保存是否已完成初始校准的标记避免每次上电都重新设定时间覆盖掉 RTC 正在走的日历。3. BCD 码与秒计数器读写时间前先过这一关3.1 BCD 编码为什么寄存器里看到的是 0x15无论是标准库的RTC_SetTime还是 HAL 库默认的FORMAT_BCD时间都是 BCD 编码存储的。BCD 的含义是每个十进制位用 4 个二进制位表示15 秒在寄存器里不是0x0F而是0x15也就是十位的 1 和个位的 5 各自编码。看代码时如果直接把这个值当整数处理显示出来就会出现时间错乱的假象。转换的位运算是这样uint8_t bin2bcd(uint8_t value) { return ((value / 10) 4) | (value % 10); // 十位在高 4 位个位在低 4 位 } uint8_t bcd2bin(uint8_t value) { return (value 4) * 10 (value 0x0F); }逻辑说明bin2bcd把十进制数拆成十位和个位例如 14 变成十六进制的 0x14bcd2bin是逆过程。调试时在 Keil5 的 Watch 窗口看到sTime.Hours 0x14不要以为这是 20 点它在 BCD 语义下代表 14 点。建议在显示层统一做一遍bcd2bin转换所有逻辑代码都操作十进制值这样写日期比较和天数计算时不用反复担心编码问题。3.2 标准库的读法拿到的是秒计数器标准库 V3.5 不提供RTC_GetTime()这种现成接口读时间的方式是RTC_GetCounter()返回一个 uint32 秒计数它表示从 2000-01-01 00:00:00 至今经过的秒数。这个设计让 RTC 模块变得异常简单——RTC 不关心日历只负责数秒年月日的换算交给应用层自己做。这也正是这套工程里必然包含日期间转换算法的原因。uint32_t seconds; uint16_t year; uint8_t month, day, hour, minute, second; // 等待影子寄存器同步避免读到跳变中的值 RTC_WaitForSynchro(); seconds RTC_GetCounter(); // 返回自 2000-01-01 00:00:00 的秒计数 // 将秒计数换算成日期时间换算函数见第 4 章 datetime_from_seconds(seconds, year, month, day, hour, minute, second);逻辑说明先调用RTC_WaitForSynchro()是标准库读取的关键一步它确保 APB1 总线上读到的RTC-CNTL/CNTH是完整的、没有发生进位错位的值。如果不等待同步直接读可能在秒计数的低 16 位翻转到高 16 位的瞬间读到错误数据表现出来就是时间偶尔回跳几秒。RTC_GetCounter()内部已经做了 CNTL 和 CNTH 的合并返回的是一个无符号 32 位数能表示的最大秒数约为 136 年对 2000 年为起点来说绰绰有余。3.3 HAL 用户读取的等价写法如果看惯了 HAL 库标准库这套读法会觉得绕但两者的数据流本质相同。HAL 库的HAL_RTC_GetTime内部已经把秒计数器换算成了时分秒封装层级更高而已。两套库的时间结构体字段对照如下含义标准库HAL 库秒计数RTC_GetCounter()hrtc.Instance-CNTL/CNTH时分秒自算RTC_TimeTypeDef.Hours/Minutes/Seconds年月日自算RTC_DateTypeDef.Year/Month/Date星期自算RTC_DateTypeDef.WeekDayBCD 转换手动传FORMAT_BCD或FORMAT_BIN移植时最常踩的坑在年份字段HAL 库RTC_DateTypeDef.Year的取值范围是 0 到 99基准是 2000 年也就是写 22 代表 2022 年写了 52 实际指 2052 年。有些示例代码习惯把Year减去 1970这在 HAL 库部分旧版本里能跑通但在 F1 的 HAL 实现里会算出错误的年份导致日期显示和星期全部错位。拿到这套标准库工程后我的建议是保持它的读秒-换算结构别改成 HAL一方面省去移植时间另一方面秒计数器到日期的换算逻辑本身就是万年历项目最值得保留的部分。4. 万年历核心算法从 2000 年算到 2099 年的闰年逻辑4.1 为什么只能可靠覆盖 2000 到 2099STM32F1 的 RTC 秒计数器是 32 位从 2000-01-01 00:00:00 开始算溢出点在 2137 年左右理论上覆盖 2000 到 2099 完全够用。真正限制范围的是 HAL 库的 Year 字段只有 7 位最大 99所以 2099 之后 Year 会回绕。标准库没有这个限制因为时间完全由你换算但万年历界面上大多数代码还是按 2000 到 2099 设计。这意味着闰年计算只需要处理 100 年的范围不需要考虑 2100 年这种能被 100 整除但不能被 400 整除的世纪闰年特例。不过算法层面我仍建议把完整规则写进去这样代码在 2100 年之前都不用再改纯属顺手的事。4.2 秒计数转日期时间可抄作业的完整实现从秒计数到日期的换算是这套工程里最值得单独拿出来的算法。基本思路是循环扣除整年的秒数确定年份再循环扣除整月的秒数确定月和日剩余部分直接除出时分秒。闰年判断和每月天数各需要一个独立函数int is_leap_year(uint16_t year) { // 闰年规则4 年一闰百年不闰四百年再闰 return (year % 4 0 year % 100 ! 0) || (year % 400 0); } uint8_t days_in_month(uint16_t year, uint8_t month) { // 1 3 5 7 8 10 12 月是 31 天2 月按闰年 29/28 天 static const uint8_t days[12] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 2 is_leap_year(year)) { return 29; } return days[month - 1]; } void datetime_from_seconds(uint32_t seconds, uint16_t *year, uint8_t *month, uint8_t *day, uint8_t *hour, uint8_t *minute, uint8_t *second) { uint16_t y 2000; uint32_t remain seconds; // 逐年代扣得到年份 while (1) { uint32_t ysec is_leap_year(y) ? 31622400UL : 31536000UL; if (remain ysec) break; remain - ysec; y; } *year y; // 逐月扣减得到月份和日期 for (uint8_t m 1; m 12; m) { uint32_t msec days_in_month(y, m) * 86400UL; if (remain msec) { *month m; *day remain / 86400UL 1; // 余数从 0 开始所以要 1 remain % 86400UL; break; } remain - msec; } *hour remain / 3600; remain % 3600; *minute remain / 60; *second remain % 60; }逻辑说明31622400UL是闰年 366 天的秒数31536000UL是普通年的秒数加UL后缀避免在 32 位平台上被当作 int 导致乘法溢出。内层循环里用86400UL一天的秒数做模运算余数范围就是当天 0 点到当前时刻的秒数。这套算法最坏情况下做 100 次年循环加 12 月循环对主频 72 MHz 的 STM32 来说耗时可以忽略不需要做二分查找优化。如果 RTC 时间显示错乱优先检查这里remain是否在进入内层循环前被错误修改了经常有人在年循环结束后不重新处理就继续用seconds原值导致月和日全算错。4.3 星期计算用蔡勒公式免去查表万年历界面要显示星期几标准库和 HAL 库在设定时间时需要手动填 WeekDay但每次开机都让用户选星期很反人类。更合理的做法是让用户只输入年月日星期由程序自动算出。蔡勒公式Zellers Congruence在 1582 年之后的公历范围内都能用代码非常精简uint8_t calc_weekday(uint16_t year, uint8_t month, uint8_t day) { uint16_t y year; if (month 3) { month 12; y--; } uint16_t c y / 100; // 世纪数 uint16_t yy y % 100; // 年份后两位 int w (yy yy / 4 c / 4 - 2 * c 26 * (month 1) / 10 day - 1) % 7; if (w 0) w 7; return w; // 0 表示周日1 表示周一以此类推 }参数说明公式里的c和yy是中间量c是世纪数yy是年份的后两位1 月、2 月按旧历法视为上一年的 13 月、14 月所以做了month 12; y--;的预处理。返回值的基准是周日为 0如果想让周一为 0返回前加 6 再取模即可。实际使用中这个函数只依赖年月日可以在用户设定完日期后立刻调用也可以在上电恢复时用换算出的年月日重新计算存到 BKP 寄存器里避免重复运算。4.4 用 BKP 寄存器记住初始化状态每次上电都做检测是否已设置时间这件事靠的是备份寄存器。标准库的BKP_WriteBackupRegister和BKP_ReadBackupRegister可以写自定义魔数一旦 RTC 初始化成功并写入标记下次上电读到标记就跳过全部初始化过程直接读取秒计数换算显示。一年前做的设备如果电池还在时间应该仍然准确这正是备份域的价值所在。需要注意备份寄存器在 VBAT 耗尽后会丢失标记此时程序应回到首次初始化流程提示用户重新校时。5. 闹钟匹配与 RTC 唤醒定时器标准库的边界在哪里5.1 标准库闹钟比较的是秒计数不是时分秒HAL 库的HAL_RTC_SetAlarm_IT直接传入时分秒结构体和掩码而标准库的RTC_SetAlarm接收一个秒计数值闹钟比较的是 RTC 计数器是否匹配设定的值。这意味着设置闹钟前要先把目标时间转成秒计数。素材里给出的 HAL 闹钟代码如果直接搬到标准库工程sAlarm.AlarmTime.Hours这些字段在标准库里是没有的编译器直接报错。标准库写法是uint32_t alarm_secs; uint16_t y 2025; uint8_t m 3, d 15, h 10, mi 0, s 0; alarm_secs datetime_to_seconds(y, m, d, h, mi, s); // 把时间转为秒计数 RTC_SetAlarm(alarm_secs); // 设定闹钟 RTC_ITConfig(RTC_IT_ALR, ENABLE); // 打开闹钟中断 NVIC_EnableIRQ(RTC_IRQn); // 使能 RTC 中断通道逻辑说明datetime_to_seconds是第 4 章换算函数的逆过程核心也是从 2000 年起逐年逐月累加秒数。标准库闹钟只支持一次性匹配每次响铃后都要重新设置下一次的秒计数不像 HAL 库那样可以用掩码实现每天同一时刻的循环闹钟。如果需要在万年历里做每日定时提醒一般用定时器配合在中断里重置RTC_SetAlarm(now 86400)把下一次触发时间补上即可。5.2 用唤醒定时器做低功耗秒级唤醒RTC 唤醒定时器WakeUp Timer是比闹钟更省电的方案它不依赖秒计数器而是由一个独立的递减计数器配合可配置分频触发中断可以在 STOP 模式下定期唤醒 MCU。标准库使能它的方式是配置RTC-CRH和RTC-CRL对应位或者使用库函数RTC_WakeUpCmd(ENABLE)。典型配置是 LSE 作为时钟源分频系数选0x0C对应的 16384 分频唤醒周期算式为唤醒周期 分频值 / 32768秒// 使能唤醒定时器分频 16384唤醒周期约 0.5 秒 RTC_WakeUpConfig(RTC_WakeUpClock_CK_SPRE_16bits, 16384); RTC_WakeUpCmd(ENABLE); RTC_ITConfig(RTC_IT_WUT, ENABLE); NVIC_EnableIRQ(RTC_IRQn);参数说明RTC_WakeUpClock_CK_SPRE_16bits表示以秒脉冲作为唤醒定时器时钟第二个参数是重装载值16 位范围最大 65535所以最长一次唤醒周期约 65535 秒。在 STOP 模式下 RTC 和备份域继续供电唤醒定时器独立运行这比定时器方案的待机功耗低得多。如果只需要闹钟级别的定日起床功能用闹钟中断就够需要周期性采集或刷屏的用唤醒定时器更合理。两者不要同时都开F1 的 RTC 中断标志位有限同时触发容易互相干扰。5.3 两个高频踩坑LSI 漂移和影子寄存器回退用 LSI 做 RTC 时钟源的场景下一天误差几分钟是正常表现不是代码逻辑问题是 RC 振荡器精度天花板决定的。验证方法很简单把 RTC 的秒信号映射到一个 GPIO 上翻转然后用逻辑分析仪实测频率对比 1 Hz 的偏差即可知道走时误差来源。至于读时间回退的问题标准库的RTC_GetCounter()在 APB1 时钟远低于 RTC 时钟时会读到未同步的影子寄存器值典型表现是时间先往前走几秒又跳回来。解决的固定套路是读之前先调用RTC_WaitForSynchro()等待RSF标志置位这个等待成本很低但对数据完整性的提升是决定性的。把这一个调用固化成习惯比任何重试逻辑都稳。本文还有配套的精品资源点击获取
返回列表