简介:这份资源是一套基于STM32的秒表程序工程,面向正在学习STM32定时器、按键与LCD显示的单片机初学者及课程设计者,可用于理解计时器中断、按键消抖与状态切换的完整实现思路。压缩包共140个文件,约2.69MB,包含34个c源文件与36个h头文件,覆盖定时器、RCC、LCD等外设驱动,另有Keil工程文件、编译中间文件及hex烧录文件,工程结构完整,可直接打开编译。程序最小计时单位为0.01秒,支持暂停与清零,keyup负责开始及继续,key0清零,key1暂停,逻辑清晰,几乎每个函数均有注释,便于读懂后移植到其他厂家开发板。目前已有2389人学习下载,适合作为秒表类小项目的参考模板,也可在此基础上扩展硬件功能、加深对STM32开发流程的理解。
1. 一块精英板上的秒表,为什么值得单独拆一遍
手里这块 STM32 精英板,买回来大半年一直躺在抽屉里吃灰,直到某天想给实验室的计时工装做个最小可用版本,才把它翻出来。压缩包里就一个工程,名字很直白——秒表。打开一看,正点原子的板级库、野火那套外设初始化风格都能对上号,核心逻辑不复杂:按键启停、数码管或 LCD 显示、定时器打时基。但恰恰是这种“看着简单”的项目最容易翻车,计时精度、按键抖动、显示刷新三件事凑一块,玄学问题就来了。这篇笔记面向两类人:刚拿到板子想跑通第一个完整工程的新手,以及想拿它当模板改造成计时器、倒计时器、计件器的熟手。下面从工程结构一路讲到怎么改,能抄的代码我都贴出来。
2. 工程结构与技术选型:一个秒表工程里到底装了什么
2.1 目录分层与文件职责
拿到一个 Keil 工程,先别急着点编译,先把目录看明白。这类基于正点原子精英板(STM32F103ZET6 为主控)的秒表工程,典型结构大致是这样:
秒表工程/ ├── CORE/ # 内核启动文件、core_cm3 相关 ├── HARDWARE/ # 板级外设驱动 │ ├── LED/ │ ├── KEY/ │ ├── TIMER/ │ └── LCD/ 或 SMG/ ├── SYSTEM/ # delay、sys、usart 系统级支撑 ├── USER/ # main.c、stm32f10x_it.c、工程配置 └── OBJ/ # 编译中间产物CORE 和 SYSTEM 基本是模板自带,不用动。真正要读的是 HARDWARE 下的 KEY、TIMER 和显示驱动,以及 USER/main.c。这种分层的好处是驱动和业务解耦:秒表的“计时状态机”写在 main 里,时基来自 TIMER,人机交互来自 KEY,显示走 LCD 或数码管,任何一层想换都不影响其他层。我第一次拆的时候没注意这点,直接把计时逻辑塞进定时器中断里,后来想加个“计次”功能,改得满目疮痍,血泪经验就是:中断只做计数,业务逻辑放主循环。
2.2 时基方案:定时器中断 vs 系统滴答
秒表的核心是时间从哪来。这个工程用的是通用定时器 TIMx 产生固定周期中断,中断里对一个毫秒计数器自增,主循环再把这个计数值换算成“分:秒:毫秒”显示。为什么不用 SysTick?SysTick 被 delay 函数占用了,共用会互相干扰,这是很多人第一次改就踩的坑。定时器配置的关键参数是预分频 PSC 和自动重装载 ARR,72MHz 主频下要出 1ms 中断,常见做法是 PSC=71、ARR=999,算下来 72M/(71+1)/(999+1)=1kHz,正好 1ms 一次。
// TIM3 初始化:72MHz -> 1ms 中断 void TIM3_Int_Init(u16 arr, u16 psc) { TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); // TIM3 挂 APB1 TIM_TimeBaseStructure.TIM_Period = arr; // 自动重装载值 TIM_TimeBaseStructure.TIM_Prescaler = psc; // 预分频 TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM3, &TIM_TimeBaseStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); // 开更新中断 TIM_Cmd(TIM3, ENABLE); NVIC_InitStructure.NVIC_IRQChannel = TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }参数说明:arr决定计数上限,psc决定分频系数,两者共同决定中断频率。调用时传TIM3_Int_Init(999, 71)即得 1ms。注意 TIM3 在 APB1 总线上,时钟使能要用RCC_APB1PeriphClockCmd,写成 APB2 是新手高频错误,编译不报错但中断永远不来。
2.3 中断服务函数与计数变量
中断里只做一件事:给毫秒计数器加一。变量必须用volatile修饰,否则编译器优化后主循环读到的可能是寄存器缓存值,显示永远不动——这个坑我调了半个下午。
volatile u32 g_ms_tick = 0; // 全局毫秒计数 volatile u8 g_running = 0; // 秒表运行标志 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) != RESET) { if (g_running) // 只有运行态才累加 { g_ms_tick++; } TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 必须清标志 } }逻辑说明:g_running把“计时”和“时基”分开,定时器一直跑,但只有按下启动键后才累加,这样暂停/继续不需要反复开关定时器,避免重新配置带来的误差。TIM_ClearITPendingBit漏写会导致中断反复触发,现象是程序像卡死一样,实际在疯狂进中断。
2.4 按键扫描与状态机
精英板一般有 KEY0/KEY1/KEY2 三个按键,工程里 KEY0 做启动/暂停,KEY1 做清零,KEY2 预留计次。按键扫描放主循环,用“检测按下—延时消抖—确认—等待释放”的经典流程,别在中断里做消抖延时,会拖垮整个系统。
u8 KEY_Scan(u8 mode) { static u8 key_up = 1; if (mode) key_up = 1; // 支持连按 if (key_up && (KEY0 == 0 || KEY1 == 0 || KEY2 == 0)) { delay_ms(10); // 消抖 key_up = 0; if (KEY0 == 0) return KEY0_PRES; if (KEY1 == 0) return KEY1_PRES; if (KEY2 == 0) return KEY2_PRES; } else if (KEY0 == 1 && KEY1 == 1 && KEY2 == 1) { key_up = 1; } return 0; }参数mode为 1 时支持长按连发,秒表的启停用 0 即可,防止一次按下触发两次状态翻转。主循环里根据返回值切换g_running和清零g_ms_tick,状态机就三行代码,但顺序不能乱:先判清零再判启停,否则清零瞬间可能又被启动。
3. 从编译到跑通:把工程烧进板子的完整步骤
3.1 环境准备与工程打开
需要 Keil MDK(5.x 即可)和对应的 STM32F1 器件包,串口驱动装 CH340 或板载下载器对应的驱动。解压后找到USER目录下的.uvprojx文件双击打开。打开后先看两处:Options for Target 里的 Device 是否选中 STM32F103ZE,Debug 页是否选对下载器(ST-Link 或 J-Link)。这两项不对,后面全是白忙。
3.2 编译与常见报错处理
直接按 F7 编译。如果报cannot open source input file "stm32f10x.h",说明头文件路径没配,去 C/C++ 页的 Include Paths 把 CORE、SYSTEM、HARDWARE 各子目录都加进去。如果报重复定义,多半是某个.c被加了两次到工程树,删掉多余的即可。编译通过后看 Build Output 的 Code/RO/RW 大小,秒表工程一般 Code 十几 KB,Flash 完全够用。
3.3 下载与运行验证
接好下载器,点 Download(F8)。烧录成功后复位,LCD 或数码管应显示00:00:000。按 KEY0 开始走时,再按暂停,按 KEY1 归零。验证精度可以拿手机秒表对 60 秒,误差在几十毫秒内属正常,因为显示刷新和中断响应都有开销。如果显示不动,先查volatile,再查中断是否真的进了(在中断里翻转一个 LED 最快)。
提示:下载后没反应,先确认板子供电和 BOOT0/BOOT1 跳线在 Flash 启动位置,这两个硬件细节比代码更容易出问题。
4. 核心代码拆解:计时、显示与状态切换的三条链路
4.1 毫秒值到“分:秒:毫秒”的换算
计数器只有一个g_ms_tick,显示层负责拆解。这里有个细节:毫秒位要取模 1000,秒位要先除 1000 再取模 60,分位直接除 60000。顺序写反,显示就会跳变。
void Show_Time(u32 ms) { u16 min = ms / 60000; u8 sec = (ms / 1000) % 60; u16 msec = ms % 1000; LCD_ShowNum(30, 120, min, 2, 16); // 分,2 位 LCD_ShowChar(54, 120, ':', 16, 0); LCD_ShowNum(66, 120, sec, 2, 16); // 秒,2 位 LCD_ShowChar(90, 120, ':', 16, 0); LCD_ShowNum(102, 120, msec, 3, 16); // 毫秒,3 位 }参数说明:前两个参数是屏幕坐标,第三个是数值,第四个是显示位数,最后是字号。位数不够会截断高位,比如分钟超过 99 就只显示后两位,长时间计时场景要把分钟位改成 3 位或加小时位。
4.2 主循环的调度顺序
主循环不是随便写的,顺序影响体验。合理顺序是:先扫按键改状态,再根据状态决定是否刷新显示,最后留一点余量给其他任务。显示刷新不要太频繁,每 10ms 刷一次足够,刷太快 LCD 会闪,也浪费 CPU。
int main(void) { u8 key; delay_init(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); KEY_Init(); LCD_Init(); TIM3_Int_Init(999, 71); while (1) { key = KEY_Scan(0); if (key == KEY0_PRES) g_running = !g_running; // 启停切换 if (key == KEY1_PRES) { g_running = 0; g_ms_tick = 0; } // 清零并停 Show_Time(g_ms_tick); delay_ms(10); // 控制刷新节奏 } }这段代码里g_running = !g_running是状态翻转,比写 if-else 简洁。清零时先停再清,避免清零后中断又加一,显示变成 001 而不是 000。
4.3 显示驱动的接口约定
如果工程用的是 LCD,底层是 FSMC 驱动 ILI9341 一类控制器;如果是数码管,则是 GPIO 段选位选加动态扫描。两种实现的业务层接口要统一成Show_Time(u32 ms),这样换显示方案只改这一个函数。我一般会在显示驱动里再包一层缓冲,先把要显示的数字算好再统一刷,减少闪烁。数码管方案还要注意动态扫描的频率,低于 50Hz 会看到明显抖动,放在定时器里扫比放主循环稳。
5. 避坑与排查:秒表工程里最容易翻车的五件事
5.1 计时越走越慢或越走越快
现象:对手机秒表,跑十分钟差好几秒。原因:中断里做了耗时操作,或者主循环里关了中断导致丢 tick。解决:中断只留自增和清标志,显示换算全部挪到主循环;检查是否有其他高优先级中断长时间占用 CPU。
5.2 暂停后时间还在涨
现象:按了暂停,显示仍在跳。原因:g_running没加volatile,或者中断里判断的是局部副本。解决:全局标志加volatile,中断和主循环引用同一个变量,编译优化级别高时尤其要注意。
5.3 按键一次触发两次
现象:按一下启动,松手又停了。原因:消抖延时太短或没等释放就返回。解决:KEY_Scan里保留key_up静态标志,确认释放后才允许下一次触发;机械按键抖动一般 5~20ms,10ms 延时是折中值。
5.4 显示闪烁或残影
现象:数字刷新时整屏抖。原因:刷新频率过高或每次全屏重绘。解决:只刷变化的数字区域,刷新周期控制在 10~20ms;数码管方案检查位选切换是否有消隐。
5.5 下载后程序不跑
现象:烧录成功但无显示。原因:BOOT 跳线不对、复位电路没触发、或者时钟配置里外部晶振起振失败。解决:先确认跳线,再用调试器单步看是否停在SystemInit,晶振问题可临时切内部 HSI 验证。
6. 二次开发:把它改成倒计时器和计件器的改造思路
秒表跑通只是起点,真正有价值的是拿它当计时类项目的底座。改造方向我按改动量从小到大排:第一,加“计次”功能,按 KEY2 把当前g_ms_tick存进一个数组,显示区多一行列出每次分段时间,改动只在主循环加一个数组和索引,不动底层。第二,改倒计时,把g_ms_tick++换成g_ms_tick--,初值由按键设定,归零时蜂鸣器或 LED 报警,注意无符号数减到 0 再减会回绕成极大值,判断要写if (g_ms_tick > 0) g_ms_tick--;。第三,改计件器,把时基换成外部脉冲计数,用定时器的外部时钟模式或外部中断计数,显示层复用Show_Time的拆位逻辑即可。
验证改造是否成功,我习惯用“双轨对照”:一边跑改造版,一边用手机或标准计时器,固定跑 5 分钟看偏差;再人为制造边界,比如倒计时到 0、计次超过数组容量、按键长按 10 秒,看会不会死机或数组越界。下面这个片段是倒计时改造里最容易写错的地方:
// 倒计时递减,防止无符号回绕 if (g_running && g_ms_tick > 0) { g_ms_tick--; if (g_ms_tick == 0) { g_running = 0; BEEP = 0; // 到点报警,低电平触发视硬件而定 } }参数和逻辑都简单,但g_ms_tick > 0这个判断不能省,省了就是经典的无符号下溢。另外报警后要主动停表,否则下一个 tick 又把它减回最大值,现象是报警响一下然后显示乱跳。
还有一个容易被忽略的点:如果改成倒计时,分钟位的显示位数要重新算,99 分钟不够用就加小时位,Show_Time里的除法和取模链要同步改,别只改显示不改换算,否则数字对不上。我一般改完这类工程,会强制走一遍“清零—启动—暂停—再启动—到点”的完整状态序列,每个状态都截图存档,下次改坏了有后悔药可吃。从那以后我每次动计时类工程,都先把状态机画在纸上再写代码,省下的调试时间远比画图多。希望帮到你。
本文还有配套的精品资源,点击获取