ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32秒表源码解析:外部中断与定时器中断的时序博弈及误差补偿

STM32秒表源码解析:外部中断与定时器中断的时序博弈及误差补偿 简介基于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完成映射然后配置触发方式和NVICvoid 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 1719100kHz自动重装载值 ARR周期 (ARR 1) / 计数时钟99910ms软件累加步进一次更新事件11厘秒注意一个新手高频翻车点标准库函数里写入寄存器的就是传入值TIM_TimeBaseInit不会替你执行减1。写PSC719实际分频系数是720写ARR999计数器从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/12050次就能把滞后补平。#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/250120。这个思路等价于在整数域做比例修正不改变定时器硬件配置也不引入浮点。如果误差方向变成快则反过来每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相当于补偿间隔放长跳过的次数减少。本文还有配套的精品资源点击获取
返回列表