
🔥铅笔小新z:个人主页
🎬博客专栏:stm32学习
💫滴水不绝,可穿石;步履不休,能至渊。

STM32 RTC 精讲:断电以后,时间为什么还能继续走
适用平台: STM32F103ZET6、STM32F1 HAL 库、STM32CubeMX
外设或技术点: RTC、LSE 32.768 kHz、备份域、BKP 备份寄存器、Unix 时间戳、闹钟中断
最终效果: 串口每秒输出日期和时间;复位后时间不丢;主电源断开后由 VBAT 继续计时;闹钟到点后点亮 PF8 上的 LED0
适合读者: 刚学完 GPIO、串口、定时器和中断,能看懂 C 语言和基本外设配置,但还没有完整项目经验
RTC 最容易被学成"会调用几个 HAL 函数"。真正做项目时,问题通常不在函数名,而在几个更底层的地方:电从哪里来、秒脉冲从哪里来、计数器里的数字怎样变成年月日、复位以后哪些数据还在、闹钟为什么没有进中断。
这篇文章不从函数清单开始,而是先沿着一条主线走:
电源选择 → 32.768 kHz 时钟 → 预分频得到 1 Hz → 32 位计数器累加 → 软件解释成日期时间 → 比较闹钟 → 进入中断。
主线:六个必须讲清的点
- RTC 为什么能"断电走时":它和 LSE 振荡器、BKP 备份寄存器一起待在备份域里,主电源消失后可以由 VBAT 接管。
- 1 Hz 是怎样来的 :32.768 kHz 经过
PRL + 1分频,得到每秒一次的TR_CLK。 - RTC 到底计的是什么 :F1 的
RTC_CNT本质上是一个秒计数器,不是开箱即用的完整日历。 - 日期为什么复位后会变成 2000-01-01:F1 没有独立的日期寄存器,HAL 把日期基线放在内存里。
- 怎样让日期也跨复位保存 :用 BKP 保存"是否初始化过"的标志,再把 Unix 时间戳放进
RTC_CNT。 - 闹钟为什么走另一个中断入口:闹钟比较走 EXTI Line 17,和秒中断、溢出中断不是同一个 NVIC 入口。
后面每讲完一个知识点,都会用"知识小结"收束成一段可以直接复述的话。
1. 原理:RTC 不是普通定时器
1.1 RTC 到底在计什么
RTC 的英文是 Real-Time Clock ,中文叫实时时钟。它最朴素的作用是:给系统提供一个不会因为主程序停机而消失的时间基准。
普通定时器计数的是总线时钟脉冲,单位可以是微秒、毫秒或一个自定义周期。主电源掉了,CPU 和外设都停了,定时器里的数也就没有意义了。RTC 的关注点不同,它希望得到的是"过了多少秒",并且希望这份秒数在系统断电时仍然保留。
在 STM32F103 里,RTC 内部有一个 32 位计数器。预分频器每秒送来一个脉冲,计数器就加一。这个"每秒加一"的关系,是后面所有时间、日期和闹钟功能的基础。
这里先分清三个东西:
- RTC 是芯片内部的低功耗计时外设。
- VBAT 是芯片上专门给备份域供电的引脚。
- 纽扣电池 是接在 VBAT 外面的备用电源。
它们不是同一个东西。RTC 是"计时的人",VBAT 是"接备用电源的接口",电池是"备用电源本身"。实验板上常见的是一颗 CR1220 3 V 纽扣电池,容量大约 40 mAh;它不需要给整个开发板供电,只需要维持备份域里很小的电流。
RTC 常出现在串口日志记录器、定时浇花器、智能电表、冷链温度记录仪这类设备里。它们的共同点是:主程序可以停,时间不能乱。
知识小结: RTC 是一个低功耗的秒级计时外设,核心价值是把时间保存在主程序之外。它能跨断电工作,不是因为 CPU 还在运行,而是因为它和 LSE 振荡器、BKP 寄存器一起位于备份域,主电源消失后可以由 VBAT 接管。RTC、VBAT 引脚和外接电池是三个不同层次的东西,不能混为一谈。
1.2 备份域:谁在停电后继续工作
STM32F103 的备份域可以理解成芯片里的一间"不断电小屋"。正常工作时,VDD 给整颗芯片供电;VDD 掉下去以后,电源选择电路把备份域切到 VBAT,CPU、GPIO、USART 等大部分模块停止工作,但备份域里的 RTC、LSE 振荡器和 BKP 寄存器还能继续运行。
#mermaid-svg-sq8IkDPcPh4PXtOA{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sq8IkDPcPh4PXtOA .error-icon{fill:#552222;}#mermaid-svg-sq8IkDPcPh4PXtOA .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sq8IkDPcPh4PXtOA .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sq8IkDPcPh4PXtOA .marker{fill:#333333;stroke:#333333;}#mermaid-svg-sq8IkDPcPh4PXtOA .marker.cross{stroke:#333333;}#mermaid-svg-sq8IkDPcPh4PXtOA svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-sq8IkDPcPh4PXtOA p{margin:0;}#mermaid-svg-sq8IkDPcPh4PXtOA .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster-label text{fill:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster-label span{color:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster-label span p{background-color:transparent;}#mermaid-svg-sq8IkDPcPh4PXtOA .label text,#mermaid-svg-sq8IkDPcPh4PXtOA span{fill:#333;color:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA .node rect,#mermaid-svg-sq8IkDPcPh4PXtOA .node circle,#mermaid-svg-sq8IkDPcPh4PXtOA .node ellipse,#mermaid-svg-sq8IkDPcPh4PXtOA .node polygon,#mermaid-svg-sq8IkDPcPh4PXtOA .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-sq8IkDPcPh4PXtOA .rough-node .label text,#mermaid-svg-sq8IkDPcPh4PXtOA .node .label text,#mermaid-svg-sq8IkDPcPh4PXtOA .image-shape .label,#mermaid-svg-sq8IkDPcPh4PXtOA .icon-shape .label{text-anchor:middle;}#mermaid-svg-sq8IkDPcPh4PXtOA .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sq8IkDPcPh4PXtOA .rough-node .label,#mermaid-svg-sq8IkDPcPh4PXtOA .node .label,#mermaid-svg-sq8IkDPcPh4PXtOA .image-shape .label,#mermaid-svg-sq8IkDPcPh4PXtOA .icon-shape .label{text-align:center;}#mermaid-svg-sq8IkDPcPh4PXtOA .node.clickable{cursor:pointer;}#mermaid-svg-sq8IkDPcPh4PXtOA .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-sq8IkDPcPh4PXtOA .arrowheadPath{fill:#333333;}#mermaid-svg-sq8IkDPcPh4PXtOA .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-sq8IkDPcPh4PXtOA .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-sq8IkDPcPh4PXtOA .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sq8IkDPcPh4PXtOA .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-sq8IkDPcPh4PXtOA .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sq8IkDPcPh4PXtOA .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster text{fill:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA .cluster span{color:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sq8IkDPcPh4PXtOA .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sq8IkDPcPh4PXtOA rect.text{fill:none;stroke-width:0;}#mermaid-svg-sq8IkDPcPh4PXtOA .icon-shape,#mermaid-svg-sq8IkDPcPh4PXtOA .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-sq8IkDPcPh4PXtOA .icon-shape p,#mermaid-svg-sq8IkDPcPh4PXtOA .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-sq8IkDPcPh4PXtOA .icon-shape .label rect,#mermaid-svg-sq8IkDPcPh4PXtOA .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-sq8IkDPcPh4PXtOA .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sq8IkDPcPh4PXtOA .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sq8IkDPcPh4PXtOA :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} VDD 掉电后停止
VDD 正常
VDD 掉电
备份域 Backup domain
LSE 振荡器
32.768 kHz
RTC 预分频器
和 32 位计数器
BKP_DRx
备份寄存器
VDD 3.3 V
电源选择
VBAT / CR1220 3 V
CPU、GPIO、USART
停止工作
这张图里最重要的一条线是 VBAT → 备份域。它说明"断电走时"并不是 RTC 自己发电,而是换了一个电源继续供电。
还有一个很容易忽略的硬件细节:LSE 的振荡器电路也在备份域里 。32.768 kHz 石英晶振焊在芯片外面,接在 OSC32_IN、OSC32_OUT(F103ZET6 上对应 PC14、PC15)之间,但驱动它的振荡器在芯片内部,并且可以由 VBAT 供电。所以主电源断掉以后,晶振和 RTC 仍然可以一起工作。
如果 VDD 和 VBAT 都没有了,备份域也会彻底失去电源。此时时间当然无法继续走,下一次上电只能重新设置。
实验板原理图里还能看到几个实际元件:LSE 晶振旁边有两只约 10 pF 的负载电容,VBAT 支路有 CR1220 电池座和 100 nF 去耦电容。具体电容值要以晶振的负载电容参数和 PCB 布线为准,不能看到 10 pF 就无脑照抄。
知识小结: "RTC 断电走时"的完整条件是:RTC 和 LSE 振荡器位于备份域,VBAT 有电,并且电源选择电路已经完成切换。VDD 掉电时 CPU 会停,RTC 不会停;VDD 和 VBAT 都掉电时,RTC 也会停。排查断电保持问题时,先量 VBAT,再检查 LSE 和备份域配置,不要只盯着 RTC 初始化代码。
1.3 从 32.768 kHz 到 1 Hz
RTC 的时钟源可以从三条路里选:
- LSE:外部 32.768 kHz 晶振,精度较好,振荡器位于备份域,适合电池供电的 RTC。
- LSI:芯片内部约 40 kHz 振荡器,省掉外部晶振,但频率会随温度和工艺漂移。
- HSE/128:把高速外部时钟再分频,能跑但不适合断电保持,主电源没了就没了。
这就是为什么做掉电计时时通常选 LSE。它不是为了"看起来高级",而是因为它同时满足两个条件:频率合适,而且能在 VBAT 下继续振荡。
底层选择最终会落到 RCC_BDCR:LSEON 控制 LSE 起振,RTCSEL 选择 RTC 时钟源,RTCEN 打开 RTC 时钟,BDRST 用于备份域复位。CubeMX 和 HAL 只是把这些位包装成了配置结构体。
#mermaid-svg-aFHNb0qnumQEI6XR{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-aFHNb0qnumQEI6XR .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-aFHNb0qnumQEI6XR .error-icon{fill:#552222;}#mermaid-svg-aFHNb0qnumQEI6XR .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-aFHNb0qnumQEI6XR .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-aFHNb0qnumQEI6XR .marker{fill:#333333;stroke:#333333;}#mermaid-svg-aFHNb0qnumQEI6XR .marker.cross{stroke:#333333;}#mermaid-svg-aFHNb0qnumQEI6XR svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-aFHNb0qnumQEI6XR p{margin:0;}#mermaid-svg-aFHNb0qnumQEI6XR .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-aFHNb0qnumQEI6XR .cluster-label text{fill:#333;}#mermaid-svg-aFHNb0qnumQEI6XR .cluster-label span{color:#333;}#mermaid-svg-aFHNb0qnumQEI6XR .cluster-label span p{background-color:transparent;}#mermaid-svg-aFHNb0qnumQEI6XR .label text,#mermaid-svg-aFHNb0qnumQEI6XR span{fill:#333;color:#333;}#mermaid-svg-aFHNb0qnumQEI6XR .node rect,#mermaid-svg-aFHNb0qnumQEI6XR .node circle,#mermaid-svg-aFHNb0qnumQEI6XR .node ellipse,#mermaid-svg-aFHNb0qnumQEI6XR .node polygon,#mermaid-svg-aFHNb0qnumQEI6XR .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-aFHNb0qnumQEI6XR .rough-node .label text,#mermaid-svg-aFHNb0qnumQEI6XR .node .label text,#mermaid-svg-aFHNb0qnumQEI6XR .image-shape .label,#mermaid-svg-aFHNb0qnumQEI6XR .icon-shape .label{text-anchor:middle;}#mermaid-svg-aFHNb0qnumQEI6XR .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-aFHNb0qnumQEI6XR .rough-node .label,#mermaid-svg-aFHNb0qnumQEI6XR .node .label,#mermaid-svg-aFHNb0qnumQEI6XR .image-shape .label,#mermaid-svg-aFHNb0qnumQEI6XR .icon-shape .label{text-align:center;}#mermaid-svg-aFHNb0qnumQEI6XR .node.clickable{cursor:pointer;}#mermaid-svg-aFHNb0qnumQEI6XR .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-aFHNb0qnumQEI6XR .arrowheadPath{fill:#333333;}#mermaid-svg-aFHNb0qnumQEI6XR .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-aFHNb0qnumQEI6XR .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-aFHNb0qnumQEI6XR .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aFHNb0qnumQEI6XR .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-aFHNb0qnumQEI6XR .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aFHNb0qnumQEI6XR .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-aFHNb0qnumQEI6XR .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-aFHNb0qnumQEI6XR .cluster text{fill:#333;}#mermaid-svg-aFHNb0qnumQEI6XR .cluster span{color:#333;}#mermaid-svg-aFHNb0qnumQEI6XR div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-aFHNb0qnumQEI6XR .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-aFHNb0qnumQEI6XR rect.text{fill:none;stroke-width:0;}#mermaid-svg-aFHNb0qnumQEI6XR .icon-shape,#mermaid-svg-aFHNb0qnumQEI6XR .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-aFHNb0qnumQEI6XR .icon-shape p,#mermaid-svg-aFHNb0qnumQEI6XR .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-aFHNb0qnumQEI6XR .icon-shape .label rect,#mermaid-svg-aFHNb0qnumQEI6XR .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-aFHNb0qnumQEI6XR .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-aFHNb0qnumQEI6XR .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-aFHNb0qnumQEI6XR :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} /128
RTCCLK
TR_CLK = 1 Hz
HSE 8 MHz
RTCSEL
LSE 32.768 kHz
LSI 约 40 kHz
RTC 预分频器
PRL + 1
RTC_CNT
每秒加 1
与 RTC_ALR 比较
RTC_Second
RTC_Overflow
RTC_Alarm
32.768 kHz 这个数字不是随便挑的,它等于 2^15 Hz。连续除以 2 十五次,正好得到 1 Hz。硬件预分频器不需要做复杂的十进制运算,就能得到整齐的秒脉冲。
分频公式是:
text
f(TR_CLK) = f(RTCCLK) / (PRL + 1)
所以 LSE 为 32.768 kHz 时:
text
PRL = 32768 - 1 = 32767 = 0x7FFF
这里最容易错的是把 32767 写成 32768。 预分频器从 0 开始计数,计到 32767 才产生一次输出,因此一个完整周期包含 32768 个输入时钟。类似"数 10 个数"从 0 数到 9,终点不是 10。
用高低电平方块表示一下这个关系(只表达"很多个输入边沿换一个输出脉冲",不画实际占空比):
#mermaid-svg-Fg95Zuie2WwJnFjn{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Fg95Zuie2WwJnFjn .error-icon{fill:#552222;}#mermaid-svg-Fg95Zuie2WwJnFjn .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Fg95Zuie2WwJnFjn .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Fg95Zuie2WwJnFjn .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Fg95Zuie2WwJnFjn .marker.cross{stroke:#333333;}#mermaid-svg-Fg95Zuie2WwJnFjn svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Fg95Zuie2WwJnFjn p{margin:0;}#mermaid-svg-Fg95Zuie2WwJnFjn .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster-label text{fill:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster-label span{color:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster-label span p{background-color:transparent;}#mermaid-svg-Fg95Zuie2WwJnFjn .label text,#mermaid-svg-Fg95Zuie2WwJnFjn span{fill:#333;color:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn .node rect,#mermaid-svg-Fg95Zuie2WwJnFjn .node circle,#mermaid-svg-Fg95Zuie2WwJnFjn .node ellipse,#mermaid-svg-Fg95Zuie2WwJnFjn .node polygon,#mermaid-svg-Fg95Zuie2WwJnFjn .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Fg95Zuie2WwJnFjn .rough-node .label text,#mermaid-svg-Fg95Zuie2WwJnFjn .node .label text,#mermaid-svg-Fg95Zuie2WwJnFjn .image-shape .label,#mermaid-svg-Fg95Zuie2WwJnFjn .icon-shape .label{text-anchor:middle;}#mermaid-svg-Fg95Zuie2WwJnFjn .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Fg95Zuie2WwJnFjn .rough-node .label,#mermaid-svg-Fg95Zuie2WwJnFjn .node .label,#mermaid-svg-Fg95Zuie2WwJnFjn .image-shape .label,#mermaid-svg-Fg95Zuie2WwJnFjn .icon-shape .label{text-align:center;}#mermaid-svg-Fg95Zuie2WwJnFjn .node.clickable{cursor:pointer;}#mermaid-svg-Fg95Zuie2WwJnFjn .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Fg95Zuie2WwJnFjn .arrowheadPath{fill:#333333;}#mermaid-svg-Fg95Zuie2WwJnFjn .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Fg95Zuie2WwJnFjn .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Fg95Zuie2WwJnFjn .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fg95Zuie2WwJnFjn .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Fg95Zuie2WwJnFjn .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fg95Zuie2WwJnFjn .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster text{fill:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn .cluster span{color:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Fg95Zuie2WwJnFjn .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Fg95Zuie2WwJnFjn rect.text{fill:none;stroke-width:0;}#mermaid-svg-Fg95Zuie2WwJnFjn .icon-shape,#mermaid-svg-Fg95Zuie2WwJnFjn .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Fg95Zuie2WwJnFjn .icon-shape p,#mermaid-svg-Fg95Zuie2WwJnFjn .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Fg95Zuie2WwJnFjn .icon-shape .label rect,#mermaid-svg-Fg95Zuie2WwJnFjn .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Fg95Zuie2WwJnFjn .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Fg95Zuie2WwJnFjn .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Fg95Zuie2WwJnFjn :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} PRL = 32767
TR_CLK:一秒输出一个脉冲
低
高
低
下一秒
LSE:一秒内有 32768 个边沿
低
高
低
高
......
F1 的预分频器还有两个寄存器:
RTC_PRL:预分频装载值,只写。实际由PRLH和PRLL拼成 20 位。RTC_DIV:预分频余数,只读。它不断向下计数,用来观察当前分频进行到哪里。
可以把 PRL 想成"一圈有多少格",DIV 想成"这一圈还剩多少格"。应用层通常只关心 PRL,不需要自己操作 DIV。
知识小结: RTC 的 1 Hz 时间基准来自
RTCCLK / (PRL + 1)。LSE 为 32.768 kHz 时,PRL必须写 32767,才能让TR_CLK每秒产生一次脉冲。PRL是装载值,不是分频比本身;少写或多写 1,都会让秒长出现固定比例误差。LSE 适合断电计时,LSI 只适合精度要求不高的场合,HSE/128 不能依靠 VBAT 维持。
1.4 三个核心部件:预分频器、计数器、闹钟寄存器
把 RTC 的内部结构压缩一下,实际只要抓住三块:
- 预分频器 把 RTCCLK 变成 1 Hz 的
TR_CLK。 - 计数器
RTC_CNT每收到一个TR_CLK就加一。F1 里它是 32 位,但拆成CNTH、CNTL两个 16 位寄存器访问。 - 闹钟寄存器
RTC_ALR保存一个目标值。RTC_CNT == RTC_ALR时产生闹钟事件。
| 寄存器 | 作用 | 读写特点 |
|---|---|---|
RTC_PRLH/PRLL |
装载 20 位预分频值 | 只写,配置时使用 |
RTC_DIVH/DIVL |
当前分频余数 | 只读,主要用于观察 |
RTC_CNTH/CNTL |
32 位秒计数器 | 可读可写,写入需进入配置模式 |
RTC_ALRH/ALRL |
32 位闹钟比较值 | 可读可写,写入需进入配置模式 |
RTC_CRL |
控制/状态 | 含 CNF、RTOFF、RSF 等关键位 |
RTC 还能产生三类事件:
- RTC_Second :每个
TR_CLK到来时触发,通常就是每秒一次。 - RTC_Overflow:32 位计数器从最大值回到 0 时触发。
- RTC_Alarm:计数器和闹钟值相等时触发。
这里有一个很容易混淆的硬件与软件差异:结构图上,闹钟事件和秒/溢出事件可能画到类似的逻辑门附近,但软件入口是分开的。
#mermaid-svg-zbvYvs9R9aKhVanl{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-zbvYvs9R9aKhVanl .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-zbvYvs9R9aKhVanl .error-icon{fill:#552222;}#mermaid-svg-zbvYvs9R9aKhVanl .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-zbvYvs9R9aKhVanl .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-zbvYvs9R9aKhVanl .marker{fill:#333333;stroke:#333333;}#mermaid-svg-zbvYvs9R9aKhVanl .marker.cross{stroke:#333333;}#mermaid-svg-zbvYvs9R9aKhVanl svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-zbvYvs9R9aKhVanl p{margin:0;}#mermaid-svg-zbvYvs9R9aKhVanl .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-zbvYvs9R9aKhVanl .cluster-label text{fill:#333;}#mermaid-svg-zbvYvs9R9aKhVanl .cluster-label span{color:#333;}#mermaid-svg-zbvYvs9R9aKhVanl .cluster-label span p{background-color:transparent;}#mermaid-svg-zbvYvs9R9aKhVanl .label text,#mermaid-svg-zbvYvs9R9aKhVanl span{fill:#333;color:#333;}#mermaid-svg-zbvYvs9R9aKhVanl .node rect,#mermaid-svg-zbvYvs9R9aKhVanl .node circle,#mermaid-svg-zbvYvs9R9aKhVanl .node ellipse,#mermaid-svg-zbvYvs9R9aKhVanl .node polygon,#mermaid-svg-zbvYvs9R9aKhVanl .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-zbvYvs9R9aKhVanl .rough-node .label text,#mermaid-svg-zbvYvs9R9aKhVanl .node .label text,#mermaid-svg-zbvYvs9R9aKhVanl .image-shape .label,#mermaid-svg-zbvYvs9R9aKhVanl .icon-shape .label{text-anchor:middle;}#mermaid-svg-zbvYvs9R9aKhVanl .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-zbvYvs9R9aKhVanl .rough-node .label,#mermaid-svg-zbvYvs9R9aKhVanl .node .label,#mermaid-svg-zbvYvs9R9aKhVanl .image-shape .label,#mermaid-svg-zbvYvs9R9aKhVanl .icon-shape .label{text-align:center;}#mermaid-svg-zbvYvs9R9aKhVanl .node.clickable{cursor:pointer;}#mermaid-svg-zbvYvs9R9aKhVanl .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-zbvYvs9R9aKhVanl .arrowheadPath{fill:#333333;}#mermaid-svg-zbvYvs9R9aKhVanl .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-zbvYvs9R9aKhVanl .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-zbvYvs9R9aKhVanl .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zbvYvs9R9aKhVanl .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-zbvYvs9R9aKhVanl .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zbvYvs9R9aKhVanl .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-zbvYvs9R9aKhVanl .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-zbvYvs9R9aKhVanl .cluster text{fill:#333;}#mermaid-svg-zbvYvs9R9aKhVanl .cluster span{color:#333;}#mermaid-svg-zbvYvs9R9aKhVanl div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-zbvYvs9R9aKhVanl .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-zbvYvs9R9aKhVanl rect.text{fill:none;stroke-width:0;}#mermaid-svg-zbvYvs9R9aKhVanl .icon-shape,#mermaid-svg-zbvYvs9R9aKhVanl .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-zbvYvs9R9aKhVanl .icon-shape p,#mermaid-svg-zbvYvs9R9aKhVanl .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-zbvYvs9R9aKhVanl .icon-shape .label rect,#mermaid-svg-zbvYvs9R9aKhVanl .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-zbvYvs9R9aKhVanl .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-zbvYvs9R9aKhVanl .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-zbvYvs9R9aKhVanl :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} TR_CLK 1 Hz
RTC_Second
RTC_CNT 溢出
RTC_Overflow
RTC 全局事件
RTC_IRQn = 3
RTC_IRQHandler
RTC_CNT == RTC_ALR
RTC_Alarm
EXTI Line 17
RTC_Alarm_IRQn = 41
RTC_Alarm_IRQHandler
在 F103 上:
- 秒中断和溢出中断走
RTC_IRQn,中断号是 3。 - 闹钟走
EXTI Line 17,对应RTC_Alarm_IRQn,中断号是 41。
闹钟单独走 EXTI17 的一个实际意义是:它可以作为低功耗模式的唤醒源。即使芯片大部分模块已经睡了,闹钟仍然可以把系统叫醒。
如果确实需要每秒进一次中断,可以调用 HAL_RTCEx_SetSecond_IT(&hrtc),同时打开 RTC global interrupt;本文的验证代码主要用主循环读取,目的是先看清计数器怎样变化。
知识小结: RTC 的主线是"预分频器产生秒脉冲,
RTC_CNT累加,RTC_ALR与计数器比较"。秒事件和溢出事件进入RTC_IRQHandler,闹钟事件经过 EXTI17 进入RTC_Alarm_IRQHandler。写回调函数时不要只记住函数名,要先确认事件走的是哪一个中断入口;否则中断已经触发,代码却进了错误的处理函数。
1.5 日期问题:F1 没有独立的日期寄存器
时间可以只保存"今天过了多少秒",但人要看的是年月日时分秒。这里就出现了 F1 RTC 的一个关键限制。
STM32F103 的 RTC 只有秒计数器,没有像 F4 那样独立的日期寄存器。HAL 提供了 HAL_RTC_SetDate 和 HAL_RTC_GetDate,接口看起来像在操作硬件日期,但在 F1 上,HAL_RTC_SetDate 主要只是把年月日写进 hrtc->DateToUpdate 这个内存结构体。芯片复位后,这个结构体重新初始化,日期基线就丢了。
所以会出现一个很典型的现象:
- 时间仍然正确,因为秒计数器在备份域里继续走。
- 日期却回到了默认的 2000-01-01,因为日期的起始点只在 RAM 里。
这不是 HAL 函数写错了,而是 F1 的硬件结构决定的。把 F1 当成"自带完整万年历"的芯片,就一定会在这里踩坑。
还有两个和时间表示有关的点要分清:
- BCD :用 4 位二进制表示一位十进制数,例如十进制 59 写成
0x59。它读起来像十进制,但本质仍是编码。 - Binary:直接把计数当作普通二进制数。做 Unix 时间戳时,应选择 Binary 数据格式。
如果把 BCD 值 0x59 当成十进制 59 去参与普通算术,结果就会错。看到 0x59 时先问自己:这是"BCD 的 59",还是"二进制的 89"?
更稳妥的做法是把 RTC_CNT 当作 Unix 时间戳:以 1970-01-01 00:00:00 为原点,保存从那一刻起经过的秒数。时间戳每秒加一,正好符合 RTC 计数器的工作方式。需要显示时,再用软件把秒数换算成年月日。
对初学者最省心的选择是:统一使用 Binary,把计数器当成纯秒数;只有需要直接观察 BCD 字段或驱动特定显示格式时,才去处理 BCD 编码。
32 位无符号时间戳可以表示到 2106 年左右。标准 Unix 时间戳常用有符号 32 位整数,会遇到 2038 年问题;这里用的是无符号 32 位计数器,范围更大。无论哪种表示,都有上限,工程上要确认产品寿命和溢出策略。
实际产品如果对日历完整性和长期精度要求更高,也常会选 DS3231 这类外置 RTC;把内置 RTC 的电源、分频、计数和中断链路理解清楚,换模块时主要就是驱动和寄存器差异。
知识小结: F1 的 RTC 计数器负责秒,不负责完整日历。
HAL_RTC_SetDate在 F1 上主要保存内存中的日期基线,所以复位后日期可能回到 2000-01-01,而时间仍然继续走。解决思路有两个:把年月日放进 BKP 备份寄存器,或者把 Unix 时间戳直接放进RTC_CNT。做时间戳时要使用 Binary 数据格式,不能把 BCD 编码值当成普通整数。
1.6 闹钟:比较相等,不是"大于等于"
闹钟的核心非常简单:RTC_CNT 和 RTC_ALR 相等时触发 RTC_Alarm。
这句话里有两个工程上的注意点:
- 它是相等比较,不是"只要超过就触发"。如果计数器已经越过目标值,而闹钟没有在正确时刻被使能,就可能错过这次事件。
- 闹钟值必须和当前计数器的表示方式一致。用日历式时间时,闹钟填时分秒;把
RTC_CNT当作 Unix 时间戳时,闹钟要填目标时间戳,不能只填一个"10 秒"的秒数。
HAL 里常用:
c
HAL_StatusTypeDef HAL_RTC_SetAlarm_IT(
RTC_HandleTypeDef *hrtc,
RTC_AlarmTypeDef *sAlarm,
uint32_t Format);
参数含义:
hrtc:RTC 句柄。sAlarm:闹钟结构体,里面包含AlarmTime和闹钟 ID。Format:RTC_FORMAT_BIN或RTC_FORMAT_BCD,表示结构体中的时间值怎样解释。
返回值是 HAL_StatusTypeDef,常见结果是 HAL_OK、HAL_ERROR、HAL_BUSY 或 HAL_TIMEOUT。返回值不要丢,配置失败时至少要让程序进入错误处理。
另一个函数 HAL_RTC_SetAlarm 只设置闹钟,不主动打开中断;如果目标是"到点自动执行动作",通常用带 _IT 的版本。
闹钟触发后的调用链可以记成:
text
CNT == ALR
↓
RTC_Alarm 事件
↓
EXTI Line 17
↓
RTC_Alarm_IRQHandler()
↓
HAL_RTC_AlarmIRQHandler()
↓
HAL_RTC_AlarmAEventCallback()
F103 只有一个闹钟,所以 RTC_ALARM_A 是唯一可用的 ID。代码里显式写出来,比依赖结构体初始化为 0 更容易读。
知识小结: 闹钟是
RTC_CNT与RTC_ALR的相等比较,不是普通定时器那种"计满就更新"。秒/溢出事件进RTC_IRQHandler,闹钟事件经过 EXTI17 进RTC_Alarm_IRQHandler,最终到达HAL_RTC_AlarmAEventCallback。如果只调用HAL_RTC_SetAlarm,不会有自动中断;需要中断就用HAL_RTC_SetAlarm_IT,并确认 EXTI17 和 NVIC 都已经打开。
2. 参数与硬件:先确认板子真的能"备电"
这一节把容易漏掉的硬件条件集中列出。软件配置再漂亮,如果 LSE 没起振、VBAT 没电,实验也不会得到正确结果。
| 项目 | 本实验取值 | 说明 |
|---|---|---|
| MCU | STM32F103ZET6 | 使用 STM32F1 HAL |
| RTC 时钟源 | LSE | 外部 32.768 kHz 晶振 |
| LSE 引脚 | OSC32_IN / OSC32_OUT(PC14/PC15) |
晶振接在两脚之间 |
| LSE 晶振示例 | Q13FC13500004,32.768 kHz | 具体型号可按板卡替换 |
| LSE 负载电容 | 约 10 pF(以实际晶振参数为准) | 两只电容分别到地 |
| 预分频值 | 32767 | 得到 1 Hz |
| 备份电源 | VBAT 接 CR1220 3 V | 实验板电池约 40 mAh |
| 备份去耦 | 100 nF 量级 | 靠近 VBAT 引脚布置 |
| 调试串口 | USART1,115200 8N1 | 以板卡实际引脚和 BSP 为准 |
| 闹钟指示灯 | PF8 接 LED0 | 低电平点亮 |
| RTC 输出 | PC13/TAMPER-RTC,可选 | 可输出秒脉冲、64 分频时钟或闹钟脉冲 |
| 时间基准 | 1970-01-01 00:00:00 | 完整工程使用 Unix 时间戳 |
硬件连接可以简化成下面这样:
#mermaid-svg-M8bjT3WimCnYUIIC{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-M8bjT3WimCnYUIIC .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-M8bjT3WimCnYUIIC .error-icon{fill:#552222;}#mermaid-svg-M8bjT3WimCnYUIIC .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-M8bjT3WimCnYUIIC .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-M8bjT3WimCnYUIIC .marker{fill:#333333;stroke:#333333;}#mermaid-svg-M8bjT3WimCnYUIIC .marker.cross{stroke:#333333;}#mermaid-svg-M8bjT3WimCnYUIIC svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-M8bjT3WimCnYUIIC p{margin:0;}#mermaid-svg-M8bjT3WimCnYUIIC .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-M8bjT3WimCnYUIIC .cluster-label text{fill:#333;}#mermaid-svg-M8bjT3WimCnYUIIC .cluster-label span{color:#333;}#mermaid-svg-M8bjT3WimCnYUIIC .cluster-label span p{background-color:transparent;}#mermaid-svg-M8bjT3WimCnYUIIC .label text,#mermaid-svg-M8bjT3WimCnYUIIC span{fill:#333;color:#333;}#mermaid-svg-M8bjT3WimCnYUIIC .node rect,#mermaid-svg-M8bjT3WimCnYUIIC .node circle,#mermaid-svg-M8bjT3WimCnYUIIC .node ellipse,#mermaid-svg-M8bjT3WimCnYUIIC .node polygon,#mermaid-svg-M8bjT3WimCnYUIIC .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-M8bjT3WimCnYUIIC .rough-node .label text,#mermaid-svg-M8bjT3WimCnYUIIC .node .label text,#mermaid-svg-M8bjT3WimCnYUIIC .image-shape .label,#mermaid-svg-M8bjT3WimCnYUIIC .icon-shape .label{text-anchor:middle;}#mermaid-svg-M8bjT3WimCnYUIIC .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-M8bjT3WimCnYUIIC .rough-node .label,#mermaid-svg-M8bjT3WimCnYUIIC .node .label,#mermaid-svg-M8bjT3WimCnYUIIC .image-shape .label,#mermaid-svg-M8bjT3WimCnYUIIC .icon-shape .label{text-align:center;}#mermaid-svg-M8bjT3WimCnYUIIC .node.clickable{cursor:pointer;}#mermaid-svg-M8bjT3WimCnYUIIC .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-M8bjT3WimCnYUIIC .arrowheadPath{fill:#333333;}#mermaid-svg-M8bjT3WimCnYUIIC .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-M8bjT3WimCnYUIIC .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-M8bjT3WimCnYUIIC .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M8bjT3WimCnYUIIC .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-M8bjT3WimCnYUIIC .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M8bjT3WimCnYUIIC .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-M8bjT3WimCnYUIIC .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-M8bjT3WimCnYUIIC .cluster text{fill:#333;}#mermaid-svg-M8bjT3WimCnYUIIC .cluster span{color:#333;}#mermaid-svg-M8bjT3WimCnYUIIC div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-M8bjT3WimCnYUIIC .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-M8bjT3WimCnYUIIC rect.text{fill:none;stroke-width:0;}#mermaid-svg-M8bjT3WimCnYUIIC .icon-shape,#mermaid-svg-M8bjT3WimCnYUIIC .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-M8bjT3WimCnYUIIC .icon-shape p,#mermaid-svg-M8bjT3WimCnYUIIC .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-M8bjT3WimCnYUIIC .icon-shape .label rect,#mermaid-svg-M8bjT3WimCnYUIIC .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-M8bjT3WimCnYUIIC .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-M8bjT3WimCnYUIIC .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-M8bjT3WimCnYUIIC :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} OSC32_IN / PC14
OSC32_OUT / PC15
VBAT
PF8,低电平点亮
STM32F103ZET6
32.768 kHz 晶振
C1 ≈ 10 pF
C2 ≈ 10 pF
CR1220 3 V
100 nF
LED0
GND
几个硬件检查点:
- 晶振两端不要直接短在一起,负载电容要按晶振手册和 PCB 实际情况选。
- VBAT 电池座的正负极不能反,装电池前先看丝印。
- 有些板卡会在 VBAT 支路使用 BAT54C 这类双二极管做电源选择,外部电源不要反灌。
- 如果板子只画了 VBAT 焊盘而没有电池座,可以临时接纽扣电池或超级电容做验证,但要注意电压范围。
- 本文实验板的 PF8 是低电平点亮,初始化时先输出高电平,表示"灭";闹钟回调里再拉低。若你的板子是高电平点亮,把这两个电平对调。
- 如果使用 PC13 做 RTC OUT,它就不能同时按普通 GPIO 随意使用,具体复用关系要查芯片引脚定义。
知识小结: RTC 掉电保持是"芯片内部备份域 + 外部 LSE 晶振 + VBAT 电源"共同完成的。软件上要选 LSE 和 32767 分频,硬件上要保证晶振、负载电容、VBAT 电池和去耦都正常。只配置 CubeMX 而不检查板子,是排查 RTC 问题时最常见的遗漏。
3. 配置:在 CubeMX 里只改与主线有关的项
配置工具里项目很多,真正影响这条主线的主要是下面几步。截图不用逐张记,按参数核对即可。
3.1 先让 LSE 起振
在 Pinout & Configuration 中打开 System Core → RCC,把低速外部时钟设为晶体/陶瓷谐振器模式。然后在 Clock Configuration 中确认:
- LSE 已使能。
- RTC 时钟源选择 LSE。
- 系统主频可以保持常规的 72 MHz,APB1 为 36 MHz;RTC 不依赖 APB1 计数。
如果只看到 LSI 可选,通常说明 LSE 还没有被打开,或者当前封装/板级配置没有提供 LSE 引脚。
生成代码里,RTC 时钟源的选择通常类似下面这样:
c
RCC_PeriphCLKInitTypeDef rtc_clk = {0};
RCC_OscInitTypeDef osc = {0};
osc.OscillatorType = RCC_OSCILLATORTYPE_LSE;
osc.LSEState = RCC_LSE_ON; /* 先让 32.768 kHz 晶振起振 */
if (HAL_RCC_OscConfig(&osc) != HAL_OK)
{
Error_Handler();
}
rtc_clk.PeriphClockSelection = RCC_PERIPHCLK_RTC;
rtc_clk.RTCClockSelection = RCC_RTCCLKSOURCE_LSE; /* RTC 使用 32.768 kHz LSE */
if (HAL_RCCEx_PeriphCLKConfig(&rtc_clk) != HAL_OK)
{
Error_Handler(); /* 时钟源配置失败时不要继续初始化 RTC */
}
3.2 RTC 参数
在 Timers → RTC 中勾选 Activate Clock Source,然后核对:
| 配置项 | 选择 | 原因 |
|---|---|---|
| Data Format | Binary Data Format | 完整工程把计数器当 Unix 时间戳 |
| Auto Predivider Calculation | Enabled | 让工具根据时钟源计算分频值 |
| Asynchronous Predivider | 32767 | 32.768 kHz 分频到 1 Hz |
| RTC OUT | Disable | 本实验不把 RTC 时钟输出到引脚 |
| Tamper | Disable | 不涉及入侵检测 |
如果你先做"只显示时分秒"的最小实验,也可以暂时使用 BCD 数据格式,配合 HAL_RTC_SetTime 和 HAL_RTC_GetTime。一旦改成时间戳方案,就要切到 Binary,避免把两种编码混在一起。
3.3 中断和 GPIO
闹钟实验需要:
- 在 RTC 配置中打开
RTC alarm interrupt through EXTI line 17。 - 在 NVIC 中使能
RTC_Alarm_IRQn,设置一个合适的优先级。 - 如果还要用秒中断,再使能
RTC global interrupt,它对应RTC_IRQn。 - 把 PF8 配成推挽输出,初始电平设为高,保证 LED0 先熄灭。
- USART1 配成 115200 8N1,并按板级 BSP 重定向
printf;串口本身的细节不展开。
3.4 生成代码里的初始化链
MX_RTC_Init 负责填写 RTC 参数并调用 HAL_RTC_Init;HAL_RTC_MspInit 负责底层时钟、备份域访问和 NVIC。简化后的关键代码如下:
c
void MX_RTC_Init(void)
{
hrtc.Instance = RTC;
hrtc.Init.AsynchPrediv = 32767U; /* 32.768 kHz 分频到 1 Hz */
hrtc.Init.OutPut = RTC_OUTPUTSOURCE_NONE; /* 不把 RTC 时钟送到 PC13 */
hrtc.Init.DataFormat = RTC_BINARY_FORMAT; /* 时间戳模式必须使用二进制 */
if (HAL_RTC_Init(&hrtc) != HAL_OK)
{
Error_Handler();
}
}
c
void HAL_RTC_MspInit(RTC_HandleTypeDef *rtcHandle)
{
if (rtcHandle->Instance == RTC)
{
__HAL_RCC_PWR_CLK_ENABLE(); /* 访问备份域需要 PWR 时钟 */
__HAL_RCC_BKP_CLK_ENABLE(); /* BKP 备份寄存器时钟 */
HAL_PWR_EnableBkUpAccess(); /* 解除备份域写保护 */
__HAL_RCC_RTC_ENABLE(); /* 给 RTC 提供总线时钟 */
__HAL_RTC_ALARM_EXTI_ENABLE_IT(); /* 允许 EXTI17 产生中断 */
__HAL_RTC_ALARM_EXTI_ENABLE_RISING_EDGE();
HAL_NVIC_SetPriority(RTC_Alarm_IRQn, 0U, 0U);
HAL_NVIC_EnableIRQ(RTC_Alarm_IRQn); /* 闹钟走 EXTI17 对应的中断 */
}
}
初始化顺序可以理解成下面这条链:
RTC BKP RCC CPU RTC BKP RCC CPU #mermaid-svg-iTd6uzvoxyaJQr8k{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-iTd6uzvoxyaJQr8k .error-icon{fill:#552222;}#mermaid-svg-iTd6uzvoxyaJQr8k .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-iTd6uzvoxyaJQr8k .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-iTd6uzvoxyaJQr8k .marker{fill:#333333;stroke:#333333;}#mermaid-svg-iTd6uzvoxyaJQr8k .marker.cross{stroke:#333333;}#mermaid-svg-iTd6uzvoxyaJQr8k svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-iTd6uzvoxyaJQr8k p{margin:0;}#mermaid-svg-iTd6uzvoxyaJQr8k .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iTd6uzvoxyaJQr8k text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iTd6uzvoxyaJQr8k .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-iTd6uzvoxyaJQr8k .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-iTd6uzvoxyaJQr8k #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-iTd6uzvoxyaJQr8k .sequenceNumber{fill:white;}#mermaid-svg-iTd6uzvoxyaJQr8k #sequencenumber{fill:#333;}#mermaid-svg-iTd6uzvoxyaJQr8k #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-iTd6uzvoxyaJQr8k .messageText{fill:#333;stroke:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iTd6uzvoxyaJQr8k .labelText,#mermaid-svg-iTd6uzvoxyaJQr8k .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .loopText,#mermaid-svg-iTd6uzvoxyaJQr8k .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-iTd6uzvoxyaJQr8k .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-iTd6uzvoxyaJQr8k .noteText,#mermaid-svg-iTd6uzvoxyaJQr8k .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-iTd6uzvoxyaJQr8k .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iTd6uzvoxyaJQr8k .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iTd6uzvoxyaJQr8k .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-iTd6uzvoxyaJQr8k .actorPopupMenu{position:absolute;}#mermaid-svg-iTd6uzvoxyaJQr8k .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-iTd6uzvoxyaJQr8k .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-iTd6uzvoxyaJQr8k .actor-man circle,#mermaid-svg-iTd6uzvoxyaJQr8k line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-iTd6uzvoxyaJQr8k :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 打开 PWR/BKP 时钟,解除备份域保护 打开 LSE,选择 LSE 作为 RTCCLK 写入 PRL = 32767 进入配置模式,写入并同步 RTOFF/RSF 就绪 读写 BKP_DR1 保存初始化标志
这里的 CNF 和 RTOFF 值得单独说一下:
CNF = 1表示进入配置模式,此时才允许修改CNT、ALR、PRL等核心寄存器。RTOFF = 1表示上一次写操作已经完成,RTC 可以接收新操作。
在 F1 的 RTC_CRL 中,CNF 是 bit 4,RTOFF 是 bit 5,RSF 是 bit 3;RTC_CRH 里的 SECIE、ALRIE、OWIE 分别对应秒、闹钟和溢出中断使能。读源码时看到这些位,不要把它们当成普通 GPIO 位来理解。
所以一段安全的写操作不是"直接写寄存器",而是:
text
等 RTOFF = 1
↓
置 CNF = 1
↓
写目标寄存器
↓
清 CNF = 0
↓
再等 RTOFF = 1
HAL 里的 RTC_EnterInitMode 和 RTC_ExitInitMode 就是围绕这两个状态位做保护。F1 的 RTC_ReadTimeCounter、RTC_WriteTimeCounter 默认是源文件里的 static 私有函数,应用层不能直接调用。学习阶段临时去掉 static 可以验证计数器,但正式工程不要靠修改库源码来获得接口;更稳妥的是自己写少量寄存器访问函数,或者使用芯片厂商提供的底层接口。
3.5 与主线有关的 HAL 函数
不需要背完整库函数,重点看下面几个函数怎样落到 RTC 寄存器。
HAL_RTC_Init
c
/* 初始化 RTC:写入预分频值,并等待配置同步完成 */
HAL_StatusTypeDef HAL_RTC_Init(RTC_HandleTypeDef *hrtc);
参数只有 RTC 句柄。它会打开 RTC 时钟、进入配置模式、写入预分频值,并等待同步完成。返回值是 HAL_StatusTypeDef,初始化失败时不要继续使用 RTC。关键寄存器是 RTC_PRLH/PRLL、RTC_CRL,关键状态位是 RTOFF。
HAL_RTC_SetTime
c
/* 设置时分秒;Format 决定结构体中的字段按二进制还是 BCD 解释 */
HAL_StatusTypeDef HAL_RTC_SetTime(
RTC_HandleTypeDef *hrtc,
RTC_TimeTypeDef *sTime,
uint32_t Format);
sTime 提供时、分、秒;Format 告诉 HAL 这些字段是 RTC_FORMAT_BIN 还是 RTC_FORMAT_BCD。函数最终会通过内部的计数器写函数更新 RTC_CNT。它返回状态码,不能把"函数调用了"当成"时间写成功了"。
HAL_RTC_GetTime
c
/* 读取时分秒;sTime 是输出参数,底层会处理 CNTH/CNTL 的进位 */
HAL_StatusTypeDef HAL_RTC_GetTime(
RTC_HandleTypeDef *hrtc,
RTC_TimeTypeDef *sTime,
uint32_t Format);
sTime 是输出参数。F1 的底层读操作会先读 CNTH,再读 CNTL,然后再读一次 CNTH;如果两次高 16 位不同,说明读低 16 位时发生了进位,需要重新组合。这个细节在 4.4 节的 RTC_ReadCounter 里直接实现了。
HAL_RTC_SetDate / HAL_RTC_GetDate
c
/* F1 的日期接口主要维护 DateToUpdate,并没有独立的日期硬件寄存器 */
HAL_StatusTypeDef HAL_RTC_SetDate(
RTC_HandleTypeDef *hrtc,
RTC_DateTypeDef *sDate,
uint32_t Format);
HAL_StatusTypeDef HAL_RTC_GetDate(
RTC_HandleTypeDef *hrtc,
RTC_DateTypeDef *sDate,
uint32_t Format);
接口形式和时间函数相似,但 F1 没有独立的日期寄存器。HAL_RTC_SetDate 主要把年月日保存到 hrtc->DateToUpdate,复位后这份内存基线会丢失,这就是日期回到 2000-01-01 的根源。
HAL_RTCEx_BKUPRead / HAL_RTCEx_BKUPWrite
c
/* 读写备份寄存器;这些寄存器位于备份域,可在 VBAT 下保持 */
uint32_t HAL_RTCEx_BKUPRead(
RTC_HandleTypeDef *hrtc,
uint32_t BackupRegister);
void HAL_RTCEx_BKUPWrite(
RTC_HandleTypeDef *hrtc,
uint32_t BackupRegister,
uint32_t Data);
读写的是 BKP 备份寄存器,不是普通 RAM。RTC_BKP_DR1 这类编号来自芯片定义,F103ZET6 有多组可用备份寄存器。它们和 RTC 一样能在 VBAT 下保持,适合保存"初始化过"的魔数;F1 的单个 BKP 数据寄存器是 16 位宽,魔数不要超过 0xFFFF。
如果把 HAL 的计数器写函数简化出来,核心逻辑就是:
c
HAL_StatusTypeDef RTC_WriteTimeCounter(
RTC_HandleTypeDef *hrtc,
uint32_t TimeCounter)
{
HAL_StatusTypeDef status = HAL_OK;
/* 配置核心寄存器前,先等待上一次 RTC 操作同步完成 */
if (RTC_EnterInitMode(hrtc) != HAL_OK)
{
return HAL_ERROR;
}
/* CNT 是 32 位,但硬件用两个 16 位寄存器拼起来 */
WRITE_REG(hrtc->Instance->CNTH, (TimeCounter >> 16U));
WRITE_REG(hrtc->Instance->CNTL, (TimeCounter & 0xFFFFU));
/* 退出配置模式后等待 RTOFF=1,保证写入真正生效 */
if (RTC_ExitInitMode(hrtc) != HAL_OK)
{
status = HAL_ERROR;
}
return status;
}
这段代码里的 RTC_EnterInitMode 和 RTC_ExitInitMode 默认也是库内部函数。理解它们的时序即可,正式工程不要为了调用它们而随意修改 HAL 源码。
知识小结: RTC 配置的关键不是把界面点满,而是保证 LSE 先稳定、RTC 时钟源正确、预分频值正确、备份域访问已经打开。修改
CNT/ALR/PRL前要等RTOFF,再置CNF进入配置模式,写完退出并等待同步。HAL 的 F1 计数器读写函数默认是私有实现,正式项目不应通过删static来调用。
4. 代码:先跑通最小核心,再补完整工程
下面先给一个能验证"时间会走、复位不重置"的最小核心,再解释日期问题,最后给出基于时间戳的完整工程代码。
4.1 最小核心:设置一次时间,然后每秒读取
这段代码放在 main.c 的用户代码区即可。它使用 BKP 备份寄存器保存一个魔数,只有在魔数不存在时才设置初始时间。
c
#include "main.h"
#include <stdio.h>
#define RTC_MAGIC 0xA55AU
/* 只在第一次上电或电池掉电后设置初始时间。
* 不能用"当前时间是否为 00:00:00"判断是否初始化,
* 因为 00:00:00 本身也是一个合法时间。 */
static void RTC_InitTimeOnce(void)
{
RTC_TimeTypeDef time = {0};
if (HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1) == RTC_MAGIC)
{
return; /* 备份寄存器还在,说明之前已经初始化过 */
}
time.Hours = 8U;
time.Minutes = 0U;
time.Seconds = 0U;
/* RTC_FORMAT_BIN 表示结构体里填的是普通二进制数;
* 实际存储格式由 hrtc.Init.DataFormat 决定。 */
if (HAL_RTC_SetTime(&hrtc, &time, RTC_FORMAT_BIN) == HAL_OK)
{
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_MAGIC);
}
}
int main(void)
{
RTC_TimeTypeDef now = {0};
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
MX_RTC_Init();
/* LED0 低电平点亮,先拉高让它熄灭 */
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);
RTC_InitTimeOnce();
printf("RTC start\r\n");
while (1)
{
if (HAL_RTC_GetTime(&hrtc, &now, RTC_FORMAT_BIN) == HAL_OK)
{
printf("Current Time: %02u:%02u:%02u\r\n",
now.Hours, now.Minutes, now.Seconds);
}
/* 这里只是演示;精确定时应在秒中断或秒标志变化时打印 */
HAL_Delay(1000U);
}
}
预期现象:
text
RTC start
Current Time: 08:00:00
Current Time: 08:00:01
Current Time: 08:00:02
...
按复位键后,时间不应该回到 08:00:00,因为 BKP 里的魔数还在,RTC_InitTimeOnce 会直接返回。
为什么不用"读到 00:00:00 就初始化"?因为设备可能真的在午夜零点运行。恰好零点复位时,程序会误判成"从没设置过",再次覆盖时间。这个问题概率低,但逻辑上是错的。用一个专门的备份标志,判断意图更明确。
4.2 把日期也保存下来
如果直接使用 HAL_RTC_SetDate 和 HAL_RTC_GetDate,在 F1 上会看到这样的结果:
text
2035-07-15 08:00:08
2035-07-15 08:00:09
... 复位 ...
2000-01-01 08:00:11
时间继续走,日期却回到了默认值。原因前面已经说过:秒计数器在备份域,日期基线在 RAM。
解决方式是把"年月日时分秒"先转成一个 Unix 时间戳,再写入 RTC_CNT。读取时做反方向转换:
text
日期时间 --换算--> 秒数 --写入--> RTC_CNT
RTC_CNT --每秒加一--> 新的秒数 --换算--> 日期时间
下面是最小核心中的关键调用,完整实现放在 4.4 节:
c
RTC_DateTime_t dt = {
.year = 2035U,
.month = 7U,
.day = 15U,
.hour = 8U,
.minute = 0U,
.second = 0U
};
uint32_t timestamp = 0U;
DateTime_To_Timestamp(&dt, ×tamp);
RTC_WriteCounter(timestamp); /* 写入 32 位秒计数器 */
uint32_t now = RTC_ReadCounter(); /* 上电后读出当前秒数 */
Timestamp_To_DateTime(now, &dt); /* 软件换算成日期时间 */
这里把 1970-01-01 作为原点,换算时要做闰年判断。完整工程使用循环累加年份和月份,逻辑直观,适合初学者阅读。需要注意,这种写法把时间当作"本地时间"处理,没有处理时区和夏令时;产品里通常应该统一存 UTC,再按地区转换显示。
4.3 闹钟核心:到点后只做一件事
如果使用普通的日历式时间,可以直接这样设置一个 10 秒后的闹钟:
c
static volatile uint8_t alarm_flag = 0U;
static void RTC_StartAlarm(void)
{
RTC_AlarmTypeDef alarm = {0};
alarm.Alarm = RTC_ALARM_A; /* F103 只有一个闹钟 */
alarm.AlarmTime.Hours = 12U;
alarm.AlarmTime.Minutes = 0U;
alarm.AlarmTime.Seconds = 10U;
/* _IT 版本会打开闹钟中断,普通 SetAlarm 不会自动开中断 */
if (HAL_RTC_SetAlarm_IT(&hrtc, &alarm, RTC_FORMAT_BIN) != HAL_OK)
{
Error_Handler();
}
}
/* 这个回调由 HAL_RTC_AlarmIRQHandler 最终调用。
* 中断里只置标志,打印和 GPIO 操作放到主循环,避免拉长中断时间。 */
void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *rtc_handle)
{
if (rtc_handle->Instance == RTC)
{
alarm_flag = 1U;
}
}
主循环里检测标志:
c
if (alarm_flag != 0U)
{
alarm_flag = 0U;
printf("alarm interrupt trigger!\r\n");
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET); /* LED0 点亮 */
}
这段代码对应"先设置 12:00:00,再设置 12:00:10 闹钟"的实验。闹钟到点后,串口打印 alarm interrupt trigger!,PF8 变低,LED0 点亮。
如果完整工程已经把 RTC_CNT 当成 Unix 时间戳,闹钟目标就应该是一个绝对时间戳,而不是单独填 10 秒。完整代码会在 4.4 节用 RTC_Alarm_SetIn(10) 把"当前时间 + 10 秒"写进 ALR。
4.4 完整工程代码
下面给出应用层需要自己维护的文件。CubeMX 生成的启动文件、时钟底层和串口底层不需要重写,但 MX_RTC_Init、HAL_RTC_MspInit 和中断入口要按这里核对。
rtc_clock.h
c
#ifndef RTC_CLOCK_H
#define RTC_CLOCK_H
#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>
typedef struct
{
uint16_t year; /* 1970 到 2100 */
uint8_t month; /* 1 到 12 */
uint8_t day; /* 1 到 31 */
uint8_t hour; /* 0 到 23 */
uint8_t minute; /* 0 到 59 */
uint8_t second; /* 0 到 59 */
uint8_t weekday; /* 0 表示周日,1 到 6 表示周一到周六 */
} RTC_DateTime_t;
/* 首次上电时写入默认时间;已有魔数时保持当前计数器不变 */
void RTC_Clock_Init(void);
/* 读取当前计数器并换算成日期时间 */
bool RTC_Clock_Get(RTC_DateTime_t *dt);
/* 把日期时间换算成时间戳并写入计数器 */
bool RTC_Clock_Set(const RTC_DateTime_t *dt);
/* 以当前计数器为基准,设置若干秒后的闹钟 */
bool RTC_Alarm_SetIn(uint32_t seconds);
#endif
rtc_clock.c
c
#include "main.h"
#include "rtc_clock.h"
#define RTC_MAGIC 0xA55AU
#define RTC_WAIT_MS 20U
extern RTC_HandleTypeDef hrtc;
/* 每个月的天数,闰年的二月在函数里单独处理 */
static const uint8_t days_in_month_table[12] =
{31U, 28U, 31U, 30U, 31U, 30U, 31U, 31U, 30U, 31U, 30U, 31U};
static bool Is_Leap_Year(uint16_t year)
{
return ((year % 4U == 0U && year % 100U != 0U) || (year % 400U == 0U));
}
static uint8_t Days_In_Month(uint16_t year, uint8_t month)
{
if (month < 1U || month > 12U)
{
return 0U;
}
if (month == 2U && Is_Leap_Year(year))
{
return 29U;
}
return days_in_month_table[month - 1U];
}
static bool Is_Valid_DateTime(const RTC_DateTime_t *dt)
{
if (dt == NULL)
{
return false;
}
if (dt->year < 1970U || dt->year > 2100U)
{
return false;
}
if (dt->month < 1U || dt->month > 12U)
{
return false;
}
if (dt->day < 1U || dt->day > Days_In_Month(dt->year, dt->month))
{
return false;
}
if (dt->hour > 23U || dt->minute > 59U || dt->second > 59U)
{
return false;
}
return (dt->weekday <= 6U);
}
/* 把日期时间换算成从 1970-01-01 00:00:00 起的秒数。
* 先累加整年,再累加整月,最后加上当月已经过去的天数。 */
static bool DateTime_To_Timestamp(const RTC_DateTime_t *dt, uint32_t *timestamp)
{
uint32_t days = 0U;
if (dt == NULL || timestamp == NULL || !Is_Valid_DateTime(dt))
{
return false;
}
for (uint16_t year = 1970U; year < dt->year; year++)
{
days += Is_Leap_Year(year) ? 366U : 365U;
}
for (uint8_t month = 1U; month < dt->month; month++)
{
days += Days_In_Month(dt->year, month);
}
days += (uint32_t)dt->day - 1U;
/* 1970-01-01 是周四;以周日为 0,则偏移是 4 */
*timestamp = days * 86400U
+ (uint32_t)dt->hour * 3600U
+ (uint32_t)dt->minute * 60U
+ (uint32_t)dt->second;
return true;
}
/* 把秒数反算成日期时间。循环次数很少,先保证逻辑清楚。 */
static bool Timestamp_To_DateTime(uint32_t timestamp, RTC_DateTime_t *dt)
{
uint32_t days;
uint32_t seconds_in_day;
uint16_t year = 1970U;
uint8_t month = 1U;
if (dt == NULL)
{
return false;
}
days = timestamp / 86400U;
seconds_in_day = timestamp % 86400U;
dt->hour = (uint8_t)(seconds_in_day / 3600U);
dt->minute = (uint8_t)((seconds_in_day % 3600U) / 60U);
dt->second = (uint8_t)(seconds_in_day % 60U);
dt->weekday = (uint8_t)((days + 4U) % 7U);
while (days >= 365U && year <= 2100U)
{
uint16_t days_in_year = Is_Leap_Year(year) ? 366U : 365U;
if (days < days_in_year)
{
break;
}
days -= days_in_year;
year++;
}
if (year > 2100U)
{
return false;
}
while (month <= 12U)
{
uint8_t days_in_this_month = Days_In_Month(year, month);
if (days < days_in_this_month)
{
break;
}
days -= days_in_this_month;
month++;
}
if (month > 12U)
{
return false;
}
dt->year = year;
dt->month = month;
dt->day = (uint8_t)days + 1U;
return true;
}
/* 等待 RTOFF=1,表示 RTC 上一次操作已经同步完成。 */
static HAL_StatusTypeDef RTC_WaitReady(void)
{
uint32_t tickstart = HAL_GetTick();
while ((hrtc.Instance->CRL & RTC_CRL_RTOFF) == 0U)
{
if ((HAL_GetTick() - tickstart) > RTC_WAIT_MS)
{
return HAL_TIMEOUT;
}
}
return HAL_OK;
}
/* 读 CNT 时必须读两次高 16 位:
* 如果读低 16 位时刚好发生进位,两次高 16 位会不同,
* 此时应以第二次高 16 位和重新读取的低 16 位为准。 */
static uint32_t RTC_ReadCounter(void)
{
uint16_t high1;
uint16_t low;
uint16_t high2;
do
{
high1 = (uint16_t)hrtc.Instance->CNTH;
low = (uint16_t)hrtc.Instance->CNTL;
high2 = (uint16_t)hrtc.Instance->CNTH;
} while (high1 != high2);
return ((uint32_t)high2 << 16U) | (uint32_t)low;
}
/* 写 CNT 前先进入配置模式。
* CNF=1 时才允许修改核心寄存器,写完必须退出并等待 RTOFF。 */
static HAL_StatusTypeDef RTC_WriteCounter(uint32_t value)
{
if (RTC_WaitReady() != HAL_OK)
{
return HAL_TIMEOUT;
}
SET_BIT(hrtc.Instance->CRL, RTC_CRL_CNF);
hrtc.Instance->CNTH = (uint16_t)(value >> 16U);
hrtc.Instance->CNTL = (uint16_t)(value & 0xFFFFU);
CLEAR_BIT(hrtc.Instance->CRL, RTC_CRL_CNF);
return RTC_WaitReady();
}
/* 时间戳模式下的闹钟目标是一个绝对秒数,
* 因此直接写 ALR,而不使用只接受时分秒的日历式 HAL 接口。 */
static HAL_StatusTypeDef RTC_WriteAlarmCounter(uint32_t target)
{
if (RTC_WaitReady() != HAL_OK)
{
return HAL_TIMEOUT;
}
SET_BIT(hrtc.Instance->CRL, RTC_CRL_CNF);
hrtc.Instance->ALRH = (uint16_t)(target >> 16U);
hrtc.Instance->ALRL = (uint16_t)(target & 0xFFFFU);
CLEAR_BIT(hrtc.Instance->CRL, RTC_CRL_CNF);
return RTC_WaitReady();
}
void RTC_Clock_Init(void)
{
RTC_DateTime_t dt =
{
.year = 2035U,
.month = 7U,
.day = 15U,
.hour = 8U,
.minute = 0U,
.second = 0U,
.weekday = 0U
};
uint32_t timestamp = 0U;
/* 完整工程使用 Unix 秒数,必须是 Binary 数据格式 */
if (hrtc.Init.DataFormat != RTC_BINARY_FORMAT)
{
Error_Handler();
}
if (HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1) == RTC_MAGIC)
{
return; /* 已经有初始时间,不要覆盖正在走的计数器 */
}
if (DateTime_To_Timestamp(&dt, ×tamp))
{
if (RTC_WriteCounter(timestamp) == HAL_OK)
{
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_MAGIC);
}
}
}
bool RTC_Clock_Get(RTC_DateTime_t *dt)
{
/* 读取秒计数器,再交给软件换算成人类可读的日期时间 */
return Timestamp_To_DateTime(RTC_ReadCounter(), dt);
}
bool RTC_Clock_Set(const RTC_DateTime_t *dt)
{
uint32_t timestamp = 0U;
if (!DateTime_To_Timestamp(dt, ×tamp))
{
return false;
}
/* 写入新时间戳后,调用方应重新设置闹钟目标 */
return (RTC_WriteCounter(timestamp) == HAL_OK);
}
bool RTC_Alarm_SetIn(uint32_t seconds)
{
uint32_t now = RTC_ReadCounter();
uint32_t target = now + seconds;
if (seconds == 0U || target < now)
{
return false;
}
if (RTC_WriteAlarmCounter(target) != HAL_OK)
{
return false;
}
/* 先清旧标志,再开 ALRIE,避免刚上电就误触发 */
CLEAR_BIT(hrtc.Instance->CRL, RTC_CRL_ALRF);
__HAL_RTC_ALARM_EXTI_CLEAR_FLAG(); /* 清掉 EXTI17 可能残留的挂起位 */
__HAL_RTC_ALARM_EXTI_ENABLE_IT(); /* 放开 EXTI Line 17 */
__HAL_RTC_ALARM_EXTI_ENABLE_RISING_EDGE();
SET_BIT(hrtc.Instance->CRH, RTC_CRH_ALRIE);
return true;
}
main.c 应用部分
c
#include "main.h"
#include "rtc_clock.h"
#include <stdio.h>
RTC_HandleTypeDef hrtc;
static volatile uint8_t g_alarm_flag = 0U;
static void Print_DateTime(void)
{
static const char *week_name[] =
{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"};
RTC_DateTime_t dt;
if (RTC_Clock_Get(&dt))
{
/* 星期数组的索引由 Timestamp_To_DateTime 计算得到 */
printf("%04u-%02u-%02u %02u:%02u:%02u %s\r\n",
dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second,
week_name[dt.weekday]);
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
MX_RTC_Init();
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_SET);
RTC_Clock_Init();
/* 上电后 10 秒触发一次;实际项目可改成每天固定时刻 */
if (!RTC_Alarm_SetIn(10U))
{
Error_Handler();
}
while (1)
{
Print_DateTime();
if (g_alarm_flag != 0U)
{
g_alarm_flag = 0U;
printf("alarm interrupt trigger!\r\n");
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET);
}
HAL_Delay(1000U);
}
}
/* HAL 的闹钟中断处理最终会调用这个回调。
* 回调里只置位,不在中断里做 printf 和长延时。 */
void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *rtc_handle)
{
if (rtc_handle->Instance == RTC)
{
g_alarm_flag = 1U;
}
}
中断入口
在 stm32f1xx_it.c 中保留 CubeMX 生成的入口:
c
void RTC_Alarm_IRQHandler(void)
{
/* 让 HAL 清 ALRF/EXTI 标志并调用 AlarmA 回调 */
HAL_RTC_AlarmIRQHandler(&hrtc);
}
如果同时还使用秒中断,则另外保留:
c
void RTC_IRQHandler(void)
{
HAL_RTC_IRQHandler(&hrtc);
}
到这里,完整工程的主线就闭环了:BKP 负责"只初始化一次",RTC_CNT 负责保存 Unix 秒数,软件负责换算日期,ALR 负责在目标秒数到达时触发 EXTI17。
5. 验证:不要只看"串口有字"
建议按下面顺序验证,每一步都只改变一个变量,出问题时才容易定位。
5.1 先验证秒递增
首次烧录后,串口应输出类似:
text
2035-07-15 08:00:00 Sun
2035-07-15 08:00:01 Sun
2035-07-15 08:00:02 Sun
相邻两行的秒数应稳定加一。若每次加 2、加 3,先检查打印循环是否阻塞太久,或者是否在多个地方重复读取了计数器。
5.2 再验证复位保持
按一次复位键,时间应从当前值继续,而不是回到 08:00:00。若回到初始值:
- 检查
RTC_MAGIC是否真的写进了BKP_DR1。 - 检查 VBAT 是否有电。
- 检查
HAL_PWR_EnableBkUpAccess是否执行。 - 检查是否在启动时无条件调用了写计数器或写备份寄存器的代码。
如果改了默认日期却看不到变化,也先检查 BKP 魔数:魔数还在时,初始化函数会直接返回,不会重新写入默认值。验证新默认值时,可以先清 BKP_DR1,或者断开 VDD 和 VBAT 让备份域彻底掉电。
5.3 验证 VDD 掉电
保持 CR1220 电池在位,断开开发板主电源。此时串口会停止输出,这是正常的,因为 USART 和 CPU 已经没有电。
等待一段时间后重新上电,串口显示的时间应该向前跳过这段时间,而不是从 08:00:00 重新开始。这个实验证明:备份域确实由 VBAT 接管了。
5.4 验证彻底掉电
取下纽扣电池,再断开 VDD,确保 VBAT 和 VDD 都没有电。重新上电后,时间和 BKP 魔数都会丢失,程序会重新执行初始化,时间回到默认值。这个反向实验很重要,它能证明你看到的时间保持确实来自备用电源,而不是来自某个没有清掉的变量。
#mermaid-svg-8iZDFMGGayPTK1sY{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8iZDFMGGayPTK1sY .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8iZDFMGGayPTK1sY .error-icon{fill:#552222;}#mermaid-svg-8iZDFMGGayPTK1sY .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8iZDFMGGayPTK1sY .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8iZDFMGGayPTK1sY .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY .marker.cross{stroke:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8iZDFMGGayPTK1sY p{margin:0;}#mermaid-svg-8iZDFMGGayPTK1sY defs #statediagram-barbEnd{fill:#333333;stroke:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY g.stateGroup text{fill:#9370DB;stroke:none;font-size:10px;}#mermaid-svg-8iZDFMGGayPTK1sY g.stateGroup text{fill:#333;stroke:none;font-size:10px;}#mermaid-svg-8iZDFMGGayPTK1sY g.stateGroup .state-title{font-weight:bolder;fill:#131300;}#mermaid-svg-8iZDFMGGayPTK1sY g.stateGroup rect{fill:#ECECFF;stroke:#9370DB;}#mermaid-svg-8iZDFMGGayPTK1sY g.stateGroup line{stroke:#333333;stroke-width:1;}#mermaid-svg-8iZDFMGGayPTK1sY .transition{stroke:#333333;stroke-width:1;fill:none;}#mermaid-svg-8iZDFMGGayPTK1sY .stateGroup .composit{fill:white;border-bottom:1px;}#mermaid-svg-8iZDFMGGayPTK1sY .stateGroup .alt-composit{fill:#e0e0e0;border-bottom:1px;}#mermaid-svg-8iZDFMGGayPTK1sY .state-note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-8iZDFMGGayPTK1sY .state-note text{fill:black;stroke:none;font-size:10px;}#mermaid-svg-8iZDFMGGayPTK1sY .stateLabel .box{stroke:none;stroke-width:0;fill:#ECECFF;opacity:0.5;}#mermaid-svg-8iZDFMGGayPTK1sY .edgeLabel .label rect{fill:#ECECFF;opacity:0.5;}#mermaid-svg-8iZDFMGGayPTK1sY .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8iZDFMGGayPTK1sY .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8iZDFMGGayPTK1sY .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8iZDFMGGayPTK1sY .edgeLabel .label text{fill:#333;}#mermaid-svg-8iZDFMGGayPTK1sY .label div .edgeLabel{color:#333;}#mermaid-svg-8iZDFMGGayPTK1sY .stateLabel text{fill:#131300;font-size:10px;font-weight:bold;}#mermaid-svg-8iZDFMGGayPTK1sY .node circle.state-start{fill:#333333;stroke:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY .node .fork-join{fill:#333333;stroke:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY .node circle.state-end{fill:#9370DB;stroke:white;stroke-width:1.5;}#mermaid-svg-8iZDFMGGayPTK1sY .end-state-inner{fill:white;stroke-width:1.5;}#mermaid-svg-8iZDFMGGayPTK1sY .node rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8iZDFMGGayPTK1sY .node polygon{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8iZDFMGGayPTK1sY #statediagram-barbEnd{fill:#333333;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-cluster rect{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8iZDFMGGayPTK1sY .cluster-label,#mermaid-svg-8iZDFMGGayPTK1sY .nodeLabel{color:#131300;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-cluster rect.outer{rx:5px;ry:5px;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-state .divider{stroke:#9370DB;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-state .title-state{rx:5px;ry:5px;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-cluster.statediagram-cluster .inner{fill:white;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-cluster.statediagram-cluster-alt .inner{fill:#f0f0f0;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-cluster .inner{rx:0;ry:0;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-state rect.basic{rx:5px;ry:5px;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-state rect.divider{stroke-dasharray:10,10;fill:#f0f0f0;}#mermaid-svg-8iZDFMGGayPTK1sY .note-edge{stroke-dasharray:5;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-note rect{fill:#fff5ad;stroke:#aaaa33;stroke-width:1px;rx:0;ry:0;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-note text{fill:black;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram-note .nodeLabel{color:black;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagram .edgeLabel{color:red;}#mermaid-svg-8iZDFMGGayPTK1sY #dependencyStart,#mermaid-svg-8iZDFMGGayPTK1sY #dependencyEnd{fill:#333333;stroke:#333333;stroke-width:1;}#mermaid-svg-8iZDFMGGayPTK1sY .statediagramTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8iZDFMGGayPTK1sY :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} VDD 上电
VDD 掉电,VBAT 有电
VDD 恢复
VBAT 也移除
重新上电并初始化
Normal
Backup
Lost
5.5 验证闹钟
闹钟触发后,串口应出现:
text
alarm interrupt trigger!
同时 PF8 被拉低,LED0 点亮。若串口有输出但 LED 不亮,先查 PF8 是否真的是板载 LED0 的控制脚,以及 LED 是高电平还是低电平有效;不要默认所有开发板都一样。
6. 排错:按"电源 → 时钟 → 计数 → 表示 → 中断"顺序查
6.1 时间完全不走
先查电源和时钟,不要一上来改 HAL 函数。
- LSE 是否真的起振?用示波器看
OSC32_OUT,或者检查 LSE ready 标志。 - RTC 时钟源是否选成 LSE?
RTC_CNT是否被反复写回同一个值?- 程序是否每次启动都调用
HAL_RTC_Init并重新配置,导致计数器被暂停或覆盖? - 如果用的是 LSI,先接受它本来就会漂移,不要拿它和标准秒表做严格比对。
6.2 一秒变成两秒,或者一天差很多
最常见的原因是预分频值差一。检查:
text
LSE = 32768 Hz
PRL 应为 32767
如果你把 PRL 写成 32768,输出周期会变成约 1.0000305 秒,短时间看是"差不多",长时间就会积累误差。若是 LSI 方案,温度变化也会造成明显漂移。
6.3 时间正确,日期却是 2000-01-01
这是 F1 的典型现象,不是秒计数器坏了。检查是否只用了 HAL_RTC_SetDate 而没有把日期放进备份域。把日期改成 Unix 时间戳写入 RTC_CNT,或者用 BKP 保存日期基线,才能跨复位保持。
6.4 闹钟不触发
按这条链逐段检查:
RTC_ALR写的目标值是否和RTC_CNT的表示方式一致?- 目标秒数是否已经过去?
HAL_RTC_SetAlarm_IT是否返回HAL_OK?- CubeMX 是否打开了
RTC alarm interrupt through EXTI line 17? RTC_Alarm_IRQn是否在 NVIC 中使能?RTC_Alarm_IRQHandler是否调用了HAL_RTC_AlarmIRQHandler?- 回调函数名是否写成了正确的
HAL_RTC_AlarmAEventCallback?
如果只调用了 HAL_RTC_SetAlarm,闹钟寄存器可能已经写好,但中断不会自动打开。这个区别要分清。
回调参数名也不要和全局 hrtc 同名;否则 &hrtc 很容易被理解成全局句柄的地址。判断实例时用 rtc_handle->Instance == RTC 更清楚。
6.5 复位几次后时间停住
频繁复位时,HAL_RTC_Init 可能让 RTC 短暂进入配置或同步状态。调试阶段可以观察 RTOFF 和 RSF,并避免在每次主循环里重复初始化 RTC。初始化应该放在启动阶段,且只在必要时执行。
6.6 断电后时间没有保持
检查顺序是:
- VBAT 电池电压是否正常。
- 电池座极性是否正确。
HAL_PWR_EnableBkUpAccess是否执行。- 是否误用了普通 RAM 变量保存时间。
- 是否把 VDD 和 VBAT 同时断开了。
如果电池被取下,备份域彻底掉电,时间恢复默认值是正确行为,不是 bug。
7. 一页纸总结
- RTC 的价值是在主程序停掉后继续保存时间,它依靠备份域和 VBAT,不是依靠 CPU 一直运行。
- LSE 32.768 kHz 经过
PRL + 1分频得到 1 Hz;LSE 加 32767 是最常见的组合。 RTC_CNT是秒计数器,RTC_ALR保存比较目标;秒/溢出走RTC_IRQn,闹钟走 EXTI17 和RTC_Alarm_IRQn。- F1 没有独立日期寄存器,
HAL_RTC_SetDate的日期基线在 RAM 里,复位后日期可能回到 2000-01-01,时间却仍然正确。 - 让日期也保持的实用办法是把 Unix 时间戳写进
RTC_CNT,再用软件做闰年和月份换算。 - BKP 魔数用来判断"是否初始化过",比判断当前时间是否为 00:00:00 可靠。
- 写
CNT/ALR/PRL要经过RTOFF和CNF的配置保护;闹钟中断要同时检查 EXTI17、NVIC、IRQHandler 和回调。
关键参数表
| 参数 | 推荐值或结论 |
|---|---|
| MCU 平台 | STM32F103ZET6 + STM32F1 HAL |
| RTC 时钟源 | LSE,32.768 kHz |
| 预分频寄存器 | PRL = 32767 |
| 时间基准 | TR_CLK = 1 Hz |
| 计数器 | 32 位,由 CNTH/CNTL 拼接 |
| 日期保存 | Unix 时间戳或 BKP 日期基线 |
| 首次初始化标志 | BKP_DR1 = 0xA55A |
| 秒/溢出中断 | RTC_IRQn = 3,RTC_IRQHandler |
| 闹钟中断 | EXTI17,RTC_Alarm_IRQn = 41 |
| 实验 LED | PF8,低电平点亮 LED0 |
| 备份电源 | VBAT 接 CR1220 3 V |
| 典型串口 | USART1,115200 8N1 |
自测问题
- 为什么 VDD 掉电后 CPU 停止了,RTC 还能继续计时?这背后有哪些电源和硬件条件?
- LSE 是 32.768 kHz,为什么预分频值要写 32767,而不是 32768?
RTC_Second、RTC_Overflow和RTC_Alarm分别走哪个中断入口?为什么不能只记住一个回调函数名?- 为什么 F1 上时间能跨复位保持,日期却可能回到 2000-01-01?HAL 的
DateToUpdate在这个过程中扮演什么角色? - 用 BKP 魔数判断"是否初始化过"比直接判断时间是否全 0 好在哪里?
- 闹钟配置成功但 LED 不亮时,你会按什么顺序检查 ALR、EXTI17、NVIC、IRQHandler 和回调?
