简介:这是一份基于STM32的秒表程序源码,面向正在学习单片机定时器、中断与按键处理的嵌入式初学者及进阶开发者,可直接在正点原子精英板等开发板上运行。程序实现0.01秒精度的计时、暂停与清零功能,keyup负责启动与继续,key1控制暂停,key0完成清零,逻辑清晰,其他厂商开发板稍作修改即可移植。压缩包共140个文件,约2.69MB,包含34个c源文件、36个h头文件,以及o、d、crf等编译中间文件,另有sct、uvprojx、hex、map等工程与烧录相关文件,覆盖STM32标准外设库的定时器、LCD、Flash、RCC等模块。目前已有2388人学习下载。作者几乎为每个函数添加了注释,读者可借此理解秒表计时框架、按键扫描与显示刷新流程,并在此基础上扩展硬件功能或优化程序结构,适合作为STM32入门练手与课程设计的参考。
1. 从一块吃灰的精英板说起:为什么我要拆这个秒表工程
手头有几块 STM32 开发板,平时跑例程、点灯、调串口,玩完就扔在抽屉里。直到有个做实训的朋友问我:能不能用最熟悉的板子做一个能计时的秒表,要求按键启停、数码管或 LCD 显示、精度到 10ms,最好还能记录分段成绩。我第一反应是这需求太朴素了,但翻了一圈网上的资料,要么是纯寄存器版本看得头疼,要么是 HAL 库工程缺文件跑不起来。后来拿到这份「精英板秒表成功」的压缩包,解压后是一个完整的 STM32 工程,基于常见的精英板硬件,用定时器做时基、按键做交互、显示模块做输出,编译下载后直接能跑。它解决的不是什么高深算法问题,而是把「定时器中断 + 按键消抖 + 显示刷新」这条最基础也最容易翻车的链路完整串了起来。适合刚上手 STM32、想找一个能跑通又能改的实战工程的人,也适合拿它当模板去改造成计时器、频率计、PWM 测量之类的项目。
2. 秒表工程的骨架:定时器时基、按键输入与显示刷新怎么串
拿到一个能跑的工程,最忌讳上来就改代码。先花十分钟把它的运行逻辑理清楚,后面改参数、加功能才不会把整个工程改崩。这个秒表工程的核心其实就三块:一个定时器负责产生固定时间基准,一组按键负责启停和复位,一个显示模块负责把计数值刷到屏幕上。三块之间通过中断和标志位解耦,主循环只做显示刷新和状态判断。
2.1 定时器选型与 10ms 时基的配置逻辑
秒表精度的下限由定时器中断周期决定。工程里用的是通用定时器,配置成 10ms 中断一次,也就是每秒钟进 100 次中断。为什么选 10ms 而不是 1ms?因为 1ms 中断对显示刷新和按键扫描来说太频繁,CPU 大部分时间都在进出中断,主循环反而被拖慢;10ms 既能保证百分秒位的显示需求,又给主循环留出足够的执行时间。常见做法是用定时器的预分频器把系统时钟降到一个合适的计数频率,再用自动重装载寄存器设定溢出周期。
// 以 72MHz 系统时钟为例,配置 10ms 定时中断 // 预分频 7200-1,计数频率 = 72MHz / 7200 = 10kHz // 自动重装载 100-1,溢出周期 = 100 / 10kHz = 10ms TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_TimeBaseStructure.TIM_Prescaler = 7200 - 1; TIM_TimeBaseStructure.TIM_Period = 100 - 1; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE);这段代码里,TIM_Prescaler决定计数频率,TIM_Period决定溢出周期,两者配合才能得到想要的 10ms。很多人只改TIM_Period忘了改预分频,结果中断周期差了几十倍,秒表跑起来像快进。中断服务函数里只做一件事:给一个全局变量累加,并置位一个更新标志。
volatile uint32_t g_ms_tick = 0; volatile uint8_t g_update_flag = 0; void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); g_ms_tick += 10; // 每次中断代表 10ms g_update_flag = 1; // 通知主循环刷新显示 } }g_ms_tick是毫秒累加器,g_update_flag是给主循环的信号。中断里不做显示、不做按键判断,只更新计数和标志,这是保证计时准确的关键。如果中断里塞了太多逻辑,中断执行时间超过 10ms,计时就会丢步,秒表越跑越慢。
2.2 按键启停与消抖:状态机比延时更靠谱
秒表至少需要两个按键:一个启停,一个复位。工程里用的是独立按键接 GPIO,按下为低电平。新手最容易踩的坑是在按键中断里直接delay_ms(20)消抖,结果整个系统被阻塞,显示卡顿、计时不准。更稳的做法是用状态机在主循环里轮询,或者用定时器中断做周期扫描。
// 按键状态机,每 10ms 调用一次 typedef enum { KEY_IDLE, KEY_DEBOUNCE, KEY_PRESSED, KEY_RELEASE } key_state_t; key_state_t key_state = KEY_IDLE; uint8_t key_press_flag = 0; void key_scan(void) { switch (key_state) { case KEY_IDLE: if (KEY_READ() == 0) key_state = KEY_DEBOUNCE; break; case KEY_DEBOUNCE: if (KEY_READ() == 0) { key_state = KEY_PRESSED; key_press_flag = 1; } else key_state = KEY_IDLE; break; case KEY_PRESSED: if (KEY_READ() == 1) key_state = KEY_RELEASE; break; case KEY_RELEASE: key_state = KEY_IDLE; break; } }这个状态机每 10ms 跑一次,刚好和定时器中断周期对齐。KEY_DEBOUNCE状态相当于等了一个周期再确认,等效于 10ms 消抖,但不会阻塞任何东西。key_press_flag置位后由主循环消费,消费完清零。这样按键响应和计时互不干扰,启停操作不会影响g_ms_tick的累加。
2.3 显示刷新:别在中断里刷屏
显示模块常见的是 LCD 或数码管,不管哪种,刷新都需要时间。如果放在定时器中断里刷新,中断执行时间会被拉长,计时精度直接崩掉。工程里的做法是主循环检测g_update_flag,置位时才刷新一次显示。
int main(void) { uint32_t last_tick = 0; uint8_t running = 0; timer_init(); key_init(); display_init(); while (1) { key_scan(); if (key_press_flag) { key_press_flag = 0; running = !running; // 启停切换 } if (running && g_update_flag) { g_update_flag = 0; display_show_time(g_ms_tick); // 只刷显示,不碰计时 } } }主循环里running控制计时是否推进,g_update_flag控制显示是否刷新。注意g_ms_tick在中断里累加,主循环只读不写,所以不需要加锁。如果要在主循环里复位计时,直接写g_ms_tick = 0即可,但最好在running == 0时操作,避免和中断里的累加产生竞争。
3. 把工程跑起来:编译环境、下载配置与首次上电验证
代码逻辑理清楚之后,下一步是让它真的在板子上跑起来。这一步翻车最多,不是编译报错就是下载后没反应。下面按顺序把环境配置、编译选项、下载设置和上电验证过一遍。
3.1 编译环境搭建与工程文件结构确认
这份工程是标准的外设库工程,解压后能看到启动文件、外设驱动、用户代码和工程文件。常见做法是用 Keil MDK 或 IAR 打开,如果用的是 GCC 工具链,需要自己补 Makefile。打开工程前先确认三件事:器件型号是否和手头板子一致、晶振频率是否匹配、下载器配置是否正确。
| 检查项 | 常见值 | 不匹配的后果 |
|---|---|---|
| 器件型号 | STM32F103 系列 | 编译通过但下载后不运行 |
| 外部晶振 | 8MHz | 系统时钟不对,定时器周期全错 |
| 下载器 | ST-Link / J-Link | 无法识别目标芯片 |
| 调试接口 | SWD | 占用引脚少,推荐 |
器件型号在工程选项的 Device 页签里选,晶振频率在system_stm32f10x.c或时钟配置函数里改。如果板子用的是 12MHz 晶振而工程按 8MHz 配置,系统时钟会变成 108MHz 而不是 72MHz,定时器中断周期跟着变,秒表走得飞快。
3.2 下载配置与上电后第一眼该看什么
下载配置里最关键的是调试接口选择和复位方式。SWD 模式只需要两根线,比 JTAG 省引脚。下载前勾选「Reset and Run」,这样烧录完自动运行,不用手动按复位。
# 如果用的是 OpenOCD + GCC 工具链,下载命令大致如下 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/stopwatch.elf verify reset exit"这段命令做三件事:连接下载器、烧录 ELF 文件、校验并复位运行。verify会回读芯片内容做比对,防止烧录不完整。烧录成功后,板子上电第一眼应该看到显示模块亮起,显示00:00.00或类似格式。按下启停键,数字开始跳动;再按一次,数字停住;按复位键,归零。如果上电后屏幕全黑,先查背光引脚和供电,再查初始化代码里显示模块的片选和复位时序。
3.3 用示波器或逻辑分析仪验证时基精度
显示能跑不代表计时准。要确认 10ms 时基是否准确,最直接的办法是拿示波器或逻辑分析仪抓一个 GPIO 翻转信号。在定时器中断里加一行翻转代码,测出实际中断周期。
void TIM2_IRQHandler(void) { if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) { TIM_ClearITPendingBit(TIM2, TIM_IT_Update); GPIO_WriteBit(GPIOA, GPIO_Pin_0, \ (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_0))); g_ms_tick += 10; g_update_flag = 1; } }抓到的方波周期如果是 10ms,说明时基准确;如果偏差超过 1%,回去检查时钟配置和预分频值。没有示波器的话,可以用另一个定时器做输入捕获,或者用串口每隔一秒打印一次g_ms_tick,看累加值是否和实际时间对得上。串口打印会引入额外开销,只适合粗测。
4. 避坑与排查:秒表工程里最容易翻车的五个地方
这个工程本身不复杂,但正因为简单,很多人改着改着就出问题。下面五条是我在拆解和复现过程中实际遇到或见别人踩过的坑,按现象、原因、解决三段式写清楚。
4.1 计时越跑越慢或越跑越快
现象:秒表跑一段时间后,和手机秒表对比偏差越来越大。原因通常是中断里执行了耗时操作,比如刷显示、做浮点运算、调用printf,导致中断响应被推迟,累加值丢步。解决:中断里只做计数和置标志,所有耗时操作挪到主循环。另外检查系统时钟配置,晶振频率和工程设置不一致会直接改变定时器周期。
4.2 按键按一下跳好几下
现象:按一次启停键,秒表状态来回切换好几次。原因是按键机械抖动被多次识别,或者状态机里KEY_PRESSED状态没有正确过渡到KEY_RELEASE。解决:确认状态机每 10ms 调用一次,KEY_DEBOUNCE状态只等一个周期就确认。如果还跳,把消抖周期加到 20ms 或 30ms,但不要用delay阻塞。
4.3 下载后没反应,但编译零错误
现象:Keil 编译通过,下载也提示成功,板子就是不动。原因可能是启动文件选错、器件型号不匹配、或者下载后没有复位运行。解决:检查工程选项里的 Device 是否和板子一致,启动文件是否对应容量型号,下载配置里勾选「Reset and Run」。如果用的是 GCC,确认链接脚本里的 Flash 起始地址和容量正确。
4.4 显示数字闪烁或残影
现象:数码管或 LCD 上的数字一直闪,或者旧数字没清干净。原因是刷新频率太低或者没有做消隐。解决:把显示刷新放在主循环里,保证每 10ms 到 20ms 刷一次。数码管动态扫描的话,段选和位选之间加短延时,但别用长延时。LCD 的话检查是否每次刷新前清了区域。
4.5 复位后计时不清零
现象:按复位键,显示归零了,但再按启停键,数字从之前的值继续累加。原因是复位只清了显示变量,没清g_ms_tick。解决:复位操作里同时写g_ms_tick = 0和g_update_flag = 1,并且确保复位时running == 0,否则中断里的累加会和清零操作打架。
5. 从秒表到分段计时:一个可复用的计时框架改法
把基础秒表跑通之后,这个工程最大的价值在于它提供了一个可复用的计时框架。定时器时基、按键状态机、显示刷新这三块拆开来看,每一块都能单独拎出来用到别的项目里。我一般会在这个框架上做两件事:一是加分段计时,二是把计数值通过串口输出,方便做数据记录。
分段计时的思路是再加一个按键,按下时把当前g_ms_tick存到一个数组里,显示模块轮流显示总时间和分段值。数组长度按需定,常见做法是存 8 到 16 个分段。
#define SEG_MAX 8 uint32_t seg_time[SEG_MAX]; uint8_t seg_count = 0; void seg_record(void) { if (seg_count < SEG_MAX) { seg_time[seg_count++] = g_ms_tick; } }seg_time存的是绝对时间戳,显示分段成绩时做差值即可。seg_count防止数组越界,满了之后要么忽略要么覆盖最旧的值,看需求定。串口输出的话,在主循环里判断g_update_flag,每隔一定周期打印一次当前计数值。
if (g_update_flag) { g_update_flag = 0; printf("time: %lu ms\r\n", g_ms_tick); }printf重定向到串口需要实现fputc,这个在标准库工程里很常见。注意printf本身耗时,别放在中断里,也别每 10ms 打一次,改成 100ms 或 500ms 打一次,减少对主循环的影响。
验证改法是否成功,最直接的办法是拿手机秒表做对照:启动后同时计时,跑 60 秒看偏差是否在可接受范围内。分段计时的话,手动按几次分段键,检查显示的分段值是否递增且差值合理。串口输出可以用串口助手看时间戳是否连续。
从那以后我每次拿到一个能跑的工程,都强制先跑一遍「改一个参数、看一个现象」的流程,确认自己真的理解了它的运行链路,再动手加功能。这个秒表工程不大,但把定时器、中断、按键、显示这几样东西的配合关系讲清楚了,拿它当起点去改计时器、频率计或者 PWM 测量,心里都有底。希望帮到你。
本文还有配套的精品资源,点击获取