
日期操作、闹钟中断与附录(时间戳 / 闹钟 / NTP)
一、RTC 日期操作:简易日历
1.1 案例目标
- 程序启动时设置一个初始时刻(含年月日、时分秒);
- 使用 RTC_CNT 保存初始时刻,随时间流逝自动更新;
- 通过串口每秒输出格式化时间;
- 断电后时间更新能继续正确进行。
1.2 日期结构体 RTC_DateTypeDef
typedef struct
{
uint8_t WeekDay; /*!< 星期几 (HAL_RTC_SetDate 不需要此参数),可选值参考 @ref RTC_WeekDay_Definitions */
uint8_t Month; /*!< 月份 (BCD格式),可选值参考 @ref RTC_Month_Date_Definitions */
uint8_t Date; /*!< 日期 (1~31) */
uint8_t Year; /*!< 年份 (0~99, 对应 2000~2099 年) */
} RTC_DateTypeDef;
两个关键约定:
/* Coded in BCD format */
#define RTC_MONTH_JANUARY ((uint8_t)0x01)
#define RTC_MONTH_FEBRUARY ((uint8_t)0x02)
#define RTC_MONTH_MARCH ((uint8_t)0x03)
#define RTC_MONTH_APRIL ((uint8_t)0x04)
#define RTC_MONTH_MAY ((uint8_t)0x05)
#define RTC_MONTH_JUNE ((uint8_t)0x06)
#define RTC_MONTH_JULY ((uint8_t)0x07)
#define RTC_MONTH_AUGUST ((uint8_t)0x08)
#define RTC_MONTH_SEPTEMBER ((uint8_t)0x09)
#define RTC_MONTH_OCTOBER ((uint8_t)0x10) // 十月 = 0x10!
#define RTC_MONTH_NOVEMBER ((uint8_t)0x11) // 十一月 = 0x11!
#define RTC_MONTH_DECEMBER ((uint8_t)0x12) // 十二月 = 0x12!
十月不能用 0x0A(那是非法 BCD),必须用 0x10——BCD 的"每个十进制位独立 4 位"规则,1 和 0 各占 4 位。
1.3 设置/获取日期函数签名
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);
| hrtc | RTC 句柄 |
| sDate | 日期结构体(SetDate 输入、GetDate 输出) |
| Format | RTC_FORMAT_BIN / RTC_FORMAT_BCD |
| 返回值 | HAL_StatusTypeDef |
⚠️ 最大的坑(F1 专属):
STM32F1 系列没有"RTC 日期寄存器"——HAL_RTC_SetDate 只是把日期写入内存中的结构体,并没有真正写入 RTC 相关寄存器。
复位开发板,日期数据就丢失!
而 STM32F0/F4 系列有独立的日期寄存器,不存在这个问题。
源码印证——跳进 HAL_RTC_SetDate 内部:
/* Change the current date */
hrtc->DateToUpdate.Year = sDate->Year;
hrtc->DateToUpdate.Month = sDate->Month;
hrtc->DateToUpdate.Date = sDate->Date;
它只是把年月日写进了 hrtc->DateToUpdate(内存结构体)。
而 RTC_HandleTypeDef 里的这个字段:
typedef struct
{
RTC_TypeDef *Instance; /*!< RTC寄存器基地址 */
RTC_InitTypeDef Init; /*!< RTC所需参数配置 */
RTC_DateTypeDef DateToUpdate; /*!< 用户设置的当前日期,会自动更新 */
HAL_LockTypeDef Lock; /*!< RTC锁定对象(用于互斥访问) */
__IO HAL_RTCStateTypeDef State; /*!< RTC通信状态 */
…
} RTC_HandleTypeDef;
注意区分:
普通寄存器(如 GPIO 的 ODR)复位也会丢数据;
RTC 系列寄存器处于备份域,靠备份电源(VBAT)供电才具备"复位不丢"的能力。
日期没进备份域,只进了 RAM,所以复位必丢。
1.4 代码版本 1:使用 HAL_RTC_SetDate
① DateTimeInit:设置初始时间日期(BKP 控制只设一次)
void DateTimeInit(void)
{
RTC_TimeTypeDef sTime = {
.Hours = 8, .Minutes = 0, .Seconds = 0
};
RTC_DateTypeDef sData = {
.Year = 35, // 2035 年
.Month = 7, // 7 月
.Date = 15 // 15 日
};
uint16_t magic_number = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1);
if (magic_number != RTC_MAGIC_NUMBER)
{
HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN);
HAL_RTC_SetDate(&hrtc, &sData, RTC_FORMAT_BIN);
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_MAGIC_NUMBER);
}
}
② GetDateTime:获取时间日期并格式化打印
void GetDateTime(void)
{
RTC_TimeTypeDef sTime = {0};
RTC_DateTypeDef sData = {0};
HAL_RTC_GetTime(&hrtc, &sTime, RTC_FORMAT_BIN);
HAL_RTC_GetDate(&hrtc, &sData, RTC_FORMAT_BIN);
printf("%02d-%02d-%02d %02d:%02d:%02d\\n",
sData.Year, sData.Month, sData.Date,
sTime.Hours, sTime.Minutes, sTime.Seconds);
}
main 里 while(1) 调用 GetDateTime + HAL_Delay(1000)。
运行现象(第一次上电正常):
2035-07-15 08:00:00
2035-07-15 08:00:01
…
⚠️ 一按复位键,日期就出问题:
复位前:2035-07-15 08:00:22 → 08:00:23 → …(正常)
复位后:2035-07-15 08:00:26 → … → 2000-01-01 08:00:26 ← 日期被清空了!
原因分析:
time 还是正确的(存在备份域 RTC_CNT),主要是 Date 不对了(存在内存结构体,复位即丢)。
而且注意:
其实保存在内存里的日期只是一个"起始日期",后续 HAL_RTC_GetDate 会在底层自动计算累计过了多少秒,再更新日期数据。
解决思路两条:
| 用后备寄存器保存年月日 | 资源占用多、太麻烦,不推荐 |
| 不再用 SetDate/GetDate,改用 UNIX 时间戳:基于 RTC_CNT 直接保存时间戳,通过时间戳转"年月日时分秒" | 采用(F1 上工程实现有局限,教学使用) |
1.5 代码版本 2:UNIX 时间戳方案
1.5.1 什么是 UNIX 时间戳
计算机保存时间,通常不按"年月日时分秒"字符串存,而是用一个整数:
以 1970 年 1 月 1 日 0 时 0 分 0 秒为基准,计算当前时刻与基准时刻的秒数之差,称为 UNIX 时间戳。
当前日期时间 = 基准时刻(1970-01-01 00:00:00) + 偏移量 N(秒)
- 时间是线性递增的 → 时间戳也是线性增长的数字;
- 现在距 1970 年已 50 多年,时间戳已经是个很大的数字(例:2038-01-01 20:14:49 → 2145960889 秒);
- 常见在线工具:时间戳(Unix timestamp)转换工具 – 在线工具
溢出问题:
| 标准 UNIX 时间戳(有符号 32 位) | 31 位有效 | 2038 年 |
| RTC_CNT(无符号 32 位) | 32 位 | 2106 年 |
工程哲学:
时间须存储方能使用,存储必占有限位宽,位宽必致数值上限,上限必生溢出。
溢出既不可免,则时间设置便非技术难题,而是工程选择:使溢出时点晚于产品设计寿命即可。
为什么选 1970 年 1 月 1 日?
UNIX 操作系统研发于 20 世纪 60~70 年代,开发 UNIX 的大佬正是 C 语言之父丹尼斯·里奇和肯·汤姆逊(C 语言其实是他们开发 UNIX 时"顺便"设计的),他们选了 1970-01-01 作为基准——如今已成为计算机"数字纪元"的起点。
1.5.2 时间与数字
如果日期/时间被转化为数字 → 日期时间就能被保存 → 被 RTC 维护
时间是线性递增的 → 数字也递增 → 这个数字就是时间戳
1.5.3 代码设计
第一步:修改 HAL 源码(真实工程禁止)——参考"RTC_CNT 操作"章节,把 RTC_ReadTimeCounter / RTC_WriteTimeCounter 的 static 去掉,并在 main.c 上方声明。
第二步:AI 生成时间戳转换代码(课件演示了 Qoder 智能体工作台,AI 擅长生成这种固定规则的复杂逻辑;AI 生成的代码每次可能不同):
TimeStamp.h
#ifndef __BSP_TIMESTAMP_H
#define __BSP_TIMESTAMP_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-6,0=周日)
} DateTime_t;
/**
* @brief 时间戳转日期时间
* @param timestamp: Unix时间戳 (从1970-01-01 00:00:00开始的秒数)
* @param dt: 输出日期时间结构体
* @retval true: 转换成功,false: 转换失败
*/
bool Timestamp_To_DateTime(uint32_t timestamp, DateTime_t *dt);
/**
* @brief 日期时间转时间戳
* @param dt: 日期时间结构体
* @param timestamp: 输出Unix时间戳
* @retval true: 转换成功, false: 转换失败
*/
bool DateTime_To_Timestamp(const DateTime_t *dt, uint32_t *timestamp);
/**
* @brief 判断闰年
* @param year: 年份
* @retval true: 闰年, false: 平年
*/
bool Is_Leap_Year(uint16_t year);
/**
* @brief 获取某月的天数
* @param year: 年份
* @param month: 月份 (1-12)
* @retval 该月的天数
*/
uint8_t Get_Days_In_Month(uint16_t year, uint8_t month);
/**
* @brief 验证日期时间是否合法
* @param dt: 日期时间结构体
* @retval true: 合法, false: 不合法
*/
bool Is_Valid_DateTime(const DateTime_t *dt);
#endif /* __BSP_TIMESTAMP_H */
TimeStamp.c
#include "BSP_Timestamp.h"
// 每个月的天数(平年)
static const uint8_t days_in_month[12] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31};
// 判断闰年
bool Is_Leap_Year(uint16_t year)
{
return ((year % 4 == 0 && year % 100 != 0) || (year % 400 == 0));
}
// 获取某月的天数
uint8_t Get_Days_In_Month(uint16_t year, uint8_t month)
{
if (month < 1 || month > 12) return 0;
if (month == 2 && Is_Leap_Year(year)) return 29;
return days_in_month[month – 1];
}
// 验证日期时间是否合法
bool Is_Valid_DateTime(const DateTime_t *dt)
{
if (dt == NULL) return false;
if (dt->year < 1970 || dt->year > 2100) return false;
if (dt->month < 1 || dt->month > 12) return false;
if (dt->day < 1 || dt->day > Get_Days_In_Month(dt->year, dt->month)) return false;
if (dt->hour > 23) return false;
if (dt->minute > 59) return false;
if (dt->second > 59) return false;
if (dt->weekday > 6) return false;
return true;
}
// 时间戳转日期时间
bool Timestamp_To_DateTime(uint32_t timestamp, DateTime_t *dt)
{
if (dt == NULL) return false;
// 计算秒数
uint32_t seconds = timestamp;
// 计算星期 (1970-01-01 是星期四)
dt->weekday = (4 + seconds / 86400) % 7;
// 计算天数
uint32_t days = seconds / 86400;
seconds %= 86400;
// 计算时分秒
dt->hour = seconds / 3600;
seconds %= 3600;
dt->minute = seconds / 60;
dt->second = seconds % 60;
// 计算年份
uint16_t year = 1970;
while (days >= 365)
{
if (Is_Leap_Year(year))
{
if (days >= 366) { days -= 366; year++; }
else break;
}
else
{
days -= 365;
year++;
}
}
dt->year = year;
// 计算月份
uint8_t month = 1;
while (month <= 12)
{
uint8_t days_in_this_month = Get_Days_In_Month(year, month);
if (days < days_in_this_month) break;
days -= days_in_this_month;
month++;
}
dt->month = month;
// 计算日期
dt->day = days + 1;
return true;
}
// 日期时间转时间戳
bool DateTime_To_Timestamp(const DateTime_t *dt, uint32_t *timestamp)
{
if (dt == NULL || timestamp == NULL) return false;
if (!Is_Valid_DateTime(dt)) return false;
uint32_t days = 0;
// 计算从1970年到目标年份的天数
for (uint16_t year = 1970; year < dt->year; year++)
{
days += Is_Leap_Year(year) ? 366 : 365;
}
// 计算从1月到目标月份的天数
for (uint8_t month = 1; month < dt->month; month++)
{
days += Get_Days_In_Month(dt->year, month);
}
// 加上日期
days += dt->day – 1;
// 转换为秒
*timestamp = days * 86400 + dt->hour * 3600 + dt->minute * 60 + dt->second;
return true;
}
1.5.4 编写调用逻辑
① DateTimeInit:把"年月日"转成时间戳写入 RTC_CNT
void DateTimeInit(void)
{
DateTime_t dt = {
.year = 2035,
.month = 7,
.day = 15,
.hour = 8,
.minute = 0,
.second = 0
};
uint16_t magic_number = HAL_RTCEx_BKUPRead(&hrtc, RTC_BKP_DR1);
if(magic_number != RTC_MAGIC_NUMBER)
{
uint32_t timestamp = 0;
DateTime_To_Timestamp(&dt, ×tamp);
printf("timestamp: %u\\n", timestamp);
RTC_WriteTimeCounter(&hrtc, timestamp); // 秒数存进备份域
HAL_RTCEx_BKUPWrite(&hrtc, RTC_BKP_DR1, RTC_MAGIC_NUMBER); // 写魔数
}
}
② GetDateTime:读时间戳转回日期时间
void GetDateTime(void)
{
DateTime_t dt;
uint32_t timestamp = RTC_ReadTimeCounter(&hrtc); // 读秒数(备份域,不丢)
Timestamp_To_DateTime(timestamp, &dt); // 秒数 → 年月日时分秒
printf("%04d-%02d-%02d %02d:%02d:%02d\\n",
dt.year, dt.month, dt.day,
dt.hour, dt.minute, dt.second);
}
运行效果:
RTC Test Example:
timestamp: 2068099200 ← 2035-07-15 08:00:00 对应的时间戳
2035-07-15 08:00:00
2035-07-15 08:00:01
…(正常走)
断电…(等一会)…重新上电
2035-07-15 08:00:18 ← 断电期间时间继续计时!
2035-07-15 08:00:19
结论:开发板复位时间日期也能正确显示;断电后时间继续计时(VBAT 供电)。
细节:如果不成功,扣一下电池——因为我们用备份寄存器标记"首次启动",扣电池让后备域彻底掉电、清掉魔数,新代码才会执行初始化。

1.6 一句总结:F1 内置 RTC 的定位
STM32F103ZET6 已问世 15 年多,内部 RTC 功能并不完善——从它只提供 RTC_CNT、没有日期类寄存器就能看出来;且 HAL 库没有直接暴露设置 RTC_CNT 的函数,所以这部分代码只做课堂练习。实际工程中会用更完善的 MCU,或外接独立的 RTC 模块来完成计时。
二、RTC 闹钟操作
2.1 案例目标
- 使用 RTC_CNT 设置系统时间(不考虑日期);
- 使用 RTC_ALR 设置闹钟时间;
- 当系统时间到达闹钟时间时,点亮板载 LED0。
2.2 准备工作
RTC 的两个中断,别搞混:
| RTC global interrupt | RTC_Second、RTC_Overflow | RTC_IRQHandler | 3 | 秒中断/溢出中断 |
| RTC alarm interrupt through EXTI line 17 | 只 RTC_Alarm | RTC_Alarm_IRQHandler | 41 | 闹钟中断,能唤醒深度睡眠 |
一般需要秒中断用 RTC global interrupt;需要闹钟中断 + 唤醒深度睡眠用 EXTI line 17。RTC_Second/RTC_Overflow 和 RTC_Alarm 在软件上是分开处理的。
2.3 闹钟关键函数
① 闹钟结构体 RTC_AlarmTypeDef
typedef struct {
RTC_TimeTypeDef AlarmTime; /*!< RTC闹钟时间成员 */
uint32_t Alarm; /*!< 闹钟ID (STM32F1仅支持1个闹钟), 参数取值参考 @ref RTC_Alarm */
} RTC_AlarmTypeDef;
typedef struct
{
uint8_t Hours; /*!< 小时, 范围 0-23 */
uint8_t Minutes; /*!< 分钟, 范围 0-59 */
uint8_t Seconds; /*!< 秒, 范围 0-59 */
} RTC_TimeTypeDef;
Alarm ID 是什么?
HAL 库为不同型号 MCU 设计,有的 MCU 有多个闹钟,需要区分设置的是哪一个。STM32F103 系列只有唯一取值 RTC_ALARM_A(0U),所以设置好之后不用关心。
② 设置闹钟(带中断)
HAL_StatusTypeDef HAL_RTC_SetAlarm_IT(RTC_HandleTypeDef *hrtc,
RTC_AlarmTypeDef *sAlarm,
uint32_t Format);
⚠️ 注意:
HAL_RTC_SetTime() 必须先调用,再使能闹钟功能。
还有 HAL_RTC_SetAlarm(不带 _IT)也能设置闹钟,但时间到了不会触发中断,需要额外写代码(比如循环读闹钟寄存器),所以优先用 HAL_RTC_SetAlarm_IT。
③ 获取闹钟
HAL_StatusTypeDef HAL_RTC_GetAlarm(RTC_HandleTypeDef *hrtc,
RTC_AlarmTypeDef *sAlarm,
uint32_t Alarm, // RTC_ALARM_A
uint32_t Format);
2.4 编写代码
① My_Init:设置初始时间 + 设置闹钟(12:00:10 触发)
void My_Init() {
// 1. 在 RTC 中设置时间
RTC_TimeTypeDef sTime = {0};
sTime.Hours = 12;
sTime.Minutes = 0;
sTime.Seconds = 0;
if (HAL_RTC_SetTime(&hrtc, &sTime, RTC_FORMAT_BIN) != HAL_OK) {
Error_Handler();
}
// 2. 设置RTC闹钟
RTC_AlarmTypeDef sAlarm = {0};
sAlarm.AlarmTime.Hours = 12;
sAlarm.AlarmTime.Minutes = 0;
sAlarm.AlarmTime.Seconds = 10; // 设置闹钟在10秒后响
// 重要,启动RTC闹钟中断
if (HAL_RTC_SetAlarm_IT(&hrtc, &sAlarm, RTC_FORMAT_BIN) != HAL_OK) {
Error_Handler();
}
printf("初始化完成!\\n");
}
调用:/* USER CODE BEGIN 2 */ My_Init(); /* USER CODE END 2 */
② GetTimeInfo / GetAlarmInfo:读取当前时间和闹钟
void GetTimeInfo() {
RTC_TimeTypeDef sTime = {0};
if (HAL_RTC_GetTime(&hrtc, &sTime, RTC_FORMAT_BIN) != HAL_OK) {
Error_Handler();
}
printf("Current Time: %02d:%02d:%02d\\n", sTime.Hours, sTime.Minutes, sTime.Seconds);
}
void GetAlarmInfo() {
RTC_AlarmTypeDef sAlarm = {0};
if (HAL_RTC_GetAlarm(&hrtc, &sAlarm, RTC_ALARM_A, RTC_FORMAT_BIN) != HAL_OK) {
Error_Handler();
}
printf("Alarm Time: %02d:%02d:%02d\\n", sAlarm.AlarmTime.Hours,
sAlarm.AlarmTime.Minutes, sAlarm.AlarmTime.Seconds);
}
③ main 主循环:1 秒间隔打印 + 检测闹钟标志
/* USER CODE BEGIN PV */
volatile int alarm_flag = 0; // 中断里置位,主循环里查询
/* USER CODE END PV */
int main(void)
{
…
BSP_Usart_Init(&huart1);
HAL_Delay(1000);
printf("RTC Test Example:\\n");
My_Init();
while (1)
{
GetTimeInfo();
GetAlarmInfo();
if(alarm_flag)
{
alarm_flag = 0;
printf("alarm interrupt trigger!\\n");
}
HAL_Delay(1000);
}
}
④ 中断回调:闹钟到达 → 置标志 + 点亮 LED0
void HAL_RTC_AlarmAEventCallback(RTC_HandleTypeDef *hrtc)
{
if(hrtc == &hrtc)
{
alarm_flag = 1; // 通知主循环
HAL_GPIO_WritePin(GPIOF, GPIO_PIN_8, GPIO_PIN_RESET); // 点亮 LED0
}
}
运行效果:
RTC Test Example:
初始化完成
Current Time: 12:00:00
Alarm Time: 12:00:10
Current Time: 12:00:01
Alarm Time: 12:00:10
…
Current Time: 12:00:10 ← 时间到达闹钟时间
Alarm Time: 12:00:10
alarm interrupt trigger! ← 闹钟中断触发!
现象:10 秒之后,时间到达闹钟时间,产生闹钟中断,并点亮了 LED0。
2.5 顺手验证秒中断
HAL_StatusTypeDef HAL_RTCEx_SetSecond_IT(RTC_HandleTypeDef *hrtc);
启动 RTC 秒中断(对应 RTC global interrupt,处理函数 RTC_IRQHandler,中断号 3)。
测试代码现场写:
使能秒中断后,每 1 秒触发一次中断,可在 RTC_IRQHandler 回调里翻转 LED 或打印。
三、附录:时钟树、LSE 位置、石英钟历史、时间从哪里来
3.1 时钟树第二部分:树形类比
把时钟树想象成一棵树:
树叶
┌──────────┴──────────┐
RTC 独立看门狗(IWDG)
RTCCLK IWDGCLK
└──────────┬──────────┘
选择器(树干)
┌──────────┴──────────┐
/128 分频 直连
│ │
HSE(8MHz) LSI(40kHz) LSE(32.768kHz)
(树根,三个时钟源,都是真硬件)
- 树根:HSE(8MHz 外部)、LSE(32.768kHz 外部)、LSI(40kHz 内部)三个时钟源;
- 树干:选择器 + /128 分频;
- 树枝:RTCCLK(给 RTC)、IWDGCLK(给独立看门狗)——只有两根树枝;
- 树叶:RTC 和 IWDG 两个外设——只有两片树叶。
关键规则:
APB1 预分频系数≠1 时定时器时钟频率不变;
APB1 最大 36MHz、APB2 最大 72MHz、HCLK 最大 72MHz(老知识复习)。
3.2 LSE 在什么位置:它是真硬件
之前说的"时钟源",在 MCU 或板子上是真真实实的硬件——灵犀板上:
- 32.768kHz 晶振:型号 Q13FC13500004_C32346(配 20pF 电容,接 PC14-OSC32_IN / PC15-OSC32_OUT);
- 8MHz 晶振:型号 X50328MSB2GI(配 20pF 电容,接 OSC_IN/OSC_OUT)。
这就是为什么 LSE 断电也能走:晶振是无源谐振器,对应振荡电路在 MCU 内部(类似后备域),LSE 的振荡电路可以在 VBAT 下工作。
3.3 石英钟历史:32.768kHz 是怎么成为标准的
(课件为 AI 生成内容,总结如下)
| 早期探索 | 1928 年 | 美国贝尔电话实验室展示世界上第一台石英钟,证明石英晶体高精度计时的可行性 |
| 先驱探索 | 1960 年代 | 瑞士、日本研究机构开发石英音叉电子手表原型 |
| 业界认可 | 1969 年 | 日本精工(Seiko)推出世界上第一款商用石英腕表,开启石英表时代 |
| 标准确立 | 随后 | 芝柏(Girard-Perregaux)等品牌机芯明确采用 32,768Hz——能精准分频出 1Hz 秒信号、功耗和体积达到完美平衡,成为行业事实标准 |
| 低成本量产 | 20 世纪 70 年代 | 尤尔根·斯托特(Juergen H. Staudte)发明光刻工艺批量生产微型音叉晶振,大规模低成本普及 |
生态推测:
32.768kHz 在石英手表领域优先使用 → 形成产业链 → 造出来的晶振大多是 32.768kHz → 单片机振荡器选型时直接拿来用。
32.768kHz = 2^15 Hz,正好方便分频成 1Hz 秒脉冲。
3.4 时间从哪里来:NTP 协议
MCU 的初始时间更多是从网络中同步来的:
- 一般走 4G 基站或以太网,通过 NTP 协议获取时间,然后进行时间校正;
- NTP(Network Time Protocol,网络时间协议):互联网上同步计算机系统时间的标准协议,解决不同设备时间不一致的核心问题;
- 可以想象成一个遍布全球、极为精确的"网络报时系统":
- 开发板 MCU → Wi-Fi 模块(STA,SSID/密码)或以太网口(RJ45 + PHY)→ 路由器/AP(DHCP/NAT)→ 互联网(NTP/UDP)→ NTP 服务器(公共或局域网,默认 UDP 123 端口)→ 返回 UTC 时间戳 → 时间同步送给 MCU 程序。
注意:
STM32F103 中用内置 RTC 的很少,一般都有独立 RTC 模块做精准计时。
我们学内置 RTC,是为了理解什么是 RTC、RTC 用来干什么,学完遇到外置的也更好理解。
常见外置 RTC 芯片/模块:DS1302(RTC 芯片)、DS3231(RTC 模块,精度高带温补)、PCF8563(RTC 芯片/模块)——一般通过 IIC 通信。
四、本篇小结
1. F1 无日期寄存器:HAL_RTC_SetDate 只写内存 DateToUpdate → 复位日期丢(2000-01-01)
F0/F4 有独立日期寄存器,无此问题
2. 方案二选一:备份寄存器存年月日(不推荐) vs UNIX 时间戳(采用)
3. 时间戳 = 1970-01-01 00:00:00 起的秒数;有符号32位2038溢出,RTC_CNT无符号可到2106
溢出是工程选择:让溢出时点晚于产品设计寿命即可
4. DateTime_t 双向换算:闰年/月天数/合法性校验 + 秒→日期、日期→秒
5. 闹钟:RTC_AlarmTypeDef(AlarmTime + RTC_ALARM_A) → HAL_RTC_SetAlarm_IT(带中断)
→ CNT==ALR → EXTI17 → HAL_RTC_AlarmAEventCallback → 置标志/点灯
6. 两个中断:RTC global(秒/溢出, IRQn=3, RTC_IRQHandler);EXTI17(闹钟, IRQn=41, RTC_Alarm_IRQHandler, 可唤醒睡眠)
7. 附录:时钟树树形类比、LSE 是真晶振、32.768kHz 石英表历史、NTP 网络校时、外置 RTC 芯片
面试题
一、日期操作
Q1. RTC_DateTypeDef 结构体有哪些成员?Year 的取值范围和含义?
A:
WeekDay(星期,SetDate 不需要)、Month(月份,BCD 格式)、Date(日期 131)、Year(年份 099,对应 2000~2099 年,.Year = 35 表示 2035 年)。
Q2. Month 为什么用 BCD 码?十月的宏定义为什么是 0x10 而不是 0x0A?
A:
HAL 要求 Month 用 BCD 码设置(RTC_MONTH_OCTOBER = 0x10)。
因为 BCD 码每个十进制位独立占 4 位:10 = "1"和"0" → 0001 0000 = 0x10;0x0A 是非法 BCD(1010 不在 0~9 范围内)。
Q3. 为什么 STM32F1 的日期复位后会丢?
A:
F1 没有日期寄存器,HAL_RTC_SetDate 只是把年月日写进内存中的 hrtc->DateToUpdate 结构体(源码:hrtc->DateToUpdate.Year = sDate->Year;),复位后 RAM 清空,日期变回 2000-01-01;而 RTC_CNT 在备份域(VBAT 供电),时间秒数不丢。F0/F4 有独立日期寄存器,无此问题。
Q4. 日期丢失问题的两种解决方案?选哪种?
A:
① 用后备寄存器保存年月日——资源多、麻烦,不推荐;
② 放弃 SetDate/GetDate,改用 UNIX 时间戳——把日期折算成秒数存进 RTC_CNT(备份域),需要时再转回年月日,采用。
Q5. 什么是 UNIX 时间戳?
A:
以 1970 年 1 月 1 日 0 时 0 分 0 秒为基准,当前时刻与基准时刻的秒数之差,用一个整数表示时间。时间是线性递增的,时间戳也是线性增长的数字:当前时间 = 基准时刻 + 偏移量 N。
Q6. 为什么标准 UNIX 时间戳 2038 年溢出,而 RTC_CNT 能到 2106 年?
A:
标准 UNIX 时间戳用有符号 32 位整数(31 位有效),2^31 秒 ≈ 2038 年;
RTC_CNT 是无符号 32 位整数(32 位有效),2^32 秒 ≈ 2106 年。
Q7. 怎么看待时间戳溢出问题?
A:
时间须存储必占有限位宽,位宽必致上限,上限必生溢出。
溢出不可免,所以时间设置是工程选择:让溢出时点晚于产品设计寿命即可,不必恐慌。
Q8. 为什么选 1970 年 1 月 1 日作为时间基准?
A:
UNIX 操作系统研发于 20 世纪 60~70 年代,开发者(C 语言之父丹尼斯·里奇和肯·汤姆逊,C 语言是开发 UNIX 时"顺便"设计的)选择了 1970-01-01 作为基准,成为计算机"数字纪元"的起点。
Q9. DateTime_To_Timestamp 的算法步骤?
A:
① 校验合法性(Is_Valid_DateTime);
② 累加 1970 到目标年份前一年的天数(闰年 366 否则 365);
③ 累加 1 月到目标月份前一月的天数(Get_Days_In_Month);
④ 加上 day-1;⑤ 总天数 ×86400 + 时×3600 + 分×60 + 秒。
Q10. Timestamp_To_DateTime 怎么算星期几?
A:
1970-01-01 是星期四,所以 weekday = (4 + seconds/86400) % 7(0=周日)。
Q11. 闰年判断规则?
A:
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
能被 4 整除且不能被 100 整除,或能被 400 整除。
二、闹钟操作
Q12. RTC_AlarmTypeDef 结构体是什么?Alarm 字段干什么用?
A:
包含 AlarmTime(RTC_TimeTypeDef,闹钟具体时间)和 Alarm(闹钟 ID)。
HAL 库为多闹钟 MCU 设计,需区分设置的是哪个闹钟;
STM32F103 只有 RTC_ALARM_A(0U),所以设完不用关心。
Q13. HAL_RTC_SetAlarm 和 HAL_RTC_SetAlarm_IT 的区别?
A:
都能设置闹钟;
SetAlarm 时间到了不触发中断,需要额外写代码(如循环读闹钟寄存器);
SetAlarm_IT 时间到了自动触发 RTC_Alarm 中断。
优先用 _IT 版本。注意必须先调用 HAL_RTC_SetTime 再使能闹钟。
Q14. RTC 的两个中断分别是什么?处理函数和中断号?
A:
① RTC global interrupt:RTC_Second/RTC_Overflow 触发,处理函数 RTC_IRQHandler,中断号 3;
② RTC alarm interrupt through EXTI line 17:只 RTC_Alarm 触发,处理函数 RTC_Alarm_IRQHandler,中断号 41,能唤醒深度睡眠。
两者在软件上分开处理。
Q15. 闹钟到点的完整链路?
A:
RTC_CNT 每秒 +1 → 与 RTC_ALR 相等 → 硬件产生闹钟事件 → EXTI line 17 触发中断 → RTC_Alarm_IRQHandler → HAL 回调 HAL_RTC_AlarmAEventCallback → 置 alarm_flag、点亮 LED0。
Q16. 为什么主循环里要用 volatile 修饰 alarm_flag?
A:
alarm_flag 在中断回调里修改、主循环里读取,volatile 防止编译器把它优化到寄存器里而读不到中断的最新值,保证每次循环都从内存重新读取。
Q17. 秒中断用哪个函数使能?
A:
HAL_RTCEx_SetSecond_IT(&hrtc),对应 RTC global interrupt,每秒触发一次,处理函数 RTC_IRQHandler。




