简介:面向STM32入门与嵌入式课程实践的万年历项目代码包,以STM32F103ZET6最小系统为主控,配合7针SPI接口OLED屏,利用芯片内部RTC实现日期、时间与日历显示,并支持通过修改计数器值重新设定当前时间。压缩包共205个文件,约5.27MB,包含C源文件与头文件、Keil工程配置及编译中间文件、HEX烧录文件、批处理脚本等,完整保留了从工程建立到编译输出的目录痕迹,便于对照学习外设初始化、RTC配置与OLED驱动组织方式。已有1203人学习下载,适合正在做课程设计、毕业设计或嵌入式入门项目的读者参考,可从中梳理最小系统板与显示屏的接线思路、RTC日历功能实现框架以及工程文件分层方法,也能借助现成工程快速验证与二次修改。
1. 从一块每天慢两分钟的板子说起
去年冬天,一个做仪表面板的朋友把焊好的板子扔到我桌上,说这 STM32 万年历每天慢两分钟,客户已经退了两批货。板子上就一颗 STM32F103ZET6,跑的是最朴素的 stm32 万年历逻辑:RTC 走时、几个按键调时间、一块 TFT 屏显示年月日时分秒和星期,外加一路串口打印调试信息。这东西看着像入门练手题,真上产品才发现,走时精度、掉电保持、按键手感三件事,随便哪一件都能让你返工三轮。这套源码适合两类人:一类是刚学完 GPIO 和定时器、想找一个能把 RTC、FSMC、状态机、字库显示串起来的完整工程练手的人;另一类是手里已经有一个类似板子,需要一份能直接改吧改吧就出货的骨架的工程师。下面按我拆这份工程的顺序讲,从目录结构一路讲到二次开发怎么加农历和闹钟。
2. 工程结构与硬件选型:144 个脚怎么分配才不打架
拿到一个裸机工程,我第一件事不是打开 main.c,而是先看目录和引脚表。原因很简单:万年历这种项目,逻辑部分几乎不会出问题,出问题的九成是外设抢引脚、时钟没配对、备份域没电。先把骨架摸清楚,后面看代码才不会迷路。
2.1 源码目录:三层结构,硬件和业务彻底分开
这份工程是典型的裸机分层写法,目录大致长这样:
Calendar_ZET6/ ├── CMSIS/ # 内核头文件、system_stm32f10x.c ├── FWlib/ # 标准外设库 src/inc ├── Start/ # startup_stm32f10x_hd.s、stm32f10x_conf.h ├── Hardware/ │ ├── lcd.c / lcd.h # FSMC 驱动 TFT,含 ILI9341 初始化序列 │ ├── key.c / key.h # 4 键扫描,输出短按/长按/连加事件 │ ├── rtc.c / rtc.h # RTC 初始化、秒计数读写、备份域魔术字 │ └── flash_font.c # 字库与图片资源(占用大容量 Flash 尾部) ├── App/ │ ├── calendar.c # 秒→公历换算、闰年、星期、农历表 │ ├── ui.c # 页面绘制、局部刷新、菜单状态 │ └── alarm.c # 闹钟比较、蜂鸣器节拍 ├── User/ │ ├── main.c │ └── stm32f10x_it.c # 只保留 SysTick 和 RTC 秒中断 └── Project/ # Keil 工程 / Makefile & CMakeLists.txtHardware和App中间那道墙是我最在意的。lcd.c里不允许出现HAL_或者业务变量,calendar.c里也不允许出现任何 GPIO 名字。这么切的好处很实际:换块屏、换颗 RTC 芯片,只动Hardware一层;改农历算法、改显示布局,只动App一层。我见过太多练手工程把printf时间和屏幕绘制塞在中断里,最后想移植到另一块板子时,愣是要重写半个工程。
还有一个小细节值得学:字库放在 Flash 尾部,用const数组直接编进程序,靠 ZET6 的 512KB Flash 兜底。这样省掉一颗外部字库芯片,代价是烧录文件变大、编译时间变长。如果你的项目要显示大量汉字,常见做法是改成 SPI Flash 存点阵,按需读取,别一次全塞进内部 Flash。
2.2 引脚与外设映射:把 144 个脚用满之前先画一张表
ZET6 是 LQFP144,脚多,但多不代表可以随便接。这份工程的资源分配大致如下:
| 外设 | 器件/接口 | 占用引脚 | 备注 |
|---|---|---|---|
| TFT LCD | FSMC 16 位并口 | PD0/PD1/PD4/PD5/PD8-PD10/PD14/PD15/PE7-PE15 | 数据线 D0-D15 + NOE/NWE + A18 作 RS |
| 背光 | TIM3_CH2 PWM | PB5 | 占空比调亮度,夜间模式用 |
| 按键 | GPIO 上拉输入 | PE0-PE3 | 4 键:设置/加/减/确认 |
| 调试串口 | USART1 | PA9/PA10 | 115200-8-N-1,只发不收 |
| 蜂鸣器 | TIM4_CH1 PWM | PB6 | 闹钟响铃,频率 2.7kHz 左右 |
| 温度(可选) | 单总线 DS18B20 | PG11 | 4.7k 上拉,读一次约 750ms |
| RTC 晶振 | LSE | PC14/PC15 | 32.768kHz,外挂 6pF 负载电容 |
| 电池 | VBAT | VBAT 脚 | 3V 纽扣电池,只喂备份域 |
FSMC 驱动并口屏是这套方案里最“重”的部分。F1 的 FSMC 支持 SRAM/NOR 模式,把屏当一片外部 SRAM 来读写,写一个像素就是往一个地址写一个 16 位数,速度比软件模拟并口快一个数量级。代价是引脚占用大,而且一旦接了 FSMC,PD/PE 口基本就交出去了,后面想加个并口外设就没地方放。我一般会在画板前把这张表先钉死,留 2-3 个空闲 GPIO 和一路空闲 USART 作为后悔药,否则后期想加个蓝牙模块都要飞线。
RTC 晶振那两脚要特别注意:PC14/PC15 在复位后默认是普通 GPIO,必须先使能 PWR 和 BKP 时钟、打开备份域访问,才能把这两个脚切给 LSE。这步顺序错了,晶振永远不起振,而现象是“能跑,就是每次上电都从 2000 年 1 月 1 日开始”。
2.3 时间基准三条路:LSE、LSI、外挂 RTC 芯片
万年历的精度,八成取决于时间基准选谁。三条常见路线,我列个对比:
| 方案 | 典型精度 | 每天误差 | 成本/复杂度 | 适用场景 |
|---|---|---|---|---|
| 内部 LSI | ±1%(未校准更差) | 分钟级 | 零成本,但温漂极大 | 只做倒计时、不显示绝对时间 |
| 外部 32.768kHz 晶振 LSE | ±20ppm(普通晶振) | 约 1.7 秒 | 一颗晶振 + 两个电容 | 桌面钟、仪表面板 |
| 外挂 DS3231 之类 TCXO 芯片 | ±2ppm | 约 0.17 秒 | 加芯片加 I2C | 需要长期免校准的场合 |
20ppm 是这么算出来的:20/1000000 × 86400 ≈ 1.73 秒。也就是说,用最普通的 32.768kHz 晶振,一天慢一秒多是正常的,一个月能差出近一分钟。想再往上提,要么换 5ppm 的晶振,要么在软件里做校准——F1 的 RTC 有一个 32 位预分频寄存器和一个 RTC_DIV 小数分频,可以按 ppm 微调,也可以纯软件每隔 N 秒补一次秒。我一般选择后者,简单,可控,还不用改硬件。
预分频的计算别写错:LSE 是 32768Hz,要得到 1Hz 的秒计数,预分频值写 32767(因为计数器是从 0 数到 32767 才溢出一次)。写 32768 的话,走时就会慢一半,这个坑后面还会提。
3. 核心代码拆解:RTC 走时、按键扫描与局部刷屏的完整链路
这一章直接看代码。需要先说明一点:F1 系列的 RTC 跟 F4 不一样,它没有硬件日历,就是一个 32 位的秒计数器加预分频,年月日时分秒全靠软件换算。所以你会看到大量“自己算”的代码,这不是作者偷懒,是这颗芯片本身的限制。如果你手里是 HAL 工程,CubeMX 生成的 RTC 初始化最终也是往这几个寄存器写值,本质一样。
3.1 RTC 上电初始化与备份域魔术字
/* rtc.c —— 上电只初始化一次,之后靠备份域电池保持 */ void rtc_hw_init(void) { uint32_t timeout = 0; RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); /* 解锁备份域,必须第一步 */ if (BKP_ReadBackupRegister(BKP_DR1) != 0xA5A5) { /* 首次上电或电池掉过:重新配置 LSE 并清空计数器 */ BKP_DeInit(); RCC_LSEConfig(RCC_LSE_ON); /* 等起振,给 5 秒超时,起不来就退回 LSI 保证还能走 */ while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) { if (++timeout > 5000) { RCC_LSEConfig(RCC_LSE_OFF); RCC_LSICmd(ENABLE); while (RCC_GetFlagStatus(RCC_FLAG_LSIRDY) == RESET); RCC_RTCCLKConfig(RCC_RTCCLKSource_LSI); break; } delay_ms(1); } if (timeout <= 5000) { RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); } RCC_RTCCLKCmd(ENABLE); RTC_WaitForSynchro(); /* 等 APB1 与 RTC 同步 */ RTC_WaitForLastTask(); RTC_SetPrescaler(32767); /* 32768Hz / 32768 = 1Hz */ RTC_WaitForLastTask(); BKP_WriteBackupRegister(BKP_DR1, 0xA5A5); /* 魔术字,标记已初始化 */ } else { /* 非首次上电:只等同步,绝不能再写预分频,否则时间清零 */ RTC_WaitForSynchro(); RTC_WaitForLastTask(); } }这段代码的逻辑主干就是“魔术字判断”:BKP_DR1里存了0xA5A5就说明备份域一直有电、RTC 一直在走,此时只需要等同步,读计数器;读不到才走完整初始化。参数说明:RTC_SetPrescaler(32767)是 20 位寄存器,上限 0xFFFFF,写满也就 1048575,所以分频比再大也得分两级;BKP_DR1是 16 位备份寄存器,掉电由 VBAT 维持,写任意非零值都能当标志位用。
RTC_WaitForSynchro()这两行最容易被删掉。RTC 挂在 APB1 上,而 RTC 计数器跑在自己的时钟域,读RTC_GetCounter()之前必须等 RSF 标志置位,否则读到的是旧值。有人在调试器里单步跑没问题,脱机跑快了就偶尔读到上一次的秒数,现象是“时间跳着走”,很玄学。
3.2 32 位秒计数与公历互转:F1 没有硬件日历,得自己算
/* calendar.c —— 秒计数 <-> 公历,2000-01-01 00:00:00 为基准 */ typedef struct { uint16_t year; /* 2000 ~ 2099 */ uint8_t month; /* 1 ~ 12 */ uint8_t day; /* 1 ~ 31 */ uint8_t hour, min, sec; uint8_t week; /* 0 = 周日 */ } cal_t; static uint8_t is_leap(uint16_t y) { return ((y % 4 == 0 && y % 100 != 0) || (y % 400 == 0)) ? 1 : 0; } static const uint8_t mdays[12] = {31,28,31,30,31,30,31,31,30,31,30,31}; void sec_to_cal(uint32_t sec, cal_t *c) { uint32_t days = sec / 86400UL; uint32_t rem = sec % 86400UL; c->hour = rem / 3600; c->min = (rem % 3600) / 60; c->sec = rem % 60; c->week = (uint8_t)((days + 6) % 7); /* 2000-01-01 是周六,故偏移 6 */ uint16_t y = 2000; for (;;) { uint16_t dy = is_leap(y) ? 366 : 365; if (days < dy) break; days -= dy; y++; } c->year = y; uint8_t m = 1; for (;;) { uint8_t dm = mdays[m - 1] + ((m == 2) ? is_leap(y) : 0); if (days < dm) break; days -= dm; m++; } c->month = m; c->day = (uint8_t)(days + 1); }逻辑说明:先用整除和取余把“天”和“当天秒数”拆开,再用循环逐年、逐月扣减。年份循环最多跑 100 次,月份循环最多 12 次,在 72MHz 的 F1 上也就几十微秒,一天调一次完全够用。参数说明:sec是以 2000-01-01 00:00:00 为原点的秒计数,不是 Unix 时间戳(Unix 以 1970 为原点,差 946656000 秒,别混用);c->week用 0 表示周日,跟公历习惯对齐,显示时再映射成“日一二三四五六”。
星期那一行是这份代码里最省事也最容易写错的地方。2000-01-01 是星期六,days = 0代入(0 + 6) % 7 = 6,正好对应周六。有人直接用蔡勒公式,也能出结果,但公式里的 1 月、2 月要按上一年的 13、14 月处理,稍不注意就得到差一天的星期。我一般宁可多写两行说明,也不愿意在这种地方埋雷。
3.3 按键状态机:短按、长按、连加一套逻辑吃掉
/* key.c —— 10ms 调一次,返回事件,不阻塞 */ typedef enum { KEY_NONE = 0, KEY_SET, KEY_UP, KEY_DOWN, KEY_OK } key_id_t; typedef enum { KS_IDLE, KS_DEBOUNCE, KS_PRESS, KS_REPEAT, KS_LONG } ks_t; static ks_t state = KS_IDLE; static uint8_t cnt = 0; static key_id_t last_key = KEY_NONE; key_id_t key_scan(void) { key_id_t k = key_read_raw(); /* 读 4 个 GPIO,返回按下的键 */ key_id_t evt = KEY_NONE; switch (state) { case KS_IDLE: if (k != KEY_NONE) { last_key = k; cnt = 0; state = KS_DEBOUNCE; } break; case KS_DEBOUNCE: /* 消抖 20ms */ if (k == last_key) { if (++cnt >= 2) { cnt = 0; state = KS_PRESS; evt = last_key; } } else { state = KS_IDLE; } break; case KS_PRESS: /* 按住 800ms 进长按 */ if (k == KEY_NONE) { state = KS_IDLE; } else if (++cnt >= 80) { cnt = 0; state = KS_LONG; evt = last_key; } break; case KS_LONG: /* 长按后每 100ms 连加 */ if (k == KEY_NONE) { state = KS_IDLE; } else if (++cnt >= 10) { cnt = 0; evt = last_key; } break; default: state = KS_IDLE; break; } return evt; }逻辑说明:状态机放在 SysTick 的 10ms 节拍里调用,四个状态分别对应“空闲、消抖、已按下、长按重复”。KS_PRESS状态一进入就先返回一次按键事件,让“按一下加 1”立即生效;继续按住 80 次(800ms)后转KS_LONG,之后每 10 次(100ms)返回一次事件,实现“按住连加”。参数说明:cnt >= 2是 20ms 消抖,机械按键的抖动一般在 5-15ms,20ms 比较稳;cnt >= 80是长按门限,觉得太慢改成 50 就是 500ms;cnt >= 10是连加间隔,太快会让人来不及松手。
这套写法的价值在于“不阻塞”。很多练手代码在while(KEY_UP == 0)里死等松手,结果屏幕刷新卡住、秒不走,按一下键时间停两秒。状态机的另一个好处是长按、短按、组合键都能在同一处扩展,不用到处改延时。
3.4 局部刷屏:只重画变化的字段
/* ui.c —— 只在字段变化时重绘,避免整屏刷新带来的闪烁 */ void ui_refresh(const cal_t *now) { static cal_t prev = {0}; if (now->year != prev.year || now->month != prev.month) { lcd_show_num(X_YEAR, Y_YEAR, now->year, 4, COLOR_FG, COLOR_BG); lcd_show_num(X_MON, Y_MON, now->month, 2, COLOR_FG, COLOR_BG); } if (now->day != prev.day || now->week != prev.week) { lcd_show_num(X_DAY, Y_DAY, now->day, 2, COLOR_FG, COLOR_BG); lcd_show_num(X_WEEK, Y_WEEK, now->week, 1, COLOR_FG, COLOR_BG); } if (now->hour != prev.hour || now->min != prev.min) { lcd_show_num(X_HH, Y_HH, now->hour, 2, COLOR_FG, COLOR_BG); lcd_show_num(X_MM, Y_MM, now->min, 2, COLOR_FG, COLOR_BG); } if (now->sec != prev.sec) { lcd_show_num(X_SS, Y_SS, now->sec, 2, COLOR_FG, COLOR_BG); } prev = *now; }逻辑说明:函数每秒被调用一次,逐字段比对,只有变化才重绘。prev是static静态变量,保留上一次的显示内容。参数说明:X_*/Y_*是每个字段在屏幕上的坐标宏,单位是像素;lcd_show_num的第 4 个参数是位数,决定了左补零的宽度(比如小时要固定两位,9显示成09,不然“9:05”和“10:05”宽度变化会让整行字符抖一下)。
这里还有一个血泪经验:prev = *now一定要放在最后。我见过把赋值放中间的版本,结果秒那一行永远不刷,作者查了一下午以为是 TFT 驱动有问题。严格按秒刷新的全屏清屏也行,但 3.5 寸屏整屏刷新在 F1 上要十几毫秒,刷新时肉眼能看见横向撕裂,局部刷新基本感受不到。
4. 编译与烧录:从零把工程跑到屏幕上出时间
代码看懂了,下一个门槛是让它在自己手上跑起来。这一节按“选工具链 → 配时钟 → 烧录自检”的顺序走,命令都能直接抄。
4.1 工具链二选一:Keil 一把梭还是 GCC 加 CMake
原工程带的是 Keil 工程,双击.uvprojx就能编译,前提是装了 F1 的器件支持包。想走开源工具链的话,arm-none-eabi-gcc加 CMake 完全可以复现,还方便接 CI。一份能用的CMakeLists.txt骨架:
cmake_minimum_required(VERSION 3.20) project(calendar_zet6 C ASM) set(CMAKE_C_STANDARD 99) # 工具链前缀,Windows 上是 arm-none-eabi-,Linux 上无需后缀 set(TOOLCHAIN arm-none-eabi-) set(CMAKE_C_COMPILER ${TOOLCHAIN}gcc) set(CMAKE_ASM_COMPILER ${TOOLCHAIN}gcc) set(CMAKE_C_FLAGS "-mcpu=cortex-m3 -mthumb -O2 -g3 -Wall \ -ffunction-sections -fdata-sections") # 启动文件与链接脚本:ZET6 是 512K Flash / 64K RAM set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/Start/stm32_flash.ld) add_executable(${PROJECT_NAME}.elf Start/startup_stm32f10x_hd.s User/main.c Hardware/lcd.c Hardware/key.c Hardware/rtc.c App/calendar.c App/ui.c ) target_link_options(${PROJECT_NAME}.elf PRIVATE "-T${LINKER_SCRIPT}" "-Wl,--gc-sections" "-specs=nano.specs" "-specs=nosys.specs") # 生成 bin 与 hex,方便用串口或 ST-Link 下载 add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD COMMAND ${TOOLCHAIN}objcopy -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin COMMAND ${TOOLCHAIN}objcopy -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.hex)逻辑说明:-mcpu=cortex-m3 -mthumb是这颗内核的硬性要求,少一个链接阶段就报错;-specs=nano.specs换成精简版 C 库,能省下十几 KB Flash;--gc-sections配合前面的-ffunction-sections把没调用的函数裁掉,对只有 512KB 但要塞字库的工程很关键。参数说明:链接脚本里的FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K和RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 64K必须跟 ZET6 的实际容量对上,写小了浪费,写大了链接能过但运行会跑飞。
这里提一句:GCC 版的启动文件和 Keil 版不通用,向量表名字、段名都不一样,别直接拷。常见做法是从 CMSIS 的模板里改,把SystemInit里的时钟配置替换成自己工程的那份。
4.2 时钟树与启动文件:72MHz 到底怎么分配
F1 跑 72MHz 的标准配置是:外部 8MHz 晶振(HSE)经 PLL 9 倍频,得到 72MHz 的 SYSCLK;AHB 不分频,HCLK = 72MHz;APB2 不分频,PCLK2 = 72MHz;APB1 二分频,PCLK1 = 36MHz。分配表如下:
| 时钟域 | 频率 | 主要挂载 |
|---|---|---|
| SYSCLK | 72 MHz | 内核 |
| HCLK (AHB) | 72 MHz | DMA、SRAM、FSMC |
| PCLK2 (APB2) | 72 MHz | GPIO、USART1、SPI1、FSMC 寄存器 |
| PCLK1 (APB1) | 36 MHz | TIM2-7、USART2-5、I2C、RTC |
| RTCCLK | 32.768 kHz | RTC 计数器 |
| ADC | 12 MHz | 不超过 14MHz |
两个地方容易翻车。一是 Flash 等待周期:72MHz 下必须设 2 个等待周期(FLASH_SetLatency(FLASH_Latency_2))并使能预取缓冲,否则跑得起来但偶尔取指出错,现象是“程序随机跑飞”。二是 APB1 最高只能 36MHz,定时器挂在 APB1 时,如果分频系数不为 1,定时器时钟会被自动倍频,PWM 频率算出来跟你想的不一样。
启动文件选startup_stm32f10x_hd.s(hd = high density),选成 md 或 ld 会在链接时提示内存段越界。另外 ZET6 的 RAM 起点是 0x20000000,但中断向量表重映射、栈顶地址这些都在启动文件里写死了,中途换型号记得一起改。
4.3 烧录、首次上电与自检流程
烧录用 ST-Link 或 DAP-Link 都行,SWD 四根线:3.3V、GND、SWDIO、SWCLK。OpenOCD 的配置文件可以简化成这样:
# flash.sh —— 一键编译并烧录 cmake --build build -j openocd -f interface/stlink.cfg \ -f target/stm32f1x.cfg \ -c "program build/calendar_zet6.elf verify reset exit"逻辑说明:program ... verify会先烧再校验,reset exit让芯片复位运行并退出 OpenOCD,一条命令到底。参数说明:interface/stlink.cfg换成interface/cmsis-dap.cfg就是 DAP-Link;如果用的是串口 ISP 下载,需要把 BOOT0 拉高、复位、再用stm32flash工具走 UART1。
首次上电自检我一般按这个顺序看:串口先打一行版本号和复位原因(读RCC_GetFlagStatus(RCC_FLAG_PORRST)区分上电复位还是看门狗复位);然后打RTC_GetCounter()的值,如果是 0 或者接近 0,说明备份域没保持住;接着看屏幕有没有正常初始化(背光亮但全白一般是初始化序列没发对,全黑多半是背光或 FSMC 时序问题);最后按一遍四个键,串口打印键值,确认消抖逻辑生效。
提示:烧录前先把 VBAT 电池装上,并且确认电池电压在 2.0V 以上。空着 VBAT 焊盘调试,每次断电都要重新对时,很容易把“备份域没配好”和“电池没装”这两个问题混在一起排查。
5. 避坑与排查:走时跑飞、按键串键、屏幕花屏的现场记录
万年历项目的坑高度集中在两类:一类跟时间基准和备份域有关,一类跟人机交互有关。下面五条都是我或者身边人真实遇到过的,按“现象 → 原因 → 解决”记,能省你几个通宵。
5.1 走时与掉电保持:三条最贵的坑
坑一:断电再上电,时间回到 2000-01-01。现象很干脆,屏幕显示 2000 年 1 月 1 日 00:00:00,按什么键都不对。原因有三个层次:最常见的是 VBAT 没接电池或者电池没电,备份域一断电,BKP_DR1的魔术字就丢了,程序走了完整初始化分支;其次是代码顺序错了,先写BKP_WriteBackupRegister再配 LSE,中间一旦复位,魔术字在但 RTC 没配好;还有一种隐蔽的,把PWR_BackupAccessCmd(ENABLE)写在了RCC_APB1PeriphClockCmd之前,备份域根本没解锁,写寄存器全部无效。解决办法是把初始化顺序固定成“开时钟 → 解锁备份域 → 读魔术字 → 分支处理 → 最后写魔术字”,并且用万用表量一下 VBAT 脚电压。
坑二:一天慢十几秒,一个月差一分钟。现象是跟手机对时后,第二天再看已经慢了两三秒。原因基本锁定在 LSE 晶振的负载电容上。32.768kHz 圆柱晶振的规格书里写着负载电容 6pF 或 12.5pF,板子上却按经验贴了 22pF,频率就被拉偏了,偏几十 ppm 很常见。更糟的是电容不匹配时晶振起振勉强,示波器一碰探头就停振。解决方法是按晶振规格书选电容(6pF 负载配两个 10pF 左右,12.5pF 负载配两个 22pF 左右),并在软件里加校准:测出实际偏差后,用 RTC 的预分频微调,或者每 24 小时补一次秒。
坑三:时间跳着走,偶尔从 10:00:05 跳到 10:00:07。这种多半不是 RTC 本身的问题,而是读计数器的时机不对。F1 的 RTC 计数器在 APB1 上,读之前必须等 RSF 同步标志,代码里漏了RTC_WaitForSynchro(),读到的就是上一次同步的旧值;如果同时还在中断里读、主循环里也读,两次读之间没有互斥,更容易出现跳秒。解决办法是把读 RTC 的操作收敛到一个函数里,进入时先等同步,读出后立刻转成cal_t结构体,其他地方只用结构体,不再碰寄存器。
5.2 交互与显示:按键串键、屏幕花屏
坑四:按一下“加”键,数字跳了三四下。现象是短按一次,时间加了 3 分钟;有时候按住不放反而不加。原因通常是消抖时间和扫描周期不匹配——扫描周期是 10ms,消抖却只等了 20ms,而手里这颗按键的抖动有 30ms;另一种情况是按键没接上拉电阻,靠内部弱上拉,线一长电平就飘,key_read_raw()连续几次读到不同值,状态机在KS_DEBOUNCE和KS_IDLE之间来回跳。解决办法是先把扫描周期和消抖次数算清楚(10ms × 2 = 20ms 不够就调到 3 次),再在硬件上补 10k 外部上拉和 100nF 电容,软件硬件一起上才稳。
坑五:屏幕花屏、雪花点、偶发白屏。这类问题在 FSMC 并口屏上特别常见。原因一是 FSMC 时序配得太激进,FSMC_AddressSetupTime和FSMC_DataSetupTime设成 0 或 1,72MHz 下屏根本来不及响应,读 ID 时好时坏;原因二是排线太长或没做等长,16 根数据线之间的 skew 让写入的数据错位,现象是某个颜色通道永远不对;原因三是背光 PWM 和 FSMC 共用电源,背光一大电流就让屏复位。解决办法是把读写时序各加 1-2 个周期、排线压到 10cm 以内、背光单独走一路供电并加 100uF 电容,基本能压住。
5.3 排查工具箱:秒脉冲与串口时间戳
真出问题时,光靠看屏幕效率极低。我一般准备两个工具:一是把 RTC 的秒中断引到一个空闲 GPIO 上,做成一秒一次翻转的方波,示波器一夹就知道 RTC 到底走没走、周期准不准;二是在串口里每秒打一行“秒计数 + 换算后的年月日时分秒”,接上电脑让它跑 24 小时,把日志和标准时间一比,慢多少秒一目了然。
如果要评估晶振的 ppm 偏差,可以这么干:记录起始的秒计数和真实时间,跑 6 小时以上再读一次,偏差秒数除以总秒数再乘 1000000 就是 ppm。跑的时间越长越准,跑 10 分钟测出来的数没有参考价值。校准值确定后写进BKP_DR2,掉电不丢,上电时读出来直接用。
6. 二次开发:加农历、加闹钟、把走时误差压下去
这份工程的骨架留得比较干净,二次开发基本不用动Hardware层。加农历是需求最多的改造,最省事的做法是查表:把 1900 到 2100 年每年的农历信息压缩成 4 个字节(闰月大小、12 个月大小、闰哪个月),存成const数组,用年份做索引。
/* lunar.c —— 每年 4 字节:bit0-11 月大小,bit12-15 闰月月份,bit16 闰月大小 */ static const uint32_t lunar_tbl[201] = { 0x04BD8, 0x04AE0, 0x0A570, /* ... 1900, 1901, 1902 ... */ }; /* 取某年农历闰月月份,0 表示无闰月 */ uint8_t lunar_leap_month(uint16_t year) { return (uint8_t)(lunar_tbl[year - 1900] & 0xF); }逻辑说明:lunar_tbl的下标是“年份 - 1900”,每个元素用低位表示 12 个月是大月还是小月,中间 4 位表示闰月是几月。参数说明:这张表从 1900 年算起,超出 2100 年就没有数据了,需要自己续;表的来源要自己核对,不同版本的表在 2057 年附近有出入。
加闹钟的坑在“比较时机”。常见做法是在 RTC 秒中断里比较时分秒,但秒中断里不适合做耗时操作,正确姿势是中断里只置一个标志位,主循环里查询标志再触发蜂鸣器。闹钟的开关状态要存进BKP_DR3或者外部 EEPROM,否则断电就丢。还有一点,闹钟响的时候如果正在调时,要临时屏蔽比较,不然调着调着就响了。
把误差压下去,我一般分两步走:先在硬件上把负载电容配对,让晶振的初始偏差进到 10ppm 以内;再在软件里做补偿,记录BKP_DR2里的 ppm 校准值,每过 N 秒给秒计数补一次或减一次。补偿的粒度要和误差量级匹配,一天差 2 秒就按“每 12 小时补 1 秒”来,别一天补十几次,那样反而会引入抖动。
验证方法很土但有效:改完之后让板子连续跑 72 小时,每 24 小时对一次标准时间,把偏差记在纸上。跑三轮如果偏差稳定在同一个方向、同一个量级,说明补偿逻辑收敛了;如果忽快忽慢,那多半不是晶振的问题,而是读秒的时序或者中断优先级有冲突。从那以后我每次动 RTC 相关的代码,都强制跑一遍 72 小时老化,再对比一次断电重启后的秒计数是否连续,宁可多等三天,也不想再被客户退第二批货。希望帮到你。
本文还有配套的精品资源,点击获取