☰
基于STM32的秒表设计:定时器、状态机与按键消抖实战
2026/10/30 5:27:28 网站建设 项目流程

简介:这份资源是一套基于STM32的秒表程序工程,面向正在学习STM32定时器、按键与LCD显示的单片机初学者及进阶开发者,可用于课程实验、自学练手或作为二次开发的基础模板。程序实现0.01秒精度的计时、暂停与清零功能,keyup负责开始与继续,key1负责暂停,key0负责清零,整体思路清晰,换用其他厂家开发板只需少量修改即可运行。压缩包共140个文件,约2.69MB,以34个c源文件与36个h头文件为核心,配合Keil工程文件、编译中间产物及hex烧录文件,覆盖定时器、LCD、RCC等外设驱动模块,目录结构完整,可直接打开工程编译验证。目前已有2389人学习下载。作者几乎为每个函数都添加了注释,读者可据此理解计时逻辑与按键消抖处理,并在此基础上扩展硬件功能、优化显示效果,适合作为嵌入式入门的参考工程。

1. 一块精英板上的秒表,为什么值得单独拆一遍

手头这块 STM32 精英板在抽屉里躺了大半年,直到有人问「能不能做个能暂停、能计次、精度到 10ms 的秒表」,我才把它翻出来。秒表看着简单,真写起来全是细节:定时器怎么配、按键怎么消抖、数码管还是 LCD 怎么刷、暂停后时间怎么保持。这份「精英板秒表」资源就是把这些细节一次性落地的完整工程,基于 STM32F103 系列主控,配套正点原子、野火两套常见开发板的引脚习惯,适合刚学完 GPIO 和定时器、想找一个能跑通又能改的中等体量项目练手的开发者。它不追求花哨界面,胜在逻辑闭环完整——计时、暂停、复位、计次四个动作全走通,代码结构清晰到可以当模板抄。

2. 工程结构与技术选型:从时钟树到显示刷新的一条链

2.1 目录怎么分层,为什么这么分

拿到一个 STM32 工程,我第一件事不是看 main,而是看目录。这份秒表工程按「硬件抽象—业务逻辑—应用入口」三层铺开,典型结构如下:

Stopwatch/ ├── Core/ │ ├── Inc/ // 头文件:stopwatch.h、key.h、display.h │ └── Src/ │ ├── main.c // 初始化 + 主循环调度 │ ├── stopwatch.c // 计时核心状态机 │ ├── key.c // 按键扫描与消抖 │ └── display.c // 显示驱动与刷新 ├── Drivers/ │ ├── CMSIS/ // 内核头文件 │ └── STM32F1xx_HAL_Driver/ // HAL 库 ├── MDK-ARM/ // Keil 工程文件 └── Stopwatch.ioc // CubeMX 配置

这种分法的好处是改显示不动计时、换按键不动主循环。很多新手把所有代码堆在 main.c 里,改一个按键逻辑要翻三百行,血泪经验就是别省这一步。业务层只暴露Stopwatch_Start/Stop/Reset/Lap四个接口,底层怎么实现上层不关心,后面换 OLED 屏只改 display.c 一个文件。

2.2 定时器选型:为什么用 TIM 做时基而不是 SysTick

秒表的核心是一个稳定时基。SysTick 被 HAL 库的HAL_Delay占着,拿来做 10ms 计时会和延时函数打架,常见做法是单独开一个通用定时器 TIM3 做 10ms 中断。STM32F103 主频 72MHz,预分频和重装载值这样算:

// TIM3 配置为 10ms 中断一次 // 72MHz / (7199+1) = 10kHz 计数频率 // 10kHz 下计 100 个数 = 10ms htim3.Init.Prescaler = 7199; htim3.Init.Period = 99; HAL_TIM_Base_Start_IT(&htim3);

Prescaler决定计数频率,Period决定溢出周期,两者相乘再除以主频就是中断间隔。这里 7200×100/72M = 10ms,正好是秒表最小显示单位。中断里只做一件事:给一个 volatile 的毫秒计数器加 10,绝不在中断里刷屏或做浮点运算,否则主循环会被拖慢。

2.3 计时状态机:暂停和计次怎么不打架

秒表最容易翻车的地方是状态管理。运行中按暂停、暂停中按计次、复位后计次清不清,这些组合不画清楚就会出玄学 bug。工程里用一个枚举加一个结构体把状态收拢:

typedef enum { SW_IDLE, // 复位态,显示 00:00.00 SW_RUNNING, // 计时中 SW_PAUSED // 暂停,时间冻结 } SW_State; typedef struct { SW_State state; uint32_t elapsed_ms; // 累计毫秒 uint32_t lap_ms[8]; // 最多存 8 次计次 uint8_t lap_count; } Stopwatch;

主循环根据state决定按键响应:RUNNING 下只认暂停和计次,PAUSED 下只认继续和复位。elapsed_ms在定时器中断里累加,暂停时状态切走、中断里判断状态再决定加不加,这样暂停后时间自然冻结,不需要额外保存快照。计次就是把当前elapsed_ms塞进数组,超过 8 次覆盖最早一条,够用且不占内存。

3. 核心模块拆解:按键消抖、显示刷新与主循环调度

3.1 按键扫描:20ms 消抖为什么比延时靠谱

机械按键抖动是秒表第一个坑。按下瞬间电平会抖 5~20ms,直接读会一次按下触发好几次。工程里用「定时扫描 + 状态确认」而不是HAL_Delay(20)死等:

// 每 10ms 调用一次,连续两次读到相同电平才认 uint8_t Key_Scan(void) { static uint8_t last = 0xFF; uint8_t now = 0; if (HAL_GPIO_ReadPin(KEY_PORT, KEY_PIN) == GPIO_PIN_RESET) now = KEY_PRESSED; if (now != last) { // 电平变化,先记下不响应 last = now; return KEY_NONE; } if (now == KEY_PRESSED && last == KEY_PRESSED) { last = 0xFF; // 消费掉这次按下,防连发 return KEY_PRESSED; } return KEY_NONE; }

逻辑是「变化不响应、稳定才响应、响应后清状态」。last记录上次电平,第一次读到按下只更新不返回,第二次仍按下才确认,天然滤掉抖动。放在 10ms 定时器里调用,等于自带 10ms 采样周期,比在主循环里HAL_Delay阻塞优雅得多。参数上KEY_PORT/KEY_PIN按板子实际接线改,正点原子精英板一般是 PE3/PE4 这类,野火板引脚不同,改宏定义即可。

3.2 显示刷新:只刷变化的位,别整屏重画

显示部分用 4 位数码管或 1602 LCD 都行,工程默认走数码管动态扫描。动态扫描的坑是刷新频率:太低会闪,太高占 CPU。常见做法是 1ms 切一位、4 位一轮 4ms,人眼完全看不出闪烁。核心是「时间变了才更新缓冲区」:

void Display_Update(uint32_t ms) { static uint32_t last_ms = 0xFFFFFFFF; if (ms == last_ms) return; // 没变不刷,省 CPU last_ms = ms; uint8_t min = ms / 60000; uint8_t sec = (ms / 1000) % 60; uint8_t cs = (ms / 10) % 100; // 百分秒 Seg_SetDigit(0, min / 10); Seg_SetDigit(1, min % 10); Seg_SetDigit(2, sec / 10); Seg_SetDigit(3, sec % 10); // 百分秒另起一组或复用,按实际位数调整 }

ms / 60000取分钟、% 60取秒、/ 10 % 100取百分秒,这套换算记住就不会错。last_ms做脏检查,时间没动就不重画,主循环空转时 CPU 占用能降一大截。如果换 OLED,把Seg_SetDigit换成字库绘制函数,接口不变。

3.3 主循环调度:一个 while 里怎么排优先级

主循环不写业务,只做调度。顺序很讲究:先扫按键(响应要快),再刷显示(视觉要稳),最后处理计次这类低频动作:

while (1) { uint8_t key = Key_Scan(); if (key == KEY_PRESSED) { switch (sw.state) { case SW_IDLE: Stopwatch_Start(&sw); break; case SW_RUNNING: Stopwatch_Pause(&sw); break; case SW_PAUSED: Stopwatch_Resume(&sw); break; } } if (Key_LongPress()) Stopwatch_Reset(&sw); // 长按复位 Display_Update(sw.elapsed_ms); HAL_Delay(1); // 让出一点时间,避免空转占满 }

按键处理走状态机分支,长按复位单独判断,避免短按误触清零。HAL_Delay(1)不是必须,但给调度留个节拍,配合 10ms 定时器中断,整体节奏就稳了。这里的关键认知是:中断负责「记时间」,主循环负责「读按键、刷显示」,两边通过volatile变量通信,职责不混。

4. 跑起来与改起来:编译烧录、参数调整与常见问题排查

4.1 从零编译烧录的完整步骤

工程用 Keil MDK 打开MDK-ARM/Stopwatch.uvprojx,首次编译前确认三件事:器件型号选对(STM32F103 对应具体容量)、调试器选 ST-Link 或 J-Link、时钟配置和板子晶振一致(精英板一般 8MHz 外部晶振)。编译通过后直接下载,复位即可看到数码管从 00:00.00 开始。如果用的是 CubeMX 重新生成,注意别覆盖自己写的stopwatch.c,生成设置里把用户代码放在USER CODE BEGIN/END之间。

4.2 想改精度、改显示、加功能,动哪里

改精度:把 TIM3 的Period从 99 改成 9,中断变 1ms,elapsed_ms每次加 1,显示就能到毫秒级,但要注意数码管位数够不够。改显示:只动display.c,把Seg_SetDigit换成 LCD 或 OLED 的绘制接口,Display_Update的换算逻辑原样保留。加计次回看:lap_ms数组已经存了历史,加一个按键在 PAUSED 态循环翻页显示即可,不用改计时核心。这三类改动互不牵连,正是分层带来的好处。

4.3 避坑清单:五个真实踩过的坑

现象一:时间走得比实际快或慢。原因多半是外部晶振没起振,HAL 回退到内部 RC,主频不准。解决:检查SystemClock_Config里时钟源,用示波器或 MCO 引脚输出时钟确认 72MHz。

现象二:按一次键触发两次。消抖逻辑写成了「读到按下就返回」,没做二次确认。解决:按 3.1 的两级确认改,确保响应后清last状态。

现象三:暂停后时间还在涨。中断里无条件累加elapsed_ms,没判断状态。解决:中断里加if (sw.state == SW_RUNNING)再累加。

现象四:数码管某一位常亮或全暗。位选和段选引脚定义和实际接线对不上,或动态扫描频率太低导致视觉残留。解决:对照原理图核对引脚宏,扫描周期控制在 4ms 一轮。

现象五:下载后没反应。多半是启动文件或中断向量表没匹配容量,或 BOOT 引脚状态不对。解决:确认器件型号、检查 BOOT0 接地、重新全片擦除再下载。

5. 二次开发:把秒表改成可复用的计时组件

这份工程最大的价值不是秒表本身,而是那套「定时器时基 + 状态机 + 分层显示」的骨架。我一般会把它抽成一个独立组件:stopwatch.c/h只依赖一个「获取当前毫秒」的回调,不直接绑 TIM3,这样换到任何有系统滴答的平台上都能用。改造时把elapsed_ms的累加从定时器中断挪到Stopwatch_Tick(uint32_t delta_ms)接口里,主循环或 RTOS 任务周期性调用,计时核心就和硬件解耦了。

验证方法很直接:写一个测试函数,手动喂Stopwatch_Tick(10)一百次,断言elapsed_ms == 1000;再模拟 Start→Pause→Resume 序列,检查暂停期间 tick 不累加。这种纯逻辑测试不需要上板,在 PC 上编译跑一遍就能挡住大部分状态机 bug。参数上建议把最大计次从 8 提到 16 或做成环形缓冲,lap_ms用uint32_t存毫秒足够跑 49 天不溢出。

改造点原实现建议改法影响范围
时基来源绑死 TIM3 中断回调或 Tick 接口注入stopwatch.c
显示介质数码管动态扫描抽象 Draw 接口display.c
计次容量固定 8 条环形缓冲可配结构体 + 显示
精度10ms1ms(改 Period)定时器配置

从那以后我每次接计时类需求,都强制先把状态机枚举和 Tick 接口定下来再写一行显示代码,返工率明显下降。这套骨架你拿去改个倒计时、跑表、甚至简单的运动计时都不费劲,希望帮到你。

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

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

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

立即咨询