在嵌入式圈子里有个老生常谈的说法能用寄存器解决的别用库能用标准库解决的别上HAL。虽然HAL库现在已经很普及但那些跑在稳定产线上的老项目十有八九还是标准库的底子尤其STM32F103ZET6这种经典大容量芯片用标准库写外部中断反而更容易把底层机制看通透。这个系列前两篇我们跑通了GPIO流水灯和按键轮询这篇进入重头戏——用外部中断实现按键控制LED。按键控制LED听起来就是“入门第一课”的水平但一旦牵扯到外部中断事情就没那么简单了。为什么这么说因为中断机制涉及GPIO输入模式、复用功能时钟、EXTI线配置、NVIC中断控制器、中断服务函数写法、标志位清除整条链路任何一个环节漏掉现象都是“按键怎么按都没反应”。这篇文章我会把整个链条拆开讲清楚每一步都解释为什么这样配置最后附上完整可运行的实测代码。适合刚学完标准库GPIO控制、想搞清楚中断底层原理的初学者也适合想复习EXTI机制的从业人员。1. 为什么按键控制LED必须用外部中断轮询与中断的本质差异1.1 轮询方案的痛点先回顾一下前一篇的做法。当时我们用按键控制LED主循环里不断调用GPIO_ReadInputDataBit去读PA0的电平读到低电平就认为是按键按下然后翻转LED再延时消抖。这套代码跑起来没问题但有一个非常明显的缺陷CPU干不了别的事。在while(1)里绝大多数的循环周期都浪费在“读一个没变化的引脚”上按键按下的那一刻MCU可能正在处理其他任务得等下一轮循环扫描到PA0才能响应。如果主循环里凑巧有一段耗时操作按键响应就会有可以感知的延迟甚至按下好几秒才有反应。这在真实项目中完全不可接受。还有低功耗场景。想让整个系统进入待机模式轮询方案就得定期唤醒CPU去查按键非常费电。而外部中断可以让CPU在无事件时深度睡眠按键按下那一刻硬件会自动唤醒MCU——这也是中断方案在绝大多数产品里最终胜出的原因。1.2 中断机制的“事件通知”模型用一个生活化的类比来理解。想象你坐在家里等一封重要邮件轮询的做法是每隔5分钟下楼开一次信箱看看有没有新邮件大部分时间都在重复无效劳动。而中断的做法是给邮递员一个电话邮件到了打电话给你你只需要在电话铃响的时候下楼取件。CPU也是一样不用一直盯着引脚看引脚状态发生变化时硬件会主动“打电话”过来。在STM32里这个“电话”的传递路径是这样的GPIO引脚的电平变化会传递给EXTI外部中断/事件控制器EXTI检测到配置好的边沿后把请求提交给NVIC嵌套向量中断控制器NVIC根据优先级决定是否打断当前正在执行的代码——如果允许CPU暂停当前任务保存现场跳转到对应的中断服务函数ISR执行处理完再跳回来继续原来的工作。1.3 一次完整的外部中断响应链路结合STM32F103ZET6的内部结构我把一次按键中断的完整流程拆一下触发源按键按下PA0引脚电平从高变低这是一个下降沿。边沿检测EXTI检测到下降沿后将EXTI挂起寄存器对应位置1。这就是中断标志位。中断请求因为EXTI_Line0对应的中断在NVIC中已经使能NVIC收到EXTI0的中断请求。优先级裁决NVIC比较当前请求的抢占优先级与CPU正在执行的代码优先级。如果抢占优先级更高则允许中断。现场保护与跳转CPU自动压栈保存当前PC、寄存器等现场信息然后跳转到中断向量表找到EXTI0_IRQHandler这个向量指向的地址。执行ISRCPU开始执行中断服务函数里的代码——判断按键状态、翻转LED。清标志返程ISR中必须手动清除EXTI中断标志位否则中断退出后还会不断重新进入。清完标志CPU恢复现场回到被中断打断的地址继续执行。这个流程中最容易忽略的就是第7步。标准库不会帮你自动清挂起位忘清一次现象就很奇特明明按键只按了一下LED却连续翻转好几次甚至整个程序看起来像卡死在中断里。这种问题不能只靠看代码解决得先理解标志位的含义。2. 标准库下的外部中断配置链GPIO、AFIO、EXTI、NVIC四件套2.1 先讲一个总览四条链路必须全部打通很多初学者第一次配置外部中断参考例程抄了一堆代码却不知道为什么需要这几步。外部中断的配置逻辑其实对应一条从引脚到CPU的完整链路GPIO把引脚配置成输入模式并且保证空闲时电平稳定。AFIO把GPIO引脚连接到对应的EXTI线上。这就是“布线”动作。EXTI配置中断线号、触发边沿上升沿、下降沿、双边沿、使能中断。NVIC打开中断通道设置优先级让CPU愿意响应这个中断。这四件事缺一不可。我在调试中见过最多的现象GPIO配置了EXTI也配置了NVIC也写了唯独忘了打开AFIO时钟结果中断死活不触发。所有代码看起来都很正确但引脚根本没有连到EXTI上信号传不进去。2.2 为什么必不可少的AFIO时钟AFIO全称是Alternate Function IO复用功能IO控制。外部中断的“连线”工作并不是在EXTI里做的而是通过调用GPIO_EXTILineConfig函数完成的。这个函数实际操作的是AFIO寄存器组里的EXTICR寄存器它负责把某个GPIO端口的某个引脚“映射”到相应的EXTI线。关键在这里EXTICR寄存器属于AFIO时钟域所以在调用GPIO_EXTILineConfig之前必须先使能AFIO时钟RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);这句代码放在整个外部中断初始化的最前面。我见过不止一个案例把AFIO时钟使能忘掉然后在NVIC、EXTI配置里反复折腾浪费一晚上。记住EXTI的引脚连接依赖于AFIOAFIO依赖于时钟使能。这一环断了后面全白搭。AFIO时钟还有一个作用是为引脚重映射提供支持。比如串口1的TX/RX默认在PA9/PA10可以重映射到PB6/PB7重映射操作同样需要先开AFIO时钟。所以如果你的工程里既要重映射又要外部中断AFIO时钟只使能一次就够了。2.3 沿选择与按键电路按键的硬件接法决定了触发方式。最常见的接法是按键一端接GND另一端接单片机引脚引脚在空闲时被配置为内部上拉输入因此电平默认是高。按键按下时引脚被拉到GND电平变成低松开后恢复高。所以一次完整的按键动作产生的是一个下降沿和一个上升沿。那到底应该用下降沿还是上升沿触发如果只做“按下控制LED翻转”标准做法是用下降沿。按键按下的那一瞬间触发比较自然。使用上升沿也可以但要小心机械按键在按下和松开的瞬间会有抖动抖动期间会出现多个上升沿和下降沿。如果你既配了下降沿又配了上升沿双边沿触发一次按键按下去再松开中断函数能被触发很多次后面消抖就更麻烦。按键引脚的空闲电平问题也值得注意。标准库GPIO配置为浮空输入时引脚完全靠外部电路决定电平如果外部电路没有上拉、也没有下拉那引脚就是悬空的电平不确定中断可能会被随机触发。所以按键闲置时要么在外部电路加上拉电阻要么在GPIO初始化时直接配置内部上拉GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入在STM32F103ZET6内部都有上拉电阻可以配置这比以前用51单片机省事多了。2.4 NVIC分组与完整配置函数NVIC是ARM内核自带的中断控制器。使用标准库配置NVIC时第一件要做的事情是设置优先级分组NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);这个函数在整个工程里只应该调用一次并且最好放在main函数最开始的位置。NVIC优先级分组分为0到4一共5种。以Group_2为例也就是2位用于抢占优先级、2位用于子优先级。抢占优先级决定是否能够打断正在执行的另一个中断服务函数子优先级是在抢占优先级相同的情况下谁先被执行。按键这种应用对实时性要求不算苛刻抢占优先级给个中等偏上的值就可以。比如设为1给更紧急的串口通信或定时器任务留出更高优先级。一组典型的配置是NVIC_InitTypeDef NVIC_InitStructure; NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; // 外部中断0 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级1 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级1 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; // 使能通道 NVIC_Init(NVIC_InitStructure);这里需要留意一个命名问题EXTI0_IRQn、EXTI1_IRQn这类中断通道只对应EXTI线0到4。扩展线5到9是一个共享的中断通道EXTI9_5_IRQn线10到15共享EXTI15_10_IRQn。也就是说多个不同引脚的外设中断可能挤在同一个中断服务函数里。后面我会提到这对代码结构的影响。3. 完整代码拆解从硬件接线到按键翻转LED3.1 硬件接线前提与引脚约定先约定一下本文的硬件环境方便对照实验。按键接在PA0和GND之间外界知道PA0空闲时为高、按下时为低。LED接在PB5引脚串联一个330欧姆限流电阻到GNDPB5输出低电平时LED点亮。这几乎是STM32F103开发板上最常见的接法不同开发板引脚略有差异但原理一致。接线确认工作往往被省略但这其实是排查问题最快的第一步。拿到一块开发板先别急着写中断程序可以先用GPIO输出加上一点点轮询逻辑跑一个最基础的程序确认PA0的电平变化和PB5的LED亮灭都正常。等硬件验证通过再进行中断改造。很多“中断不生效”的问题根源其实是按键杜邦线接触不良或者LED接反了限流电阻位置——这些硬件问题在中断代码里排查完全是浪费时间。3.2 软件工程结构与初始化顺序标准库工程一般有一个main.c用来放主函数和各个模块的初始化函数中断服务函数可以放在stm32f10x_it.c也可以跟外设配置放一起写法上各有各的习惯。我倾向于把按键相关的初始化和中断回调放在同一个模块文件里这样按键逻辑集中管理其他文件里引用起来也清晰。初始化顺序有讲究。优先级分组要放在最前面其次配置时钟、GPIO、AFIO、EXTI、NVIC。顺序不完全固定但有一个原则被依赖的资源要先准备。GPIO要先配好EXTI连接才能引用到引脚NVIC使能放在EXTI配置之后这样中断使能的那一刻外部中断已经处于可触发状态不会出现“中断先使能了引脚却还没配好”的尴尬局面。3.3 外部中断初始化模块的完整代码下面给出我实测通过的外部中断初始化模块直接可以用标准库编译void KEY_EXTI_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; // 1. 打开按键引脚和LED引脚所在端口时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); // 2. 配置按键引脚PA0为上拉输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; GPIO_Init(GPIOA, GPIO_InitStructure); // 3. 将PA0连接到EXTI线0 GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 4. 配置EXTI线0下降沿触发中断模式 EXTI_InitStructure.EXTI_Line EXTI_Line0; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure); // 5. 配置NVIC使能EXTI0中断通道 NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2); NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }注意第1步中我把GPIOA时钟和AFIO时钟放在同一个RCC_APB2PeriphClockCmd调用里一起打开。这样写是标准库支持的一个函数可以同时使能多个外设时钟用或运算做参数拼接即可。分开使能也可以但统一写在这里更简洁也减少了漏写的概率。3.4 中断服务函数与主函数的完整代码中断服务函数的关键动作有三步判断中断源、处理业务、清除标志位。对应代码是void EXTI0_IRQHandler(void) { uint8_t led_status; if (EXTI_GetITStatus(EXTI_Line0) ! RESET) // 判断EXTI线0是否有中断标志 { delay_ms(20); // 简单延时消抖实际工程中建议移出ISR if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) // 二次确认按键确实被按下 { led_status GPIO_ReadOutputDataBit(GPIOB, GPIO_Pin_5); GPIO_WriteBit(GPIOB, GPIO_Pin_5, (led_status) ? Bit_RESET : Bit_SET); } EXTI_ClearITPendingBit(EXTI_Line0); // 清除中断标志位这一步绝对不能忘 } }主函数就简单了int main(void) { GPIO_InitTypeDef GPIO_InitStructure; // 配置PB5为推挽输出驱动LED RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_5; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOB, GPIO_InitStructure); GPIO_SetBits(GPIOB, GPIO_Pin_5); // 初始关闭LED KEY_EXTI_Init(); // 外部中断初始化 while (1) { // 主循环可以安心做其他任务按键响应由中断搞定 } }用GPIO_ReadOutputDataBit读取当前LED输出状态再通过三目运算符取反写入比直接操作ODR寄存器更符合标准库风格。这种翻转逻辑我在多个项目里反复用过可读性也还行。3.5 编译烧录后的预期效果与验证方法程序下载后现象应该是按下按键LED状态翻转一次再按一次再次翻转。按键按住不放保持几秒再松开LED也只翻转一次因为硬件下降沿只产生了那么一次。验证中断是否真正生效有个简单粗暴又直观的方法在主循环的while(1)里让另一个LED或者其他引脚不断闪烁。如果按键按下后LED翻转之外原来的闪烁也一直在继续就说明CPU确实被“打断”过又恢复回了主循环。如果闪烁停了或者异常说明中断导致主循环没回来通常是标志位没清或者堆栈设置有问题。4. 实测中躲不开的几个深坑4.1 EXTI线路冲突PA0和PB0不能共存STM32F103的外部中断线数量是有限的只有EXTI0到EXTI15这16条。GPIOA、GPIOB等端口的引脚编号相同的共用同一条EXTI线。比如PA0、PB0、PC0都对应EXTI0但在同一时刻EXTI0只能连接到其中一个引脚。很多项目踩过这个坑PA0接了按键同时还把某个传感器输出接到了PB0两个引脚都初始化成外部中断。配置后你会发现后配置的那个把先配置的“挤掉了”。因为你调用GPIO_EXTILineConfig时EXTI0这条线跟PA0的连接会被PB0的连接覆盖。解决方案有三个思路把不同功能的引脚分配到不同的EXTI线号上也就是说PA0、PB1、PC2这种不同编号的引脚才能同时使用。如果必须使用多个引脚完全可以用一个EXTI线对应的中断服务函数里同时检测多个不同线的标志比如EXTI9_5_IRQHandler里遍历判断Line5到Line9。用查询方式处理其中一个功能把中断资源让给关键的那个。4.2 中断服务函数里的延时消抖代码能跑但藏着隐患文章前面给出的例子里我在中断服务函数里加了个20毫秒延时。这种写法很多教程都在用跑起来也没什么问题但我得负责任地提醒你真实项目中不要在ISR里放延时。为什么因为中断服务函数执行期间CPU不能执行主循环里的任务也不能执行同优先级或低优先级的中断服务函数。如果在ISR里延了20毫秒相当于整个系统白白卡住了这20毫秒。如果系统里恰好有更紧急的任务比如串口接收超时判断、定时器精确计时这20毫秒的卡顿就可能导致问题。在极端情况下如果多个中断同时到达高优先级中断一直被这个延时所阻塞系统还会出现不可预测的响应抖动。更好的做法是把“消抖”和“动作执行”分离volatile uint8_t key_pressed_flag 0; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { key_pressed_flag 1; // 只做一个标记立刻退出ISR EXTI_ClearITPendingBit(EXTI_Line0); } }主循环里再判断标志while (1) { if (key_pressed_flag) { key_pressed_flag 0; delay_ms(20); // 消抖放在主循环里 if (GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) 0) { GPIOB-ODR ^ GPIO_Pin_5; } } // 其他任务照常执行 }这样做的好处是ISR的执行时间只有几个微秒系统的实时性几乎不受影响。消抖的20毫秒放在主循环里虽然算不上完美但至少不会阻塞其他中断。再想进一步优化可以把消抖也改成基于定时器的时间戳方案不过那就是后话了。4.3 现场调试的几个实用技巧与排查思路中断程序不工作现象很一致——按键按下去LED没反应。排查思路如果不成体系很容易绕圈子。我按经验给出一个排查顺序基本能覆盖90%的问题第一步确认中断到底有没有进。在EXTI0_IRQHandler最开头不要清标志不要处理业务先翻转一个调试引脚或者在ISR里设置一个静态变量计数。比如volatile uint32_t irq_count 0; void EXTI0_IRQHandler(void) { irq_count; if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { EXTI_ClearITPendingBit(EXTI_Line0); } }调试器里看irq_count有没有增加。没增加说明信号根本没到EXTI增加了但LED不翻转说明中断服务函数内部逻辑有问题。这一下就能把问题范围缩小一半。第二步按链路逐个排查。中断没进来的话先从GPIO开始查起。用调试模式读PA0的IDR寄存器按下按键时电平是否变化。如果不变化查硬件接线、查GPIO模式是不是配成了浮空输入且外部没有上拉。然后查AFIO时钟再看GPIO_EXTILineConfig参数是否写对了端口源和引脚源。接着查EXTI_Init里的触发方式最后查NVIC是否真的使能了对应中断通道。这个顺序是从硬件到外设、从引脚到内核老老实实走一遍问题一定会浮出来。第三步留意调试器下的中断陷阱。如果用调试器在中断服务函数里下了断点你会发现每进一次中断程序就停住看起来像死机其实是错觉。还有如果在ISR里使用串口打印而串口发送又依赖查询等待标志位而串口中断优先级比EXTI低极端情况下串口发送会被EXTI打断产生奇怪的现象。调试时优先用变量观察少用打印。第四步配置踩到HardFault要警惕。如果程序下载后一启动就跑进HardFault_Handler基本可以判断是中断服务函数没有被正确实现。中断一旦被触发CPU会跳转到中断向量表中对应的地址如果这个地址没有有效的ISR就会一路跑到默认的HardFault。检查一下EXTI0_IRQHandler是否正常定义、是否拼写错了函数名。最后分享一个我个人的调试小习惯给中断服务函数里的业务逻辑入口加一个计数器。任何一个外部中断都要先在ISR入口累加计数再去判断标志位。这个习惯养成了所有中断问题都能在几分钟内定位到是“没进来”还是“进来出不去”省下的时间远比写那两行代码多。按键控制LED这个实验做到这里外部中断的核心机制算是真正落地了。接下来想深入的话可以在这个基础上把EXTI和定时器配合起来实现长按检测、双击识别、组合按键这类更接近真实产品的功能。思路都是一样的中断只负责“告诉CPU有事件发生”具体怎么解析事件交给主循环或者定时器去处理。