STM32定时器中断驱动8位数码管时钟:Proteus仿真与HAL库实现
2026/9/12 5:54:18 网站建设 项目流程

简介:这是一份基于Proteus与Keil的STM32时钟设计与实现仿真资源,使用STM32F103和8位数码管,通过定时器实现小时、分钟、秒、毫秒的精确显示。适合嵌入式初学者、电子设计爱好者以及需要完成STM32时钟课程设计或Proteus仿真验证的开发者。压缩包共154个文件,大小约6.12MB,包含Proteus仿真电路图(pdsprj)、Keil工程文件(uvprojx)、C/H源码、hex固件及map、lst等编译辅助文件,结构清晰,便于直接打开运行或二次开发。内容核心覆盖STM32定时器配置、数码管动态扫描显示、时钟逻辑处理等关键代码。已有4340人学习下载,可直接在Proteus中加载hex运行查看效果,也可结合Keil源码理解时钟设计原理,是入门STM32定时器与数码管应用的实用参考。

1. 为什么这个STM32时钟仿真值得拆开看

一块Proteus仿真电路,加载一个hex文件,STM32F103就控制8位数码管走起了时、分、秒、毫秒的时间显示。这套工程拿出来,不只是给人看“时钟能跑”,它其实是把定时器中断、HAL库时钟初始化、数码管动态扫描三件事串在了一起。对很多被delay卡住的人而言,这个资源的直接价值是告诉你:完整的时间基准不用靠空循环,定时器才是正路。我把它拆开讲,从原理、仿真到代码逐层过一遍,目标是让拿到资源的人能快速复现现象,同时看清每个文件在系统里的位置。

2. STM32定时器与数码管显示原理:从时钟树到段码映射

STM32F103的定时器之所以适合做时钟,是因为它有独立的预分频器和自动重载计数器,可以在不占用CPU主循环的情况下产生精确的中断。相比SysTick,通用定时器的分频范围更大,还能输出PWM或捕获外部信号。Proteus仿真中,虽然不需要担心真实晶振是否起振,但必须理解时钟树里PLL、AHB、APB1的配置关系,否则中断频率会和预期差出好几倍。

2.1 通用定时器产生1ms时基的配置逻辑

在做毫秒计数前,先需要回答一个问题:为什么选择TIM2而不是TIM3或SysTick。TIM2在STM32F103系列里是32位定时器,TIM3和TIM4是16位定时器。如果只做毫秒级累加,16位也够用;但后续如果想把中断周期直接扩展到1秒,32位定时器可以不用级联就完成,省去一堆组合逻辑。Proteus工程里的定时器代码通常会放在TIM2上,也和CubeMX生成默认配置保持一致。

定时器时钟来源是APB1。当APB1分频器设置为2时,PCLK1为36MHz,而TIM2时钟会被自动倍频到72MHz。要在72MHz下得到1kHz的定时中断,需要预分频器把72MHz降到1MHz,再由自动重载寄存器把1MHz分到1kHz。计算公式是:

中断频率 = 定时器时钟 / ((Prescaler + 1) * (Period + 1))

这个公式是检查代码配置是否正确的最快工具。我见过有人把Prescaler写成72,Period写成1000,从而得到66.6Hz,数码管秒信号慢到怀疑人生。下面这段是HAL库的常规配置:

// stm32f1xx_hal_tim.c 中TIM2的1ms时基配置 htim2.Instance = TIM2; htim2.Init.Prescaler = 72 - 1; // 72MHz / 72 = 1MHz htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 1000 - 1; // 1MHz / 1000 = 1kHz htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_Base_Init(&htim2); HAL_TIM_Base_Start_IT(&htim2);

Prescaler写成72-1,是因为计数器从0开始计数,实际生效的分频系数是Prescaler加1。Period同理。AutoReloadPreload置为ENABLE时,新的重载值要到下一次更新事件才写入影子寄存器,目的就是避免在运行中修改周期时产生毛刺。如果用STD库,对应写法是TIM_Prescaler = 71,TIM_Period = 999,逻辑相同,只是HAL库把参数藏得深一些,出错时不容易一眼看出来。

不同目标周期下的配置值可以借助下面这张表快速对照:

目标周期PrescalerPeriod实际中断频率备注
1ms72-11000-11000 Hz最常用的毫秒时基
10ms72-110000-1100 Hz适合按键扫描
100ms72-1100000-110 Hz慢刷新或超时判断
1s72-11000000-11 Hz仅32位定时器可直配

注意第三行和第四行的Period值已经超过16位定时器的65535上限,所以不能用于TIM3和TIM4。在做课程设计时,有人把网上TIM3的1ms配置直接改成1000000,结果计数器溢出后自动重载成小周期,仿真里秒针完全不正常。这就是没有区分定时器位宽造成的。

2.2 8位数码管动态扫描为什么必须交由定时器驱动

数码管显示表面上是“输出段码”这么简单,实际上因为有8位,每一位都需要在恰当的时机被选通并输出对应段码。如果所有位同时点亮,引脚数量不够,驱动电流也可能超限,所以工程中几乎都采用动态扫描:同一时刻只让一位显示,以足够快的频率轮换。Proteus里通常使用7SEG-MPX8-CC或7SEG-MPX8-CA模型,前者是共阴极,后者是共阳极。从常见的例程看,这份资源大概率用的是共阴极数码管,位选低电平有效。

动态扫描的帧率直接决定肉眼看到的效果。人眼对低于50Hz的刷新会感到闪烁,而8位扫描一帧至少需要8个位选周期,所以每一位的通电频率不能低于400Hz。如果每位停留1ms,一帧8ms,帧率125Hz,余量足够。这也是很多工程把显示刷新放进1ms中断里的原因。

但如果把8位扫描全放在1ms中断里执行,中断程序本身可能占用超过1ms,下一次中断就会被迫推迟,最终看到的秒跳变得不规律。常见做法是:每次定时器中断只切换一位,用静态变量记录当前显示位置,然后更新段码和位选。这样中断函数只有十几条指令的负载,主循环仍然可以做按键扫描和其他任务。这个思路比单纯提高晶振频率更有效。

2.3 RCC与Flash等待周期:为什么系统时钟没有想象中快

打开工程目录里的stm32f1xx_hal_rcc.c和stm32f1xx_hal_flash.c,它们负责把系统时钟配置到72MHz,并让Flash读取速度与CPU匹配。STM32F103内部Flash在较高频率下需要插入等待周期,当SYSCLK高于48MHz时至少要设置2个等待周期,否则程序运行到一定时候会随机进入HardFault。Proteus仿真中没有物理Flash延迟,但HAL库的SystemClock_Config仍会执行这段配置,并影响后续所有总线时钟的开关。

下面这段代码是把外部8MHz晶振倍频到72MHz时最常用的写法:

// SystemClock_Config 中与RCC和Flash相关的关键调用 void SystemClock_Config(void) { RCC_OscInitTypeDef RCC_OscInitStruct = {0}; RCC_ClkInitTypeDef RCC_ClkInitStruct = {0}; RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE; RCC_OscInitStruct.HSEState = RCC_HSE_ON; RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON; RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE; RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9; // 8MHz * 9 = 72MHz if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK) { Error_Handler(); } __HAL_FLASH_SET_LATENCY(FLASH_LATENCY_2); __HAL_RCC_APB2_CLK_ENABLE(RCC_APB2PERIPH_GPIOA); __HAL_RCC_APB1_CLK_ENABLE(RCC_APB1PERIPH_TIM2); }

PLLMUL固定为9,前提是外部晶振必须是8MHz。如果Proteus器件属性里的Clock Frequency改成了其他值,这个倍频系数也要跟着调整。RCC_OscInitStruct被清零是为了避免堆栈上残留随机值影响判断。另一点容易被忽略:配置顺序必须先开启HSE,再配置PLL和总线分频,如果颠倒,PLL可能锁不住,最终系统还是跑在内部低速时钟上,定时器的1ms也就名存实亡了。

3. Proteus仿真加载hex:先让数码管转起来

拿到这份资源时,STM32_Timer.hex和STM32_Timer.axf都在根目录,工程本身并没有附带Proteus原理图,所以第一步不是直接打开,而是新建一个Proteus工程设计,再按资源描述的用法加载hex。如果按原说明双击STM32器件选择hex文件后运行,应该能看到8位数码管按时、分、秒、毫秒跳动。跑不出来的,问题大多出在元件选型、引脚分配或hex路径上。

3.1 元件放置与属性设置

在Proteus 8元件库中搜索“STM32F103”,会得到一个可以放置的模型,虽然它的引脚排列和真实LQFP封装不完全一致,但仿真只关心逻辑连接。再搜索“7SEG-MPX8-CC”或“7SEG-MPX8-CA”放置8位数码管。两个元件放好后,需要确认模型与代码里的段码表一致。代码按共阴极写而仿真用了共阳极时,显示的数字会非常奇怪,比如0变成8,或者段全部翻转。

除了这两个元件,建议再加一个按钮连接到NRST用于复位,以及一个LED接到某个空闲GPIO用于调试。晶振和电容在Proteus里画上也不影响运行,因为STM32模型的时钟不是从外部引脚读取的,而是在Edit Component对话框的Clock Frequency里设置的。很多人把外部晶振画上后仍然觉得不工作,说明对这个模型的理解还停留在51单片机时代。

3.2 引脚分配与驱动能力差异

从常见接法看,PA0-PA7负责段选a到g和dp,PB0-PB7负责8位位选。这样PA和PB两组GPIO全部做推挽输出。对共阴极数码管,段选引脚输出高电平时点亮对应段,位选引脚输出高电平时选中该位。如果初始化代码把ODR寄存器保留为0,则上电后所有位选都是低电平,数码管不会亮,这是正常现象。

引脚分配与电平关系可以按下面这张表核对:

功能模块典型引脚GPIO模式有效电平
段选a~dpPA0~PA7推挽输出高电平点亮段
位选1~8PB0~PB7推挽输出高电平选中位
时基定时器TIM2内部内部外设无需接线
调试输出PA8推挽输出翻转输出时基

Proteus的GPIO模型不会像实物那样严格限制灌电流,所以数码管不加限流电阻也能显示。真实的STM32F103 GPIO灌电流并不大,如果直接用GPIO驱动多位共阴极数码管,长时间点亮会发热甚至损坏引脚,但这套仿真项目要验证的是逻辑,不是驱动能力。

3.3 加载hex并验证文件完整性

在Proteus中双击STM32F103器件打开Edit Component对话框,Program File一栏选择生成的STM32_Timer.hex,然后点击确定。运行后,Proteus会把hex按Intel HEX格式解析后写入模拟Flash,再从复位向量开始执行。它不会检查Keil工程的芯片型号是否一致,也不会检查原理图上的引脚是否和代码匹配。所以看到异常时,第一反应应该是检查hex文件本身和它的来源。

当手里只有一个hex而不确定它是否被完整复制时,可以用下面这个Python脚本快速检查文件结尾和记录类型:

# check_hex.py 检查Intel HEX中是否存在EOF记录 path = "STM32_Timer.hex" has_eof = False with open(path) as f: for line in f: line = line.strip() if not line.startswith(":"): continue byte_count = int(line[1:3], 16) record_type = int(line[7:9], 16) if record_type == 0x01: has_eof = True print(f"found EOF, bytes_in_last_record={byte_count}") break elif record_type not in (0x00, 0x04): print(f"unknown record type: 0x{record_type:02X}") print("EOF found" if has_eof else "no EOF record, hex may be truncated")

这段脚本读取每行第8个字符作为记录类型,0x01表示文件结束,0x04表示扩展线性地址记录,常见于Keil输出的HEX386格式。如果脚本输出no EOF,说明文件被截断,Proteus虽然可能加载,但程序会跑飞或停在奇怪的地方。

3.4 晶振电容在仿真中的权重

搜“stm32晶振电容计算”的人,往往是想在Proteus里还原真实硬件。实际上STM32F103仿真模型并不依赖外部晶振电容,器件属性里的Clock Frequency设成8MHz后,内部时钟源就按这个频率工作。真实项目中晶振负载电容要根据晶振规格书和走线寄生电容计算,而不是套用固定值22pF。仿真里把电容删掉,时钟照样走,但这不意味着实物可以这样省略。这个区别也是仿真与真实项目之间的第一个认知拐点。

4. Keil工程源码:定时器中断与数码管刷新的协作

回到Keil工程,资源里给出的STM32_Timer.uvguix.ASUS是Keil用户界面布局文件,ASUS是当时电脑的用户名,它只影响窗口排列,不影响编译结果。真正决定hex行为的是stm32f1xx_hal_tim.c、stm32f1xx_hal_rcc.c和stm32f1xx_hal_flash.c这几个HAL库文件,以及写代码的人如何把它们拼起来。

4.1 工程文件在编译中承担的角色

把整个工程的文件夹展开,会看到许多C文件。下面这几个是时钟项目里真正参与逻辑的:

  • stm32f1xx_hal_tim.c和stm32f1xx_hal_tim_ex.c:定时器初始化、中断处理、PWM扩展接口。
  • stm32f1xx_hal_rcc.c:系统时钟、总线分频和外设时钟使能。
  • stm32f1xx_hal_flash_ex.c和stm32f1xx_hal_flash.c:Flash等待周期和烧写相关配置。
  • stm32f1xx_hal_dma.c:DMA控制器驱动。很多工程没有直接调用,但CubeMX模板会把它带上。
  • STM32_Timer.axf:带调试信息的ARM可执行文件,Proteus不认这个,它只加载hex。

如果发现工程编译后体积比预期大很多,通常是HAL库把所有文件都参与了编译,而代码里只用了RCC、GPIO、TIM三个模块。手动裁剪掉dma和flash_ex也可以,但新手不建议这样做,因为时钟配置里的某些底层函数会引用它们。

4.2 定时器中断里的毫秒累加与时分秒换算

时间基准的维护代码通常在中断回调里完成。HAL库把更新中断统一送到HAL_TIM_PeriodElapsedCallback,需要在里面判断是哪个定时器产生的:

// 定时器更新中断回调:累加毫秒并换算时分秒 volatile uint32_t g_tick_ms = 0; uint8_t hour = 12, minute = 0, second = 0, ms_high = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { g_tick_ms++; ms_high = (uint8_t)(g_tick_ms / 100); if (g_tick_ms < 1000) { return; } g_tick_ms = 0; ms_high = 0; if (++second >= 60) { second = 0; if (++minute >= 60) { minute = 0; hour = (hour + 1) % 24; } } } }

这里把g_tick_ms设计成volatile类型,是因为它在中断中修改,而主循环要读取它来刷新显示;没有volatile修饰时,编译器可能把变量优化进寄存器,导致主循环永远读到旧值。ms_high只取了毫秒的百位和十位,这是为了适配8位数码管的位数限制。如果你想显示毫秒的个位,刷新时间会更短,但人眼已经分不清了。

中断回调里一律不要调用printf或HAL_Delay这类阻塞函数。printf重定向到UART后,发送一个字节可能要等几十微秒,在1kHz中断里会进一步压缩主循环的时间,严重时甚至出现秒跳变慢。

4.3 显示缓冲区与段码映射

时分秒毫秒加起来一共是十位数字,但8位数码管最多显示8位。实际项目中常见的排布是:前两位小时,中间两位分钟,后两位秒,最后两位毫秒。秒与毫秒之间无法完全同时展示,所以一般只在秒更新后的前几百毫秒显示毫秒,或者直接把毫秒当成滚动调试信息。资源描述里说“显示小时、分钟、秒、毫秒”,最合理的实现是时分秒占用6位,毫秒占用2位,例如“12 30 45 12”。

显示缓冲区的更新可以这样组织:

uint8_t disp[8]; void build_display_buffer(void) { disp[0] = hour / 10; disp[1] = hour % 10; disp[2] = minute / 10; disp[3] = minute % 10; disp[4] = second / 10; disp[5] = second % 10; disp[6] = (g_tick_ms / 100) % 10; // 毫秒百位 disp[7] = (g_tick_ms / 10) % 10; // 毫秒十位 }

build_display_buffer不需要在1ms中断里调用,只放到秒更新后或主循环中低频调用即可。显示刷新函数则是每次中断时从缓冲区读取一位,段码通过查表输出。缓冲区的内容是数字索引,刷新函数再映射到段码,而不是在中断里直接改段码,这样能把显示数值和硬件位分离开。

刷新时序可以对照下表:

回调位置内容周期
HAL_TIM_PeriodElapsedCallback切换一位数码管,g_tick_ms++1ms
主循环读取g_tick_ms,更新disp数组10ms左右
second进位清除g_tick_ms,并调整时分秒1000ms

4.4 共阴共阳极的段码表适配

标准共阴极数码管的段码表从0到9是0x3F、0x06、0x5B、0x4F、0x66、0x6D、0x7D、0x07、0x7F、0x6F。如果Proteus里放的是共阳极,就需要把每个值按位取反,变成0xC0、0xF9等。判断方法是看数字0是否只显示六段而不是八段。

// 共阴极段码表:bit0=a, bit1=b, ..., bit6=g, bit7=dp const uint8_t seg_code[10] = { 0x3F, 0x06, 0x5B, 0x4F, 0x66, 0x6D, 0x7D, 0x07, 0x7F, 0x6F };

如果代码和硬件不匹配,最简单的是改仿真里的数码管型号,而不是去改一大堆段码。改完之后停止仿真再重新运行,Proteus会再次加载hex并复位芯片,不需要退出程序。

5. 进阶:仿真时钟走时误差与动态校准方法

Proteus里的STM32时钟源虽然是理想模型,但程序执行指令需要消耗仿真时间,HAL库启动时的SysTick配置也会占掉几百微秒,这些都会让时钟与真实秒表之间产生偏移。常见的现象是仿真运行10分钟,数码管秒数比手机秒表慢2秒左右。这不一定是分频配置错误,更可能是虚拟时间粒度带来的累积漂移。

5.1 微调自动重载值

校准的核心是修改TIM2的重载值。原配置Period是999,对应1kHz。如果走慢,需要缩短计数周期,让中断频率变高。比如10分钟慢2秒,实际每秒慢了约0.33%,把中断目标频率调整为1003Hz后,新的Period按公式计算:

Period = 72MHz / 72 / 1003 - 1 ≈ 996

也就是说,把TIM2->ARR从999改成996,中断周期缩短约0.3%。在Keil中改完并重新生成hex,再加载到Proteus,观察一段时间确认走时误差。如果走快了,就把ARR调回997或998,反复两三次就能逼近真实时间。

// 运行时直接调整ARR,立即生效需要手动产生更新事件 TIM2->ARR = 996; TIM2->EGR |= TIM_EGR_UG; // 软件触发更新,加载新的ARR

如果需要标定值,可以把ARR放在一个全局变量里,用串口或按键上下调整。只要能预估误差方向,这个办法比修改预分频器更精细,因为Prescaler每次只能改变1MHz的整数倍分频,而ARR可以按单个计数周期微调。

5.2 用虚拟示波器验证中断频率

校准不能只靠眼睛盯数码管。找一个空闲GPIO,比如PA8,在1ms中断回调里执行GPIO翻转,再把Proteus的虚拟示波器探头接到PA8上。正常时示波器显示方波周期为2ms,因为一次中断里电平翻转一次,两次翻转才构成一个完整周期。如果测得周期是2.2ms,就说明实际中断频率比1kHz低了,直接按上面的公式重新计算ARR。

测量周期实际频率调整方向
2.00ms1000Hz无需调整
2.10ms952Hz减小ARR
1.95ms1026Hz增大ARR

虚拟示波器是Proteus里检查定时器时基最直接的工具,不需要额外连线,只是把探头拖到目标引脚上。

5.3 把毫秒时基变成工程可复用的资源

当毫秒时基稳定后,时钟只是它的第一个应用。可以在此基础上增加按键调整时间、整点蜂鸣、定时提醒等功能。再进一步,把秒脉冲映射到另一个定时器的输出比较通道,就能对外输出周期性的PWM信号,驱动其他模拟电路。Proteus仿真工程的好处是一份hex可以在不同原理图上验证,比如把8位数码管换成LCD1602,只需要改写显示缓冲区接口,定时器逻辑完全复用。搬过去之后,最应该改的是显示刷新函数里的段码表和引脚映射,时间基准部分基本不用动。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询