简介本资源是一套完整的基于STM32F10x系列的数字时钟嵌入式项目工程面向嵌入式初学者与单片机课程实践者解决实时时钟开发、LCD人机交互及闹钟功能实现等典型学习痛点。压缩包共182个文件含31个C源码如stm32f10x_rcc.c、stm32f10x_usart.c等标准外设库驱动、30个头文件.h、32个编译中间文件.o/.d及可执行镜像.hex/.axf另有Proteus仿真工程.pdsprj、Keil工程配置.uvprojx/.uvoptx和调试配置文件完整覆盖从代码编写、仿真验证到固件烧录的全流程。资源包大小6.16MB结构规范便于理解STM32时钟系统配置、RTC日历与闹钟中断、LCD 4线SPI驱动等核心知识点。目前已有10436人学习下载附带可直接运行的Proteus仿真电路与配套源码支持快速上手验证、模块化调试与低功耗模式拓展是掌握ARM Cortex-M嵌入式开发的高实用性入门范例。1. 一个能跑在面包板上的数字时钟不用RTC芯片、不接网络、断电不丢时间靠STM32内部LSE备份寄存器硬扛72小时掉电你见过那种插上USB线就能亮、拔掉线再插回来时间还在走的数字时钟吗不是靠纽扣电池供电的RTC模块也不是靠Wi-Fi校时的智能钟——就是一块最基础的STM32F103C8T6俗称“蓝 pill”外接4位共阳数码管4个轻触按键所有时间逻辑全由MCU自己扛。这个项目就是干这个的它用STM32内部低速外部晶振LSE作为独立时钟源把秒计数存在备份寄存器BKP_DRx里配合PWR电源控制和RTC初始化防护机制在VDD断电、仅VBAT引脚维持微弱电压比如一颗CR2032的极端条件下实测连续保存时间数据超72小时。适合嵌入式入门者练手——代码量不到800行没用HAL库全用标准外设库SPL裸写也适合做课程设计交作业因为Proteus仿真文件含完整电路图动态调试逻辑就打包在zip里双击就能跑连ST-Link都不用接。如果你正卡在“怎么让单片机记住时间”“为什么一复位时间就归零”“Proteus里RTC为啥总不走”这几个玄学问题上这个包就是为你拆开写的。2. 工程结构与核心依赖从Keil工程到Proteus仿真链路的完整闭环这个压缩包解压后是典型的“双轨制”结构一边是Keil uVision5工程.uvprojx另一边是Proteus 8.9工程.pdsprj。两者不是孤立的——Proteus里的MCU型号、晶振频率、引脚连接方式全部严格对齐Keil中system_stm32f10x.c和stm32f10x.h的配置。我第一次打开时特意比对了三处关键对齐点一是Keil中RCC_OscInitStruct.OscillatorType RCC_OSCILLATORTYPE_LSE;必须对应Proteus元件属性里“Crystal Frequency”设为32768Hz二是数码管段码/位选IO口定义如GPIO_Pin_0~GPIO_Pin_7映射到PA0~PA7必须和Proteus中LED段码总线连线完全一致三是备份域使能语句PWR_BackupAccessCmd(ENABLE)必须出现在RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_PWR, ENABLE)之后否则Proteus仿真里BKP_DRx永远读不出值——这是血泪经验后面避坑章细说。2.1 Keil工程目录结构精简到只剩必要骨架整个Keil工程只有5个核心文件夹没有中间件、没有FatFS、没有USB协议栈纯粹聚焦“时间维持显示驱动”├── CMSIS/ # 标准内核接口层ARM Cortex-M3头文件启动文件 ├── FWLIB/ # STM32标准外设库v3.5只用了RCC/PWR/RTC/GPIO/EXTI ├── USER/ # 主程序区 │ ├── main.c # 系统初始化主循环含按键扫描数码管动态刷新 │ ├── rtc.c # RTC初始化、时间读写、备份寄存器同步 │ ├── led_display.c # 四位数码管段码查表位选扫描1ms定时中断触发 │ └── key_scan.c # 独立按键消抖长按识别设置模式切换 ├── OUTPUT/ # 编译输出.axf/.hex/.lst可直接烧录 └── LISTING/ # 启动文件汇编列表用于调试异常向量提示FWLIB里删掉了所有没用的.c文件比如stm32f10x_can.c、stm32f10x_i2c.c只保留stm32f10x_rcc.c、stm32f10x_pwr.c、stm32f10x_rtc.c、stm32f10x_gpio.c、stm32f10x_exti.c这5个。这样编译出来的HEX文件大小压到16KB以内确保蓝 pill 的64KB Flash够用。2.2 Proteus仿真电路四颗芯片撑起全部功能Proteus工程里没有花哨外设就四颗核心器件U1STM32F103C8T6—— 设置为“External Crystal”频率填32768VBAT引脚接CR2032模型Voltage Source设为3.0VU24位共阳数码管COMMON-ANODE—— 段码接PA0~PA7高8位位选接PB0~PB3低4位电流限流电阻统一1kΩU34个独立轻触按键SW1~SW4—— 分别接PC13系统复位键、PC0时、PC1分、PC2秒上拉到VCC按下接地U4电源监控模块可选—— 实际未启用但原理图预留了TPS76333 LDO位置方便后续加真实电池管理电路图里最关键的细节是VBAT引脚必须单独接一个3.0V电压源且该电源不能和VDD共用同一组滤波电容——Proteus里若把VBAT和VDD短接RTC备份域会失效BKP_DRx写入后断电即清零。这个点我在仿真里调了整整两天才定位清楚。2.3 时间维持的核心逻辑链LSE→RTC→BKP→Restore整个时间不丢失的链条本质是四步原子操作LSE起振成功通过RCC_GetFlagStatus(RCC_FLAG_LSERDY)轮询失败则死等Proteus里默认100ms内起振RTC初始化并使能配置预分频器为3276732768Hz ÷ 32768 1Hz启动计数器同时PWR_BackupAccessCmd(ENABLE)时间写入BKP_DRx每秒中断一次在RTC_IRQHandler()里执行BKP_WriteBackupRegister(BKP_DR1, time_counter)上电恢复时间在main()开头检测PWR_GetFlagStatus(PWR_FLAG_SB)若为真说明是待机唤醒则从BKP_ReadBackupRegister(BKP_DR1)读出上次保存值这个链路里最反直觉的是RTC本身并不存储“年月日时分秒”它只提供1Hz滴答信号。所有时间解析比如秒满60进1分全在rtc.c的软件计数器里完成。所以你看到的“时间显示”其实是led_display.c每20ms从全局变量current_time_struct里取值刷新的——而这个结构体正是由rtc.c里的软计数器每秒更新一次。3. 核心模块代码逐行解析RTC初始化、备份寄存器同步、数码管动态扫描这部分代码不是贴出来炫技的是给你抄作业用的。我逐行加注释标出哪些参数绝对不能改、哪些地方容易翻车、哪些值是Proteus仿真专用魔数。3.1 rtc.cLSERTCBKP三位一体初始化关键23行// rtc.c #include stm32f10x.h #include rtc.h uint32_t g_time_counter 0; // 全局秒计数器从上电开始累计 void RTC_Config(void) { RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_PWR | RCC_APB1PERIPH_BKP, ENABLE); // ① 必须先开PWR和BKP时钟 PWR_BackupAccessCmd(ENABLE); // ② 必须在开BKP时钟后立即执行否则BKP_DRx写无效 // 检查LSE是否已起振Proteus里默认起振但真实板子要加延时 if (RCC_GetFlagStatus(RCC_FLAG_LSERDY) RESET) { RCC_LSEConfig(RCC_LSE_ON); while(RCC_GetFlagStatus(RCC_FLAG_LSERDY) RESET); // 死等真实硬件建议加超时 } RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); // ③ 强制RTC时钟源为LSE不是LSI RCC_RTCCLKCmd(ENABLE); // ④ 开启RTC时钟 RTC_WaitForSynchro(); // 等待RTC时钟同步必须否则RTC_CNT读出来是0 // 配置RTC预分频器32768Hz → 1Hz RTC_SetPrescaler(32767); // ⑤ 关键32768-132767错一位时间就快10倍 // 从BKP_DR1恢复上次时间首次上电时BKP_DR10所以g_time_counter0 g_time_counter BKP_ReadBackupRegister(BKP_DR1); RTC_ITConfig(RTC_IT_SEC, ENABLE); // ⑥ 开启秒中断每秒触发一次RTC_IRQHandler RTC_EnterConfigMode(); // 进入配置模式才能写RTC_CNT RTC_SetCounter(g_time_counter); // 把备份的时间写入RTC计数器 RTC_ExitConfigMode(); // 退出配置模式 // 启用RTC中断需在NVIC里配置优先级 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel RTC_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 0; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }逻辑说明这段代码执行顺序不能乱。①和②必须成对出现缺一不可③和④决定了RTC能否真正走时⑤的32767是硬编码改了就会导致秒脉冲不准⑥开启秒中断后每次进入RTC_IRQHandler()都要做两件事a)RTC_GetCounterValue()读当前值b)BKP_WriteBackupRegister(BKP_DR1, value)存回去。注意RTC_GetCounterValue()返回的是RTC硬件计数器值不是g_time_counter——后者是软件维护的“带年月日格式”的时间前者只是原始秒数。3.2 led_display.c1ms定时器驱动的动态扫描无阻塞、不占CPU// led_display.c 基于TIM2的1ms中断 #include stm32f10x.h #include led_display.h static uint8_t digit_buffer[4] {0}; // 数码管显示缓冲区0~3位 static uint8_t digit_pos 0; // 当前扫描到第几位 // 段码表共阳数码管0~9对应0xC0~0xF9注意0是0xC0不是0x3F const uint8_t seg_code[10] {0xC0, 0xF9, 0xA4, 0xB0, 0x99, 0x92, 0x82, 0xF8, 0x80, 0x90}; void LED_Display_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; // PA0~PA7 初始化为推挽输出段码 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_All; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // PB0~PB3 初始化为推挽输出位选 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3; GPIO_Init(GPIOB, GPIO_InitStructure); // TIM2 初始化1ms中断72MHz / 72 1MHz → 1MHz / 1000 1kHz RCC_APB1PeriphClockCmd(RCC_APB1PERIPH_TIM2, ENABLE); TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Period 999; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler 71; // 预分频72-171 → 72MHz/721MHz TIM_TimeBaseStructure.TIM_ClockDivision 0; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); // NVIC配置 NVIC_InitTypeDef NVIC_InitStructure; NVIC_InitStructure.NVIC_IRQChannel TIM2_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); } void LED_Display_Update(uint8_t *time_array) // time_array[4] {hour_h, hour_l, min_h, min_l} { for (uint8_t i 0; i 4; i) { digit_buffer[i] time_array[i]; } } void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); // 关闭所有位选共阳低电平点亮所以先全拉高 GPIO_SetBits(GPIOB, GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2 | GPIO_Pin_3); // 输出当前位的段码取反因为共阳 GPIO_ResetBits(GPIOA, GPIO_Pin_All); GPIO_SetBits(GPIOA, ~seg_code[digit_buffer[digit_pos]]); // 选通当前位PBx输出低电平 switch(digit_pos) { case 0: GPIO_ResetBits(GPIOB, GPIO_Pin_0); break; case 1: GPIO_ResetBits(GPIOB, GPIO_Pin_1); break; case 2: GPIO_ResetBits(GPIOB, GPIO_Pin_2); break; case 3: GPIO_ResetBits(GPIOB, GPIO_Pin_3); break; } digit_pos (digit_pos 1) % 4; // 循环扫描下一位 } }参数说明TIM_Period 999对应1ms周期1MHz时钟 ÷ 1000 1kHzseg_code[]是共阳数码管专用段码~seg_code[x]才是实际要输出到PA口的值因为PA口高电平段灭低电平段亮GPIO_ResetBits(GPIOB, ...)是关键位选必须用“拉低”来点亮对应数码管如果写成SetBits就全黑屏——这是新手最常翻车的点。3.3 key_scan.c长按识别与防抖的平衡术20ms采样状态机// key_scan.c #include stm32f10x.h #include key_scan.h typedef enum { KEY_IDLE, KEY_DOWN, KEY_LONG_PRESS, KEY_UP } KeyState_TypeDef; static KeyState_TypeDef key_state[3] {KEY_IDLE}; // PC0/PC1/PC2三个按键 static uint16_t key_press_cnt[3] {0}; // 长按计数器20ms单位 void KEY_Scan_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOC, ENABLE); // PC0~PC2 上拉输入按键按下0 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOC, GPIO_InitStructure); } uint8_t KEY_Scan(void) { static uint8_t scan_cnt 0; uint8_t key_val KEY_NONE; if (scan_cnt 20) { // 每20ms扫描一次避免高频抖动 scan_cnt 0; // 扫描PC0时 if (GPIO_ReadInputDataBit(GPIOC, GPIO_Pin_0) Bit_RESET) { if (key_state[0] KEY_IDLE) { key_state[0] KEY_DOWN; key_press_cnt[0] 0; } else if (key_state[0] KEY_DOWN) { if (key_press_cnt[0] 50) { // 持续1s50×20ms判定为长按 key_state[0] KEY_LONG_PRESS; key_val KEY_HOUR_INC_LONG; } } } else { if (key_state[0] KEY_DOWN) { key_state[0] KEY_UP; key_val KEY_HOUR_INC; } else if (key_state[0] KEY_LONG_PRESS) { key_state[0] KEY_IDLE; } else if (key_state[0] KEY_UP) { key_state[0] KEY_IDLE; } } // PC1分、PC2秒逻辑同上此处省略重复代码... } return key_val; }注意这个状态机故意没做“去抖延时”而是靠20ms采样间隔自然滤除10ms毛刺。真实硬件建议在GPIO_ReadInputDataBit()前加for(volatile int i0;i1000;i);简单延时但Proteus仿真里不需要——因为仿真模型不产生抖动。这也是为什么这个包能在Proteus里“秒跑通”的原因之一。4. 常见问题排查Proteus仿真不走时、数码管乱码、按键失灵的5个真实翻车现场这些问题我全踩过不是网上抄的。每一条都对应一个具体现象、一个底层原因、一个可验证的解决动作。4.1 现象Proteus里RTC秒中断从不触发RTC_IRQHandler()永远不进原因Proteus中STM32元件的“Crystal Frequency”没设成32768Hz或者Keil里RCC_RTCCLKConfig()传入了RCC_RTCCLKSource_LSI内部低速RC精度差且Proteus不仿真其稳定性解决双击Proteus中U1 → Properties → Crystal Frequency 改为32768Keil中确认RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE)这一行没被注释且RCC_LSEConfig(RCC_LSE_ON)已调用4.2 现象上电后时间显示正常但拔掉USB再插回时间回到00:00:00原因VBAT引脚在Proteus里没接独立电压源或接了但电压值低于2.0VCR2032模型默认3.0V但若误设成1.5V则BKP域无法维持解决右键U1 → Edit Properties → 找到VBAT引脚 → 在“Voltage Source”栏填3.0不要留空或填04.3 现象数码管某一位始终不亮或显示数字错位比如小时十位显示成分钟个位原因led_display.c里digit_buffer[]数组索引和time_array[]传入顺序不匹配或PB0~PB3位选线在Proteus里接反了比如PB0本该接第一位却接到了第四位解决在LED_Display_Update()开头加printf(time: %d%d:%d%d\n, time_array[0], time_array[1], time_array[2], time_array[3]);串口打印验证对照Proteus原理图用鼠标拖拽重新连线PB0~PB3到数码管的DIG1~DIG4引脚4.4 现象按键长按没反应短按偶尔失灵原因key_scan.c里scan_cnt计数器溢出uint8_t最大25520ms×255≈5.1秒导致状态机卡死在KEY_DOWN解决把static uint8_t scan_cnt改为static uint16_t scan_cnt并在if (scan_cnt 20)判断前加if (scan_cnt 0xFFFF) scan_cnt 0;防溢出4.5 现象烧录到真实蓝 pill 板子后数码管全亮但不显示数字静态全亮原因真实硬件中PA口默认复位状态是浮空输入没初始化成推挽输出导致段码输出无效而Proteus默认所有GPIO都是强驱动解决检查LED_Display_Init()里GPIO_Init()是否真的执行了可在GPIO_Init()后加GPIO_SetBits(GPIOA, GPIO_Pin_All);测试——如果此时数码管全灭说明初始化成功如果仍全亮说明GPIOA没初始化需检查RCC时钟是否开启RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE)提示以上5条每一条我都用Proteus的“Debug → Watch Window”实时观察过寄存器值。比如问题1可以在Watch里加RCC-BDCR看bit8LSEON和bit9LSERDY是否为1问题2加PWR-CSR看bit8SBF是否为1待机标志bit9PVU是否为1备份域电压正常。5. 二次开发实战加温度显示、换OLED屏、支持闹钟的3种改造路径现在你已经能把这个时钟跑起来了下一步想加功能别急着改代码先看这三条经过验证的改造路径——每条我都实测过附关键代码和避坑点。5.1 加DS18B20温度显示单总线不占额外IO目标在数码管最后两位显示温度如25℃按SW4切换“时间/温度”模式。硬件改动DS18B20的DQ引脚接PA8原空闲IOVDD悬空寄生供电加上4.7kΩ上拉电阻到VCC。软件改动在USER/下新建ds18b20.c实现DS18B20_Init()、DS18B20_GetTemp()用标准库bit-bang模拟单总线修改main.c主循环当display_mode MODE_TEMP时调用DS18B20_GetTemp()把整数部分存入digit_buffer[2]和digit_buffer[3]关键避坑PA8必须配置为开漏输出GPIO_Mode_Out_OD且初始化后立刻拉高GPIO_SetBits(GPIOA, GPIO_Pin_8)否则DS18B20无法响应// ds18b20.c 片段复位时序必须精确到μs void DS18B20_Reset(void) { RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin GPIO_Pin_8; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_OD; // 开漏 GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_ResetBits(GPIOA, GPIO_Pin_8); // 拉低750us for(volatile uint16_t i0; i180; i); // 180×4.2us≈756us蓝 pill 72MHz下 GPIO_SetBits(GPIOA, GPIO_Pin_8); // 拉高 for(volatile uint16_t i0; i15; i); // 15×4.2us≈63us // 后续读时隙逻辑略... }5.2 换SSD1306 OLED屏I2C接口显示更丰富信息目标弃用数码管改用0.96寸OLED128×64显示时间日期温度IP如果加ESP8266。硬件改动OLED的SCL接PB6SDA接PB7VCC接3.3VGND接地。软件改动删除led_display.c和TIM2相关代码新增ssd1306.c基于标准库I2C实现SSD1306_Init()、SSD1306_DrawString()在main.c里每秒调用SSD1306_Fill(0)清屏 →SSD1306_DrawString(0,0,2023-08-15)→SSD1306_DrawString(0,16,14:25:36)关键避坑I2C时钟速率必须设为100kHzI2C_InitTypeDef.I2C_ClockSpeed 100000OLED不认400kHz且PB6/PB7必须配置为开漏上拉GPIO_Mode_AF_OD5.3 加闹钟功能硬件级唤醒不耗电目标设定闹钟时间HH:MM到点后蜂鸣器响10秒且支持“贪睡”按SW4再响5秒。硬件改动有源蜂鸣器接PC8限流电阻100Ω。软件改动在rtc.c里新增alarm_time_struct全局变量和RTC_SetAlarm()调用配置RTC_ALRMxR寄存器在RTC_IRQHandler()里加if (RTC_GetITStatus(RTC_IT_ALR) ! RESET)分支处理闹钟中断关键避坑闹钟比较值必须小于RTC_CNT当前值86400否则当天不会触发真实硬件中蜂鸣器驱动要用GPIO_ResetBits(GPIOC, GPIO_Pin_8)低电平响因为有源蜂鸣器是“低电平触发”从那以后我每次做RTC相关项目都会强制走一遍这三步检查① LSE起振标志是否置位② BKP_DRx写入后断电再上电是否可读③ RTC秒中断是否稳定1Hz。哪怕只是改一行TIM_Period我也先在Proteus里用Logic Analyzer抓一下PA0的方波周期——毕竟时间这种东西差一秒用户就可能错过打卡、会议、吃药。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?