【STM32学习笔记-12】Unix 时间戳、BKP 备份寄存器与 RTC 实时时钟

目录

[1 前言](#1 前言)

[2. Unix 时间戳](#2. Unix 时间戳)

[2.1 定义与底层设计逻辑](#2.1 定义与底层设计逻辑)

[2.2 存储格式与溢出界限](#2.2 存储格式与溢出界限)

[2.3 全球时区同步与偏移计算](#2.3 全球时区同步与偏移计算)

[2.3.1 时区与时间戳的关系](#2.3.1 时区与时间戳的关系)

[2.3.2 软件层的偏移处理逻辑](#2.3.2 软件层的偏移处理逻辑)

[2.4 GMT、UTC 与闰秒](#2.4 GMT、UTC 与闰秒)

[3. C 标准库 的核心机制](#3. C 标准库 的核心机制)

[3.1 核心数据结构](#3.1 核心数据结构)

[3.1.1 time_t 类型](#3.1.1 time_t 类型)

[3.1.2 struct tm 结构体](#3.1.2 struct tm 结构体)

[3.2 核心转换函数](#3.2 核心转换函数)

[3.2.1 mktime:日历时间 秒数](#3.2.1 mktime:日历时间 秒数)

[3.2.2 localtime 与 gmtime:秒数 日历时间](#3.2.2 localtime 与 gmtime:秒数 日历时间)

[3.3 字符串辅助函数](#3.3 字符串辅助函数)

[3.3.1 strftime](#3.3.1 strftime)

[3.3.2 ctime 与 asctime](#3.3.2 ctime 与 asctime)

[4. BKP 备份寄存器](#4. BKP 备份寄存器)

[4.1 BKP 备份寄存器的作用](#4.1 BKP 备份寄存器的作用)

[4.2 BKP 的基本特性](#4.2 BKP 的基本特性)

[4.3 VBAT 引脚与供电方案](#4.3 VBAT 引脚与供电方案)

[4.4 存储容量与寄存器映射](#4.4 存储容量与寄存器映射)

[4.4.1 存储容量](#4.4.1 存储容量)

[4.4.2 寄存器映射](#4.4.2 寄存器映射)

[4.5 利用 BKP 实现 RTC 初始化状态检测](#4.5 利用 BKP 实现 RTC 初始化状态检测)

[4.6 备份域写保护与配置流程](#4.6 备份域写保护与配置流程)

[4.7 TAMPER 侵入检测功能](#4.7 TAMPER 侵入检测功能)

[4.7.1 触发机制与物理实现](#4.7.1 触发机制与物理实现)

[4.7.2 侵入事件的硬件响应](#4.7.2 侵入事件的硬件响应)

[4.7.3 低功耗特性与引脚复用约束](#4.7.3 低功耗特性与引脚复用约束)

[4.8 RTC 时钟校准与信号输出](#4.8 RTC 时钟校准与信号输出)

[4.8.1 频率校准原理](#4.8.1 频率校准原理)

[4.8.2 时钟信号输出](#4.8.2 时钟信号输出)

[5. RTC 实时时钟](#5. RTC 实时时钟)

[5.1 RTC 概述](#5.1 RTC 概述)

[5.2 RTC 外设框图剖析](#5.2 RTC 外设框图剖析)

[5.3 预分频器(RTC_PRL / RTC_DIV)](#5.3 预分频器(RTC_PRL / RTC_DIV))

[5.3.1 时钟降频机制](#5.3.1 时钟降频机制)

[5.3.2 RTC_PRL 的预装载特性](#5.3.2 RTC_PRL 的预装载特性)

[5.4 时钟源选择](#5.4 时钟源选择)

[5.4.1 LSE(外部低速时钟,32.768 kHz)](#5.4.1 LSE(外部低速时钟,32.768 kHz))

[5.4.2 LSI(内部低速时钟,约 40 kHz)](#5.4.2 LSI(内部低速时钟,约 40 kHz))

[5.4.3 HSE/128(高速外部时钟除以 128)](#5.4.3 HSE/128(高速外部时钟除以 128))

[5.5 闹钟功能](#5.5 闹钟功能)

[5.6 中断系统](#5.6 中断系统)

[5.7 读操作同步机制(RSF 标志位)](#5.7 读操作同步机制(RSF 标志位))

[5.8 写操作时序保护(RTOFF 状态位)](#5.8 写操作时序保护(RTOFF 状态位))

[5.9 后备域访问使能(DBP)](#5.9 后备域访问使能(DBP))

[6. 实验](#6. 实验)

[6.1 实验一:读写 BKP 备份寄存器](#6.1 实验一:读写 BKP 备份寄存器)

[6.1.1 实验目标](#6.1.1 实验目标)

[6.1.2 硬件连接](#6.1.2 硬件连接)

[6.1.3 整体配置流程](#6.1.3 整体配置流程)

[6.1.4 实验现象](#6.1.4 实验现象)

[6.2 实验二:RTC 实时时钟](#6.2 实验二:RTC 实时时钟)

[6.2.1 实验目标](#6.2.1 实验目标)

[6.2.2 硬件连接](#6.2.2 硬件连接)

[6.2.3 整体配置流程](#6.2.3 整体配置流程)

[6.2.4 实验现象](#6.2.4 实验现象)


1 前言

在嵌入式系统设计中,实时计时与掉电数据保持是两类关联紧密的共性需求。无论是工业设备的运行日志、电子仪器的定时控制,还是消费类产品的时钟显示,均要求系统在主电源断开后,依然能够维持时间的流逝并保留关键配置参数。这类应用的共同挑战在于:时间基准不仅需要被精确记录,更需要在系统复位或断电后保持逻辑的连续性。

然而,以 STM32F103C8T6 为代表的通用微控制器,其内部 SRAM 属于易失性存储器,在主电源(VDD)掉电或系统复位后数据将彻底丢失。这意味着,若缺乏额外的硬件机制支持,系统每次上电都必须重置时间,无法形成跨电源周期的连续时间流。

要在资源受限的硬件条件下构建可靠的实时时间系统,必须采取软硬件协同设计的策略。本文围绕这一目标,从以下三个层面展开分析:

  • 软件算法层:Unix 时间戳的定义与设计逻辑,以及 C 标准库中<time.h>的核心数据类型与转换函数;
  • 硬件存储层:BKP 备份寄存器的供电机制与掉电保持原理,及其在 RTC 初始化状态管理中的应用;
  • 硬件执行层:RTC 实时时钟的计数架构、时钟源选择、跨时钟域读写同步机制与完整初始化流程。

三者在工程中的协作关系如下所示:

  • RTC 提供连续的硬件时间计数基准;
  • BKP 在掉电场景下保存关键运行状态与初始化标志;
  • Unix 时间戳及<time.h>在软件层提供统一的时间表达与处理方式。

三者并非专为实时时钟功能共同设计,而是分别来自硬件计时单元、掉电保持机制与软件时间抽象标准,通过系统级组合设计,共同构建出可靠的跨电源周期时间管理方案。


2. Unix 时间戳

2.1 定义与底层设计逻辑

人类日常使用的 "年-月-日 时:分:秒" 是一种多级进位的时间表示体系:秒与分之间为 60 进制,时与日之间为 24 进制,天与月之间的进位则取决于大小月、平闰年等历法规则。这种不规则性导致以下问题:

  • 计算两个时刻之间的差值时,程序必须逐级处理复杂的进位与借位,代码逻辑繁琐;
  • 若在硬件层面直接实现这套历法逻辑,需要为每个时间字段配备独立的计数器及进位控制电路,设计成本与功耗显著增加。

Unix 时间戳 的核心思想是将时间从多分量的复杂结构抽象为一个单一的物理量:从协调世界时(UTC)1970 年 1 月 1 日 00:00:00 起至当前时刻所经过的总秒数(不考虑闰秒)。任意一个具体的时刻,都被映射为一个单调递增的整数。如下图所示:

这种简化的时间模型直接塑造了 STM32 RTC 外设的设计:硬件核心任务仅为单调累计秒数,秒数与日历时间之间的转换由软件完成。

与多级计时体系相比,Unix 时间戳在工程上带来以下优势:

  • 简化硬件电路:硬件只需维护一个整数计数器,无需实现复杂的历法进位逻辑;
  • 简化时间差计算:两个时刻之间的差值直接用整数相减得出,无需逐级处理进位;
  • 节省存储空间:完整的日期时间信息用一个整型变量表示,而多级表示需要多个独立字段。

唯一的代价在于展示层的软件开销:每次需要调用 <time.h> 库函数完成秒数到年月日时分秒的转换。在当前主流微控制器的性能标准下,这部分固定的算法开销对系统整体性能的影响可以忽略不计。

2.2 存储格式与溢出界限

Unix 时间戳的存储类型决定了计时系统所能覆盖的时间范围上限。

(1)32 位有符号整型(int32_t)

早期 Unix 系统普遍采用此类型。其最大可表示秒数为 (最高位为符号位,用于表示正负,因此实际可用的数值位只有31位)。当时间到达 UTC 2038 年 1 月 19 日 03:14:07 时,下一秒将使数值超出上限,导致符号位翻转,计数值突变为 ,系统时间将被错误解释为 1901 年。这一整数溢出导致的系统性时间回滚故障即为"2038 年问题"。

(2)32 位无符号整型(uint32_t)

STM32F103 系列 RTC 内部的 CNT 计数寄存器采用此类型。由于不存在符号位,所有位均用于表示数值,最大可表示秒数为 ,对应溢出时间节点约为 2106 年 2 月 7 日。对于绝大多数产品的实际生命周期,该时间跨度已足够。

因此,STM32 通过采用无符号型定义,在 32 位寄存器尺寸限制下,将溢出时间节点从 2038 年推迟至 2106 年。虽然这并非彻底消除溢出,但在绝大多数嵌入式产品的生命周期内,该方案已提供了足够的计时冗余。

(3)64 位有符号整型(int64_t)

当前主流操作系统(如64位Linux、Windows)已全面将时间戳迁移至此类型。其最大值约为 秒,对应时间跨度约为 2920 亿年,这一格式从架构层面彻底消除了计时溢出风险,适用于任何对时间连续性有长期要求的系统。

2.3 全球时区同步与偏移计算

在理解了Unix时间戳作为全球统一"秒计数器"的本质后,不同地区本地时间的处理方式便自然演化为一个简单的偏移计算问题。

2.3.1 时区与时间戳的关系

全球依据经度划分为不同时区,以穿过英国伦敦格林尼治天文台原址的本初子午线(经度 0°)为基准,其所在时区为零时区(UTC)。向东每跨越一个时区时间增加 1 小时,向西每跨越一个时区时间减少 1 小时。如下图所示:

Unix 时间戳与这一时区体系精确配合:全球所有地点在任一相同时刻的时间戳数值完全一致,该数值等于 UTC 零时区 1970 年 1 月 1 日 00:00:00 至当前时刻所经过的秒数。任何时区的本地时间,均通过在该秒数对应的 UTC 时间基础上加减固定的小时偏移量来获得,不同时区之间无需维护独立的秒计数器。

以中国使用的北京时间为例,其位于东八区,偏移量为 +8 小时,记为 UTC+8。因此,北京时间比UTC零时区时间早8小时。其与时间戳秒数之间的换算关系为:

北京时间 = UTC时间 + 8小时

换算为秒数,东八区对应的固定秒数偏移量为:

本文实验中,RTC 硬件计数器(CNT 寄存器)内部保存的是标准 UTC 时间戳秒数,时区换算仅在软件应用层进行。

2.3.2 软件层的偏移处理逻辑

在秒数维度直接进行线性偏移,再交由 <time.h> 库函数完成历法转换,是规避 "年月日时分秒" 层面复杂的跨月、跨年及闰年进位逻辑,最稳健的方案。转换逻辑如下图所示:

其具体执行流程如下:

(1)读取时间(RTC 原始秒数 北京时间显示)

当系统需要展示本地时间时,处理路径如下:

  • 硬件读取 :调用 RTC_GetCounter() 获取硬件中存储的 UTC 标准秒数,记为

  • 时区补偿 :在秒数维度直接叠加东八区的偏移量(8 小时 3600 秒):

  • 历法解析 :将偏移后的本地秒数 传入 localtime() 函数,其解析生成的 struct tm 结构体即为北京时间的年、月、日、时、分、秒。

(2)设置时间(用户输入 硬件寄存器)

当用户设定北京时间并保存时,执行逆向处理路径:

  • 结构化输入:将用户设定的北京时间分量填入 struct tm 结构体(注意按规范处理 tm_year - 1900 与 tm_mon - 1 等偏移,详见第 3.1.2 节);

  • 线性化转换 :调用 mktime() 将结构体转换为对应的本地总秒数

  • 逆向偏移 :将本地秒数还原为标准 UTC 秒数 ,以维持硬件底层的纯粹性:

  • 硬件写入 :将计算得到的 通过 RTC_SetCounter() 写入 CNT 寄存器。

通过以上在软件出入口进行双向秒数偏移补偿的设计,系统既保证了硬件底层计时数据的纯粹性(始终为UTC秒数),又使应用层能够方便地处理本地时区,同时避免了时区逻辑的复杂性对硬件驱动层实现的影响,简化了时间同步与校准的实现复杂度。

2.4 GMT、UTC 与闰秒

在学习Unix时间戳的定义时,涉及到GMT、UTC以及闰秒的说明。以下分述其区别与关系。

GMT(格林尼治标准时间) 以地球自转为基准,将地球自转一周的时间等分为 24 小时。由于潮汐力等因素,地球自转速度在长期趋势下逐渐减慢,导致基于自转定义的一秒长度并不恒定,这对需要稳定时间基准的科学研究是不可接受的。

UTC(协调世界时) 以原子钟为基础,根据铯-133 原子在两个超精细能级间跃迁辐射的周期数定义了长度固定的一秒,精度可达上千万年误差不超过 1 秒。UTC 的一秒时长在制定时与 1970 年的 GMT 保持一致,因此两者起点相同,但 UTC 的秒长是绝对恒定的。

由于原子钟的恒定一天与地球自转的实际周期之间存在微小且逐渐累积的偏差,UTC 引入了闰秒机制:当监测到两者相差超过 0.9 秒时,在指定的 UTC 时间点插入或减去 1 秒(例如在最后一分钟出现 23:59:60)。

Unix 时间戳定义中明确"不考虑闰秒",时间戳计数器在闰秒发生时不做任何特殊调整,仍按每秒自增的规则运行,从而导致时间戳与标准 UTC 之间存在最多数十秒的固有偏差(历史上累计发生约 27 次闰秒)。在绝大多数嵌入式应用场景中,此偏差可以忽略。

在日常使用中,GMT 与 UTC 可视为等同,系统时区配置中的"GMT+8"与"UTC+8"表达同一含义。


3. C 标准库 <time.h> 的核心机制

了解 Unix 时间戳的定义后,接下来需要在底层的秒数计数与应用层的历法表示(年-月-日-时-分-秒)之间建立高效且严谨的双向转换机制。C 标准库 <time.h> 完整封装了这一转换逻辑,并在 STM32 的 ARM-GCC 工具链中得到原生支持,通过 #include <time.h> 直接引入即可使用,无需手动处理闰年判定和各月天数差异等繁琐规则。

3.1 核心数据结构

在 <time.h> 中,时间主要通过两种数据形式存在:一种是直接对应底层硬件寄存器CNT 的数值型表示 (time_t) ,另一种则是符合人类日常历法认知习惯的结构化表示(struct tm)

3.1.1 time_t 类型

time_t 是 <time.h> 中定义的类型别名,用于存储 Unix 时间戳的秒数值,本质为整型。在不同平台和编译器下,time_t 的具体底层类型有所不同。在 STM32 开发所用的 ARM-GCC 编译环境下,time_t 被定义为 unsigned int,即 32 位无符号整型,与 STM32 内部 RTC 外设的 CNT 计数寄存器位宽完全一致。从 RTC 读出的秒数可直接赋值给 time_t 变量,不存在类型转换问题。

cpp 复制代码
time_t TimeCounter;
TimeCounter = RTC_GetCounter(); // 直接将 RTC 秒数赋给 time_t 变量

在 PC 端程序中,time_t 通常通过 time() 函数获取操作系统维护的当前时间戳。在 STM32 裸机环境下,time() 函数不可用,秒数需从 RTC 硬件寄存器直接读取。

3.1.2 struct tm 结构体

struct tm 结构体用于以人类可读的格式存储日期和时间分量。其设计遵循早期 C 标准,部分成员变量存在固定的设计偏移,在填充和读取时必须显式处理:

成员 含义 取值范围与特殊说明
tm_year 年份 自 1900 年起的偏移量。实际年份 Y 对应 tm_year = Y - 1900
tm_mon 月份 0 ~ 11。0 代表 1 月,实际月份 M 对应 tm_mon = M - 1
tm_mday 日期 1 ~ 31
tm_hour 小时 0 ~ 23
tm_min 分钟 0 ~ 59
tm_sec 秒钟 0 ~ 59(规范允许 60 以处理闰秒)
tm_wday 星期 0 ~ 6。0 表示星期日
tm_yday 年内天数 0 ~ 365
tm_isdst 夏令时标志 正数表示实行,0 表示不实行,负数表示未知

tm_wday 和 tm_yday 两个字段在将 struct tm 作为 mktime 的输入时无需手动填写,函数会根据年月日自动计算并回填。

3.2 核心转换函数

3.2.1 mktime:日历时间 秒数

cpp 复制代码
time_t mktime(struct tm *timeptr);

mktime 接收一个指向 struct tm 的指针,将其中描述的年、月、日、时、分、秒转换为对应的 time_t 秒数值并返回。转换过程中,函数还会对传入的结构体执行"规范化"处理:若某成员超出正常范围(如 tm_sec = 70),函数会自动折算到合法范围并进位修改其他成员。转换完成后,tm_wday 和 tm_yday 被自动计算并回填到结构体中。

在 STM32 裸机环境下,mktime 以零时区(UTC)为基准进行计算,不读取任何系统时区配置。结合第 2.3.2 节的偏移逻辑,写入 RTC 时需在 mktime 返回值的基础上减去 28800 秒。

3.2.2 localtime 与 gmtime:秒数 日历时间

cpp 复制代码
struct tm *localtime(const time_t *timer);
struct tm *gmtime(const time_t *timer);

这两个函数的功能都是将 time_t 类型的秒数转换为 struct tm 格式的日历时间,并返回指向结果结构体的指针,只在时区处理上存在关键差异:

  • gmtime(Greenwich Mean Time):格林威治标准时间。它始终将输入的秒数视为 UTC+0 时区进行转换,不附加任何偏移。

  • localtime(Local Time):本地时间。

    • 在 PC 端(有操作系统):函数会自动读取系统的时区设置(如 Windows 的区域设置),并根据当前时区自动在秒数上增加偏移(例如北京时间会自动 +8 小时),输出本地时间。

    • 在 STM32 裸机端:由于没有操作系统提供时区配置文件,标准库无法得知设备所处的地理位置。因此,在 STM32 中 localtime 与 gmtime 的行为通常是完全等效的,均以零时区 UTC+0 为基准输出结果。 因此,在裸机环境下从 RTC 读取时间时,需在秒数层面手动加上 28800 秒后再传入 localtime(见第 2.3.2 节)。

cpp 复制代码
time_t time_cnt = RTC_GetCounter() + 28800; // 手动增加 8 小时偏移
struct tm *time_date = localtime(&time_cnt); // 此时解析出的即为北京时间

返回的指针指向一块函数内部的静态存储区,每次调用会覆盖上一次的结果。若需要保留转换结果,应将结构体内容复制到用户自定义的变量中:

cpp 复制代码
time_date = *localtime(&time_cnt); // 解引用以复制结构体内容

3.3 字符串辅助函数

在嵌入式系统中,时间数据往往需要通过串口、日志或显示接口以字符串形式输出。<time.h> 提供了多种时间格式化函数,其中 strftime 是最通用且工程上最推荐的方案,它能够在保证安全性的同时提供灵活的格式控制能力。

3.3.1 strftime

strftime 的作用是将结构化的日历时间转换为格式化的字符串:通过解析格式字符串 format 中的占位符,从 struct tm 中提取对应字段,并自动完成标准化处理(如年份偏移修正、月份范围调整以及数值补零),最终将结果写入由指针 s 指向的目标缓冲区。

函数原型:

cpp 复制代码
size_t strftime(char *s, size_t max, const char *format, const struct tm *tm);

参数及返回值说明:

  • **s:**指向目标字符数组的指针,用于存储生成的字符串。
  • **max:**目标数组的最大容量,防止缓冲区溢出。
  • **format:**格式控制字符串,由普通字符和格式占位符组成。
  • **tm:**指向待转换的 struct tm 结构体的常数指针。
  • **返回值:**返回实际写入缓冲区的字符数(不包含结尾的 \0)。若输出内容超过缓冲区容量,则返回 0。

常用格式占位符如下:

占位符 含义 示例
%Y 四位年份 2026
%m 两位月份(01-12) 05
%d 两位日期(01-31) 01
%H 24 小时制小时(00-23) 18
%M 分钟(00-59) 30
%S 秒(00-59) 05

示例代码:

cpp 复制代码
char buf[32];
// 假设 time_date 已经通过 localtime 转换完成
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &time_date);

该调用会生成类似 "2024-03-14 10:30:00" 的字符串,适用于日志记录与串口输出等常见场景。

3.3.2 ctime 与 asctime

除了 strftime,标准库还提供了 ctime 与 asctime 两个便捷函数,用于将时间直接转换为固定格式字符串:"Www Mmm dd hh:mm:ss yyyy\n",例如 "Fri May 01 18:30:00 2026\n")。

两者的区别在于输入类型不同:

  • **ctime:**接收 time_t 指针,直接将秒数转换为固定格式字符串。
  • **asctime:**接收 struct tm 指针,将日历分量转换为固定格式字符串。

尽管使用简单,但这类函数在工程中存在明显局限:

  • 格式不可控:输出格式固定,且自动附带换行符,不适合正式输出场景
  • 线程不安全:返回值指向内部静态缓冲区,多次调用会覆盖数据,在中断或多任务环境下存在风险

因此,在嵌入式开发中,这类函数更适合作为调试辅助工具,而不应作为正式输出接口。


4. BKP 备份寄存器

在进入 BKP 的具体介绍之前,先明确它在系统架构中要解决的核心问题。

4.1 BKP 备份寄存器的作用

前两章已建立完整的软件时间处理方案------用 Unix 时间戳在 RTC 与 <time.h> 之间传递数据。但这里有一个前提尚未解决:RTC 必须在某次上电时被正确初始化,包括配置时钟源、分频系数和初始时间,并在随后的运行中保持连续计数。

若每次上电都重新初始化并将时间设置回代码中写死的固定初始值,RTC 就失去了"连续走时"的意义------每次断电后时间都会归零。因此,系统需要一种机制来判断RTC 是否已经在过去某次上电中完成了初始化,并且时间一直在正常走。

STM32 内部的 SRAM 是易失性存储器,依赖 VDD 主电源持续供电才能保持数据。一旦 VDD 断电或发生复位,SRAM 中的全部内容会清零,无法保存任何跨电源周期的状态信息。

BKP 备份寄存器正是解决这一状态留存问题的硬件基础:它是一组能够在主电源断开后、依靠独立备用电源继续保持数据的寄存器,专门用于跨电源周期保存少量关键状态信息。

4.2 BKP 的基本特性

BKP (Backup Registers,备份寄存器,参考手册中译为"后备寄存器 ") 是一组位于 STM32 后备域 (Backup Domain,亦称备份域,是 STM32 中由 VBAT 供电维持的一组独立功能区域,主要包括 RTC、BKP 备份寄存器以及低速外部振荡器 LSE )的 16 位 SRAM 寄存器,可用于存储用户自定义数据,内容格式无限制。

BKP 能够实现掉电保持的根本原因,在于其内部集成的双电源自动切换电路:

  • 在主电源(VDD)正常供电期间,后备区域由 VDD 供电,VBAT 引脚上的电池不会被消耗;
  • 当 VDD 电压降低至阈值以下时,芯片内部的电源控制逻辑(PWR)通过一个模拟开关,自动将后备区域的供电来源切换至 VBAT 引脚,如下图所示的后备供电区域:

图 STM32F1 电源框图

只要 VBAT 引脚持续有电(例如连接了一颗纽扣电池),BKP 寄存器中的数据就会被持续保存,以下操作均不会清除 BKP 中的内容:

  • 系统复位(如按下复位按键,或 NRST 引脚被拉低)
  • 上电复位(POR/PDR,即 VDD 从 0 上升时产生的内部复位)
  • 从待机模式(Standby Mode)或停机模式(Stop Mode)唤醒

需要特别注意的是,BKP 的数据保持是有条件的,它依赖于 VBAT 引脚的持续供电。BKP 不同于 Flash 存储器(Flash 是真正的非易失性存储,断电后数据不丢失)。若 VDD 与 VBAT 同时断电,BKP 中的数据将全部清零,恢复至默认值 0x0000。

4.3 VBAT 引脚与供电方案

VBAT 是 STM32 中独立于 VDD 的备用电源引脚。在需要掉电后保持 BKP 数据或 RTC 走时的应用中,必须为该引脚提供持续的电源。参考手册给出以下几种供电方案:

方案一:直连纽扣电池

将纽扣电池(如 CR2032,标称电压 3 V)的正极直接连接至 VBAT 引脚,负极与系统共地(GND)。这是最常见的产品级设计方案。如下图所示,野火的STM32开发板的VBAT引脚连接了一个纽扣电池。

方案二:带二极管隔离的纽扣电池

在电池正极与 VBAT 引脚之间,以及 VDD 与 VBAT 引脚之间各串联一个低压降二极管(D1和 D2),并在 VBAT 引脚处对地并联 100 nF(0.1uF)陶瓷滤波电容(C3)。二极管 D1 和 D2 的作用是防止 VDD 上电期间电流倒灌进入电池,从而延长电池寿命。在该方案中,必须选用 肖特基二极管(如 BAT54 或 1N5819),利用其低压降(约 0.2V~0.3V)特性,确保纽扣电池在电压衰减过程中仍能维持 VBAT 在 1.8V 以上的有效工作区间。该方案在对电池续航要求较高的场景中更为适用。如下图所示:

方案三:无电池时直连 VDD

若应用场景不需要在主电源断开后保持数据,参考手册建议在外部将 VBAT 引脚直接连接至 VDD,并对地并联 100 nF 陶瓷电容。

在没有实装纽扣电池的开发板实验环境中(比如STM32最小系统板),可从 ST-Link 的 3.3 V 引脚引线接至开发板的 VBAT 引脚,以模拟持续供电的备用电源。实验效果与接电池等效,但拔掉 USB 后 VBAT 同样断电,数据会清零。

4.4 存储容量与寄存器映射

4.4.1 存储容量

STM32F10xxx 系列中,BKP(备份寄存器)的资源规模随器件容量等级不同而有所差异,其数据寄存器配置如下:

器件容量等级 数据寄存器数量 编号范围 总存储容量
小容量、中容量(如 STM32F103C8T6) 10 个 BKP_DR1 ~ BKP_DR10 20 字节
大容量、互联型 42 个 BKP_DR1 ~ BKP_DR42 84 字节

每个 BKP 数据寄存器为 16 位宽,可存储 0x0000 ~ 0xFFFF 范围内的无符号整数。由于整体容量极为有限,BKP 并不适合用于存储大规模业务数据,其典型应用应限定在少量关键状态信息的掉电保持,例如:

  • RTC 初始化状态标志(特征码)
  • 系统校准参数(如频率补偿值)
  • 少量关键配置参数

这种"低容量 + 高可靠性"的定位,本质上决定了 BKP 更接近于一种状态寄存器扩展,而非通用存储资源。

4.4.2 寄存器映射

在寄存器结构上,BKP 域除了核心的数据存储寄存器(BKP_DRx)外,还包含少量用于实现侵入检测(Tamper)及校准输出控制的辅助寄存器。

虽然本章重点在于 RTC 的掉电保持,未深入涉及安全防护功能,但了解其硬件定义有助于完整理解 BKP 的边界。相关寄存器包括:

(1)BKP_CR(备份控制寄存器)

主要用于配置侵入检测引脚的有效电平和使能状态。如下图所示:

(2)BKP_CSR(备份控制/状态寄存器)

用于管理侵入检测事件的标志位、中断使能及清除逻辑。值得注意的是,一旦检测到侵入事件,硬件将自动清除所有 BKP_DRx 中的数据以确保信息安全。如下图所示:

从存储映射角度看,上述所有 BKP 寄存器均被映射在备份域的统一地址空间内,其地址范围为:

cpp 复制代码
0x40006C00 ~ 0x40006FFF

4.5 利用 BKP 实现 RTC 初始化状态检测

BKP 最典型的应用场景之一,是与 RTC 配合,通过特征码机制区分"首次彻底上电"与"复位或休眠唤醒"。每次系统启动时,首先读取 BKP_DR1 的内容:

  • 若等于约定的特征码:说明 BKP 数据在上次断电前已被写入,且 VBAT 一直有电,RTC 已在运行,无需重新初始化,直接读取 RTC 计数器继续使用即可;
  • 若不等于约定的特征码:说明这是首次上电,或 VBAT 曾完全断开导致 BKP 数据清零,需执行完整的 RTC 初始化流程,并在初始化完成后将特征码写入 BKP_DR1。

特征码的具体数值由开发者自行决定,但需选择一个在正常情况下不会随机出现在 BKP_DR1 中的值,以避免误判。0xA5A5 是常见的选择,其二进制形式为 1010 0101 1010 0101,在电路调试中具有明显的交替电平特征,便于示波器观测,同时也是 STM32 相关代码中的惯用特征码。

这套机制的完整初始化流程在第 5.11 节中结合 RTC 的配置步骤给出。

4.6 备份域写保护与配置流程

为了防止系统在复位瞬间或异常运行时意外篡改关键数据(如 RTC 计数值、校准参数等),STM32 的后备域 (后备供电区域,包括 BKP 寄存器与 RTC 核心寄存器)在硬件复位后默认处于写保护状态。对该区域执行任何写入操作前,必须严格遵循以下三步解锁流程。

步骤一:开启外设时钟

PWR 与 BKP 外设均挂载在 APB1 总线上,必须首先使能其时钟,才能访问后备域相关寄存器。

需要注意的是,RTC 并不存在独立的 APB 时钟使能位;开启 PWR 与 BKP 时钟,已经完成了访问整个后备域(包括 RTC)的基础准备。

cpp 复制代码
// 开启电源控制(PWR)与备份寄存器(BKP)的 APB1 总线时钟
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE);

步骤二:解除后备域写保护

在时钟使能之后,还需通过电源控制寄存器 PWR_CR 中的 DBP 位(Disable Backup Domain write protection),显式关闭后备域的写保护,从而获取写入权限。

cpp 复制代码
// 解除后备区域写保护
PWR_BackupAccessCmd(ENABLE);

此操作的本质,是允许软件对 BKP 与 RTC 寄存器执行写操作;若未执行该步骤,所有写入尝试将被硬件忽略。

步骤三:执行读写操作

完成上述解锁后,即可调用标准库函数访问 BKP 数据寄存器,相关操作直接映射到底层寄存器地址,无需额外同步或等待机制。

cpp 复制代码
// 向 BKP_DR1 写入特征码
BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);

// 从 BKP_DR1 读取数据
uint16_t Flag = BKP_ReadBackupRegister(BKP_DR1);

// 将所有 BKP 数据寄存器恢复为默认值 0x0000
BKP_DeInit();

补充说明:

  • 读操作不受写保护限制
    即使未执行解锁流程,仍可直接读取 BKP 数据寄存器。这一点对于启动阶段的状态判定(如第 4.5 节中的特征码检测)尤为重要。
  • 该流程同样适用于 RTC 配置
    上述"时钟使能 + 写保护解除"并不仅限于 BKP,而是访问整个后备域的统一入口。在后续 RTC 初始化(第 5.9 节)中,同样必须先执行这一解锁流程。

4.7 TAMPER 侵入检测功能

BKP 外设集成了一套硬件级防侵入检测机制(TAMPER),其核心功能是:当检测到设备遭受非法物理拆解时,由硬件电路自动清除 BKP 数据寄存器中存储的敏感数据(如密钥、证书等),从而实现物理层面的信息安全保护。

4.7.1 触发机制与物理实现

该机制通过专用的侵入检测引脚实现(TAMPER 引脚,与 PC13 复用)。通过配置 BKP_CR 寄存器中的 TPAL 位,可将该引脚设定为 上升沿触发下降沿触发

在实际产品设计中,TAMPER 引脚通常连接至设备外壳的物理防拆开关(如微动开关或导电橡胶)。当设备外壳处于闭合状态时,引脚电平保持稳定;一旦外壳被拆开,防拆开关动作导致引脚电平产生预设跳变,BKP 硬件电路将立即识别为侵入事件。

4.7.2 侵入事件的硬件响应

侵入事件发生后,硬件将执行以下两个自动化动作,无需软件干预:

  • 数据强制清零:立即清零所有 BKP 数据寄存器(BKP_DRx)。该操作由内部硬件逻辑直接驱动,程序无法拦截或阻止。

  • 状态标记与中断:硬件将 BKP_CSR 中的侵入事件标志位(TEF)置 1,并根据配置触发侵入中断。CPU 进入中断服务函数后,可执行进一步安全措施,如锁定系统或清除外部 Flash 中的加密扇区。

侵入事件发生后的状态信息记录在 BKP_CSR 中,包含侵入事件标志位(TEF)、侵入中断标志位(TIF)及相关清除位,供软件在下次上电时查询和处理,如下所示:

注意: 写入权限锁定机制侵入事件发生后,TEF 标志位不仅记录状态,还会锁死后备域的写入权限。只要 TEF 为 1,任何对 BKP 数据寄存器的写入尝试都将被硬件忽略。因此,系统在受到侵入并重新启动后,必须先调用 BKP_ClearFlag() 清除 TEF 标志位,方可恢复正常的 BKP 存储功能。

4.7.3 低功耗特性与引脚复用约束

  • 掉电监控能力:侵入检测电路由 VBAT 引脚独立供电。即使在主电源(VDD)断开的情况下,检测电路仍保持工作,确保设备在关机或运输状态下同样具备物理防拆能力。

  • PC13 引脚的功能互斥 :PC13 引脚在后备域中承担了三项功能的复用:TAMPER 侵入检测CCO 校准时钟输出 (512Hz)以及 ASOE 闹钟/秒脉冲输出。由于硬件内部逻辑共享引脚资源,这三项功能在同一时间只能使能其中一项。例如,若开启了 RTC 闹钟脉冲输出(ASOE=1),则必须关闭侵入检测功能。硬件设计阶段需根据产品定义提前明确 PC13 的功能分配。


4.8 RTC 时钟校准与信号输出

BKP 中包含一个 RTC 时钟校准寄存器(BKP_RTCCR),该寄存器承担两项独立的功能:RTC 走时频率的软件校准,以及将 RTC 内部时钟信号从 PC13 引脚输出供外部测量。如下图所示:

4.8.1 频率校准原理

即使使用精度较高的 32.768 kHz 外部晶振,实际振荡频率也会因晶振本身的制造误差、温度漂移等因素略高于标称值,导致 RTC 长期走时偏快。BKP_RTCCR 的 CAL[6:0] 字段(位 6:0)提供了一种软件补偿手段。

CAL[6:0] 的工作原理 :该字段配置的是一个"每隔固定周期跳过若干个时钟脉冲"的机制。具体而言,硬件每计满 =1,048,576 个输入时钟脉冲,就会从中丢弃 CAL 个脉冲,使 RTC 实际计入的脉冲数减少,从而让走时速度略微降低。

以 CAL = 1 为例:每 个脉冲中丢弃 1 个,等效于将时钟频率减慢了:

CAL[6:0] 为 7 位字段,最大值为 127,对应的最大减速量为:

这与参考手册中"RTC 时钟可以被减慢 0 ~ 121 ppm"的描述一致。需要注意的是,该校准机制只能减慢时钟,不能加快时钟,因此只适用于晶振实际频率略高于标称值(即走时偏快)的情况。

4.8.2 时钟信号输出

BKP_RTCCR 还提供两种将 RTC 内部信号从 PC13 引脚输出的方式,用于辅助外部测量与调试:

  • CCO 位(位 7):置 1 后,将 RTC 时钟经 64 分频后的信号(即 32768 / 64 = 512 Hz)从 PC13 引脚输出,供外部频率计测量实际晶振频率,再据此计算 CAL 的校准值。
  • ASOE 位(位 8)与 ASOS 位(位 9):ASOE 置 1 后使能脉冲输出功能,ASOS 位用于选择输出的信号类型------ASOS = 0 输出闹钟脉冲,ASOS = 1 输出秒脉冲。输出脉冲宽度为一个 RTC 时钟周期。

5. RTC 实时时钟

5.1 RTC 概述

STM32 的 RTC 实时时钟(Real-Time Clock)是一个独立的硬件外设,其设计目标与通用定时器(TIM)完全不同:TIM 用于产生 PWM 波形或精确定时中断,而 RTC 的核心任务是在极低的功耗下持续维持时间计数,并在主电源断电后依靠 VBAT 继续运行。

若要在硬件层面实现完整的年月日时分秒日历时钟,需要六个独立的计数器,以及处理大月(31 天)、小月(30 天)、平年 2 月(28 天)、闰年 2 月(29 天)等各种不规则进位规则的复杂控制逻辑,电路复杂度极高。STM32 的 RTC 采用第 2 章所述的 Unix 时间戳设计思路,将这一复杂性彻底转移到软件层,从而实现软硬件职责的解耦:

  • 硬件层(极简):仅维护一个 32 位无符号秒计数寄存器(RTC_CNT),持续累加秒数,在主电源断电后依靠 VBAT 继续运行,如下图所示:
  • 软件层(灵活):利用第 3 章介绍的 <time.h> 库函数,在每次读取或写入时完成 "线性秒数" 与 "结构化日历(struct tm)"之间的双向转换。

图 - RTC外设结构框图

注:框图中浅灰色的部分都属于备份域区域

这种分工使硬件电路得到了极大简化------RTC 核心只需一个计数器 RTC_CNT 和一个预分频器 RTC_DIV 即可完成全部计时任务,而历法转换的计算负担由成熟的标准库函数承担,开发者无需手动处理闰年判定和各月天数差异等繁琐规则。

与外部专用 RTC 芯片(如 DS1302)相比,STM32 的内置 RTC 无需额外占用 SPI 或 I²C 总线,仅需为 VBAT 引脚提供备用电源、为 LSE 振荡器连接一颗 32.768 kHz 外部晶振即可工作,在降低外围器件数量的同时,也简化了系统硬件设计。

5.2 RTC 外设框图剖析

如上图 - RTC 外设结构框图所示,RTC 模块在硬件物理结构上由两个完全独立的部分组成。这两个部分分属不同的电源域,且工作在频率差异巨大的两个时钟域中:

(1)APB1 接口(高速域 / 主电源域)

  • 功能:负责连接 APB1 总线,是 CPU 读写 RTC 内部寄存器的通信桥梁。

  • 时钟源 :由系统总线时钟 PCLK1 驱动(典型频率为 36 MHz)。

  • 供电状态:依赖主电源 VDD。如框图右侧标注,当系统进入待机模式或主电源断电时,该接口不供电。

(2)RTC 核心(低速域 / 后备电源域)

  • 功能:执行实际的计时与比较逻辑。对应框图中的浅灰色阴影区域。

  • 时钟源 :由独立的 RTCCLK 驱动(通常连接 32.768 kHz 的外部 LSE 晶振)。

  • 供电状态:位于后备区域内。当主电源掉电时,自动切换至 VBAT 引脚供电,这是 RTC 在整机关机后仍能维持走时的硬件基础。

其核心组件包括如下:

  • 预分频器(RTC_PRL / RTC_DIV):将高频的RTCCLK降频为精确的1 Hz秒脉冲TR_CLK。

  • 32 位可编程计数器(RTC_CNT):接收 1 Hz 脉冲,每秒自增 1,用于直接存储 Unix 时间戳的秒数值。

  • 闹钟寄存器(RTC_ALR):存储目标秒数值,由内部比较器监控,当 CNT 值与 ALR 值匹配时,触发 RTC_Alarm 信号。

**注意:**由于 APB1 接口(36 MHz)与 RTC 核心(32.768 kHz)之间存在巨大的频率鸿沟,CPU 通过 APB1 发起的读写请求无法立刻被低速的 RTC 核心执行。这种跨时钟域的硬件特性,要求我们在编写程序时必须严格遵守特定的"读写同步与等待机制"(详见第 5.7 节和第 5.8 节),否则会导致数据读写错乱。

5.3 预分频器(RTC_PRL / RTC_DIV)

RTC 的输入时钟(RTCCLK)通常为 32.768 kHz(LSE),远高于实际所需的 1 Hz 秒节拍。因此,必须通过硬件预分频器对其进行分频,生成稳定的 1 Hz 时间基准,用于驱动 32 位秒计数器 RTC_CNT。

该功能由 RTC 内部预分频单元实现,其核心由两个寄存器协同完成,体现为一个"递减计数 + 自动重载"的硬件机制:

  • RTC_PRL:由软件配置,用于设定分频系数(重装载值);

  • RTC_DIV:硬件递减计数器(20 位,只读),反映当前分频周期的计数进度;

需要注意,实际分频系数为:

分频系数 = RTC_PRL+1

5.3.1 时钟降频机制

RTC 预分频器的输出频率由下式决定:

当配置如下时(RTC_PRL = 32767, = 32768 Hz):

cpp 复制代码
RTC_SetPrescaler(32768 - 1);

输出频率为 TR_CLK = 1 Hz,硬件的具体计次流程如下:

(1)初始装载:首先,在一个分频周期开始时,硬件会将 RTC_PRL 的值(32767)自动装载到 RTC_DIV 中,作为当前周期的初始计数值。

(2)递减计次:RTC_DIV 在每一个 RTCCLK 时钟周期(1/32768 秒,约 30.5 μs)到来时递减 1,例如:

cpp 复制代码
32767 → 32766 → 32765 → ... → 1 → 0

(3)触发与重载:当 RTC_DIV 递减至 0 后,并不会立即产生秒脉冲,而是在下一次时钟到来时发生下溢。此时硬件同步完成以下操作:

  • 生成脉冲:向后端 RTC_CNT 发送一个有效时钟沿(TR_CLK),使 RTC_CNT 数值加 1。
  • 同步重载:自动将 RTC_PRL 的值(32767)重新拉取并锁存到 RTC_DIV 中。
  • 循环往复:开始下一秒的递减周期。

通过这种"计满(PRL+1)个时钟周期 → 下溢 → 触发"的机制,RTC 将 32.768 kHz 精确分频为 1 Hz。

5.3.2 RTC_PRL 的预装载特性

在理解分频计数过程之后,还需要特别注意 RTC_PRL 的更新行为。

当软件修改 RTC_PRL 时,新值不会立即作用于当前正在运行的分频计数过程。此时 RTC_DIV 仍然按照旧的分频值继续递减,直到当前周期结束。

只有当 RTC_DIV 递减至 0 并在下一次时钟到来时发生下溢、触发重载操作时,新的 RTC_PRL 值才会被加载,并在下一周期开始时生效。

例如,假设当前 RTC_DIV = 15000,此时执行:

cpp 复制代码
RTC_SetPrescaler(100);

硬件不会立即应用新的分频值15000,而是继续按照原有分频系数完成当前计数周期,即 RTC_DIV 仍从 15000 递减直至 0,并在下一次时钟到来时发生下溢,产生一次秒脉冲;随后,在新一轮计数周期开始时,才将更新后的 RTC_PRL 值(如 100)重新装载到 RTC_DIV 中并生效。

cpp 复制代码
RTC_DIV ← 100

因此,新分频参数始终在"下一周期"起作用,而不会中断当前正在进行的计数过程。

5.4 时钟源选择

RTC 模块的运转需要稳定的驱动源。根据 STM32 的时钟树配置,RTC 核心(RTCCLK)共有三路独立的可选输入,这些时钟源决定了计时的精度以及在掉电状态下的生存能力。如下图所示:
图 - 时钟树

如图所示,通过配置 RCC 备份域控制寄存器(RCC_BDCR)中的 RTCSEL[1:0] 位,可灵活选择时钟源(对应库函数为 RCC_RTCCLKConfig())。完成选择后,需使能时钟输出(调用 RCC_RTCCLKCmd(ENABLE)),此时 RTC 核心方能正常工作。

5.4.1 LSE(外部低速时钟,32.768 kHz)

LSE 采用外接的 32.768 kHz 无源晶振,是绝大多数 RTC 应用的标配时钟源,其核心优势如下:

  • 分频计算完美契合 :32768 即 。配合 STM32 的 20 位可编程预分频器,只需将重装载值(RTC_PRL)配置为 32767,即可毫无误差地产生精确的 1 Hz 秒脉冲。

  • 低功耗掉电维持 :LSE 振荡电路和 RTC 核心同处于后备区域(Backup Domain)。当主电源 VDD 掉电时,它们均可无缝切换至 VBAT 引脚供电,这是实现设备断电后 RTC 持续走时的唯一可行方案。

  • 产业生态成熟:32.768 kHz 广泛应用于钟表与计时设备,晶振工艺成熟、精度高且成本低廉。

配置代码与工程警示:

cpp 复制代码
RCC_LSEConfig(RCC_LSE_ON);                          // 开启 LSE 外部晶振
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);  // 等待 LSE 起振稳定
RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);             // 选择 LSE 作为 RTC 时钟源
RCC_RTCCLKCmd(ENABLE);                              // 使能 RTCCLK 输出

工程警示(死循环风险):LSE 晶振上电后需要数十至数百毫秒的起振时间。上述代码中的 while 轮询是一个阻塞操作。若硬件出现虚焊、晶振损坏或负载电容严重不匹配导致 LSE 无法起振,程序将在此处永久死机。在调试阶段,若怀疑硬件问题,可临时切至 LSI 时钟源以排除软件逻辑故障。

5.4.2 LSI(内部低速时钟,约 40 kHz)

LSI 是芯片内部集成的 RC 振荡器,标称频率约 40 kHz。

  • 局限性 1(精度极差):RC 振荡器受环境温度、电压及制造工艺影响极大,实际频率可能在 30 kHz ~ 60 kHz 之间波动,无法用于需要准确计时的场景。

  • 局限性 2(无法掉电维持):LSI 电路依赖主电源 VDD 供电。一旦 VDD 断电,LSI 立即停振,RTC 随之瘫痪。

LSI 通常仅在 LSE 晶振因硬件问题未能起振时,作为验证 RTC 其他功能逻辑的临时替代方案。

5.4.3 HSE/128(高速外部时钟除以 128)

系统主晶振 HSE(通常为 8 MHz)由于频率过高,即使使用 RTC 内部最大的 20 位预分频器也无法降频至 1 Hz。

因此,硬件提供了一个固定的 128 分频预处理链路:8000000 / 128 = 62500 Hz 。这 62.5 kHz 的信号再送入 RTC 预分频器。与 LSI 类似,HSE 依赖 VDD 供电,VDD 断电即停振,不适用于掉电走时需求。


5.5 闹钟功能

除基础计时外,RTC 硬件还内置了一个专门的 32 位闹钟寄存器(RTC_ALR):

向 RTC_ALR 写入一个目标 UTC 秒数值(Unix 时间戳的秒数值)后,当 RTC_CNT 的计数值与 RTC_ALR 的设定值相等时,硬件自动产生 RTC_Alarm 闹钟信号。该信号有两个作用:

  • 触发常规中断:若在控制寄存器中使能了闹钟中断(ALRIE = 1),该事件将向 CPU 发送中断请求,程序可跳转至中断服务函数执行预定任务(如定时响铃、定时控制继电器等)。

  • 低功耗唤醒:若设备处于待机模式(Standby Mode),闹钟信号可将设备从待机状态直接唤醒。

闹钟功能是低功耗周期性唤醒的核心机制。例如,某个需要每隔 1 小时采集一次传感器数据的设备,可以在完成一次采集后计算出下一次采集对应的 UTC 秒数,将其写入 RTC_ALR,然后进入待机模式。闹钟触发时设备自动唤醒,完成采集后再次进入待机,其余时间维持极低的静态电流。

5.6 中断系统

RTC 模块内部整合了三个独立的事件源,它们通过逻辑门电路汇聚后,最终向 NVIC(嵌套向量中断控制器)提交中断请求。

通过配置控制寄存器(RTC_CR)的对应使能位,可以选择性开启以下三种中断:

  • 秒中断(RTC_Second,由 SECIE 位使能)

    由预分频器输出的 1 Hz 信号(TR_CLK)直接触发。开启后,每流逝 1 秒钟,程序就会进入一次中断服务函数。适用于需要在中断中定时刷新时间显示或执行周期性任务的应用。

  • 闹钟中断(RTC_Alarm,由 ALRIE 位使能)

    当计数值 RTC_CNT 与闹钟寄存器值 RTC_ALR 匹配时触发。需要特别注意的是,在 STM32 的中断体系中,RTC 闹钟事件是专门连接到 EXTI 线 17(外部中断控制器)上的。这种特殊硬件连线正是闹钟信号能够将 CPU 从停机(Stop)或待机(Standby)等深度睡眠模式中唤醒的根本原因。

  • 溢出中断(RTC_Overflow,由 OWIE 位使能)

    当 32 位计数器(RTC_CNT)从最大值 (0xFFFFFFFF)溢出归零时触发。由于 32 位容量从 1970 年起算可支撑至 2106 年,在绝大多数实际产品的生命周期内根本不会触发。通常仅在需要极完善的异常处理逻辑时才会使能此中断。

5.7 读操作同步机制(RSF 标志位)

如第 5.2 节所述,APB1 接口(PCLK1,约 36 MHz)与 RTC 核心(RTCCLK,32.768 kHz)工作在两个完全独立的时钟域。由于跨时钟域的存在,RTC 内部关键寄存器(如 RTC_CNT、RTC_ALR、RTC_PRL)的数值并不会被 APB1 总线直接实时访问,而是通过一套同步机制映射到总线侧寄存器中。

在系统上电、复位,或从待机/停机模式恢复后,APB1 接口重新获得时钟,但此时 RTC 核心侧的数据尚未完成向 APB1 域的同步。如果在这一时刻立即读取 RTC 寄存器,可能得到不正确的结果(典型表现为读到 0 或未更新的旧值)。

为保证读取结果的有效性,必须先等待跨时钟域同步完成。该过程由 RTC_CRL 寄存器中的RSF(Register Synchronization Flag)位指示,如上图所示:当硬件完成从 RTC 核心到 APB1 接口的寄存器同步后,RSF 会被置 1。标准库提供的 RTC_WaitForSynchro() 函数即用于完成这一过程,其实现方式是先清零 RSF,再轮询等待其被硬件重新置位:

cpp 复制代码
void RTC_WaitForSynchro(void)
{
  /* 清除 RSF 标志位 */
  RTC->CRL &= (uint16_t)~RTC_FLAG_RSF;
  /* 等待硬件完成同步 */
  while ((RTC->CRL & RTC_FLAG_RSF) == (uint16_t)RESET)
  {
  }
}

因此,在每次 APB1 侧重新获得 RTC 访问能力之后(如系统复位或低功耗唤醒后),都必须调用该函数,再进行任何 RTC 寄存器读取操作。其本质是确保"总线侧读取到的数据"已经与"RTC 核心实际运行状态"完成一致性同步。

5.8 写操作时序保护(RTOFF 状态位)

与读操作类似,跨时钟域特性同样对写操作提出了严格约束。由于 RTCCLK(32.768 kHz)远低于 APB1 时钟频率,CPU 对 RTC 关键寄存器(RTC_CNT、RTC_ALR、RTC_PRL)的写入不会立即完成,而是需要等待 RTC 核心时钟对写请求进行采样,并在内部完成实际更新。这一过程在 CPU 视角下表现为"延迟生效"。

如果在前一次写操作尚未完成时再次发起写入,可能导致后续写操作被忽略或产生不可预期的结果。因此,硬件通过 RTC_CRL 寄存器中的 RTOFF(RTC Operation OFF)标志位来指示当前写操作状态:

  • RTOFF = 1:RTC 当前处于空闲状态,可以发起新的写操作
  • RTOFF = 0:RTC 正在执行内部写入过程,软件必须等待

标准库函数 RTC_WaitForLastTask() 基于该标志位实现,用于等待上一次写操作彻底完成:

cpp 复制代码
void RTC_WaitForLastTask(void)
{
  /* Loop until RTOFF flag is set */
  while ((RTC->CRL & RTC_FLAG_RTOFF) == (uint16_t)RESET)
  {
  }
}

在实际开发中,需要遵循如下基本时序约束:在写入 RTC 寄存器前,先确认 RTOFF 为 1;执行写操作后,再调用 RTC_WaitForLastTask() 等待写入完成,以确保数据已经真正生效。尤其是在连续配置多个寄存器(如先写 PRL,再写 CNT)时,这一步是保证时序正确的关键。

需要注意的是,标准库中的 RTC_SetCounter()、RTC_SetPrescaler()、RTC_SetAlarm() 等函数,已经在内部封装了进入/退出配置模式(即通过设置 RTC_CRL 的 CNF 位)的过程,开发者无需手动干预。

cpp 复制代码
void RTC_SetCounter(uint32_t CounterValue)
{ 
  RTC_EnterConfigMode();
  /* Set RTC COUNTER MSB word */
  RTC->CNTH = CounterValue >> 16;
  /* Set RTC COUNTER LSB word */
  RTC->CNTL = (CounterValue & RTC_LSB_MASK);
  RTC_ExitConfigMode();
}

但需要注意,标准库函数虽然封装了配置模式的开关,但并未在函数入口处进行状态检查。在工程实践中,必须遵循"先等待,再操作,后等待"的原则。即在调用 RTC_SetCounter() 等函数前,先调用 RTC_WaitForLastTask() 确保硬件空闲;函数执行完毕后,再次调用以确保该操作已被同步至 RTC 核心时钟域。

从本质上看,RTOFF 机制是对"跨时钟域写入延迟"的一种硬件同步保护,用于防止 CPU 以过高速度连续写入而破坏 RTC 内部状态一致性。

5.9 后备域访问使能(DBP)

RTC 与 BKP(备份寄存器)同属于 后备域(Backup Domain),该区域在系统复位后默认处于写保护状态,以防止误操作破坏关键数据(如时间基准或掉电保持数据)。因此,在对 RTC 进行任何写操作之前,必须先解除后备域写保护。

该过程包括两个步骤:

cpp 复制代码
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE);

PWR_BackupAccessCmd(ENABLE);

其含义分别为:

  • 使能 PWR 与 BKP 外设的 APB1 时钟(提供访问通道)
  • 通过设置 PWR_CR 寄存器中的 DBP 位,解除后备域写保护

需要强调的是:

  • 这是访问 整个后备域(RTC + BKP) 的统一前置条件
  • 在系统初始化阶段执行一次即可,无需重复配置
  • RTC 本身没有独立的外设时钟使能位,其访问能力依赖于 PWR/BKP 模块

完成上述配置后,才能对 RTC 的关键寄存器(如 RTC_CNT、RTC_PRL、RTC_ALR)进行写入操作,否则写入将被硬件直接忽略。


6. 实验

6.1 实验一:读写 BKP 备份寄存器

6.1.1 实验目标

  • 掌握操作 BKP 之前必须执行的时钟使能与写保护解除流程;
  • 验证 BKP 数据在系统复位(VDD 未中断)后的保持特性,以及完全断电后数据清零的特性;
  • 熟练使用 BKP_WriteBackupRegister() 和 BKP_ReadBackupRegister() 对指定地址的数据寄存器进行读写。

6.1.2 硬件连接

6.1.3 整体配置流程

本实验的软件逻辑分为两个层次:初始化阶段 完成外设时钟使能与写保护解除;主循环阶段通过轮询按键触发写操作,并持续读取 BKP 寄存器内容显示在 OLED 上。

整体流程如下:

cpp 复制代码
① 初始化 OLED 与按键外设
      ↓
② 开启 PWR 和 BKP 外设的 APB1 时钟
      ↓
③ 解除后备区域写保护
      ↓
④ 进入主循环:
      ├─ 检测按键 → 按下则更新写入数据 → 写入 BKP_DR1、BKP_DR2
      └─ 每次循环读取 BKP_DR1、BKP_DR2 → 显示至 OLED

步骤一:外设初始化

OLED 和按键外设在操作 BKP 之前完成初始化,与 BKP 配置流程相互独立。

cpp 复制代码
OLED_Init();   // OLED 初始化
Key_Init();    // 按键初始化

// 显示静态标签
OLED_ShowString(1, 1, "W:");
OLED_ShowString(2, 1, "R:");

步骤二:开启 PWR 和 BKP 外设时钟

PWR 和 BKP 外设均挂载在 APB1 总线上,必须优先使能其时钟,否则后续所有针对 BKP 和后备区域的操作均无效。这两行代码也可以合并为一次调用:

cpp 复制代码
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE);

步骤三:解除后备区域写保护

系统复位后,后备区域默认处于写保护状态。该函数通过将 PWR_CR 寄存器的 DBP 位置 1,解除硬件写保护,获取对 BKP 数据寄存器的写入权限。读操作不受写保护限制,无需此步骤即可直接读取 BKP 寄存器。

cpp 复制代码
PWR_BackupAccessCmd(ENABLE);  // 解除后备区域写保护

步骤四:主循环中的读写逻辑

写操作仅在按键按下时触发,读操作在每次循环中持续执行。OLED 第一行(W)显示当前写入值,第二行(R)显示从 BKP 寄存器实时读回的值,两者在正常情况下应保持一致。

cpp 复制代码
while (1)
{
    KeyNum = Key_GetNum();   // 轮询按键

    if (KeyNum == 1)         // 按键 1 按下时触发写操作
    {
        ArrayWrite[0]++;     // 写入数据自增
        ArrayWrite[1]++;

        // 将更新后的数据写入 BKP 数据寄存器
        BKP_WriteBackupRegister(BKP_DR1, ArrayWrite[0]);
        BKP_WriteBackupRegister(BKP_DR2, ArrayWrite[1]);

        OLED_ShowHexNum(1, 3, ArrayWrite[0], 4);  // 显示写入值
        OLED_ShowHexNum(1, 8, ArrayWrite[1], 4);
    }

    // 每次循环读取 BKP 寄存器内容
    ArrayRead[0] = BKP_ReadBackupRegister(BKP_DR1);
    ArrayRead[1] = BKP_ReadBackupRegister(BKP_DR2);

    OLED_ShowHexNum(2, 3, ArrayRead[0], 4);  // 显示读取值
    OLED_ShowHexNum(2, 8, ArrayRead[1], 4);
}

具体代码如下:

main.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "Delay.h"

/**
  * 函    数:按键初始化
  * 参    数:无
  * 返 回 值:无
  */
void Key_Init(void)
{
	/*开启时钟*/
	RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE);		//开启GPIOB的时钟
	
	/*GPIO初始化*/
	GPIO_InitTypeDef GPIO_InitStructure;
	GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
	GPIO_InitStructure.GPIO_Pin = GPIO_Pin_1 | GPIO_Pin_11;
	GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
	GPIO_Init(GPIOB, &GPIO_InitStructure);						//将PB1和PB11引脚初始化为上拉输入
}

/**
  * 函    数:按键获取键码
  * 参    数:无
  * 返 回 值:按下按键的键码值,范围:0~2,返回0代表没有按键按下
  * 注意事项:此函数是阻塞式操作,当按键按住不放时,函数会卡住,直到按键松手
  */
uint8_t Key_GetNum(void)
{
	uint8_t KeyNum = 0;		//定义变量,默认键码值为0
	
	if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0)			//读PB1输入寄存器的状态,如果为0,则代表按键1按下
	{
		Delay_ms(20);											//延时消抖
		while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_1) == 0);	//等待按键松手
		Delay_ms(20);											//延时消抖
		KeyNum = 1;												//置键码为1
	}
	
	if (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0)			//读PB11输入寄存器的状态,如果为0,则代表按键2按下
	{
		Delay_ms(20);											//延时消抖
		while (GPIO_ReadInputDataBit(GPIOB, GPIO_Pin_11) == 0);	//等待按键松手
		Delay_ms(20);											//延时消抖
		KeyNum = 2;												//置键码为2
	}
	
	return KeyNum;			//返回键码值,如果没有按键按下,所有if都不成立,则键码为默认值0
}

6.1.4 实验现象

  • 数据同步更新:每按一次按键,W 行数值递增,R 行立即同步,验证 BKP 读写功能正常。

  • 复位保持验证:按下复位按键后,程序重新运行,ArrayWrite 数组在 SRAM 中重新初始化,W 行恢复初始值;而 R 行仍显示复位前最后一次写入的数据,证明 BKP 在 VDD 未中断的情况下具备复位非易失性。

  • 掉电清除验证:同时断开 VDD 与 VBAT(拔掉 USB 即可,实验中 VBAT 来自 ST-Link 的 3.3 V,随 USB 一并断电)。再次上电后,R 行显示 0x0000,证明 BKP 的数据保持依赖 VBAT 持续供电。


6.2 实验二:RTC 实时时钟

6.2.1 实验目标

  • 掌握 RTC 外设的完整初始化流程,包括 LSE 时钟源配置、预分频系数设定、读写同步等待;
  • 正确应用 <time.h> 标准库函数,结合时区偏移补偿,实现北京时间与 Unix 时间戳的双向转换;
  • 理解并验证 BKP 特征码检测机制,确保复位后 RTC 走时不被重置;
  • 在 VBAT 备用电源接入时,验证主电源断开期间 RTC 走时的连续性。

6.2.2 硬件连接

6.2.3 整体配置流程

本实验采用模块化封装:MyRTC.c 负责 RTC 底层驱动,main.c 负责调用接口与数据展示。

如下图所示,为本实验对应的 RTC 完整初始化流程图。该流程以"是否已完成初始化"为分支核心,通过 BKP 特征码判断系统当前状态,从而在"直接使用已有时间"与"执行完整初始化"两种路径之间进行切换。

从整体结构上看,流程可以划分为三个阶段:前置准备(时钟开启与后备域解锁)、状态判定(BKP 特征码检测)、以及按需执行的初始化流程(LSE 配置、同步等待、预分频设置与时间写入)。其中,右侧分支为首次上电或掉电后的完整初始化路径,而左侧分支则对应 RTC 已正常运行情况下的快速恢复路径。

在理解该流程图后,下面将按照执行顺序,对每一个关键步骤进行逐一展开分析。

步骤一:开启时钟与解除写保护

这两步是访问整个后备区域的统一前置条件,BKP 和 RTC 的操作均依赖这两步完成,原理已在第 4.6 节详述,初始化流程中只需执行一次:

cpp 复制代码
// MyRTC_Init() 中的前两步,与 BKP 实验完全一致
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
PWR_BackupAccessCmd(ENABLE);

步骤二:BKP 特征码检测,决定是否执行初始化

读取 BKP_DR1 的值,与约定的特征码 0xA5A5 进行比较,以此区分"首次上电"与"复位后重新上电"两种场景。该机制的原理已在第 4.5 节详细介绍。读 BKP 不受写保护限制,此处无需任何额外配置即可直接读取。

即使判断为非首次上电(走 else 分支),仍需调用 RTC_WaitForSynchro() 和 RTC_WaitForLastTask(),原因是复位后 APB1 接口重新开启时,RTC 核心的寄存器值尚未同步到总线侧缓存,直接读取 RTC_CNT 可能得到错误数值(详见第 5.7 节)。

cpp 复制代码
if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)
{
    // 首次上电或备用电源曾完全断开,执行完整初始化
    // ...(步骤三至步骤六)
}
else
{
    // RTC 已初始化且走时连续,直接同步后使用
    RTC_WaitForSynchro();
    RTC_WaitForLastTask();
}

步骤三:配置 LSE 时钟源

RCC_LSEConfig(RCC_LSE_ON) 开启 LSE 振荡器后,晶振需要一段起振时间才能稳定输出,程序通过 while 循环轮询 LSERDY 标志位进行阻塞等待。若 LSE 晶振因虚焊或负载电容值不匹配无法起振,程序将永久阻塞在此处,此时可将代码替换为 LSI 版本(代码注释中已提供),以排查其他逻辑。LSE 选为时钟源的理由已在第 5.4.1 节详述:

cpp 复制代码
RCC_LSEConfig(RCC_LSE_ON);                             // 开启 LSE 外部低速晶振
while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);     // 阻塞等待 LSE 起振稳定

RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);               // 选择 LSE 作为 RTCCLK 时钟源
RCC_RTCCLKCmd(ENABLE);                                 // 使能 RTCCLK 输出

步骤四:等待寄存器同步与完成

使能 RTCCLK 后必须立即调用 RTC_WaitForSynchro(),确保 RTC 核心的寄存器值已同步至 APB1 总线侧,后续的读写操作才能得到正确结果。RTC_WaitForLastTask() 确保 RTOFF 标志为 1,即当前没有未完成的写操作。两个函数的原理分别在第 5.7 节和第 5.8 节详述:

cpp 复制代码
RTC_WaitForSynchro();    // 等待 APB1 接口与 RTC 核心时钟域完成同步
RTC_WaitForLastTask();   // 等待上一次写操作(如有)彻底完成

步骤五:配置预分频系数

根据预分频输出频率公式:

本实验使用 LSE 32.768 kHz,令 PRL = 32767(即 32768 - 1),可使输出信号 TR_CLK 恰好为 1Hz(即,每秒计数一次),因此预分频寄存器的 PRL 应配置为 32767。

由于 RTC 的预分频寄存器(PRL)写入属于受 RTC 时钟域控制的写事务,其更新不会在 CPU 写入瞬间立即生效,而是需要在 RTCCLK 的时钟同步机制下完成内部采样与更新,因此必须通过 RTC_WaitForLastTask() 等待 RTOFF 标志置位,确认该次写操作已在 RTC 内部完成并生效后,才能进行后续配置操作。

cpp 复制代码
RTC_SetPrescaler(32768 - 1);   // 配置分频系数,输出 1 Hz 秒脉冲

RTC_WaitForLastTask();         
// 等待本次写操作在 RTC 核心中完成生效(RTOFF=1)
// 确保 PRL 更新已被 RTC 时钟域采样,否则可能影响后续配置

步骤六:写入初始时间(MyRTC_SetTime

struct tm 中 tm_year 和 tm_mon 存在固定偏移量(详见第 3.1.2 节),赋值时必须减去相应偏移。mktime 在 STM32 裸机环境下以零时区为基准进行转换,还需减去 28800 秒(8 时区×3600 秒/时区)才能得到真正的 UTC 时间戳写入 RTC_CNT。时区换算的完整原理在第 2.3 节详述。

cpp 复制代码
void MyRTC_SetTime(void)
{
    time_t time_cnt;
    struct tm time_date;

    // 将全局时间数组中的北京时间填入 struct tm(注意年份和月份的偏移量)
    time_date.tm_year = MyRTC_Time[0] - 1900;
    time_date.tm_mon  = MyRTC_Time[1] - 1;
    time_date.tm_mday = MyRTC_Time[2];
    time_date.tm_hour = MyRTC_Time[3];
    time_date.tm_min  = MyRTC_Time[4];
    time_date.tm_sec  = MyRTC_Time[5];

    // 由于标准库 mktime 默认将输入视为本地时间且不带时区偏移,
    // 在填充北京时间后,需手动减去 8 小时的秒数(28800s)
    // 从而将计算结果强制对齐为标准的标准 UTC 时间戳并存入硬件计数器。
    time_cnt = mktime(&time_date) - 8 * 60 * 60;

    RTC_SetCounter(time_cnt);   // 将 UTC 秒数写入 RTC_CNT
    RTC_WaitForLastTask();      // 等待写入完成
}

步骤七:写入特征码,完成初始化标记

在完成全部 RTC 配置和初始时间写入后,向 BKP_DR1 写入约定的特征码 0xA5A5,标记本次初始化已完成。下次上电时,步骤二中的特征码检测将读到此值,从而跳过重复初始化,保证 RTC 走时的连续性。

cpp 复制代码
BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);  // 初始化完成,写入特征码

步骤八:读取时间(MyRTC_ReadTime

RTC_GetCounter() 读出的是 RTC_CNT 中存储的 UTC 秒数,加上 28800 秒后得到北京时间对应的等价秒数,再传入localtime()完成转换。读取完成后将结构体成员回填至全局数组 MyRTC_Time,同样需还原年份(加 1900)和月份(加 1)的偏移。

cpp 复制代码
void MyRTC_ReadTime(void)
{
    time_t time_cnt;
    struct tm time_date;

    // 读取 RTC_CNT 中的 UTC 秒数,加上东八区偏移量,得到北京时间对应的秒数
    time_cnt = RTC_GetCounter() + 8 * 60 * 60;

    // 将本地秒数转换为分解日历时间
    time_date = *localtime(&time_cnt);

    // 将结构体内容还原为真实年份和月份后存入全局数组
    MyRTC_Time[0] = time_date.tm_year + 1900;
    MyRTC_Time[1] = time_date.tm_mon  + 1;
    MyRTC_Time[2] = time_date.tm_mday;
    MyRTC_Time[3] = time_date.tm_hour;
    MyRTC_Time[4] = time_date.tm_min;
    MyRTC_Time[5] = time_date.tm_sec;
}

步骤九:主循环显示

主循环以高频率持续调用 MyRTC_ReadTime(),刷新全局数组后将年月日、时分秒显示在 OLED 前两行。第三行显示 RTC_CNT 的原始 UTC 秒数,便于直接验证计数器是否在递增。第四行显示 RTC_DIV 的当前递减值,用于观察 32.768 kHz 时钟分频过程------DIV 从 32767 快速递减至 0,每完成一个完整循环恰好对应 1 秒。

cpp 复制代码
int main(void)
{
    OLED_Init();
    MyRTC_Init();

    OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
    OLED_ShowString(2, 1, "Time:XX:XX:XX");
    OLED_ShowString(3, 1, "CNT :");
    OLED_ShowString(4, 1, "DIV :");

    while (1)
    {
        MyRTC_ReadTime();   // 从 RTC 硬件读取当前时间,刷新全局数组

        OLED_ShowNum(1, 6,  MyRTC_Time[0], 4);   // 年
        OLED_ShowNum(1, 11, MyRTC_Time[1], 2);   // 月
        OLED_ShowNum(1, 14, MyRTC_Time[2], 2);   // 日
        OLED_ShowNum(2, 6,  MyRTC_Time[3], 2);   // 时
        OLED_ShowNum(2, 9,  MyRTC_Time[4], 2);   // 分
        OLED_ShowNum(2, 12, MyRTC_Time[5], 2);   // 秒

        OLED_ShowNum(3, 6, RTC_GetCounter(), 10); // RTC_CNT 原始 UTC 秒数
        OLED_ShowNum(4, 6, RTC_GetDivider(), 10); // RTC_DIV 当前递减值
    }
}

具体代码如下:

main.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include "Delay.h"
#include "OLED.h"
#include "MyRTC.h"

int main(void)
{
	/*模块初始化*/
	OLED_Init();		//OLED初始化
	MyRTC_Init();		//RTC初始化
	
	/*显示静态字符串*/
	OLED_ShowString(1, 1, "Date:XXXX-XX-XX");
	OLED_ShowString(2, 1, "Time:XX:XX:XX");
	OLED_ShowString(3, 1, "CNT :");
	OLED_ShowString(4, 1, "DIV :");
	
	while (1)
	{
		MyRTC_ReadTime();							//RTC读取时间,最新的时间存储到MyRTC_Time数组中
		
		OLED_ShowNum(1, 6, MyRTC_Time[0], 4);		//显示MyRTC_Time数组中的时间值,年
		OLED_ShowNum(1, 11, MyRTC_Time[1], 2);		//月
		OLED_ShowNum(1, 14, MyRTC_Time[2], 2);		//日
		OLED_ShowNum(2, 6, MyRTC_Time[3], 2);		//时
		OLED_ShowNum(2, 9, MyRTC_Time[4], 2);		//分
		OLED_ShowNum(2, 12, MyRTC_Time[5], 2);		//秒
		
		OLED_ShowNum(3, 6, RTC_GetCounter(), 10);	//显示32位的秒计数器
		OLED_ShowNum(4, 6, RTC_GetDivider(), 10);	//显示余数寄存器
	}
}

MyRTC.c文件:

cpp 复制代码
#include "stm32f10x.h"                  // Device header
#include <time.h>

uint16_t MyRTC_Time[] = {2023, 1, 1, 23, 59, 55};	//定义全局的时间数组,数组内容分别为年、月、日、时、分、秒

void MyRTC_SetTime(void);				//函数声明

/**
  * 函    数:RTC初始化
  * 参    数:无
  * 返 回 值:无
  */
void MyRTC_Init(void)
{
	/*开启时钟*/
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);		//开启PWR的时钟
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);		//开启BKP的时钟
	
	/*备份寄存器访问使能*/
	PWR_BackupAccessCmd(ENABLE);							//使用PWR开启对备份寄存器的访问
	
	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)			//通过写入备份寄存器的标志位,判断RTC是否是第一次配置
															//if成立则执行第一次的RTC配置
	{
		RCC_LSEConfig(RCC_LSE_ON);							//开启LSE时钟
		while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) != SET);	//等待LSE准备就绪
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE);				//选择RTCCLK来源为LSE
		RCC_RTCCLKCmd(ENABLE);								//RTCCLK使能
		
		RTC_WaitForSynchro();								//等待同步
		RTC_WaitForLastTask();								//等待上一次操作完成
		
		RTC_SetPrescaler(32768 - 1);						//设置RTC预分频器,预分频后的计数频率为1Hz
		RTC_WaitForLastTask();								//等待上一次操作完成
		
		MyRTC_SetTime();									//设置时间,调用此函数,全局数组里时间值刷新到RTC硬件电路
		
		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);			//在备份寄存器写入自己规定的标志位,用于判断RTC是不是第一次执行配置
	}
	else													//RTC不是第一次配置
	{
		RTC_WaitForSynchro();								//等待同步
		RTC_WaitForLastTask();								//等待上一次操作完成
	}
}

//如果LSE无法起振导致程序卡死在初始化函数中
//可将初始化函数替换为下述代码,使用LSI当作RTCCLK
//LSI无法由备用电源供电,故主电源掉电时,RTC走时会暂停
/* 
void MyRTC_Init(void)
{
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR, ENABLE);
	RCC_APB1PeriphClockCmd(RCC_APB1Periph_BKP, ENABLE);
	
	PWR_BackupAccessCmd(ENABLE);
	
	if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5)
	{
		RCC_LSICmd(ENABLE);
		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
		RCC_RTCCLKCmd(ENABLE);
		
		RTC_WaitForSynchro();
		RTC_WaitForLastTask();
		
		RTC_SetPrescaler(40000 - 1);
		RTC_WaitForLastTask();
		
		MyRTC_SetTime();
		
		BKP_WriteBackupRegister(BKP_DR1, 0xA5A5);
	}
	else
	{
		RCC_LSICmd(ENABLE);				//即使不是第一次配置,也需要再次开启LSI时钟
		while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) != SET);
		
		RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI);
		RCC_RTCCLKCmd(ENABLE);
		
		RTC_WaitForSynchro();
		RTC_WaitForLastTask();
	}
}*/

/**
  * 函    数:RTC设置时间
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,全局数组里时间值将刷新到RTC硬件电路
  */
void MyRTC_SetTime(void)
{
	time_t time_cnt;		//定义秒计数器数据类型
	struct tm time_date;	//定义日期时间数据类型
	
	time_date.tm_year = MyRTC_Time[0] - 1900;		//将数组的时间赋值给日期时间结构体
	time_date.tm_mon = MyRTC_Time[1] - 1;
	time_date.tm_mday = MyRTC_Time[2];
	time_date.tm_hour = MyRTC_Time[3];
	time_date.tm_min = MyRTC_Time[4];
	time_date.tm_sec = MyRTC_Time[5];
	
	time_cnt = mktime(&time_date) - 8 * 60 * 60;	//调用mktime函数,将日期时间转换为秒计数器格式
													//- 8 * 60 * 60为东八区的时区调整
	
	RTC_SetCounter(time_cnt);						//将秒计数器写入到RTC的CNT中
	RTC_WaitForLastTask();							//等待上一次操作完成
}

/**
  * 函    数:RTC读取时间
  * 参    数:无
  * 返 回 值:无
  * 说    明:调用此函数后,RTC硬件电路里时间值将刷新到全局数组
  */
void MyRTC_ReadTime(void)
{
	time_t time_cnt;		//定义秒计数器数据类型
	struct tm time_date;	//定义日期时间数据类型
	
	time_cnt = RTC_GetCounter() + 8 * 60 * 60;		//读取RTC的CNT,获取当前的秒计数器
													//+ 8 * 60 * 60为东八区的时区调整
	
	time_date = *localtime(&time_cnt);				//使用localtime函数,将秒计数器转换为日期时间格式
	
	MyRTC_Time[0] = time_date.tm_year + 1900;		//将日期时间结构体赋值给数组的时间
	MyRTC_Time[1] = time_date.tm_mon + 1;
	MyRTC_Time[2] = time_date.tm_mday;
	MyRTC_Time[3] = time_date.tm_hour;
	MyRTC_Time[4] = time_date.tm_min;
	MyRTC_Time[5] = time_date.tm_sec;
}

6.2.4 实验现象

  • 时间线性递增:OLED 第一、二行显示的日期与时间按公历规则正常走时,CNT 值每秒加 1,DIV 值每秒完成一次从 32767 到 0 的完整递减循环。

  • 复位保持验证:按下复位按键后,OLED 显示的时间保持连续,不会跳回 MyRTC_Time数组中代码预设的初始时间(2023-01-01 23:59:55),证明 BKP 特征码检测逻辑有效,复位后程序识别到 BKP_DR1 中已有特征码,跳过了重新初始化步骤。

  • 掉电持续运行:断开 VDD 主供电,保持 VBAT 引脚的备用电源(ST-Link 3.3 V)持续供电。经过一段时间后重新上电,OLED 显示的时间与实际流逝时间相符,证明 RTC 计数器在主电源断开期间依靠 LSE 晶振和 VBAT 继续正常走时。

  • LSI 备用方案:若 LSE 无法起振导致程序阻塞,可将 MyRTC_Init() 替换为代码注释中提供的 LSI 版本。注意:使用 LSI 时,即使已完成初始化(走 else 分支),每次上电也必须重新执行 RCC_LSICmd(ENABLE) 和就绪等待,因为 LSI 位于 VDD 供电域,掉电后状态不会保留。

相关推荐
python零基础入门小白2 小时前
Transformer、Token、RAG全解析,一篇读懂大模型核心机制!
人工智能·深度学习·学习·语言模型·大模型·transformer·产品经理
hoiii1872 小时前
基于STM32的扫地机器人源码工程
stm32·单片机·机器人
我是发哥哈2 小时前
东莞AI培训主流方案横向评测:5大选型维度解析
大数据·人工智能·学习·机器学习·chatgpt·ai编程
千寻girling2 小时前
机器学习 | 感知机 | 尚硅谷学习
人工智能·学习·机器学习
可爱の小公举2 小时前
Java 后端程序员转 AI Agent 工程师:一条可执行学习路线
java·人工智能·学习
Bechamz2 小时前
大数据开发学习Day26
java·大数据·学习
代码的小搬运工2 小时前
Masonry学习
学习·macos·cocoa
玖妍呐3 小时前
纠结课外辅导选线上还是线下?2026高适配线上学习软件推荐
学习
wuxinyan1233 小时前
大模型学习之路006:RAG 零基础入门教程(第三篇):BM25 关键词检索与混合检索实战
人工智能·学习·rag