STM32外部中断、定时器中断与引脚复用:从原理到工程实战
发布时间:2026/10/20 18:32:59 锦皓数字建站

很多玩STM32的新手第一个礼拜还在点亮LED第二个礼拜就开始卡在中断上。外部中断、定时器中断、引脚复用这三个词听起来像是三道坎实际上拆开看每个概念都不算难难的是把它们组合在一个工程里时寄存器、库函数、中断回调全混在一起脑子容易绕晕。这篇就以小白视角把这三块内容一次性讲透从原理到配置从代码到避坑让刚接触的人也能跟着把工程跑起来。我的建议是不要一上来就抱着几百页的参考手册啃。先把“引脚复用是什么”“外部中断怎么触发”“定时器中断怎么算时间”这三件事用大白话理解了再对照代码看你会发现硬件底层并没有那么神秘。这篇内容会先分别讲清楚三个概念再用一个把三者串起来的综合案例演示完整工程怎么搭最后附上我调试过程中踩过的坑和排查方法。1. 引脚复用一个引脚干多种活1.1 复用到底是什么意思STM32的引脚不是“一个萝卜一个坑”那种固定功能。同一个物理引脚既能当普通I/O口输出高低电平又能被内部硬件连接到某个外设比如串口、定时器PWM、I2C、SPI。这种“一个引脚由外设功能接管”的使用方式就叫引脚复用。举个例子很多开发板上的PA9引脚既可以作为普通GPIO输出也可以被配置成USART1的发送引脚TX。如果你只是把LED接在PA9上那它就是普通I/O口如果你想通过串口发送数据就要把PA9的复用功能打开让内部串口外设控制这个引脚。关键点是同一个引脚同一时刻只能选择一种复用功能不能在跑串口的同时又当普通IO用这是硬件上就确定死的。对新手来说最直观的理解方式就是打开STM32CubeMX选中一个引脚会看到一长串可选的复用功能编号。这个编号对应数据手册里的“Alternate Function mapping”表不同引脚、不同外设对应不同的AF编号。比如某个引脚支持USART1_TX对外设的AF编号是AF7那配置的时候就得把引脚的复用功能设置为AF7。1.2 GPIO引脚配置的两种常用模式在实际工程里我要分清楚“复用输出”和“复用开漏”这两个概念很多新手在这里翻车。普通输出模式下代码里HAL_GPIO_WritePin直接控制引脚电平这是推挽输出GPIO_MODE_OUTPUT_PP。但在复用模式下引脚的电平是由外设内部产生的不是CPU通过GPIO数据寄存器控制的。此时配置为GPIO_MODE_AF_PP复用推挽外设发送逻辑高电平时引脚输出高外设发送逻辑低电平时引脚输出低这是最常见的复用配置串口TX、SPI、PWM基本都这么配。如果外设需要的是开漏输出比如I2C就需要配置成GPIO_MODE_AF_OD复用开漏。这两者如果选错现象可能很隐蔽。我见过一个同学用I2C总线读取传感器引脚配置成了复用推挽结果通信时好时坏加了上拉电阻也没完全解决。后来查了手册才发现I2C要求开漏推挽模式在电平翻转时会跟外部上拉电阻形成短暂冲突导致波形毛刺。所以配置复用功能时一定要先查所选外设的电气要求不是所有复用都配推挽。1.3 实际工程中如何选复用功能这里给出一个明确的流程避免你在CubeMX里迷路先查数据手册或CubeMX的芯片视图确定你要用的外设信号对应哪个引脚。在CubeMX里选中这个引脚从下拉列表里选择你要的复用功能。回到GPIO设置页确认Mode、Speed、Pull-up/Pull-down这几项是否正确。确认外设本身的时钟已经打开CubeMX会自动帮你处理然后生成代码。手动写寄存器的方式我建议新手先不要碰。CubeMX能自动生成GPIO_InitStruct你看看它生成的内容就能理解底层寄存器大概是怎么操作的。比如GPIO_InitTypeDef里的Alternate字段就是在设置引脚的AFR寄存器。第一次看代码时对照一下手册里的AFR寄存器位说明基本就懂了。注意事项复用功能配置完成后如果调试发现引脚电平不对优先检查是不是把模式配成了输入模式或者AF编号选择错误。新手最常见的错误是在CubeMX里选中了引脚却忘了在GPIO设置里改模式生成代码后默认还是输入模式外设功能自然不起作用。2. 外部中断让CPU在关键时刻停下手里的事2.1 外部中断是怎么触发的STM32的外部中断EXTI可以理解为某个引脚电平发生变化时硬件会向CPU发送一个中断信号把CPU手里正在执行的程序打断跳到一个固定的中断服务函数里执行你写的逻辑执行完再回到原来的程序继续跑。硬件上外部中断线EXTI0到EXTI15对应的是PORTA到PORTG的引脚编号。也就是说PA0和PB0都可以映射到EXTI0但同一时刻只能有一个引脚映射到某条EXTI线。如果一个工程里PA0和PB0同时要触发外部中断那就冲突了因为两个引脚对应的是同一条EXTI线。这是新手最容易忽略的硬件限制。外部中断支持多种触发方式上升沿触发、下降沿触发、双边沿触发。上升沿就是引脚电平从低变高瞬间触发下降沿是高变低瞬间触发。实际项目里按键按下接地松开是高电平那按下时就是下降沿配置成下降沿触发就能在按下的瞬间进中断。2.2 外部中断的配置步骤以HAL库为例完整配置步骤大概是在CubeMX里把目标引脚设置为GPIO_MODE_IT_FALLING外部中断下降沿触发同时打开这个引脚的内部上拉电阻因为按键另一端接地平时引脚需要被上拉到高电平。在NVIC设置页勾选对应的EXTIx_IRQn中断建议把抢占优先级设得比定时器中断低或高按你的业务逻辑决定。生成代码后需要在中断回调函数HAL_GPIO_EXTI_Callback里写你自己的逻辑注意这个回调是所有外部中断共用的需要通过GPIO_Pin参数区分是哪个引脚触发的。检查stm32f4xx_it.c里的EXTIx_IRQHandler确认它调用了HAL_GPIO_EXTI_IRQHandler在这之后才会进入Callback。回调函数的写法是这样的void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_0) { // PA0触发了外部中断在这里处理业务逻辑 } else if (GPIO_Pin GPIO_PIN_1) { // PA1触发了外部中断 } }注意HAL_GPIO_EXTI_IRQHandler函数里会自动清除中断标志位不需要你手动清除。但如果你用的是标准外设库就需要手动操作EXTI_ClearITPendingBit。这是HAL库和标准库比较大的差异。2.3 外部中断在真实工程里的用途外部中断最常见的两个场景一是按键检测二是IO唤醒低功耗模式。按键使用外部中断的好处是CPU不需要一直死轮询按键状态平时可以干其他事按键按下瞬间才打断CPU。这个思路我用在一个模拟项目X里面板上有四个按键每个按键对应一条EXTI线中断回调里通过判断引脚号识别是哪个按键然后启动不同的业务流程。但这套设计有个坑按键存在抖动按下瞬间电平会来回反弹多次可能一次按键触发多次中断。解决的办法可以是硬件上加RC滤波也可以在软件上做消抖。软件消抖最简单的思路是在中断回调里记录当前系统时间与上一次触发时间比较小于某个阈值就丢弃本次触发。定时器中断在这里就有用了因为需要系统时间基准来记录时间这也是为什么这篇要把外部中断和定时器中断放在一起讲。3. 定时器中断精确的“闹钟”机制3.1 定时器中断原理STM32的定时器本质上是一个计数器它按照时钟信号递增计数计到设定值后会产生更新事件。如果开启了更新中断就会跳进中断服务函数执行你写的周期任务。你可以把定时器理解成一个带闹钟的秒表秒表走到设定的时间点闹钟就响一次。定时器中断最重要的两个参数是预分频器PSC和自动重装载寄存器ARR。PSC把时钟源的频率降到一个合理范围ARR决定计数多少个脉冲后触发一次更新事件。最终中断周期就是中断周期 (PSC1) × (ARR1) / 定时器输入时钟频率为什么公式里都要加1因为这两个寄存器都是从0开始计数的实际分频系数和计数值都要加1。比如72MHz时钟PSC71ARR999中断周期就是72×1000/72MHz 1ms。以STM32F407为例它的定时器时钟通常来自APB1总线系统主频168MHz时APB1定时器时钟是84MHz。很多新手直接用72MHz算结果时间偏差很大。所以第一步要确认定时器挂在哪条总线上查芯片手册或者直接用CubeMX生成的时钟树确认。3.2 定时器中断的配置步骤最常用的通用定时器是TIM2和TIM3以TIM2为例HAL库配置步骤如下在CubeMX的Timers里选择TIM2勾选Activate配置预分频器PSC和自动重装载ARR。在NVIC里勾选TIM2 global interrupt。生成代码后在main.c里启动定时器中断调用HAL_TIM_Base_Start_IT(htim2)。编写更新中断回调函数HAL_TIM_PeriodElapsedCallback。需要注意HAL库的定时器中断回调函数有多个不同的事件类型会调用不同的回调。更新事件对应HAL_TIM_PeriodElapsedCallback捕获比较事件对应HAL_TIM_IC_CaptureCallback别混了。我这里就吃过亏往捕获比较回调里写更新中断的逻辑结果函数永远不会被调用。代码示例// 在main.c中启动定时器 HAL_TIM_Base_Start_IT(htim2); // 在stm32f4xx_it.c的TIM2_IRQHandler中HAL库会自动调用HAL_TIM_IRQHandler // 最终进入HAL_TIM_PeriodElapsedCallback void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { // 每1ms执行一次的周期任务 // 例如LED闪烁或者更新一个计数器变量 } }3.3 定时器中断常见的使用方式定时器中断最常见的两个用法一是产生固定的时间基准比如每1ms进一次中断在里面累加一个毫秒计数变量实现简单的软件时钟二是在固定的时间间隔里执行周期任务比如每100ms读取一次传感器。我自己的习惯是不在定时器中断里做太耗时的操作。中断服务函数执行时间过长会导致其他中断不能及时响应严重时会丢中断。一个典型反例是某个开发者把串口打印直接放在定时器中断回调里打印一帧数据要几百微秒恰好此时外部中断触发CPU还在打印关键按键事件就延迟了。所以定时器中断里只做置标志位、累加计数这些轻量操作具体业务逻辑放到主循环处理。4. 综合实操把三个功能拧成一个工程4.1 项目需求与分析光分开讲概念很多新手还是不知道怎么组合。这里做一个完整的模拟项目X一个按键通过外部中断触发LED灯通过定时器中断实现闪烁串口通过引脚复用输出调试信息打印按键按下的次数。需求拆开看就是三个部分外部中断检测按键按下每次按下列数加1。定时器中断每500ms翻转一次LED状态实现闪烁同时作为软件计时基准。引脚复用USART1的TX/RX引脚配置为复用功能把按键计数通过串口打印到上位机。这个案例基本覆盖了小白接触STM32时最常用的几个外设组合。如果你能把这个工程完全跑通后面再学PWM、ADC、DMA思路会顺畅很多。4.2 CubeMX配置CubeMX配置步骤我按顺序列出来照着操作就行时钟树选择STM32F407芯片外部晶振HSE配置为8MHz系统时钟SYSCLK设置为168MHz。时钟树点熟之后APB1总线定时器时钟会自动算好此处以84MHz为例。引脚配置PA0配置为GPIO_MODE_IT_FALLING外部中断下降沿触发内部上拉PA1配置为定时器输出比较不做这里直接使用TIM2的更新中断所以不需要专门的输出引脚只需在Timers里开启TIM2。USART1PA9配置为GPIO_MODE_AF_PP复用推挽USART1_TXPA10配置为GPIO_MODE_AF_IN复用输入USART1_RX波特率115200-8-N-1。NVIC设置勾选EXTI0_IRQn和TIM2_IRQn抢占优先级分别设置为2和1这样定时器中断优先级高于外部中断。生成代码使用Keil打开工程。要注意USART1的引脚在不同型号上可能不一样比如在F103上USART1_TX默认是PA9但有些开发板把串口接到了PB6上这时候复用功能配置要对应GPIO_AF7_USART1。还是那句话先查你板子的原理图再确认CubeMX里的引脚编号。4.3 代码实现与关键点注释生成代码后在main.c里加入用户代码。第一个关键点是全局变量用于记录按键次数和毫秒计数volatile uint32_t key_count 0; volatile uint32_t tick_ms 0;使用volatile是因为这些变量在中断回调里被修改而在主循环里被读取。编译器优化时可能会把主循环的读取优化掉导致值一直不变。这是新人常遇到的现象函数逻辑看着没问题但变量就是不更新检查了半天才发现是缺了volatile。下面是在用户代码区添加的初始化部分/* 初始化串口 */ UART_HandleTypeDef huart1; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); MX_USART1_UART_Init(); /* 启动定时器中断 */ HAL_TIM_Base_Start_IT(htim2); while (1) { /* 主循环每500ms打印一次按键次数 */ if (tick_ms - last_print_time 500) { char buf[64]; sprintf(buf, key count: %d\r\n, key_count); HAL_UART_Transmit(huart1, (uint8_t *)buf, strlen(buf), 100); last_print_time tick_ms; } } }外部中断回调void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) { if (GPIO_Pin GPIO_PIN_0) { /* 消抖与上一次触发时间间隔小于30ms则忽略 */ if (tick_ms - last_key_time 30) { return; } last_key_time tick_ms; key_count; } }定时器中断回调void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { tick_ms; if (tick_ms % 500 0) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } } }注意我在外部中断回调里用了软件消抖比较两次按键的时间间隔。这个时间基准来自定时器中断的tick_ms变量。如果不做消抖按键按下时机械触点会在几毫秒内反复通断可能一次按键会连续触发三四次中断计数就会多好几下。4.4 中断优先级设置心得关于NVIC优先级我在这个项目里把TIM2设为抢占优先级1外部中断设为2。想让外部中断响应更快就把外部中断优先级设得比定时器高即数字更小。具体怎么设取决于业务需求。比如按键操作需要立即响应那就让外部中断抢占优先级更高如果定时任务比较紧急那就让定时器优先级更高。另一个容易忽略的是优先级分组。STM32的NVIC优先级分组决定了抢占优先级和子优先级各自的位数。CubeMX生成的默认分组通常是NVIC_PRIORITYGROUP_4全部4位都用于抢占优先级子优先级固定为0。如果你在CubeMX里改了分组设置而代码里没有同步配置就会出现优先级设置“看起来设置了实际没生效”的诡异现象。我建议使用默认分组不要把分组改来改去尤其是新手阶段。5. 常见问题与排查技巧5.1 常见问题速查表这里把我见过的新手易错问题整理成一张表遇到类似现象可以直接对照现象可能原因排查/解决办法外部中断一次触发多次按键没有消抖增加软件消抖记录触发时间间隔外部中断完全不触发引脚模式配成普通输入CubeMX里设为GPIO_MODE_IT_FALLING外部中断与某个引脚冲突两条引脚复用了同一条EXTI线检查EXTI线号避免PA0和PB0同时使用定时器周期比预期长分频系数或ARR计算错误确认APB1定时器时钟频率按公式重新计算串口输出乱码波特率不匹配或时钟频率不对检查CubeMX时钟树、串口助手波特率中断回调不触发未调用HAL_TIM_Base_Start_IT确认启动函数已调用且NVIC已使能变量在主循环不更新volatile关键字缺失中断和主循环共享的变量必须加volatile引脚复用后外设工作异常AF编号选错或模式配错对照数据手册的AF映射表逐项检查5.2 排查思路分享对新手来说遇到问题不要先怀疑硬件。拿一个串口打印工具为切入点在关键位置打印变量值能快速缩小范围。我自己排查中断问题的习惯是先看中断标志位是否被触发再看是否进入了中断服务函数最后看是否进入了回调函数。用调试器打断点或者在回调函数里置一个GPIO翻转引脚看示波器或逻辑分析仪这种方法最直接。举个例子有个朋友说他外部中断不进入回调我让他用调试器在EXTI0_IRQHandler里打断点发现根本没进入这个函数于是问题定位在NVIC没使能还有一次是进入了EXTI0_IRQHandler但没进回调问题在于系统初始化时调用了HAL_GPIO_EXTI_IRQHandler但引脚参数没有传对。这两类问题都很常见靠纯看代码比较难发现打断点最快。6. 从入门到进阶的个人体会整个工程跑通之后我建议你回头做三件事第一把CubeMX生成的GPIO_InitTypeDef、TIM_HandleTypeDef结构体打开看一遍理解每个字段对应哪个寄存器位第二把stm32f4xx_it.c里的中断处理函数打开看看HAL库的封装层次第三在数据手册里找到EXTI和TIM的寄存器章节对照代码再浏览一遍。这三件事做完你对STM32的理解会从“会调用API”升级到“大概知道底层发生了什么”。以后再遇到问题就不会只能靠满世界搜索而是能自己推演出问题在哪一层。我个人在实际操作中还有一个体会中断名称跟库函数名称的对应关系一定要自己理一遍。比如EXTI0_IRQHandler对应HAL_GPIO_EXTI_IRQHandlerTIM2_IRQHandler对应HAL_TIM_IRQHandler这些看起来啰嗦的封装其实已经把很多重复工作帮你干完了。只要你记得“中断服务函数→HAL库处理函数→用户回调函数”这条链路后面学DMA中断、ADC中断都是一通百通。最后分享一个小技巧给中断里共享的所有变量加一个前缀或者放进同一个结构体比如g_key_count、g_tick_ms调试的时候就能一眼看出哪些变量可能被中断修改排查起来思路会更清晰。别小看这个命名习惯遇到难以复现的bug时它能帮你节省大量时间。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。