日期操作、闹钟中断与附录(时间戳 / 闹钟 / NTP)
一、RTC 日期操作:简易日历
1.1 案例目标
- 程序启动时设置一个初始时刻(含年月日、时分秒);
- 使用 RTC_CNT 保存初始时刻,随时间流逝自动更新;
- 通过串口每秒输出格式化时间;
- 断电后时间更新能继续正确进行。
1.2 日期结构体 RTC_DateTypeDef
cpptypedef 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;两个关键约定:
- Year 是 0~99,起始年份是 2000 年------所以
.Year = 35表示 2035 年;- Month 必须用 BCD 码设置------注意十月、十一月、十二月:
cpp/* 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内部:
cpp/* Change the current date */ hrtc->DateToUpdate.Year = sDate->Year; hrtc->DateToUpdate.Month = sDate->Month; hrtc->DateToUpdate.Date = sDate->Date;它只是把年月日写进了
hrtc->DateToUpdate(内存结构体)。而 RTC_HandleTypeDef 里的这个字段:
cpptypedef 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 控制只设一次)
cppvoid 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:获取时间日期并格式化打印
cppvoid 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
cpp#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
cpp#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
cppvoid 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:读时间戳转回日期时间
cppvoid 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); }运行效果:
cppRTC 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 准备工作
- 创建新项目;
- 重定位 printf、连接串口;
- 启用 PF8 引脚:配置为推挽输出、输出高电平(点亮板载 LED0,注意这是低电平点亮还是高电平点亮以板子为准------课件配图 GPIO output level 设为 High,中断回调里写
GPIO_PIN_RESET点亮,即 PF8 低电平点亮);- 配置 RTC 闹钟中断:勾选
RTC alarm interrupt through EXTI line 17。RTC 的两个中断,别搞混:
中断 触发事件 处理函数 中断号 用途 RTC global interrupt RTC_Second、RTC_Overflow RTC_IRQHandler3 秒中断/溢出中断 RTC alarm interrupt through EXTI line 17 只 RTC_Alarm RTC_Alarm_IRQHandler41 闹钟中断,能唤醒深度睡眠 一般需要秒中断用 RTC global interrupt;需要闹钟中断 + 唤醒深度睡眠用 EXTI line 17。RTC_Second/RTC_Overflow 和 RTC_Alarm 在软件上是分开处理的。
2.3 闹钟关键函数
① 闹钟结构体 RTC_AlarmTypeDef
cpptypedef 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 触发)
cppvoid 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 通信。
四、本篇小结
cpp1. 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。




