简介这是一份基于STM32F103C8T6实现内部低速时钟驱动RTC日期时间的完整工程包面向嵌入式开发初学者与需要做低功耗时钟应用的STM32用户。包内共192个文件包含大量C/H源代码、编译生成的HEX/AXF文件以及Keil工程配置文件uvprojx/uvoptx还带有文档与工程备份整体压缩包约3.86MB结构清晰便于直接打开编译与烧录验证。已有2073人学习下载。工程演示了LSI作为RTC时钟源的初始化、预分频配置、日期时间读取及串口发送文本格式时间等关键环节代码注释与工程组织有助于快速掌握STM32时钟树和RTC外设的使用方法是学习低功耗时钟方案和串口调试的实用参考资料。1. 为什么 STM32F103C8T6 的日历敢用内部低速时钟STM32F103C8T6 的 RTC 时钟源常见默认配置是外部 32.768kHz 低速晶振LSE但手上这块最小系统板往往没有焊接 32.768k 晶振或者 PC14/PC15 两个引脚被复用去做了触摸按键、脉冲计数之类的功能。此时把 RTC 挂到内部低速时钟LSI上就能在不加任何外部元件的前提下跑出秒中断和日期计数省下一颗晶振和两个引脚位。代价是 LSI 标称 40kHz、实际出厂离散大、随温度漂移明显直接做 1Hz 分频会有肉眼可见的走时偏差。这篇就把 LSI 驱动 RTC 做“日期”这件事拆开先讲配置再讲日历换算然后把校准方法讲透最后给一套不带示波器也能验证精度的手段。2. LSI 驱动 RTC 的最小配置时钟选择与预分频2.1 为什么 F103 的 RTC 只有一路低频时钟可用F103 的 RTC 和备份寄存器挂在PWR域里电源由VBAT引脚或主电源供电主电源掉电后 RTC 计数器能不能继续走完全取决于时钟源和备份供电是否还活着。RTC 的时钟输入在 F103 上有三个选项LSE外部 32.768kHz、LSI内部 RC 约 40kHz、HSE 分频最高 128 分频主电源掉电后失效。LSE 需要外部晶振而市面上大量 STM32F103C8T6 最小系统板默认不焊这颗晶振LSI 则完全集成在芯片内部不占引脚上电即可用是“无外部时钟做日期”的最短路。需要特别注意一个反常识点F103 的 RTC 预分频器只有一个是 20 位可编程分频写入RTC_PRL的数值是“分频系数减 1”。它不像 F0/F4 那样分为异步分频和同步分频两段配置时不能照搬其他系列的在线教程。对 40kHz 的 LSI 来说把RTC_SetPrescaler(39999)写进库函数得到的就是 40000 分频、整 1 秒的计数节拍。2.2 标准外设库下跑通 LSI 时钟 RTC 秒中断F103 的 RTC 配置步骤在接口上比较绕因为 RTC 写保护由 PWR 和 BKP 两个外设共同控制。常见做法是先把PWR和BKP的时钟打开再解除备份域写保护然后操作RCC把时钟源切到 LSI。下面这段基于标准外设库的代码可以直接套在一个空工程里做秒中断void rtc_lsi_init(void) { uint32_t tmp 0; RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); // 解锁备份域允许写 RTC 和 BKP RCC_LSICmd(ENABLE); // 打开内部低速时钟 LSI while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) RESET); // 等待 LSI 稳定 RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI); // 把 RTC 时钟源切到 LSI RCC_RTCCLKCmd(ENABLE); // 使能 RTC 时钟 RTC_WaitForSynchro(); // 等待 RTC 寄存器与 APB 同步 RTC_WaitForLastTask(); // 等待上次写操作完成 RTC_SetPrescaler(39999); // 40kHz / 40000 1Hz RTC_WaitForLastTask(); RTC_SetCounter(0); // 基准时间本例以 2000-01-01 00:00:00 为 0 }这段代码里的关键点是RTC_WaitForSynchro()和RTC_WaitForLastTask()缺一不可。F103 的 RTC 寄存器不在 APB1 总线上直接可读写寄存器后 APB 与 RTC 时钟域存在同步延迟跳过等待函数会随机出现第一次上电读数全零、日期跳变异常。RTC_SetPrescaler(39999)的微观含义是“分频系数 写入值 1 40000”因为 LSI 典型值 40kHz所以计满 40000 个 LSI 周期就是 1 秒。若换到 LSE 晶振这个值要改成 32767这也是网上大量代码在两种时钟之间移植报错的根源。2.3 秒中断的打开顺序要让日期每秒自动走一步还需要打开 RTC 全局中断。F103 的 RTC 中断只有一个入口RTC_IRQHandler秒中断、闹钟中断都走它要在中断函数里查询中断标志位来区分代码位置如下void rtc_lsi_irq_enable(void) { RTC_WaitForLastTask(); RTC_ITConfig(RTC_IT_SEC, ENABLE); // 使能秒中断 RTC_WaitForLastTask(); NVIC_InitTypeDef nvic; nvic.NVIC_IRQChannel RTC_IRQn; nvic.NVIC_IRQChannelPreemptionPriority 1; nvic.NVIC_IRQChannelSubPriority 1; nvic.NVIC_IRQChannelCmd ENABLE; NVIC_Init(nvic); }中断服务函数里一般只做三件事读RTC_GetCounter()、把秒值交给上层换算成日期、清中断标志。要注意在中断里尽量别做除法或者大循环F103 主频 72MHz 虽然不缺算力但 RTC 寄存器每次读写都有同步等待中断里处理太多事会拉长中断服务时间影响其他实时任务。比较干净的做法是中断里置一个全局标志主循环里再读计数器和做日期换算。2.4 LSI 时钟源的寄存器级注意事项做产品移植时如果不想依赖标准外设库寄存器级配置要关注三个位RCC_CSR里的LSION和LSIRDY以及RCC_BDCR里的RTCSEL[1:0]。很多人在配置RTCSEL之后直接操作 RTC 寄存器发现写不进去原因是PWR_BackupAccessCmd(ENABLE)没有执行备份域处于写保护状态。另外一旦选择 LSI 作为 RTC 时钟源RTC 的日历特性就会受制于 LSI 的温漂这不是软件标志位能掩盖的需要接上一章校准思路来补偿。启动 LSI 后读到RCC_FLAG_LSIRDY置位不代表频率就是 40kHz只代表振荡器起振了。LSI 的实际频率会在 30kHz 到 60kHz 之间分布同一个芯片在不同温度下也会飘。所以“在最小系统板上跑秒中断”很容易难的是让日期一天下来误差可控这就是这一系列配置里最需要提前设计的地方。3. 把 RTC 计数器变成日期闰年、月份与星期换算3.1 为什么不用 BCD 寄存器而是自己算日期F103 的 RTC 不像 F4 系列带完整的 BCD 日历寄存器组它内部只有一个 32 位RTC_CNT计数器。所有日期表示都必须由软件换算换算基准由RTC_SetCounter(0)那一刻的定义决定。我这个工程把 0 定义为 2000 年 1 月 1 日 00:00:00好处是 2000 年至今只需处理 20 多年的闰年算法短、分支少在 F103 上换算一次耗时几乎可以忽略。如果你习惯 Unix 时间戳也可以把基准定义为 1970-01-01但对 2020 年之后的日期需要多处理约 50 年天数代码逻辑会更长。3.2 秒数到日期时间的 C 实现下面的函数把RTC_GetCounter()的返回值直接换算成年/月/日/时/分/秒不需要任何运行时库适合嵌入到 Keil 或 IAR 工程里typedef struct { uint16_t year; uint8_t month; uint8_t day; uint8_t hour; uint8_t minute; uint8_t second; } rtc_datetime_t; uint8_t rtc_is_leap_year(uint16_t y) { return ((y % 4 0) (y % 100 ! 0)) || (y % 400 0); } void rtc_seconds_to_datetime(uint32_t counter, rtc_datetime_t *dt) { static const uint8_t mdays[12] {31,28,31,30,31,30,31,31,30,31,30,31}; uint32_t days counter / 86400; uint16_t year 2000; uint8_t mon; dt-second counter % 60; dt-minute (counter % 3600) / 60; dt-hour (counter % 86400) / 3600; // 先用整年减天数再在剩余天数里减月份 while (1) { uint16_t ydays rtc_is_leap_year(year) ? 366 : 365; if (days ydays) { days - ydays; year; } else break; } dt-year year; for (mon 0; mon 12; mon) { uint8_t dmax mdays[mon]; if (mon 1 rtc_is_leap_year(year)) dmax 29; if (days dmax) days - dmax; else break; } dt-month mon 1; dt-day days 1; }这段代码的核心思路是“先用除法拿到天数把年、月逐级剥掉”。这样写比直接用localtime移植性好因为 F103 的 C 库不一定完整而且嵌入式的time_t通常是 32 位带符号数2038 年问题在 32 位系统上是真实存在的风险。自建换算函数之后所有日期输出路径都由自己控制后面做 LSI 补偿也容易接入。3.3 星期几的计算与闰年边界表标题里强调“日期”很多消费类产品界面必须显示星期。利用前面算出的总天数days可以顺手得到星期已知 2000-01-01 是星期六那么(days 6) % 7得到的数值 0 表示星期日、6 表示星期六。这是从基准日期推导出来的不需要额外的查表算法。需要注意闰年的边界能被 400 整除的年份是闰年但这个规则仅在 2000 年这一层生效过一次日常产品很少会跑到 2100 年但代码里保留完整规则后就不会出边界差错。年份范围闰年判定2 月天数2000是400 年规则2920012003否282004是4 且不被 100 整除292100否被 100 整除但不被 400 整除284. 内部低速时钟不准三种校准手段与掉电陷阱4.1 先量化 LSI 的实际频率再谈校准LSI 的 40kHz 是典型值而不是保证值同一批芯片之间的偏差可能达到百分之几直接表现为日期一天快或慢几分钟到十几分钟。校准的第一步是把 LSI 的真实频率测出来。最省工具的做法是利用 STM32 的 MCO 引脚把 LSI 从 PA8 引出来用频率计或者示波器测一次得到实际频率F_lsi然后反推出预分频值。下面的初始化片段展示了 MCO 输出 LSI 的配置GPIO_InitTypeDef gpio; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); gpio.GPIO_Pin GPIO_Pin_8; gpio.GPIO_Mode GPIO_Mode_AF_PP; gpio.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, gpio); RCC_MCOConfig(RCC_MCO_LSI); // PA8 输出内部低速时钟频率约 40kHz测到实际频率后把初始化代码里的RTC_SetPrescaler(39999)替换为F_lsi - 1。比如实测 LSI 是 42.35kHz那么预分频写入 42349计数 42350 个周期正好是 1 秒。这样校准后误差就能控制在百分之零点几也就是一天内只有几秒到几十秒的偏差。4.2 软件补偿不用改硬件的时间微调法如果产品已经量产不方便每片都去测量可以采用“软件秒补偿”的思路让 RTC 以 1Hz 跑但在主循环里对“今天累计偏离了多少秒”做记录每隔若干分钟或者若干小时手动加/减一次秒。具体做法是在 RTC 秒中断里维护一个 16 位计数器累计到预设的补偿间隔后调用一次RTC_SetCounter(RTC_GetCounter() 1)或减 1。假设一片芯片实测每天快 3.6 秒则可以设定每 40 秒把时间减 1 秒因为 86400 秒 / 2160 秒 ≈ 40 秒。补偿精度取决于你对频率的测量精度如果频率只有两位有效数字补偿效果也会打折扣。4.3 掉电陷阱LSI 不是完整的日历保险这是 LSI 做日期最容易翻车的地方。F103 的 RTC 计数器在VBAT引脚有后备电池时主电源掉电后依然保持计数但 LSI 振荡器在VDD掉电后会直接停振。也就是说“LSI 日期”在断电后只有 RAM 里的数据还在时间本身不走了。这与 LSE 晶振由VBAT域供电、掉电后继续走秒的特性完全不同。因此如果你的产品是带后备电池、需要“断电后继续走日历”的类型必须改用外部 32.768kHz LSE或者额外挂一个 RTC 芯片。反过来说标题这个方案适用于设备主电源常年在线、掉电后只要求上电时间不乱的场景例如网关、电表、带电池但不做长期离线日历的终端。做方案选型时先把这个场景锁死就不会出现样机正常、量产出货后被客户投诉“关机一天时间停了”的尴尬局面。4.4 温度漂移和 LSI 校准的分级思路LSI 的频率会随温度变化典型温漂范围在几百到几千 ppm 之间。如果产品使用环境跨越 -20℃ 到 60℃即使出厂时校准到很准极端温度下依然可能一天差出几十秒。常见的处理方式是做两级校准第一级在出厂测试时通过 MCO 测频并写入预分频校准值第二级在关键时间点用外部对时源例如串口或移动网络校时把 RTC 计数器重新对齐。不需要对 LSI 做动态温度补偿F103 内部没有温度传感器与 LSI 的关联校准通道软件建模的投入产出比太低。5. 校验 LSI 日期精度的三个随身工具LSI 做日期最大的隐忧是“不知道走得准不准”。我这里整理三个不需要高精度仪器的验证方法按成本从低到高排列适合在开发阶段快速判断方案是否可用。第一种是串口打点法。把秒中断里的计数器通过串口每秒输出一行或者用 GPIO 翻转配合逻辑分析仪计数连续跑 1 小时。假设计数得到 3601 秒说明每小时快 1 秒换算成日误差是 24 秒ppm 值为(3601 - 3600) / 3600 * 1000000 ≈ 278ppm。用这个数值可以判断否需要改预分频。第二种是“秒表粗校法”。把 RTC 复位后设置一个固定目标时间同时按下手机秒表等 RTC 中断里打印的日期走到目标时间时停表。这个方法误差受人工反应时间影响约 ±0.3 秒但对于分钟级误差的产品判断绰绰有余。第三种是“双计数值比对法”。如果系统里同时存在其他定时器或外部晶振可以用一个硬件定时器测量 LSI 分频后的 1Hz 信号宽度。比如用 TIM2 的输入捕获测量秒中断 GPIO 脉冲的高电平时间若测得 1.02 秒说明分频结果偏慢 2%需要减小预分频值。这个方法比串口法更快定位问题而且能直接得到百分比误差。校准完成后建议在程序里保留一个rtc_ppm_get()之类的调试接口让产测或工程现场通过串口指令直接读取当前累计的秒数和误差估算值。只要 LSI 实际频率测量误差控制在 ±0.1% 以内日期显示完全可以满足绝大多数内部时钟场景的精度需求。本文还有配套的精品资源点击获取