简介:基于STM32的秒表项目源码包,面向嵌入式初学者与STM32开发者,演示如何利用外部中断与定时器实现带启动、暂停、归零功能的秒表,并可在TFTLCD屏上精确显示至0.01秒。工程以正点原子Mini开发板和标准库例程为模板,加入外部中断模块,三个按键分别触发标志位,在定时器中断中完成计时控制。包内含完整MDK工程文件,共203个文件,包括C源码、头文件、编译生成的中间文件以及烧录hex等,压缩包仅6.76MB,便于下载与直接打开查看。资料目前已有435人学习浏览,适合作为入门中断编程和简单人机交互的参考项目。需要说明的是,受定时器精度限制,该秒表约一分钟慢0.5秒,适合学习原理,不建议用于精确计时场景。
1. 基于STM32的秒表源码:外部中断、定时器中断与LCD刷新之间的时序博弈
一个看起来只需要"计个数、显示一下"的秒表项目,真正跑起来之后,误差和卡顿往往出在三个地方:按键是轮询还是中断、定时器周期设多短、LCD刷新放在哪里。这套基于STM32的秒表项目源码,把启动、暂停、归零三个按键分别挂在外部中断(EXTI)上,再靠定时器中断里的状态机翻转计数,最后在TFTLCD上显示到0.01秒。工程以正点原子STM32Mini开发板和TFTLCD标准库例程为模板,用KEIL MDK V5.38编译。作者在说明里自曝了它一分钟慢0.5秒左右的精度问题——这个数字反而比"精度完美"更值得研究,因为它直接指向了定时器中断服务时长和重装载值取整这两件事。下面从外部中断这条链路开始拆。
2. 按键外部中断:从GPIO上拉到EXTI触发器的完整链路
2.1 为什么选外部中断而不是扫描轮询
秒表的使用场景决定了按键响应必须以"事件"而不是"电平"的方式处理。按钮按下是一个下降沿,如果放在主循环里轮询,主循环中一旦被LCD刷新这类耗时操作拖住,按键响应就可能延迟几个毫秒甚至十几毫秒。在显示到0.01秒(10ms)的刻度下,一次10ms的按键延迟就直接反映成计数跳动一位。
外部中断的方案意味着按键跳变由硬件电路捕捉,触发后CPU暂停当前指令流,进入对应的服务函数。在使用标准外设库(StdPeriph Library)开发时,这个流程非常顺:GPIO配成输入,EXTI线配成下降沿触发,NVIC分配中断优先级,剩下的就是在中断函数里置位标志位、在定时器中断里消费标志位。这类设计在正点原子例程基础上改,改动面最小,也是把这个项目拿来练手外部中断模块的最佳入口。
2.2 按键引脚、GPIO模式与EXTI线映射
2.2.1 GPIO初始化与上拉选择
正点原子STM32Mini开发板的按键分别接在PE2、PE3、PE4,按下接地,平时悬空。标准做法是把引脚配置成上拉输入(GPIO_Mode_IPU),没有按下时读到高电平,按下瞬间被拉到低电平,硬件上就形成了一个下降沿。
// bsp_key.c 按键GPIO初始化 void KEY_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOE, ENABLE); // PE端口时钟 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_2 | GPIO_Pin_3 | GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入 GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; // 输入模式不关注速度 GPIO_Init(GPIOE, &GPIO_InitStructure); }这段代码把三个按键放在同一个GPIO端口上做一次组初始化。GPIO_Mode_IPU是上拉输入模式,内部弱上拉电阻保证浮空引脚有确定电平;GPIO_Speed在这里不参与输入采样,保留它只是因为标准库的初始化结构体需要填充完整。上拉阻值大约在30kΩ到50kΩ,对按键这种直流电平信号没有任何时序压力,抖动波形会被后续的软件处理吸收。
2.2.2 EXTI线映射与NVIC优先级分配
GPIO本身不产生中断,必须把引脚映射到EXTI线上。STM32的每条EXTI线可以对应一组固定引脚,PE2、PE3、PE4分别对应EXTI2、EXTI3、EXTI4,通过GPIO_EXTILineConfig完成映射,然后配置触发方式和NVIC:
void KEY_EXTI_Config(void) { EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE); // AFIO时钟,EXTI映射必须开 GPIO_EXTILineConfig(GPIO_PortSourceGPIOE, GPIO_PinSource2); GPIO_EXTILineConfig(GPIO_PortSourceGPIOE, GPIO_PinSource3); GPIO_EXTILineConfig(GPIO_PortSourceGPIOE, GPIO_PinSource4); EXTI_InitStructure.EXTI_Line = EXTI_Line2 | EXTI_Line3 | EXTI_Line4; EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt; // 中断模式,不是事件模式 EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling; // 下降沿触发 EXTI_InitStructure.EXTI_LineCmd = ENABLE; EXTI_Init(&EXTI_InitStructure); NVIC_InitStructure.NVIC_IRQChannel = EXTI2_IRQn; // EXTI3/EXTI4 同样配置 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2; NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2; NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; NVIC_Init(&NVIC_InitStructure); }三个EXTI中断通道的优先级分配,要与后文定时器中断做统一考虑。这个工程中按键中断只做置位操作,执行时间在几百ns以内;定时器中断负责计数累加,只在运行态追加一条自增指令。两者都放在抢占优先级2、子优先级2是合理的——即便启动键按下瞬间与定时器中断同时到来,先处理谁都不会影响累计值,最多让某次按键事件延后一个中断周期。
2.3 中断服务函数:把引脚状态翻译成逻辑事件
外部中断服务函数不能直接操作计数变量,尤其是"清零"。如果清零动作发生在定时器中断计数过程中间,sw_count = 0赋值会被下一个tick覆盖,出现"每次清零后马上又变成1"的怪现象。正确做法是中断里只置位一个全局标志,真正的状态变更交给定时器中断统一处理:
// stm32f10x_it.c 外部中断服务函数 volatile uint8_t key_flag_start = 0; volatile uint8_t key_flag_pause = 0; volatile uint8_t key_flag_reset = 0; void EXTI2_IRQHandler(void) // 归零键 { if (EXTI_GetITStatus(EXTI_Line2) != RESET) { key_flag_reset = 1; // 只标记,不处理 EXTI_ClearITPendingBit(EXTI_Line2); } } void EXTI3_IRQHandler(void) // 启动键 { if (EXTI_GetITStatus(EXTI_Line3) != RESET) { key_flag_start = 1; EXTI_ClearITPendingBit(EXTI_Line3); } } void EXTI4_IRQHandler(void) // 暂停键 { if (EXTI_GetITStatus(EXTI_Line4) != RESET) { key_flag_pause = 1; EXTI_ClearITPendingBit(EXTI_Line4); } }三个标志位都需要用volatile修饰,因为它们会在外部中断上下文和定时器中断上下文之间共享。清除挂起位必须放在读取状态之后,否则按键抖动产生的重复中断会重入服务函数,导致标志位被重复置位。这个工程没有做定时器扫描消抖,对秒表来说一次误触发最多导致一次状态误切换,直接按归零键就能恢复,属于学习项目里可以接受的妥协。
那么标志位在哪儿被消费?入口在定时器中断的状态机里。
3. 定时器时基与计时状态机:启动、暂停、清零的边界条件
3.1 定时器选型与时基参数计算
秒表的最小显示单位是0.01秒,也就是10ms。定时器中断周期的选择直接影响显示粒度和误差累积。如果每1ms中断一次,计数变量每100次折算成1秒,显示刷新更平滑,但中断频率更高、主循环被打断的次数更多;如果每10ms中断一次,一次中断就是1个厘秒,状态机逻辑更直观。这个工程采用后者。
用TIM3做时基,72MHz系统主频下,预分频器PSC设为719,计数器时钟变成100kHz;自动重装载值ARR设为999,更新事件周期就是10ms。参数关系如下:
| 参数 | 计算式 | 本例取值 | 结果 |
|---|---|---|---|
| 定时器输入时钟 | 来自APB1,倍频后 | 72MHz | — |
| 预分频器 PSC | 实际分频系数 = PSC + 1 | 719 | 100kHz |
| 自动重装载值 ARR | 周期 = (ARR + 1) / 计数时钟 | 999 | 10ms |
| 软件累加步进 | 一次更新事件 | 1 | 1厘秒 |
注意一个新手高频翻车点:标准库函数里写入寄存器的就是传入值,TIM_TimeBaseInit不会替你执行减1。写PSC=719,实际分频系数是720;写ARR=999,计数器从0计到999才溢出,这才是1000个计数周期。少写一个1,周期会从10ms直接变成9.99ms,误差率瞬间到0.1%,比作者的0.83%还要难排查。
3.2 计数状态机:三个状态下各自做什么
秒表有复位、运行、暂停三个状态。按键事件改变状态,定时器中断根据状态决定是否累加计数。状态定义如下:
// stopwatch.h typedef enum { SW_STATE_RESET = 0, // 归零状态 SW_STATE_RUN, // 运行状态 SW_STATE_PAUSE // 暂停状态 } SW_State; extern volatile SW_State sw_state; // 当前状态 extern volatile uint32_t sw_count; // 厘秒计数,100 = 1秒 extern volatile uint8_t lcd_refresh_request; // 显示刷新请求在TIM3中断服务函数中,先消费按键标志位,再根据状态累加计数。处理顺序是这里的关键:清零必须在累加之前完成。否则用户按下归零键的瞬间,定时器中断已经完成"RUSN状态下进入累加"的判断,归零后计数又立刻加一,LCD上会出现"清不掉最后一个数"的错觉。
// 在 TIM3_IRQHandler 中 void TIM3_IRQHandler(void) { if (TIM_GetITStatus(TIM3, TIM_IT_Update) == RESET) return; TIM_ClearITPendingBit(TIM3, TIM_IT_Update); // 第一步:消费按键事件,统一在这里改状态 if (key_flag_start) { key_flag_start = 0; if (sw_state == SW_STATE_RESET) { sw_state = SW_STATE_RUN; // 仅复位态下启动键有效 } } if (key_flag_pause) { key_flag_pause = 0; if (sw_state == SW_STATE_RUN) { sw_state = SW_STATE_PAUSE; // 仅运行态下暂停键有效 } } if (key_flag_reset) { key_flag_reset = 0; sw_count = 0; // 计数清零 sw_state = SW_STATE_RESET; // 回到初始状态 } // 第二步:状态机累加 if (sw_state == SW_STATE_RUN) { sw_count++; } // 第三步:请求显示刷新,不在中断里直接刷屏 lcd_refresh_request = 1; }这段代码体现了外部中断与定时器中断的协作范式:外部中断只负责把"按键被按下"翻译成一个置位操作,定时器中断在每个时基到达时统一处理标志位并推进状态机。所有对sw_count的修改都串行化在TIM3中断上下文中,不存在数据竞争,也不需要关中断保护临界区。
3.3 边界条件:运行中再启动、暂停后清零、复位后自动停
按键的物理顺序不一定符合预期,状态机要把所有非法组合收敛到合法状态。例如运行中再次按下启动键,key_flag_start被置1时sw_state不是SW_STATE_RESET,状态不变,不会出现"按一下启动就从0重跑"的歧义。暂停后按启动同样不反应,需要先归零再启动。这个设计比"暂停后可继续"更简单,也避免了暂停—启动—暂停之间误操作造成的计数错乱。
再看运行中按下归零:直接清零并回到RESET状态,计数值归零、状态停摆。暂停后归零的行为也一样。代码里没有为这些边界组合定义错误提示,LCD上表现为数字变为00.00,符合直觉。如果你要基于这个项目做毕设或二次开发,可以在状态机入口加一个switch包装,把非法组合显式丢弃并记录日志,方便演示时讲清楚设计意图。
4. TFTLCD显示与误差实测:0.01秒刷新率下的瓶颈与修正
4.1 LCD显示驱动接入与坐标规划
TFTLCD例程提供的LCD_ShowString、LCD_ShowNum、LCD_Fill等接口,内部封装了FSMC总线时序,直接按坐标写显存。秒表界面只显示一行时间,安排在屏幕中部即可。坐标规划上,秒的整数位、小数点、厘秒位分别固定在三段区域,避免字符串整体输出时坐标计算混乱。
刷新时机由主循环消费lcd_refresh_request标志位来驱动。这里有一个工程上的硬约束:LCD刷屏动作绝不能放进定时器中断。LCD_ShowString写一串字符需要数百微秒到数毫秒,一旦放进10ms周期的TIM3中断里,中断服务时间占掉周期的20%以上,计数值会明显变慢——作者提到的一分钟慢0.5秒,如果有人在移植时把刷新挪进了中断,误差会放大到几秒甚至几十秒。
// main.c 主循环 int main(void) { /* 系统时钟、GPIO、EXTI、TIM3、LCD 初始化省略 */ LCD_ShowString(40, 100, 200, 16, 16, "STOPWATCH"); while (1) { if (lcd_refresh_request) { lcd_refresh_request = 0; Stopwatch_Display(); // 只在主循环中刷屏 } } }主循环的空转查询在这个场景下够用。如果你还想加LED闪烁或蜂鸣器提示,可以继续在这个循环里加状态判断,不会跟刷新冲突。lcd_refresh_request每10ms被置位一次,主循环的执行时间远小于10ms,不会出现漏刷。
4.2 时间格式化的坑:sprintf与整数拆分
很多初学者会想着把sw_count转成浮点数再显示,比如seconds = sw_count / 100.0。但在标准库下,浮点格式化sprintf会引入较大的代码体积和运行开销,而且在C库的printf系函数未重定向时,浮点支持默认是关闭的,链接时直接报错。更稳妥的是整数拆分加补零:
// display.c 秒表显示函数 void Stopwatch_Display(void) { uint16_t sec_part = sw_count / 100; // 秒整数部分 uint16_t cent_part = sw_count % 100; // 厘秒部分 LCD_Fill(40, 120, 160, 136, WHITE); // 清空旧显示区域,避免残影 LCD_ShowNum(40, 120, sec_part, 2, 16); // 两位秒数 LCD_ShowChar(64, 120, '.', 16); // 小数点 LCD_ShowNum(72, 120, cent_part, 2, 16); // 两位厘秒 }LCD_ShowNum要求传入最大位数;厘秒部分99以内,传2位正好。注意LCD_ShowNum输出的是无符号数,如果未来要显示负向倒计时,需要先取绝对值再拆位。这个函数每次刷新约需1ms到3ms,取决于屏幕分辨率和FSMC时序,放到主循环里完全无感。
4.3 一分钟慢0.5秒:误差从哪来
作者源码里给出的误差是"一分钟慢0.5秒左右"。按这个数字折算,实际定时器中断周期不是10ms,而是约10.0833ms。偏差率0.833%,已经超出了无源晶振30ppm的正常误差范围,说明问题主要不在晶振,而在两个可量化的地方:
第一是中断服务函数的进出与执行时间。虽然第三步只做了一次标志位赋值,CPU自动压栈、跳转、出栈的开销每次都存在,累计起来就是可测的偏差。第二是自动重装载值取整。TIM3把10ms映射成720000个时钟周期,这个数字能整除,取整损耗几乎为零;但如果分频系数或ARR刻意取整,误差会直接叠加。
针对固定负载,ARR补偿是一个直接手段。实测周期10.0833ms与目标10ms的比例是0.9917,把ARR从999改为990,理论周期变成99100us,即9.91ms,结果是每分钟快约0.54秒。误差方向反转,数值变小但没有消除。这种只改一处配置的办法适合演示,不适合做真正计时器,下一章说软件补偿。
5. 让秒表走得更准:软件补偿校准与KEIL工程排错
5.1 不碰硬件的计数补偿法
既然每分钟固定慢0.5秒,就可以在软件里"定期吞掉"多出来的脉冲:定时器中断每分钟产生6000次,慢0.5秒等于多计了50个10ms周期。只要每120次中断跳过1次累加,即6000/120=50次,就能把滞后补平。
#define COMP_INTERVAL 120 // 每120次中断补偿1次,约1.2秒 // TIM3中断累加部分,替代原来的 sw_count++ void Stopwatch_Tick(void) { static uint8_t comp_cnt = 0; comp_cnt++; if (comp_cnt >= COMP_INTERVAL) { comp_cnt = 0; // 跳过本次累加,相当于把多出来的50个tick吃掉 } else { sw_count++; } }补偿间隔要通过实测确定,不要照搬120。方法是拿秒表跑300秒,记录显示值与真实值之差,假设显示慢了2.5秒,对应少显示250个厘秒,即实际中断比理想多250次。300秒内共30000次中断,补偿间隔就是30000/250=120。这个思路等价于在整数域做比例修正,不改变定时器硬件配置,也不引入浮点。如果误差方向变成"快",则反过来每N次中断额外多累加一次。
5.2 KEIL MDK V5.38打开工程与GB2312乱码修复
工程源码里的中文注释默认是GB2312编码,KEIL MDK V5.38的Editor默认可能用UTF-8解析,打开后注释会变成一串乱码。处理方法是:Edit → Configuration → Editor → Encoding下拉框选择Chinese GB2312 (Simplified),然后关闭文件重新打开。如果个别.c文件仍然乱码,用Notepad++打开后批量转为ANSI编码再保存回工程。
另外,源码包里的stm32f10x_adc.c、stm32f10x_can.c、stm32f10x_i2c.c、stm32f10x_usart.c等文件是标准外设库整目录拷贝带进来的,这个工程实际只用到RCC、GPIO、TIM、EXTI和LCD驱动。即使文件没被调用,KEIL标准库配置下也会逐个编译,白白增加构建时间。建议在工程窗口里右键移除用不到的外设源文件,keilkilll.bat可以顺手清掉编译产生的中间文件,保持工程目录干净。
5.3 用对表法验证补偿效果
补偿是否生效,要跑一个可重复的验证:两个手机同时启动秒表功能,和板载显示对齐启动;跑满5分钟或10分钟后停表,记录偏差。注意手机秒表走时不一定准,但短期稳定性足够。更严谨一点,用带秒脉冲输出的GPS模块或示波器测TIM3的更新事件引脚翻转频率,直接量出10ms周期是否被修正到了目标值。补偿参数生效后再用同样的方法复测一遍,看偏差是否收敛到原先的十分之一以内。如果复测发现误差方向变了,把COMP_INTERVAL调大即可,例如从120改成121,相当于补偿间隔放长,跳过的次数减少。
本文还有配套的精品资源,点击获取