本篇使用 STM32F407VE,实现 RTC 实时时钟的日历功能(包含断电续存、串口更新时间)。
目录
[一、RTC 实时时钟 核心要点](#一、RTC 实时时钟 核心要点)
[二、RTC 硬件电路:外部晶振 + 备用电池](#二、RTC 硬件电路:外部晶振 + 备用电池)
[三、工程准备:CubeMX / Keil 基础工程搭建](#三、工程准备:CubeMX / Keil 基础工程搭建)
[四、CubeMX 配置 RTC 模块](#四、CubeMX 配置 RTC 模块)
[五、读取 RTC 日历数据](#五、读取 RTC 日历数据)
[七、更新 RTC 时间 / 日期](#七、更新 RTC 时间 / 日期)
一、RTC 实时时钟 核心要点
RTC(Real-Time Clock,实时时钟)是 STM32 芯片内置的独立低功耗定时器模块,核心特性是:主电源(VDD)断电后可由备用电池(VBAT)持续供电,能长期精准保持日期、时间数据不丢失。
STM32F407 的 RTC 核心功能:
- 基础日历时钟(记录年 / 月 / 日 / 时 / 分 / 秒 / 星期,如传感器数据采集时间戳)
- 两路可编程闹钟:支持按时间点 / 时间间隔触发闹钟中断(如每天 8 点触发提醒)
- 一路可编程唤醒:休眠模式下定时唤醒芯片执行任务(低功耗场景核心功能)
⚠️ 踩坑提醒:
- 读取顺序:必须先读时间,后读日期(违反顺序会导致数据错乱)
- 写入顺序:必须先写时间,后写日期(违反顺序会导致数据错乱)
二、RTC 硬件电路:外部晶振 + 备用电池
STM32 RTC 功能的稳定实现,必须依赖外部硬件电路!核心包含两部分:外部低速晶振(LSE)电路 、VBAT 备用电池电路。
原理图设计,参考下图:

硬件实物,参考下图:

1. 外部低速晶振电路 LSE (RTC的时钟源)
STM32F407 的 RTC 支持 3 种时钟源,实际项目中 99% 选用 LSE。各特性对比如下:
| 时钟源 | 频率 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| LSI | 约 32kHz | 芯片内部集成,省成本 | 精度低 | 仅临时测试 |
| LSE | 32.768kHz | 精度高、功耗低 | 需外部晶振+电池电路 | 实际项目首选 |
| HSE | 如 25MHz | 精度极高 | 功耗高、断电后无法工作 | 持续主供电场景 |
LSE 电路设计要点(新手必看):
| 器件 | 规格要求 & 注意事项 |
|---|---|
| 32.768kHz 晶振 | 必须用**无源晶振;**有源晶振需额外供电,不适合 RTC 低功耗场景 |
| 匹配电容 | 2 个等值电容,参考容值 12pF; 电容过大 / 过小会导致晶振起振失败; 电容偏小时RTC 走时偏快,偏大时走时偏慢; 正常误差约 1.5 秒 / 天,误差过大可微调电容值; |
2. 备用电池电路 (VBAT 引脚)
STM32 的 VBAT 引脚是 RTC、备份域的专属供电引脚:
- 主电源VDD正常时:由 VDD 给 RTC / 备份寄存器供电;
- 主电源断电后:自动切换为备用电池供电,保证时间 / 备份数据不丢失。
VBAT 电路设计要点:
| 器件 | 规格要求 & 注意事项 |
|---|---|
| 电池座 | 常用 CR1220;空间充足选 CR2032(容量约 220mAh,可供电 RTC 数月至数年) |
| 纽扣电池 | 配套电池座选型;必须使用 3V 规格(禁用 1.5V/5V 电池,会损坏芯片); |
| 去耦电容 | 100nF陶瓷电容,尽量靠近 VBAT 引脚焊接,滤除供电杂波; |
| BAT54C | 隔离 VDD 和备用电池,防止电源倒灌,保证供电切换可靠; |
⚠️ 提醒 1 : VBAT电路仅为RTC、备份寄存器供电,不是为整个芯片供电!
⚠️ **提醒 2 :**长期供电场景可替换为「锂电池 + 充放电管理电路」,续航和稳定性优于纽扣电池。
三、工程准备:CubeMX / Keil 基础工程搭建
本节仅聚焦 RTC 功能相关的基础准备,确保后续能通过串口打印 / 更新 RTC 日历数据。
步骤 3-1:创建基础工程
基于已有的 STM32F407 CubeMX 工程添加 RTC 功能;你可以使用自己的 CubeMX 工程添加。
若需新建工程,可参考:
步骤 3-2:配置 UART1 收发 & 实现 printf 重定向
调试时需要通过 printf 打印 RTC 数据,同时接收串口助手的数据以更新时间。
因此,工程需要实现 UART1 通信,和printf 重定向到UART1:
⚠️ **提醒 :**可以用自己熟悉的串口收发方案,只需保证能与串口助手双向通信即可,无需照搬特定方法。
步骤 3-3:工程验证(必做)
完成 printf 重定向后,添加测试代码并烧录:
cpp
printf( "Hello World! RTC 测试 \r\n" );
打开串口助手(关闭 16 进制显示),确认能正常接收字符串,验证:
- 系统时钟配置正确;
- printf 重定向是否成功(核心,保证后续 RTC 数据能正常打印)

四、CubeMX 配置 RTC 模块
步骤 4-1:启用外部低速晶振(LSE)
具体操作:
- 在 CubeMX 的【RCC】页面中,LSE选择:Crystal/Ceramic Resonator (晶振)
步骤 4-2:选择 RTC 时钟源
具体操作:
- 在【时钟树】中, RTC 时钟源选择: LSE (32.768KHz)
⚠️ 提醒 :
LSE 固定频率为 32.768kHz,后续预分频参数计算完全依赖此值;而HSE、LSI 频率不同,切勿选错!
步骤 4-3:启用 RTC 并配置预分频参数
具体操作:
打开【TIM】组,进入【RTC】页面
勾选 Activate Clock Source ,使能 RTC 时钟
勾选 Activate Calendar,启用 日历设置
其它参数:默认;(不要改动,后面将用代码进行日历数据更新)
⚠️ 参数解释:
如果上一步选择 LSE (固定为32.768KHz), 这里参数已默认配置好了,无需手动设置:
- 小时格式选择: 24 Hours(24 小时制,符合常规习惯);
- 异步预分频值 (Asynchronous Prescaler) : 127,即 128-1。值越大,RTC 功耗越低;
- 同步预分频值 (Synchronous Prescaler):255,即 256-1。值越大,计时精度越高
核心计算公式:
LSE 时钟频率 ÷ 异步预分频值 ÷ 同步预分频值 = 32768Hz ÷ 128 ÷ 256 = 1Hz
即 RTC 每秒计数 1 次。
当使用 HSE、LSI时,需要手动配置上述两个预分频值,使计时实现常用的1Hz:
步骤 4-4:生成初始化代码
具体操作:
- 点击 右上角 Generate Code 按钮,CubeMX 自动将 RTC 配置代码添加到工程中。
至此,RTC 的底层初始化配置已完成,接下来可进入应用层代码编写(如时间读写、闹钟配置等)。
五、读取 RTC 日历数据
步骤 5-1:日历读取 & 打印代码
具体操作:
- 在
while(1)循环中添加以下代码,实现【每 1 秒读取一次 RTC 数据并打印】。
cpp/** 每隔1000ms,读取一次日历数据,并打印 **/ static uint32_t lastTime_1000ms = 0; // 定义静态变量,用于记录上次执行这个任务的时间点(单位:毫秒) if (HAL_GetTick() - lastTime_1000ms >= 1000) // 检查当前时间距离上次执行是否已经过了1000ms; HAL_GetTick()是获取启动后经过的毫秒数; { RTC_TimeTypeDef time = { 0 }; // 时间 时-分-秒 RTC_DateTypeDef data = { 0 }; // 日期 年-月-日 // 重点:必须先读时间、后读日期 否则数据可能错乱 HAL_RTC_GetTime(&hrtc, &time, RTC_FORMAT_BIN); // 读取时间数据; 必须先读时间后读日期; 写操作时,也必须先写时间后写日期,否则数据可能错乱 HAL_RTC_GetDate(&hrtc, &data, RTC_FORMAT_BIN); // 读取日期数据; 必须先读时间后读日期; 写操作时,也必须先写时间后写日期,否则数据可能错乱 printf("%04d年%02d月%02d日 ", 2000 + data.Year, data.Month, data.Date); // 打印 日期 printf("%02d:%02d:%02d ", time.Hours, time.Minutes, time.Seconds); // 打印 时间 switch (data.WeekDay) // 打印 星期 { case 1: printf("星期一"); break; case 2: printf("星期二"); break; case 3: printf("星期三"); break; case 4: printf("星期四"); break; case 5: printf("星期五"); break; case 6: printf("星期六"); break; case 7: printf("星期日"); break; default: printf("星期错误"); break; } printf(" \r\n"); // 打印 换行 // 记录任务时间戳 lastTime_1000ms = HAL_GetTick(); // 更新时间戳:将当前时间设为新基准,确保下一次周期准确从此时开始计算 }
步骤 5-2:编译烧录 & 验证 (发现断电不续存问题)
完成代码编写后,编译工程并烧录到开发板:
- 打开串口助手(波特率、校验位等与 UART1 配置一致);
- 可以看到,串口助手会每秒打印一次 RTC日历数据;
- 先运行一会,如几十秒
- 断电、重新上电;通过串口助手发现,日历时间被重置了,没有续存!

⚠️ 时间被重置的原因:
MX_RTC_Init() 函数里,每次上电会执行「RTC 初始化 + 写入 CubeMX 预设的默认时间」,导致程序每次重新运行都被重置时间。下文解决这个事件!
六、用备份寄存器实现断电续存
VBAT 引脚的备用电池可同时为 RTC 和 备份寄存器供电,因此可通过备份寄存器记录「RTC 是否已初始化」,避免程序重新运行时再次写入默认时间。
这一节,通过备份寄存器的 RTC_BKP_DR1 存储一个标志值,判断 RTC 是否已配置过日历数据:
- 若备份寄存器 RTC_BKP_DR1 值 == 0x01,表示已设置过日历,跳过函数下方的日历设置
- 若备份寄存器 RTC_BKP_DR1 值 ≠ 0x01,表示第一次运行,需要设置一次日历数据
**⚠️ 提示 1:**F407共有20个备份寄存器(0~19); 只要VBAT引脚上保持有3V供电,它们就有效;
**⚠️ 提示 2:**其它存储方式:使用芯片内部Flash、24C02等均可,但备份寄存器更适合。
步骤 6-1:修改 MX_RTC_Init () 函数
具体操作:
- 打开 rtc.c 的 MX_RTC_Init() 函数,
- 在 /* USER CODE BEGIN Check_RTC_BKUP */ 区域添加:
cpp// 1.读取备份寄存器1的值,如果等于1,就跳过下面的设置,退出函数 if (HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1) == 0x01) { return; } // 2.写入备份寄存器1的值,标记已设置过日历数据 HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, 0x01);
添加完成后,位置如下图:

步骤 6-2:编译烧录 & 验证
编译工程,并烧录:
- 打开串口助手,先运行几十秒;
- 断电、重新上电、通过串口助手观察,
- 打印的时间,已能断电续存,实现了断电不丢失。
运行效果如下图:

七、更新 RTC 时间 / 日期
实际项目中,RTC 时间更新的方式有很多:
- 触摸屏 / 按键:读取用户输入的时间;
- GPS 同步:解析 GPS 模块输出的时间戳,再校准 RTC;
- 网络同步:通过 NTP 协议从服务器获取标准时间,更新 RTC。
本节以串口助手为例,实现通过发送固定格式字符串更新 RTC 时间。
核心规则设计思路:
- 串口助手发送固定格式的字符串(无空格、无换行、字段完整),以便于用代码解释
- 范样:2026年02月04日19时11分30秒星期三
- 格式拆解:YYYY年MM月DD日HH时MM分SS秒星期X(X 为 "一 / 二 / 三 / 四 / 五 / 六 / 日")
步骤 7-1:编写字符串解析函数 RTC_ParseTimeString()
具体操作:
- 位置,在 main.c 的 /* USER CODE BEGIN 0 */ 区域内
- 编写下面的 RTC_ParseTimeString() 函数:
cpp#include <string.h> #include <stdio.h> /****************************************************************************** * 函 数: RTC_ParseTimeString * 功 能: 解析固定格式的时间字符串,提取有效数据并更新RTC时间 * 格 式: "YYYY年MM月DD日HH时MM分SS秒星期X"(如:2026年02月04日19时11分30秒星期三) * 约 定: 1. 字符串无空格、无换行,字段顺序/单位不可修改; * 2. 年4位、月/日/时/分/秒各2位,星期为中文单字(一/二/.../日); * 3. 星期映射:一=1、二=2、三=3、四=4、五=5、六=6、日=7; * 参 数: const char* timeStr 待解析的时间字符串(串口接收的原始数据) * 返回值: 无 * 异常处理:空指针/格式错误/星期非法 → 触发提示并退出,不执行更新 ******************************************************************************/ void RTC_ParseTimeString(const char* timeStr) { // 1. 空指针校验,避免程序崩溃 if (timeStr == NULL) { printf("解释失败:输入字符串为空! \r\n"); // 空字符串直接退出 return; } // 2. 定义变量存储解析结果 uint16_t year = 0; // 年(4位,如2026) uint8_t month = 0; // 月(2位,01-12) uint8_t date = 0; // 日(2位,01-31) uint8_t hours = 0; // 时(2位,00-23) uint8_t minutes = 0; // 分(2位,00-59) uint8_t seconds = 0; // 秒(2位,00-59) uint8_t weekday = 0; // 星期(1-7,对应一-日) char weekStr[2] = {0}; // 存储星期中文单字 // 3. 按固定格式解析字符串 // %4hu:匹配4位无符号短整型(对应uint16_t) // %2hhu:匹配2位无符号字符(对应uint8_t) // %2s:匹配星期中文单字(适配GBK/UTF-8编码) int parseRet = sscanf(timeStr, "%4hu年%2hhu月%2hhu日%2hhu时%2hhu分%2hhu秒星期%2s", &year, &month, &date, &hours, &minutes, &seconds, weekStr); // 4. 解析结果校验(正常应提取7个字段,否则格式不匹配) if (parseRet != 7) { printf("解释失败:字符串格式错误! 请按示例格式发送:\n"); // 格式不匹配,进入错误处理 printf("2026年03月05日15时45分00秒星期四 \n\n"); // 字符串格式,必须严格匹配 return; } // 5. 中文星期转换为数字(匹配RTC_SetNewData参数) if (strcmp(weekStr, "一") == 0) weekday = 1; // 中文"一"对应RTC星期1(周一) else if (strcmp(weekStr, "二") == 0) weekday = 2; // 中文"二"对应RTC星期2(周二) else if (strcmp(weekStr, "三") == 0) weekday = 3; // 中文"三"对应RTC星期3(周三) else if (strcmp(weekStr, "四") == 0) weekday = 4; // 中文"四"对应RTC星期4(周四) else if (strcmp(weekStr, "五") == 0) weekday = 5; // 中文"五"对应RTC星期5(周五) else if (strcmp(weekStr, "六") == 0) weekday = 6; // 中文"六"对应RTC星期6(周六) else if (strcmp(weekStr, "日") == 0) weekday = 7; // 中文"日"对应RTC星期7(周日) else // 未知星期字符,解析失败 { printf("\n解释失败,星期格式错误!\r\n "); return; } // 6. 解析成功,调用函数更新RTC时间 RTC_SetNewData(year, month, date, hours, minutes, seconds, weekday); }
步骤 7-2:编写 RTC_SetNewData() 函数 (写入日历数据)
具体操作:
- 位置,在刚才的 RTC_ParseTimeString() 函数上方,
- 编写下面的 RTC_SetNewData() 函数 :
cpp/****************************************************************************** * 函 数: RTC_SetNewData * 功 能: 设置RTC的时间和日期(严格遵循"先时间后日期"的操作顺序) * 说 明: 1. 操作前需解锁后备域; * 2. CubeMX默认以2000年为RTC年份基准,需做偏移处理; * 3. 中国不实行夏令时,相关参数固定为NONE; * 参 数: uint16_t year 实际年份(2000-2127,超出范围则报错) * uint8_t month 月份(1-12) * uint8_t date 日期(1-31) * uint8_t hours 小时(0-23,24小时制) * uint8_t minutes 分钟(0-59) * uint8_t seconds 秒数(0-59) * uint8_t weekday 星期(1=周一,2=周二,...,7=周日) * 返回值: 无 ******************************************************************************/ void RTC_SetNewData(uint16_t year, uint8_t month, uint8_t date, uint8_t hours, uint8_t minutes, uint8_t seconds, uint8_t weekday) { /* 1.定义RTC时间、日期结构体 */ RTC_TimeTypeDef sTime = {0}; // 时间结构体 (时/分/秒) RTC_DateTypeDef sDate = {0}; // 日期结构体 (年/月/日/星期) /* 2.检查参数合法性 */ if ((year < 2000) || (year > 2127) || (month < 1) || (month > 12) || (date < 1) || (date > 31) || (hours > 23) || (minutes > 59) || (seconds > 59) || (weekday < 1) || (weekday > 7)) { printf("日历配置函数:参数非法,退出本次设置。\r\n"); // 打印 非法参数 return; // 非法参数直接退出,不执行后续配置 } /* 3.使能时钟、解锁后备域 */ // __HAL_RCC_PWR_CLK_ENABLE(); // 使能PWR时钟; // HAL_PWR_EnableBkUpAccess(); // 解锁后备域访问; F4默认锁定; /* 4.配置时间; RTC强制要求:先写时间,后写日期 */ sTime.Hours = hours; // 时; 注意小时的格式,检查MX_RTC_Init()函数里的HourFormat参数,有24小时、12小时两种格式 sTime.Minutes = minutes; // 分 sTime.Seconds = seconds; // 秒 sTime.DayLightSaving = RTC_DAYLIGHTSAVING_NONE; // 关闭夏令时。中国不实行夏令时,因此固定设置为NONE sTime.StoreOperation = RTC_STOREOPERATION_RESET; // 夏令时配套参数,固定为RESET if (HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN) != HAL_OK) // 设置时间; RTC要求:先设置时间(必须顺序) { printf("RTC时间写入失败! \n"); return; // 直接退出,不执行后续配置 } /* 5.配置日期; 必须在时间写入后执行 */ sDate.WeekDay = weekday; // 星期:1-一、2-二、3-三、4-四、5-五、6-六、7-日 sDate.Month = month; // 月 sDate.Date = date; // 日 sDate.Year = year - 2000; // 年份偏移; CubeMX默认2000为基准, 需减去2000 if (HAL_RTC_SetDate(&hrtc, &sDate, RTC_FORMAT_BIN) != HAL_OK) // RTC要求:后设置日期(必须顺序) { printf("RTC日期写入失败! \n"); return; // 直接退出,不执行后续配置 } /* 6.写入标记值到备份寄存器,标记已配置过日历 */ HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, 0x01); // 备份寄存器1写入标志值。用于下次上电时判断是否已设置日历; 网上很多示例会用类似0x5A5A的值,原理是一样的,但0x5A5A容易被误解有特殊作用 /* 7.锁定后备域、关闭PWR时钟 */ // HAL_PWR_DisableBkUpAccess(); // 操作完成后锁定后备域,防止误操作 // __HAL_RCC_PWR_CLK_DISABLE(); // 关闭PWR时钟,节省功耗 /* 8.打印配置成功提示 */ printf("\nRTC日历数据写入成功!\r\n"); // 打印 提示 }
步骤 7-3:配置串口接收 & 触发解析
具体操作:
- 位置,在
main()函数的while(1)循环中,- 添加编写下面的串口接收处理、调用字符串解释。
cppif (UART1_GetRxNum()) // 判断USART1是否获取到新一帧数据:函数返回的接收字节数,大于0,即为收到新一帧数据 { // 获取数据 uint8_t *rxData = UART1_GetRxData(); // 获取数据的地址 uint16_t rxNum = UART1_GetRxNum(); // 获取数据的字节数 // 把收到的数据,printf到串口助手观察 printf("\r<<<<< USART1 收到一帧数据 \r"); // 提示 printf("字节数:%d \r", rxNum); // 显示字节数 printf("ASCII : %s\r", (char *)rxData); // 显示数据,以ASCII方式显示,即以字符串的方式显示 printf("\r\r"); // 显示换行 RTC_ParseTimeString((const char*) rxData); // 解释日历时间,并更新保存 UART1_ClearRx(); // 重要:清0接收标志,即清0接收到的字节数; 每次处理完成数据,就要调用这个函数清0 }
编写完成后,位置参考如下图:

注意 & 优化:
上面的UART1接收处理,你可以替换成自己熟悉的方法、方案。
步骤 7-4:编译烧录 & 验证
具体操作:
- 烧录后,串口助手将每秒打印当前时间;
- 发送固定格式字符串,如:
2026年03月10日18时57分30秒星期二 - 串口提示:RTC日历数据写入成功!
- 断电重启,时间仍为更新后的数值,验证断电续存。

至此,RTC 初始化、日历读取和写入、日历更新,已全部实现。



