【stm32】RTC(实时时钟)

🔥铅笔小新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 位计数器累加 → 软件解释成日期时间 → 比较闹钟 → 进入中断。

主线:六个必须讲清的点

  1. RTC 为什么能"断电走时":它和 LSE 振荡器、BKP 备份寄存器一起待在备份域里,主电源消失后可以由 VBAT 接管。
  2. 1 Hz 是怎样来的 :32.768 kHz 经过 PRL + 1 分频,得到每秒一次的 TR_CLK。
  3. RTC 到底计的是什么 :F1 的 RTC_CNT 本质上是一个秒计数器,不是开箱即用的完整日历。
  4. 日期为什么复位后会变成 2000-01-01:F1 没有独立的日期寄存器,HAL 把日期基线放在内存里。
  5. 怎样让日期也跨复位保存 :用 BKP 保存"是否初始化过"的标志,再把 Unix 时间戳放进 RTC_CNT。
  6. 闹钟为什么走另一个中断入口:闹钟比较走 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 的内部结构压缩一下,实际只要抓住三块:

  1. 预分频器 把 RTCCLK 变成 1 Hz 的 TR_CLK。
  2. 计数器 RTC_CNT 每收到一个 TR_CLK 就加一。F1 里它是 32 位,但拆成 CNTH、CNTL 两个 16 位寄存器访问。
  3. 闹钟寄存器 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。

这句话里有两个工程上的注意点:

  1. 它是相等比较,不是"只要超过就触发"。如果计数器已经越过目标值,而闹钟没有在正确时刻被使能,就可能错过这次事件。
  2. 闹钟值必须和当前计数器的表示方式一致。用日历式时间时,闹钟填时分秒;把 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

闹钟实验需要:

  1. 在 RTC 配置中打开 RTC alarm interrupt through EXTI line 17。
  2. 在 NVIC 中使能 RTC_Alarm_IRQn,设置一个合适的优先级。
  3. 如果还要用秒中断,再使能 RTC global interrupt,它对应 RTC_IRQn。
  4. 把 PF8 配成推挽输出,初始电平设为高,保证 LED0 先熄灭。
  5. 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, &timestamp);
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, &timestamp))
    {
        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, &timestamp))
    {
        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。若回到初始值:

  1. 检查 RTC_MAGIC 是否真的写进了 BKP_DR1。
  2. 检查 VBAT 是否有电。
  3. 检查 HAL_PWR_EnableBkUpAccess 是否执行。
  4. 检查是否在启动时无条件调用了写计数器或写备份寄存器的代码。

如果改了默认日期却看不到变化,也先检查 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 闹钟不触发

按这条链逐段检查:

  1. RTC_ALR 写的目标值是否和 RTC_CNT 的表示方式一致?
  2. 目标秒数是否已经过去?
  3. HAL_RTC_SetAlarm_IT 是否返回 HAL_OK?
  4. CubeMX 是否打开了 RTC alarm interrupt through EXTI line 17?
  5. RTC_Alarm_IRQn 是否在 NVIC 中使能?
  6. RTC_Alarm_IRQHandler 是否调用了 HAL_RTC_AlarmIRQHandler?
  7. 回调函数名是否写成了正确的 HAL_RTC_AlarmAEventCallback?

如果只调用了 HAL_RTC_SetAlarm,闹钟寄存器可能已经写好,但中断不会自动打开。这个区别要分清。

回调参数名也不要和全局 hrtc 同名;否则 &hrtc 很容易被理解成全局句柄的地址。判断实例时用 rtc_handle->Instance == RTC 更清楚。

6.5 复位几次后时间停住

频繁复位时,HAL_RTC_Init 可能让 RTC 短暂进入配置或同步状态。调试阶段可以观察 RTOFF 和 RSF,并避免在每次主循环里重复初始化 RTC。初始化应该放在启动阶段,且只在必要时执行。

6.6 断电后时间没有保持

检查顺序是:

  1. VBAT 电池电压是否正常。
  2. 电池座极性是否正确。
  3. HAL_PWR_EnableBkUpAccess 是否执行。
  4. 是否误用了普通 RAM 变量保存时间。
  5. 是否把 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

自测问题

  1. 为什么 VDD 掉电后 CPU 停止了,RTC 还能继续计时?这背后有哪些电源和硬件条件?
  2. LSE 是 32.768 kHz,为什么预分频值要写 32767,而不是 32768?
  3. RTC_Second、RTC_Overflow 和 RTC_Alarm 分别走哪个中断入口?为什么不能只记住一个回调函数名?
  4. 为什么 F1 上时间能跨复位保持,日期却可能回到 2000-01-01?HAL 的 DateToUpdate 在这个过程中扮演什么角色?
  5. 用 BKP 魔数判断"是否初始化过"比直接判断时间是否全 0 好在哪里?
  6. 闹钟配置成功但 LED 不亮时,你会按什么顺序检查 ALR、EXTI17、NVIC、IRQHandler 和回调?

相关推荐
糖糖单片机设计1 小时前
基于 STM32 的风力发电机控制系统
stm32·单片机·嵌入式硬件
企鹅的蚂蚁2 小时前
Zephyr RTOS 常用 API 详解:从 GPIO、线程到 CAN FD(附 STM32/FreeRTOS 对照)
stm32·单片机·嵌入式硬件·rtos·zephyr
2501_922639581 天前
率能半导体 SS6285L直流双向马达驱动芯片:18V/5A,覆盖电子锁、按摩仪、电动玩具、机器人四大应用场景
单片机·嵌入式硬件·硬件工程
铅笔小新z1 天前
【stm32】输入捕获-超声波测距
stm32·单片机·嵌入式硬件
Zw-awa1 天前
PID 的花样:先看懂模糊 PID 和单神经元自适应 PID
stm32
菩提树下的凡夫1 天前
机器视觉LED光源控制器
单片机·嵌入式硬件
铅笔小新z1 天前
【stm32】全彩led灯(PWM)
stm32·单片机·嵌入式硬件
小鸡脚来咯1 天前
【无标题】
单片机·嵌入式硬件
澄纸气1 天前
STM32 外部中断实战:对射式计数器和编码器计数器,两个实验跑通 EXTI
stm32·单片机·嵌入式硬件