简介:这是一份基于STM32F10x的RTC实时时钟日历与闹钟功能工程代码包,面向嵌入式学习者和电子爱好者,演示如何通过I2C接口驱动RTC芯片、实现时间显示、日历切换及闹钟中断触发。包内共有233个文件,以C源码(38个.c、43个.h)、Keil工程文件(.uvprojx)、编译输出文件(.axf、.hex、.o、.lst)及调试配置为主,涵盖STM32标准外设库的定时器、Flash、RCC、I2C等模块,便于直接打开工程查看完整程序结构并重新编译验证。压缩包大小约5.91MB,已有261人浏览学习。资源不仅给出核心驱动代码,还包含工程备份、链接映射和烧录镜像,适合对照学习RTC初始化、闹钟匹配、中断服务函数编写以及低功耗电源管理思路,可用于课程设计、竞赛作品或智能日历项目的起步参考。
1. RTC 日历带闹钟,本质上是一个时间状态机
把“22_RTC实现带闹钟功能的日历.rar”这个标题拆开,核心是三个词:RTC、日历、闹钟。很多人第一反应是“用单片机读个时间,再在屏上显示,到点响一下”,实际做完就会发现,难点不在“读时间”,而在“时间从哪来”和“怎么让闹钟可靠地触发”。RTC(Real-Time Clock,实时时钟)负责维护一套不受主程序卡顿影响的秒计数,日历是把秒计数翻译成年月日星期,闹钟则是基于这套时间基准做比较和唤醒。三者的关系是单向依赖的:RTC 提供时间源,日历做格式转换,闹钟做事件触发。
常见的实现方式是 MCU(比如 STM32)内部 RTC 外设 + 外部 32.768kHz 晶振 + LCD 显示 + 按键输入,也有用独立 RTC 芯片(DS1302、DS3231)的方案。两者各有取舍:内部 RTC 省成本、省 PCB 面积,但断电要额外电池供电 VBAT;外部 RTC 芯片自带时钟精度补偿,走时更稳,但多一路 I2C 或 SPI 通信。作为带闹钟的日历应用,我更推荐独立 RTC 芯片,或者至少给 MCU 内部 RTC 配上外部 32.768kHz 晶振而不是用 LSI(内部低速时钟),否则一个月下来误差可能到分钟级。这篇就按“MCU + 外部晶振 + 内部 RTC”为主线,配合按键和显示,把整套日历和闹钟逻辑讲透。
2. RTC 时间基准搭建:从 32.768kHz 晶振到秒计数
2.1 为什么非得是 32.768kHz
32768 这个数字不是随便定的,它是 2 的 15 次方。RTC 外设内部有一个 15 位的分频器,外部给它一个 32768Hz 的信号,分频 32768 次后恰好得到 1Hz 的秒脉冲。用 32768Hz 的晶振,分频电路就是一个简单的计数器,不需要额外的校准逻辑。如果用其他频率(比如 8MHz 系统时钟直接分频),要么分频系数不是整数,要么得用软件补偿,误差和功耗都会变大。
晶振选型上有两个参数要留意:负载电容(CL)和频率精度。常见贴片晶振的负载电容是 6pF 或 12.5pF,这决定了你陪焊的两个匹配电容取多大。匹配电容的计算公式是 CL = (C1 * C2) / (C1 + C2) + Cstray,Cstray 是引脚和走线的寄生电容,一般取 2~4pF。如果负载电容 12.5pF,匹配电容两个都取 22pF 就差不多;取大了会走慢,取小了会走快。
2.2 初始化 RTC 的最小代码与参数选择
以 STM32F1 系列为例,RTC 的初始化步骤是固定的:开启 PWR 和 BKP 时钟,使能后备寄存器访问,然后选择 LSE 作为 RTC 时钟源,等待 LSE 起振,配置预分频器,最后设置初始时间。
void RTC_Init(void) { // 1. 使能电源管理和备份域时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); // 允许访问备份域寄存器 // 2. 如果备份域还没有初始化过,才执行 RTC 配置 if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { RCC_LSEConfig(RCC_LSE_ON); // 启动外部 32.768kHz 晶振 while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET); // 等待起振 RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); // RTC 时钟源选 LSE RCC_RTCCLKCmd(ENABLE); // 使能 RTC 时钟 RTC_WaitForSynchro(); // 等待同步 RTC_WaitForLastTask(); // 等待上一次写操作完成 // 3. 分频:32768 / (32767 + 1) = 1Hz RTC_SetPrescaler(32767); RTC_WaitForLastTask(); // 4. 设置初始时间:2024-01-01 00:00:00 RTC_SetCounter(UnixTimeFromDate(2024, 1, 1, 0, 0, 0)); RTC_WaitForLastTask(); BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); // 标记已初始化 } }这段代码里有三个关键点。第 2 步的判断很重要,如果每次上电都重新设置时间,日历会从初始时间重新走,闹钟就失去了意义。BKP_DR1 寄存器在没有 VBAT 掉电的情况下不会丢失,用它做“是否已初始化”的标记是最常见做法。第 3 步预分频系数是 32767 而不是 32768,因为 RTC 的分频器是从 0 计数的,计数到 32767 恰好是 32768 个周期。第 4 步用 Unix 时间戳做中间量,优点是日期计算只需要一个 32 位整数,比较大小、计算星期、闹钟触发判断都变成数值比较,比拆成年月日时分秒再比较快得多。
提示:RTC_WaitForLastTask() 必须每次写操作后都调用,否则连续写入时后一次操作可能覆盖前一次,导致时间设置错误。
2.3 读时间不能直接读计数器
RTC 的秒计数器在后台一直在变,如果你读的时候刚好赶上低位进位,读到的可能是一个“断裂”的值。STM32 的 RTC 外设在读操作时会自动锁存,但前提是你必须先读一次再读第二次,取一致的结果。
typedef struct { uint16_t year; uint8_t month; uint8_t day; uint8_t week; uint8_t hour; uint8_t minute; uint8_t second; } RTC_Time_t; RTC_Time_t RTC_GetTime(void) { uint32_t counter_1, counter_2; // 连续读两次,如果两次不一致说明读到了进位边界,重新读 do { counter_1 = RTC_GetCounter(); counter_2 = RTC_GetCounter(); } while (counter_1 != counter_2); return UnixTimeToDate(counter_1); }用 do-while 循环做一致性确认,是业内处理 RTC 读竞争的标准做法,比关中断更优雅,也不会影响其他外设的响应。实际测试中,读 10 万次最多出现两三次不一致的情况,但每次不一致都可能让日历跳过一天,必须处理。
3. 日历模块:把秒计数翻译成年月日和星期
3.1 公历转换的核心算法
Unix 时间戳是从 1970-01-01 00:00:00 开始计数的秒数,要转成年月日得处理闰年和每个月天数的差异。这段代码是日历模块的地基,嵌入式开发里几乎不用系统库的 date 函数,都是自己写。
const uint8_t days_in_month[] = {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; int IsLeapYear(int year) { return (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0); } RTC_Time_t UnixTimeToDate(uint32_t timestamp) { RTC_Time_t t; uint32_t days = timestamp / 86400; // 总天数 uint32_t remain = timestamp % 86400; // 当天内的秒数 t.hour = remain / 3600; t.minute = (remain % 3600) / 60; t.second = remain % 60; // 计算星期:1970-01-01 是星期四 t.week = (days + 4) % 7; // 0=周日, 1=周一, ..., 6=周六 // 推算年份 int year = 1970; int days_per_year; while (1) { days_per_year = IsLeapYear(year) ? 366 : 365; if (days < days_per_year) break; days -= days_per_year; year++; } t.year = year; // 推算月份 int month = 0; while (1) { int dim = days_in_month[month]; if (month == 1 && IsLeapYear(year)) dim = 29; // 二月闰年 if (days < dim) break; days -= dim; month++; } t.month = month + 1; t.day = days + 1; return t; }这里有个细节值得注意:星期计算直接用 (days + 4) % 7,因为 1970-01-01 恰好是星期四。不需要查表,不需要基准年份调整,这是 Unix 时间戳带给日历模块的最大便利。月历显示时,需要知道某月 1 号是星期几,做法是:取当月 1 号零点的时间戳,调用同一个转换函数拿到星期值,这个值就是月历第一行的空格数。
3.2 月份天数表与闰年边界
嵌入式日历最容易踩的坑是 2000 年这种世纪闰年。公历规则是“四年一闰,百年不闰,四百年再闰”,所以 2000 年是闰年,1900 年不是,2100 年也不是。很多移植代码只写了 year % 4 == 0,会导致 2100 年 2 月少算一天,日历整体错位。上面代码里的 IsLeapYear 已经包含了完整的三个条件,可以直接用。
另一个坑是月末切换。RTC 本身不知道今天是几号,它只负责秒数递增。日历模块显示“2024-02-29”纯粹是转换函数的计算结果。如果某天应用层往 RTC 里写了一个不存在的日期(比如 2024-02-30),再进行显示就会出现怪异结果。好的做法是在设置时间前先做日期合法性校验,把闰年的判断前置到入口处。
int IsValidDate(int year, int month, int day) { if (month < 1 || month > 12) return 0; if (day < 1) return 0; if (month == 2 && IsLeapYear(year)) return day <= 29; return day <= days_in_month[month - 1]; }3.3 星期显示与中文对照
做中文日历界面时,需要把星期几转换成中文,但注意 C 语言字符串数组的元素是 char[4],必须留够字节数,否则中文字符串存储会越界。
// 注意:中文汉字在 UTF-8 编码下占 3 字节,再加结束符,所以数组长度要取 4 const char *week_str[] = {"周日", "周一", "周二", "周三", "周四", "周五", "周六"}; void Display_Calendar(uint32_t timestamp) { RTC_Time_t t = UnixTimeToDate(timestamp); printf("%04d-%02d-%02d %s %02d:%02d:%02d\r\n", t.year, t.month, t.day, week_str[t.week], t.hour, t.minute, t.second); }中小尺寸 LCD 上显示月历时,常见做法是先算出当月第一天星期几,然后按 7 列排布日期数字。这个排布逻辑和 PC 端日历一致,移植时只需要把 printf 换成 LCD 绘图函数。
4. 闹钟业务逻辑:比较、触发、响铃与自动重复
4.1 闹钟数据结构与分钟级粒度
带闹钟的日历,闹钟的数据结构不需要太复杂。两个核心字段是时刻和重复模式。重复模式至少支持“只响一次”“每天”“工作日”三种,周末模式其实可以由工作日取反得到。
typedef struct { uint8_t hour; // 0-23 uint8_t minute; // 0-59 uint8_t repeat_mode; // 0=单次, 1=每天, 2=工作日(周一~周五) uint8_t enabled; // 1=开启, 0=关闭 uint32_t ring_count; // 已响铃次数(单次闹钟用) } Alarm_t; Alarm_t g_alarm = {7, 30, 2, 1, 0}; // 默认工作日早上 7:30 int IsAlarmMatch(const RTC_Time_t *t) { if (!g_alarm.enabled) return 0; if (t->hour != g_alarm.hour) return 0; if (t->minute != g_alarm.minute) return 0; if (g_alarm.repeat_mode == 0) { // 单次闹钟:响完即关,或检查日期 if (g_alarm.ring_count > 0) return 0; g_alarm.ring_count++; g_alarm.enabled = 0; // 单次响铃后自动关闭 return 1; } if (g_alarm.repeat_mode == 1) return 1; // 每天 if (g_alarm.repeat_mode == 2) { return (t->week >= 1 && t->week <= 5); // 周一~周五 } return 0; }闹钟精度做到分钟级就够了,不需要秒级比较。设计上要避免“同一分钟里主循环多次触发响铃”的问题,常见做法是记录闹钟触发时的分钟值,同一分钟内不再重复触发。上面代码里用 ring_count 和 enabled 字段控制单次闹钟,但没有处理“每分钟只触发一次”的问题,严格来说还差一个 last_trigger_minute 字段。
uint8_t last_trigger_minute = 0xFF; // 初始化为一个不可能的值 void Alarm_Task(const RTC_Time_t *t) { uint8_t cur_minute = t->hour * 60 + t->minute; if (last_trigger_minute == cur_minute) return; // 同一分钟只触发一次 if (IsAlarmMatch(t)) { last_trigger_minute = cur_minute; // 触发蜂鸣器或输出 GPIO 高电平 Buzzer_On(); // 显示闹钟界面 LCD_ShowAlarmIcon(1); } }4.2 配置交互:按键改时间与闹钟设置
按键交互是日历闹钟最容易做到一半发现“状态处理不过来”的部分。推荐的交互模型是“选中区域 + 加减调节”两段式:按确认键在“年/月/日/时/分/星期”之间循环切换光标,按加减键修改当前选中项,长按确认键保存并退出。闹钟设置复用同一套框架,只是数据改成闹钟的时、分和重复模式。
// 设置菜单状态机 typedef enum { MENU_NONE = 0, MENU_TIME_YEAR, MENU_TIME_MONTH, MENU_TIME_DAY, MENU_TIME_HOUR, MENU_TIME_MINUTE, MENU_ALARM_HOUR, MENU_ALARM_MINUTE, MENU_ALARM_REPEAT } MenuState_t; uint32_t AdjustDateTime(uint32_t old_timestamp, MenuState_t item, int8_t delta) { RTC_Time_t t = UnixTimeToDate(old_timestamp); switch (item) { case MENU_TIME_YEAR: t.year += delta; break; case MENU_TIME_MONTH: t.month += delta; break; case MENU_TIME_DAY: t.day += delta; break; case MENU_TIME_HOUR: t.hour += delta; break; case MENU_TIME_MINUTE: t.minute += delta; break; default: return old_timestamp; } // 合法性检查 if (t.year < 2000 || t.year > 2099) return old_timestamp; if (!IsValidDate(t.year, t.month, t.day)) return old_timestamp; // 规范化:分钟 60 进 1,小时 24 进 1,日期按实际月份天数进位 if (t.minute >= 60) { t.minute = 0; t.hour++; } if (t.hour >= 24) { t.hour = 0; t.day++; } // 月份和年份进位 if (t.month > 12) { t.month = 1; t.year++; } return DateToUnixTime(t); }这里关键是“进位”和“借位”的处理。如果用户把分钟从 59 加到 60,正确行为是分钟归零、小时加一。如果写死“超过 59 就不让加”,用户体验很差。同理,把日期从 31 加到 32 时,应该进位到次月 1 号,而不是报错。嵌入式设备上的日期调节,宁可采用“范围校验 + 自动进位”的方式,也不要“越界拒绝”。
4.3 唤醒方案:低功耗模式下的闹钟触发
如果设备要用电池供电,RTC 闹钟通常需要配合低功耗模式。STM32 的 RTC 闹钟输出可以通过 EXTI 中断唤醒 MCU。关键配置是:RTC 闹钟中断映射到 EXTI 17 号线,MCU 进入 STOP 模式前使能 RTC 闹钟中断,唤醒后先清理中断标志再恢复系统时钟。
void RTC_Alarm_EXTI_Config(void) { EXTI_InitTypeDef EXTI_InitStructure; RTC_ITConfig(RTC_IT_ALR, ENABLE); // 使能闹钟中断 EXTI_ClearITPendingBit(EXTI_Line17); EXTI_InitStructure.EXTI_Line = EXTI_Line17; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising; EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel = RTC_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }进入低功耗前,要确保当前不是正在触发响铃的状态,否则会反复唤醒形成死循环。正确的顺序是:先关闹钟标志位,再进入 STOP,唤醒后显示初始化并重新开启闹钟中断。
提示:使用内部 RTC 的 MCU 进入 STOP 模式前,必须把 RTC 时钟源切到 LSE,否则 STOP 模式下 LSI 会被关闭,RTC 停走,闹钟永远不会触发。
5. 边界操作与排错技巧:从日界线到锋鸣器不响
5.1 跨日与夏令时场景的处理
日历走到 23:59:59 再跳一秒,显示应该变成次日 00:00:00,星期也要同步加一。这个逻辑不需要特殊处理,UnixTimeToDate 的除法决定了它一定正确。真正要留意的是“设置时间时跨日”。比如当前时间是 2024-03-01 23:50,用户要把分钟调到 20,直接改分钟字段会把系统时间变成 2024-03-02 00:20,还是 2024-03-01 23:20?这取决于 DateToUnixTime 的实现。推荐的做法是:修改时间时先转成时间戳,修改完再一次性转回,让时间戳计算自动处理进位。不要在修改日期字段时同步修时分字段,否则会产生中间态不一致。
夏令时在国内场景不需要考虑,但如果产品面向海外市场,RTC 固件最好支持一个“时区偏移量”配置项,日期转换前先减去偏移量,再转成结构化时间。注意时区偏移和夏令时是两回事,夏令时是动态规则,RTC 芯片一般只管“本地时间”,不需要在底层处理夏令时,应用层做规则判断即可。
5.2 走时误差的实测校准方法
即使用了 32.768kHz 晶振,走时误差仍然是存在的,只是量级不同。测量走时误差最快的方法是“秒信号测量法”:把 RTC 的秒脉冲输出引脚(有些 MCU 是 RTC_TAMP 或 MCO 引脚)接到频率计或示波器上,测量 1Hz 信号的实际频率。频率偏高说明走快,偏低说明走慢。
频率偏差的来源主要有三个:晶振本身的频率精度(常见 ±20ppm)、匹配电容值偏离负载电容、环境温度。±20ppm 的晶振一个月误差大约 51.8 秒(30 * 86400 * 20e-6 = 51.84)。如果一个月误差不超过 1 秒,需要温补晶振(TCXO),误差典型值在 ±2ppm 以内。
软件校准的常见做法是对 1Hz 信号做“每秒 512 次或 256 次的定时补偿”,在每秒的第 N 次中断里做一些加减计数的修正。STM32 的 RTC 没有直接的数字补偿寄存器,多数工程师用“每隔一段时间重写一次秒计数器”的方式做粗校。如果追求更准,配合外部高精度时钟源(比如 GPS 授时或网络校时)做对时是最可靠的,单片机上通常通过串口向主控请求时间同步。
5.3 闹钟不响或乱响的排查路径
做带闹钟的日历,调试期最多的报障是“闹钟到点了没反应”。按经验,首查的不是蜂鸣器,而是三类问题:
第一,RTC 时钟源异常。LSE 晶振没起振时,RTC_WaitForSynchro() 会一直等待,程序卡死在初始化里。排查方法是测量 32.768kHz 引脚波形,或者看 RCC_GetFlagStatus(RCC_FLAG_LSERDY) 是否始终为 RESET。起振失败常见原因:晶振负载电容配错、PCB 走线过长导致寄生电容过大、晶振两端对地电容不对称。
第二,闹钟时间的时区/进制问题。用户设置的 07:30 被存成了 0x0730,比较时按十进制 730 和 750 比,怎么比都不相等。规范的做法是分开存 hour 和 minute,各用一个 uint8_t,比较前不进行任何进制转换。
第三,中断优先级和主循环冲突。RTC 闹钟中断里如果做了耗时操作(比如 LCD 刷新),会阻塞主循环,下一秒的主循环才执行响铃逻辑,造成“晚响一秒”或“重复响”。正确的做法是闹钟中断里只置一个标志位,主循环检测到标志后处理响铃动作:
volatile uint8_t alarm_flag = 0; void RTC_IRQHandler(void) { if (RTC_GetITStatus(RTC_IT_ALR) != RESET) { RTC_ClearITPendingBit(RTC_IT_ALR); alarm_flag = 1; // 只置标志,不在中断里做重活 } } // 主循环中 while (1) { if (alarm_flag) { alarm_flag = 0; Buzzer_On(); LCD_ShowAlarmScreen(&g_alarm); } // 其他显示和按键检测任务 }“响应时间差一个 tick”的问题,在中断里做重活比直接丢响应更严重。置标志位这个模式在整个嵌入式系统里通用,闹钟、低功耗唤醒、按键消抖全都能用。
5.4 日历显示中“今天”的高亮定位
日历面板上要高亮显示今天,做法是把当前时间戳转换出来的年月日,和正在绘制的那一格日期做三维比较。注意月份切换时,当前显示的可能是上个月或下个月的补白日期,高亮判断要同时满足年、月、日三者都相等,不要只比“日”字段。
void Draw_Calendar(int display_year, int display_month, uint32_t now_timestamp) { RTC_Time_t today = UnixTimeToDate(now_timestamp); // 计算 display_month 的天数和 1 号是星期几 int days = GetDaysInMonth(display_year, display_month); int first_weekday = GetFirstWeekday(display_year, display_month); for (int i = 0; i < days; i++) { int day = i + 1; int col = (first_weekday + i) % 7; int row = (first_weekday + i) / 7; int is_today = (display_year == today.year && display_month == today.month && day == today.day); LCD_DrawHighlightIf(day, col, row, is_today); } }这段逻辑对移植到任意 LCD 驱动都通用,区别只在 LCD_DrawHighlightIf 内部的像素操作。uview 日历组件、桌面级日历工具里的“今天标识”逻辑也完全一致,只是把 LCD 换成了前端渲染。
历法计算里还有一个容易忽略的边界:如果设备时间被设置到 2038 年之后,32 位时间戳会溢出。嵌入式日历通常把年份范围限定在 2000~2099,并且在设置时间时做范围校验。这个在设计闹钟和日历时要提前定好上限,不要等用户真把时间调到 2100 年再排查。
本文还有配套的精品资源,点击获取