资讯详情

资讯详情

STM32秒表项目源码解析:定时器中断、按键消抖与数码管显示

简介基于STM32F103的秒表完整工程源码选用正点原子Mini开发板与TFTLCD标准库例程为模板围绕启动、暂停、归零三个按键实现秒表操作计时数据实时显示在LCD屏上精度到0.01秒重点演示外部中断与定时器中断的配合方式。项目共203个文件压缩包约6.76MB包含38个h头文件、36个c源文件及uvprojx工程文件便于阅读工程结构和直接编译同时附有axf、hex烧录文件与map、lst链接输出可辅助调试分析。作者在描述中明确说明该秒表受定时器分辨率所限存在约每分钟慢0.5秒的误差并提醒仅作学习参考这一工程取舍对理解STM32定时器机制很有价值。源码注释注意用GB2312编码打开目前已有435人浏览学习适合正在结合开发板练习中断、定时器和LCD显示的嵌入式入门者。1. 为什么 STM32 秒表项目值得自己写一遍一块 STM32 最小系统板、几个按键、一个四位数码管就能把“基于STM32的秒表项目源码.zip”变成桌面上的真实秒表。它不只是“能计时”而是把中断、定时器、按键消抖、动态扫描和状态机这几项基本功完整串了一遍。不少人直接烧录现成源码结果不走时、按键乱跳、走时偏快问题几乎都出在定时器配置和中断响应上。这份源码最适合两类人正在做 STM32 单片机课程设计或毕业设计的学生以及刚学完 GPIO、想要一个能在线调试的综合性例子的嵌入式入门者。一个可靠的秒表源码通常只有几百行但你得弄清三件事计时基准从哪来、按键怎样被可靠识别、显示刷新会不会偷走 CPU 时间。下面按原理、实现和误差调试这条主线把 STM32 秒表的配置参数和常见坑一次讲透。2. STM32 秒表计时原理定时器、时钟源与精度边界2.1 秒表的核心不是 delay而是定时器中断秒表的本质是低频的“时间戳累加器”。许多初学者会让主循环里转一圈就delay_ms(1)然后认为过去了一毫秒。这个假设并不可靠delay只是让 CPU 空转空转结束后还要继续执行按键扫描、显示刷新、状态判断这些代码耗时不稳定下一次循环到delay开始点的时间差不是固定的 1ms。真正计时不是“睡够了再醒”而是“让硬件主动告诉你现在到了哪一刻”。所以工程上普遍的做法是配置一个硬件定时器让它以固定频率产生更新中断。中断服务函数中只做一件事把全局毫秒计数器g_tick_ms加一。主循环需要知道“现在是多少毫秒”时直接读这个变量需要定时刷新显示时用当前值和上次记录值的差去判断。主循环忙它的中断照常计数两边互不阻塞。选哪个定时器也有讲究。STM32F103 系列有 SysTick、TIM6/TIM7 基本定时器和 TIM2~TIM5 通用定时器。秒表的最佳选择通常不是 SysTick而是 TIM2~TIM5。SysTick 在很多 Keil5 工程模板里被HAL_Delay、裸机延时函数或 RTOS 的时基占用如果秒表和这些代码共用一旦有人改了系统节拍频率秒表精度立刻受影响。独立通用定时器还能在需要时外接石英晶振做精准频率源甚至用输入捕获功能测外部脉冲这是 SysTick 做不到的。2.2 预分频与自动重装在 1ms 定时中的参数计算要把定时器配置成 1ms 中断关键是理解 PSC、ARR 和输入时钟的关系。STM32 通用定时器的时钟来自时钟树。以最常见的 72 MHz 主频工程为例APB1 定时器时钟通常也是 72 MHzAPB1 分频为 2 时定时器时钟倍频为 2。公式是定时周期 T (PSC 1) * (ARR 1) / TIM_CLKT 的单位是秒。这里PSC 1是预分频的除数ARR 1是计数器从 0 数到 ARR 产生的周期数。72 MHz 下要让 T0.001s可取PSC71ARR999。因为(72)*1000/72_000_000 1000微秒。下面这段劫取自标准外设库工程是很多 STM32 秒表源码里定时器初始化的常见形态TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; NVIC_InitTypeDef NVIC_InitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM3, ENABLE); TIM_TimeBaseStructure.TIM_Prescaler 71; // PSC 7172MHz/(711)1MHz TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseStructure.TIM_Period 999; // ARR 999计数1000次溢出 TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseInit(TIM3, TIM_TimeBaseStructure); TIM_ITConfig(TIM3, TIM_IT_Update, ENABLE); NVIC_InitStructure.NVIC_IRQChannel TIM3_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); TIM_Cmd(TIM3, ENABLE);逐行解释RCC_APB1PeriphClockCmd开启 TIM3 时钟TIM_Prescaler71把 72MHz 变成 1MHzTIM_CounterMode_Up向上计数TIM_Period999设定自动重装值TIM_ClockDivision是采样时钟分频与中断周期无关这里设DIV1即可。中断配置里抢占优先级设为 1子优先级 0TIM3_IRQn对应中断向量。参数上有个容易忽略的点PSC和ARR不是唯一的。要得到 1kHz 中断也可以PSC719, ARR99但这样计数器每 10 微秒才加 1如果你之后想在中断里读 CNT 获取更精细时间分辨率就只有 10 微秒。秒表一般并不需要读 CNT但你要是准备扩展成“记录两次按键之间的精确间隔”就应把计数分辨率做到 1us也就是让 PSC 尽量小。目标时基PSCARR计数分辨率中断频率0.5 ms714991 us2 kHz1 ms719991 us1 kHz10 ms7199991 us100 Hz1 ms7199910 us1 kHz这个表里有意思的是第三行和第四行都用PSC1、ARR1得到相同“目标”。实际不同在于 CPU 被中断打扰的频率和计数分辨率。秒表项目建议第一行或第二行。如果你的源码包里给出的定时器中断频率是 100Hz也就是每 10ms 累加一次看起来也能跑但按键消抖和显示扫描的时序会紧很多而且停止计时的瞬间误差最大可能到 10ms。2.3 为什么不用 SysTick秒表计时和系统节拍的分工很多 STM32 工程模板默认用 SysTick 产生 1ms 的节拍HAL_Delay依赖它自己写的delay_ms也常靠它。表面上看秒表直接借用这个 1ms 节拍就够了不用再配置一个定时器。可问题在于SysTick 的优先级和执行时机不可控。调试时你可能会在某个中断函数里调用HAL_Delay它会把 SysTick 的计数时序拉偏如果秒表累加和系统延时共用同一个节拍变量显示刷新、串口发送稍微晚一点秒表也跟着晚一点。更稳妥的分工是SysTick 继续做系统时基负责延时和调度秒表单独占用一个通用定时器并且把它的中断优先级设得比 SysTick 更高。这样即使主循环里某个中断卡了 20us秒表的硬件中断仍会按时触发g_tick_ms依然严格按 1ms 步进。代价只是多占一个定时器但换来了可单独校准、可输入捕获、可防 RTOS 冲突的独立时基值得。如果是长时间运行还要考虑低频累计。毫秒计时变量uint32_t能连续跑约 49.7 天如果中间还记录日期时间建议把“日累计”交给带 LSE 外部低速晶振的 RTC秒表定时器只做毫秒和秒的细分。RTC 的功耗低、有独立电源域待机时也能走时但它的时钟源一般不是毫秒级所以不能替代定时器中断。常见做法是“TIM 产生秒信号或毫秒信号秒信号再喂给 RTC 或状态机而不是在 RTC 中断里补毫秒”。这句话看起来简单却是很多秒表源码后期改掉电保存时才想明白的。第二个要注意的是中断服务函数里最好只更新一个volatile uint32_t g_tick_ms。不要在中断里做g_tick_ms之外的事比如清零、重新装载、判断状态。清零操作放在主循环里做避免和中断产生竞争。如果在中断里直接写g_tick_ms 0而主循环正读到一半32 位宽度的读取在 Cortex-M3 上是单条指令一般不会撕裂但编译器优化后可能出现读到旧值。把它声明为volatile是最低要求更严格的做法是用临界区保护读取。3. 阅读和复现秒表源码按键控制、显示刷新与状态机3.1 源码.zip 里的文件划分一个规范的“基于STM32的秒表项目源码.zip”打开后首先不是main.c而是 README、工程文件和应用层目录。能直接编译通过的工程通常会把板级支持代码和业务逻辑分开。以 STM32CubeIDE 生成的 HAL 库工程为例业务代码可以放在顶层App/硬件驱动用 CubeMX 生成的Core/和Drivers/stm32-stopwatch/ ├── Core/ │ ├── Inc/main.h │ └── Src/main.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ ├── App/ │ ├── app_stopwatch.c │ ├── app_stopwatch.h │ ├── bsp_key.c │ └── bsp_display.c ├── MDK-ARM/ │ └── project.uvprojx └── README.md这种分层的好处是改秒表状态机不用碰寄存器操作换开发板时只需要替换bsp_key.c和bsp_display.c中的 GPIO 引脚映射。很多源码包没有 README只有.uvprojx和一个main.c也能运行。我一般建议先看main.c里的while(1)循环如果主循环里用delay_ms串起按键扫描和显示说明这个源码的计时思路很可能是循环延时改成中断计时后再继续用。3.2 按键处理消抖与状态转换秒表至少需要两个键一个“开始/暂停”一个“清零”。按键输入的最大问题是机械抖动。按下一次按键引脚电平在 5~20ms 内会多次跳变如果主循环每 1ms 读一次跳变就会被当成多次按下。常见的解决办法是延时消抖检测到按下后delay_ms(20)再读一次。这个方法能消抖但阻塞主循环如果按键恰好出现在计时中断和显示刷新的间隙就会让显示卡顿。更好的方案是“电平持续计数”。主循环每 5ms 采样一次按键连续 4 次读到按下才认为有效累计时间 20ms。代码如下#define KEY_SAMPLE_MS 5 #define KEY_DEBOUNCE_CNT 4 uint8_t key_scan(uint8_t key_id) { static uint8_t stable_cnt[2] {0, 0}; uint8_t level GPIO_ReadInputDataBit(KEY_GPIO_PORT, key_id 0 ? KEY_START_PIN : KEY_RESET_PIN); if (level KEY_PRESSED_LEVEL) { if (stable_cnt[key_id] 0xFF) stable_cnt[key_id]; if (stable_cnt[key_id] KEY_DEBOUNCE_CNT) { stable_cnt[key_id] KEY_DEBOUNCE_CNT 1; return 1; } } else { stable_cnt[key_id] 0; } return 0; }关键参数KEY_SAMPLE_MS5决定调用频率KEY_DEBOUNCE_CNT4与 5ms 配合给出 20ms 消抖窗口。stable_cnt数组保存每个按键的稳定计数达到阈值后不再增加确保长按时不会反复触发。key_scan返回值可以视为“边沿事件”适合喂给状态机。注意key_scan每次只查询一个引脚key_id用来区分按键如果要支持两个键同时按下需要轮流扫描并记录事件而不是在函数里直接while等待。状态转换表如下状态按键事件下一状态动作STOPPEDKEY_STARTRUNNING清空累计值启动定时器RUNNINGKEY_STARTPAUSED记录当前值停止定时器PAUSEDKEY_STARTRUNNING恢复定时器继续累计STOPPEDKEY_RESETSTOPPED清零显示和g_tick_msPAUSEDKEY_RESETSTOPPED清零并回到初始态直接在 RUNNING 状态屏蔽 RESET 键也是一种常见做法防止误按。在状态机里尽量别用“if 状态A 且键KEY_A else if 状态B”而是维护next_state和action两个变量一次执行动作一次切换状态方便以后扩展“单圈计时”或者“倒计时”模式。3.3 显示刷新多位数码管动态扫描不能长时间关中断四位数码管显示秒表的MM.SS可以用共阴或共阳数码管。段选接同一个 GPIO 端口的 8 位位选接另外 4 个 GPIO。动态扫描的原则是同一时刻只点亮一位每隔 1~2ms 切换位选并输出对应位的段码。显示函数里最容易出错的地方是先写段码再切位选或反过来顺序不对。正确顺序是先关全部位选消隐再写段码再开当前位的位选。如果先开位选再写段码数码管会把上一位的段码残留在当前位显示上一瞬肉眼看到的便是“拖影”或“鬼影”。void display_scan_one_digit(void) { static uint8_t pos 0; GPIO_Write(GPIOA, 0); // 先关位选消隐 GPIO_Write(GPIOB, seg_code[pos]); // 写当前位的段码 GPIO_SetBits(GPIOA, (uint16_t)(1 pos)); // 再开位选 pos (pos 1) % 4; } void display_poll(uint32_t now_ms) { static uint32_t last_display_ms 0; if (now_ms - last_display_ms 2) { last_display_ms now_ms; display_scan_one_digit(); } }这里display_scan_one_digit每 2ms 刷新一位4 位一轮约 8ms刷新率 125Hz人眼不会感到闪烁。主循环里用now_ms - last_display_ms 2而不是delay_ms(2)是为了让按键扫描也能在同一个循环里及时执行。now_ms可以来自HAL_GetTick()也可以直接读秒表的g_tick_ms两者不要混用否则显示刷新基准和计时基准不一致。另一个常见问题是把显示刷新放到 TIM 中断里。如果只刷一位还好一旦用GPIO_Write同时写段选和位选中断服务函数从几行变成几十行定时器下次中断到来时还在执行中断标志会积压。结果计时周期不再严格秒表从“毫秒级”退化到“循环级”。中断里只做计数显示扫描放在主循环这条边界越多越早定下来越好。4. STM32 秒表误差校准与调试从 0.5 秒漂移到毫秒级4.1 误差来源晶振、中断延迟与计时变量的位数秒表跑一段时间后比正确时间慢或快多数人第一反应是改程序。实际上先要分清时间基准误差和程序误差。晶振误差属于硬件层面。常见的 8MHz 晶振精度为 20ppm 到 50ppm一天累计误差 1.7~4.3 秒。如果 STM32 秒表每次都快 0.05 秒一分钟大约漂移 0.8ms很可能就是晶振标称精度。这种误差靠 PSC/ARR 整数调整只能压缩到 ppm 级不能完全消除因为 ARR 只能取整数而修正量可能是小数。实际做法是连续测 10 分钟把误差换算成 ppm再决定是否需要调整 ARR。程序层面的误差更隐蔽。如果 TIM 中断服务函数里不仅执行g_tick_ms还调用了LCD_ShowNum或printf每次中断执行时间可能差几十微秒。这些时间会被计入下一次中断导致秒表累计不等于实际时间。另一个常见错误是把“暂停”实现成关闭全局中断。关闭期间按键key_scan检测不到脉冲恢复中断的瞬间g_tick_ms已经少了暂停期间的时间。应当在暂停时禁用 TIM 的更新中断或关闭定时器本身而不是把全局中断关掉。计时变量的位数也是排错重点。用uint16_t存毫秒最多到 65.535 秒就回绕用uint8_t存秒255 秒后归零。正规源码会写成uint32_t g_tick_ms显示时再把它拆成时、分、秒、毫秒。看到源码里unsigned int也要多留个心眼在 STM32 上unsigned int是 32 位但有些移植到其他单片机时是 16 位换平台就会溢出。4.2 用外部秒脉冲验证走时误差校准秒表前先测误差。用手机秒表或者电脑时钟作为参考同时启动 STM32 秒表跑满 5 分钟记录偏差。偏差换算成 ppm 的公式是误差(ppm) (STM32显示时间 - 参考时间) / 参考时间 * 1_000_000比如 300 秒参考STM32 显示 300.20 秒误差就是(300.20-300)/300 * 1000000 666 ppm。这个数远大于普通晶振的标称值说明不是晶振精度导致的而是定时器参数配错或中断处理过长。此时先回到第 2 章的 PSC/ARR 配置确认实际中断频率再回头校准。现象可能原因优先排查点秒表比参考时间慢ARR 偏大或中断内耗时过长打印两次tick差值秒表比参考时间快ARR 偏小或晶振频率偏高测晶振频率或用示波器看翻转周期运行几分钟后按键无响应中断里出现长阻塞把显示、打印移出中断时间到 65 秒附近跳变计时变量宽度不足改用uint32_t定时器开但秒表不走未使能TIM_Cmd或NVIC优先级被屏蔽查RCC和NVIC配置如果确认是晶振频率偏差软件校准的做法是修改 ARR。误差 666ppm 意味着计数器比预期多计了约 0.666 次ARR 应从 999 改为 998 或 997具体需要多次长时间测试。校准值不要写死放到 Flash 末页或 EEPROM开机读取后写入定时器。这样生产不同板子时不用改源码只写一个校准参数。提示不要用手机秒表按暂停键来校准按下手机暂停按钮本身也有 0.2 秒级延迟应该让两表同时自然跑最后再对照读数。4.3 用串口打印和 ST-LINK 在线观察确认中断确实按 1ms 触发误差校准有个前提你得确认 MCU 的中断确实是按设计值触发。最简单的验证是在主循环里翻转一个 GPIO周期性地观察它的频率。没有示波器时把g_tick_ms每秒通过串口打印一次看打印间隔static uint32_t last_report_ms 0; if (g_tick_ms - last_report_ms 1000) { last_report_ms g_tick_ms; printf(tick%lu\r\n, (unsigned long)g_tick_ms); }这段代码放在主循环不能放中断。printf通过串口重定向发送如果串口波形正好碰上某个耗时操作printf会阻塞几毫秒放在中断里就会直接破坏计时。输出每秒一行波特率 115200阻塞时间约为 0.1ms对秒表主循环没有影响。看到打印值从 0,1000,2000 递增说明中断链路基本正确如果第二次打印出现在 2001 或 1999就需要查中断竞争和校准值。在线调试时可以用 ST-LINK 连接目标板在TIM3_IRQHandler里打断点。断点会暂停芯片不方便长时间验证。更好的做法是开变量窗口观察g_tick_ms实时刷新看它是否连续递增。如果程序进入 HardFault秒表数字停在某一值ST-LINK Utility 能读出当前的 PC 和 LR。把 LR 地址换算到.map文件里的函数名就能找到是哪一个中断里出了问题。HardFault 的常见位置按键 EXTI 回调里写了太多显示代码显示刷新函数访问了未初始化的 GPIO 端口或者数码管位选关闭时把BSRR低 16 位写成了0导致段选和位选引脚同时短路。解决思路是尽量把外设操作从回调移到主循环回调只置一个event_flag。5. 秒表源码交付加入输入捕获单圈计时与验收技巧5.1 用输入捕获记录单圈时间如果你不满足于“开始/暂停”想给秒表加上“单圈”功能不要用 GPIO 中断读g_tick_ms。原因很简单g_tick_ms的精度到 1ms 上限而外部中断服务函数里读变量、清标志代码执行顺序不同会造成误差。通用定时器的输入捕获模块能在硬件层面锁存当前 CNT误差只取决于计数分辨率。仍用 TIM3 作例将一圈结束信号接到 CH1配置上升沿捕获TIM_ICInitTypeDef TIM_ICInitStructure; TIM_ICInitStructure.TIM_Channel TIM_Channel_1; TIM_ICInitStructure.TIM_ICPolarity TIM_ICPolarity_Rising; TIM_ICInitStructure.TIM_ICSelection TIM_ICSelection_DirectTI; TIM_ICInitStructure.TIM_ICPrescaler TIM_ICPSC_DIV1; TIM_ICInitStructure.TIM_ICFilter 0x0F; TIM_ICInit(TIM3, TIM_ICInitStructure); TIM_ClearITPendingBit(TIM3, TIM_IT_CC1); TIM_ITConfig(TIM3, TIM_IT_CC1, ENABLE);TIM_ICFilter0x0F是硬件滤波等效于给输入信号加一个约十几 MHz 的带宽限制能滤掉短于 1us 的毛刺。输入捕获中断里读TIM_GetCapture1(TIM3)减掉上一次捕获值last_ccr得到两次圈信号之间的计数个数。如果 TIM3 配置成 1us 计数分辨率捕获差值直接就是微秒级间隔比读g_tick_ms精细 1000 倍。5.2 秒表源码交付前的验收清单复制一份源码去改功能前先把“能不能跑”验证清楚。我一般按下面这套流程走能省下大量“下载下来跑不通”的时间上电后先确认g_tick_ms通过串口按 1000 递增排除晶振参数错误。用手机秒表同时跑 5 分钟记录偏差并按 ppm 换算决定是否调整 ARR。连续快速按开始/暂停 20 次观察状态机是否有一次没切换。按住清零键不超过 30ms 松手确认不会出现抖动导致的两次清零。用逻辑分析仪看位选波形确认每位点亮约 2ms四位数码管没有拖影。这几项做完一个“基于 STM32 的秒表项目源码.zip”才真正具备可复现性。如果你在最后一步把 ARR 改为 Flash 中保存的校准值就完成了从“能编译”到“能交付”的最后一跳。答辩演示前还有一个小技巧在 Keil 的 Debug 设置里勾选 ST-LINK 的 Reset and Run下载完程序自动复位运行现场不用再手动按复位键。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →