STM32F103双外部中断配置实战:EXTI映射、NVIC优先级与避坑指南

发布时间:2026/8/5 7:44:03
STM32F103双外部中断配置实战:EXTI映射、NVIC优先级与避坑指南 1. 项目背景与核心需求最近在做一个基于STM32F103的小型控制器项目里面有个需求挺有意思需要同时监控两个来自不同传感器的数字信号一旦信号发生变化主程序就得立刻停下手里的事去处理。比如一个传感器用来检测设备门是否被意外打开安全信号另一个传感器用来检测物料是否到位触发信号。这两个信号都要求实时性极高用轮询Polling的方式去查GPIO状态显然不现实既浪费CPU资源又可能错过关键的变化沿。这时候外部中断EXTI就成了最自然的选择。但问题来了STM32F103的同一个外部中断线比如EXTI0只能映射到一个GPIO引脚上。如果你想把PA0和PB0都配置成上升沿触发中断直接配置是会冲突的因为EXTI0的输入源只能选择一个。所以“同时配置两个外部中断”这个需求更准确的表述是如何为两个独立的GPIO引脚分别配置其专属的外部中断服务让它们能同时、独立地工作。这涉及到对STM32中断系统特别是EXTI和NVIC的深入理解和正确配置。网上很多教程只讲单个中断的配置当需要处理多个中断源时引脚冲突、优先级设置不当、中断服务函数ISR编写混乱这些问题就全冒出来了。我这次就把从原理到代码再到调试中踩过的坑完整地梳理一遍。2. STM32外部中断EXTI系统架构精讲在动手写代码之前必须把STM32F103的中断特别是外部中断的硬件机制搞清楚。这就像盖房子要先看图纸盲目堆代码只会导致后面调试时一头雾水。2.1 EXTI与GPIO的映射关系一把钥匙开一把锁这是理解多中断配置的基础也是最容易出错的地方。STM32F103有最多20个外部中断/事件线EXTI0 ~ EXTI19。注意这里的“线”是一个逻辑概念它并不是直接连到某个具体的引脚上。关键规则在于每条EXTI线在同一时刻只能连接到唯一一个GPIO端口的某个引脚上。具体映射关系是EXTI0可以选择连接到 PA0, PB0, PC0, PD0, PE0, PF0, PG0 中的任意一个。EXTI1可以选择连接到 PA1, PB1, PC1 ... PG1 中的任意一个。以此类推直到 EXTI15。这意味着PA0和PB0不能同时用作外部中断因为它们都需要占用EXTI0这条线硬件上只能二选一。如果你试图同时配置后配置的会覆盖先前的配置导致其中一个中断失效。那么如何实现标题说的“两个外部中断”呢答案很简单为两个中断源选择不同的EXTI线。例如将安全信号接到PA0使用EXTI0将触发信号接到PC1使用EXTI1。这样EXTI0和EXTI1就是两条独立的通道可以并行工作。注意EXTI16连接到PVD电源电压检测EXTI17连接到RTC闹钟EXTI18连接到USB唤醒EXTI19连接到以太网唤醒仅限互联型。这些通常用于特定外设不用于普通GPIO中断。2.2 中断与事件两条不同的处理路径EXTI模块的另一个重要特性是它支持“中断”和“事件”两种触发模式。很多人会混淆但其实它们的路径完全不同中断路径信号 → EXTI边沿检测 → 产生中断请求 → NVIC → CPU → 跳转到中断服务函数ISR执行软件代码。目的是让CPU执行一段处理程序。事件路径信号 → EXTI边沿检测 → 产生事件脉冲 → 直接触发其他硬件如DMA、ADC开始转换。目的是不经过CPU直接唤醒或触发其他外设。在我们的场景里显然是需要CPU执行代码来处理传感器信号所以必须配置为“中断”模式并确保中断使能。2.3 嵌套向量中断控制器NVIC中断的交通警察EXTI产生了中断请求谁来通知CPU谁来决定如果两个中断同时到来先处理谁这就是NVIC的工作。你可以把NVIC看作一个高度智能的中断调度中心。对于多个外部中断NVIC的两个配置至关重要中断使能Enable必须为每一条用到的EXTI线如EXTI0_IRQn, EXTI1_IRQn单独开启NVIC通道。只配置了EXTI而不开启NVIC中断永远不会被响应。中断优先级PrioritySTM32使用抢占优先级Preemption Priority和子优先级Subpriority的嵌套优先级模型。当两个中断同时挂起时抢占优先级高的先执行并且可以打断正在执行的、抢占优先级低的中断。如果抢占优先级相同则比较子优先级如果都相同则按硬件中断编号顺序决定。对于我们的双中断场景通常的配置策略是如果两个中断重要性相当且处理程序都很短可以设置为相同的抢占优先级。这样谁先来谁先执行互相不打断避免复杂的重入问题。如果安全信号的中断必须立即响应哪怕正在处理触发信号也要打断它那么就需要将安全信号中断的抢占优先级设置得比触发信号更高。3. 双外部中断配置的完整实战流程理论讲完我们进入实战。这里我以最常见的库函数开发方式标准外设库为例演示如何将PA0按键K1下降沿触发和PC13按键K2上升沿触发配置为两个独立的外部中断。选择这两个引脚是因为它们在最小系统板上通常都连接了按键方便测试。3.1 硬件与工程准备首先确认硬件连接K1 连接至 PA0默认上拉按下为低电平故采用下降沿触发。K2 连接至 PC13默认上拉按下为低电平但注意STM32F103的PC13在作为GPIO输出时驱动能力很弱不过作为输入中断源是没问题的。我们采用上升沿触发释放按键时以区分于K1。在IDE如Keil MDK中创建一个标准工程包含必要的启动文件、main.c、stm32f10x_it.c中断服务函数文件等并正确配置工程路径包含标准外设库的头文件。3.2 引脚与时钟初始化任何外设使用前必须先开启对应的时钟。EXTI的控制器SYSCFG时钟以及GPIO端口时钟都需要开启。void GPIO_EXTI_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; EXTI_InitTypeDef EXTI_InitStructure; NVIC_InitTypeDef NVIC_InitStructure; /* 1. 开启时钟 */ RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOC | RCC_APB2Periph_AFIO, ENABLE); // 注意AFIO复用功能IO时钟必须开启因为GPIO与EXTI的映射配置需要通过AFIO的寄存器完成。 /* 2. 配置PA0为上拉输入用于下降沿中断 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_0; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IPU; // 上拉输入默认高电平 GPIO_Init(GPIOA, GPIO_InitStructure); /* 3. 配置PC13为浮空输入用于上升沿中断 */ GPIO_InitStructure.GPIO_Pin GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; // 浮空输入板上已有上拉电阻 GPIO_Init(GPIOC, GPIO_InitStructure);3.3 配置EXTI0线映射到PA0下降沿触发接下来我们将PA0连接到EXTI0线并配置为下降沿触发中断模式。/* 4. 将PA0引脚连接到EXTI0线 */ GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0); // 这个函数内部操作的是AFIO-EXTICR[0]寄存器它告诉EXTI模块“EXTI0的输入信号请从PA0引脚取”。 /* 5. 配置EXTI0线 */ EXTI_InitStructure.EXTI_Line EXTI_Line0; // 选择EXTI0线 EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; // 设置为中断模式非事件模式 EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Falling; // 下降沿触发 EXTI_InitStructure.EXTI_LineCmd ENABLE; // 使能该线 EXTI_Init(EXTI_InitStructure);3.4 配置EXTI13线映射到PC13上升沿触发同理配置PC13。这里有个细节PC13对应的是EXTI13线。/* 6. 将PC13引脚连接到EXTI13线 */ GPIO_EXTILineConfig(GPIO_PortSourceGPIOC, GPIO_PinSource13); /* 7. 配置EXTI13线 */ EXTI_InitStructure.EXTI_Line EXTI_Line13; EXTI_InitStructure.EXTI_Mode EXTI_Mode_Interrupt; EXTI_InitStructure.EXTI_Trigger EXTI_Trigger_Rising; // 上升沿触发 EXTI_InitStructure.EXTI_LineCmd ENABLE; EXTI_Init(EXTI_InitStructure);3.5 配置NVIC让CPU能接收中断请求EXTI配置好了信号来了能产生中断请求但还得让NVIC放行并安排好优先级。/* 8. 配置NVIC为EXTI0中断设置优先级并使能 */ NVIC_InitStructure.NVIC_IRQChannel EXTI0_IRQn; // 中断通道号 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级设为1 NVIC_InitStructure.NVIC_IRQChannelSubPriority 0; // 子优先级设为0 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; // 使能该中断通道 NVIC_Init(NVIC_InitStructure); /* 9. 配置NVIC为EXTI15_10中断设置优先级并使能 */ // 注意EXTI10到EXTI15这6条线共享同一个中断向量即EXTI15_10_IRQn。 NVIC_InitStructure.NVIC_IRQChannel EXTI15_10_IRQn; // PC13属于这个范围 NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority 1; // 抢占优先级也设为1 NVIC_InitStructure.NVIC_IRQChannelSubPriority 1; // 子优先级设为1与EXTI0区分 NVIC_InitStructure.NVIC_IRQChannelCmd ENABLE; NVIC_Init(NVIC_InitStructure); }这里有一个非常重要的点中断向量共享。STM32为了节省中断向量表空间将多条EXTI线分组共享一个中断入口。EXTI0有独立的EXTI0_IRQnEXTI1有独立的EXTI1_IRQn但EXTI10到EXTI15这6条线共享一个EXTI15_10_IRQn。这意味着PC13EXTI13和PB12EXTI12如果都使能了中断它们触发时都会进入同一个中断服务函数。我们需要在服务函数内部通过检查标志位来区分是谁触发的。4. 中断服务函数ISR的编写与调试要点中断服务函数是中断触发后真正执行代码的地方。编写ISR有几个黄金法则快进快出、清除标志、避免阻塞操作。4.1 编写独立的EXTI0中断服务函数在stm32f10x_it.c文件中找到并修改EXTI0_IRQHandler函数。void EXTI0_IRQHandler(void) { // 1. 首先检查是否是EXTI0产生的中断标志 if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { // 2. 这里是你的中断处理代码 // 例如翻转一个LED表示K1被按下 GPIO_WriteBit(GPIOA, GPIO_Pin_1, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOA, GPIO_Pin_1))); // 3. 非常重要清除EXTI0线上的中断挂起标志位 // 如果不清除退出后会立刻再次进入中断形成死循环。 EXTI_ClearITPendingBit(EXTI_Line0); } // 理论上因为只有EXTI0映射到此函数这个if判断总是成立的。 // 但良好的习惯是总是先判断再处理再清除。 }4.2 编写共享的EXTI15_10中断服务函数对于PC13EXTI13我们需要在共享的中断函数中先判断是哪条线触发的。void EXTI15_10_IRQHandler(void) { // 1. 检查是否是EXTI13产生的中断PC13 if (EXTI_GetITStatus(EXTI_Line13) ! RESET) { // 2. 中断处理代码 // 例如翻转另一个LED表示K2被释放 GPIO_WriteBit(GPIOC, GPIO_Pin_13, (BitAction)(1 - GPIO_ReadOutputDataBit(GPIOC, GPIO_Pin_13))); // 注意PC13做输出要小心 // 3. 清除EXTI13线上的中断挂起标志位 EXTI_ClearITPendingBit(EXTI_Line13); } // 4. 这里还可以检查EXTI_Line10, Line11, Line12, Line14, Line15 // 如果这些线也开启了中断需要类似地判断和清除。 // if (EXTI_GetITStatus(EXTI_Line12) ! RESET) { ... } }提示在ISR中我用了翻转LED这种简单操作来示意。实际项目中常见的做法是设置一个标志变量volatile uint8_t flag_K1_Pressed 0;在ISR里将其置1然后在主循环中检查这个标志位并执行耗时操作。这保证了ISR的快速执行是中断编程的经典模式。4.3 主函数中的调用与测试在main.c中调用初始化函数然后进入主循环。#include stm32f10x.h int main(void) { // 初始化系统时钟等SystemInit通常由启动文件调用 // 初始化LED GPIO略 GPIO_EXTI_Config(); // 调用我们写好的中断配置函数 while (1) { // 主循环可以执行其他任务 // 例如检查由中断服务函数设置的全局标志位并进行处理 // if (flag_K1_Pressed) { ... flag_K1_Pressed 0; } } }编译下载程序后你应该能观察到按下PA0的按键对应的LED灯状态改变按下并释放PC13的按键另一个LED灯状态改变。两者互不影响实现了真正的“同时配置两个外部中断”。5. 实战中踩过的坑与深度避坑指南配置过程看似顺理成章但实际调试时我遇到了好几个让人抓狂的问题。这里分享出来希望能帮你节省大量时间。5.1 坑一忘记开启AFIO时钟这是新手最常掉进去的坑。症状是EXTI和NVIC配置看起来完全正确但中断死活不触发。你用仿真器跟踪发现即使短路引脚到地EXTI的挂起标志位PR也不置位。根因函数GPIO_EXTILineConfig是用来配置AFIO-EXTICR寄存器的而这个AFIO复用功能重映射和调试I/O配置模块的时钟是独立的。如果你没有通过RCC_APB2PeriphClockCmd开启RCC_APB2Periph_AFIO时钟那么对AFIO-EXTICR寄存器的任何写操作都是无效的GPIO引脚自然就没有和EXTI线连接上。排查与解决检查代码确保在GPIO初始化之前有RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);。在调试模式下查看AFIO-EXTICR[0]寄存器的值。如果你配置了PA0到EXTI0那么这个寄存器的低4位应该是0x0000。如果全是0xFF或0x00很可能就是时钟没开。5.2 坑二中断标志位未清除或清除方式错误症状是中断只进入一次之后再也进不去了或者更恐怖中断疯狂重复进入导致程序卡死。根因与解决不清除标志位中断服务函数执行完毕后硬件不会自动清除EXTI的挂起标志位。如果不清除该中断请求会一直存在导致CPU不断重复进入同一个中断。必须在ISR退出前调用EXTI_ClearITPendingBit(EXTI_Linex)来清除对应的标志位。错误清除标志位切勿使用EXTI_ClearFlag函数来代替EXTI_ClearITPendingBit。ClearFlag清除的是“事件”标志位而ClearITPendingBit清除的是“中断”挂起标志位。虽然它们操作的是同一个寄存器位但语义不同使用错误的函数可能导致中断状态机出现奇怪的问题。在中断服务函数中坚持使用EXTI_ClearITPendingBit。检查与清除的顺序一定要先使用EXTI_GetITStatus检查标志位再进行业务处理最后用EXTI_ClearITPendingBit清除。这个顺序是安全的。如果先清除再处理在极短的时间内如果再次发生中断可能会丢失这次中断事件。5.3 坑三共享中断向量内的标志位遗漏清除症状你只使用了EXTI13但程序运行一段时间后EXTI15_10_IRQHandler这个中断偶尔会莫名其妙地进入。根因虽然你只使能了EXTI13但EXTI10到EXTI15中其他线的中断标志位可能因为噪声、静电等原因被意外置位即使你没有配置它们为中断模式。由于这些线共享一个中断入口只要其中任何一条线的挂起标志位为1且NVIC使能就会触发中断。解决方案在EXTI15_10_IRQHandler函数的最后做一个“清扫”工作。在确保你自己的中断处理完毕后清除所有你可能用到的、以及容易受干扰的线的标志位。这是一种防御性编程。void EXTI15_10_IRQHandler(void) { // ... 处理EXTI13 ... // 防御性清除清除这个向量内所有可能的中断标志位 EXTI_ClearITPendingBit(EXTI_Line10 | EXTI_Line11 | EXTI_Line12 | EXTI_Line13 | EXTI_Line14 | EXTI_Line15); }5.4 坑四GPIO模式配置不当症状中断触发不灵敏或者总是误触发。根因GPIO的输入模式没选对。外部中断检测的是引脚上的电平变化如果引脚内部状态不稳定中断就会乱跳。浮空输入GPIO_Mode_IN_FLOATING引脚完全悬空极易受外部电磁干扰。除非外部电路有确定的上拉或下拉电阻否则不要用。上拉/下拉输入GPIO_Mode_IPU/IPD这是最推荐的模式。通过内部电阻将引脚拉到一个确定状态高或低。例如按键连接在引脚与地之间引脚就应配置为上拉输入这样未按下时为高电平按下时为低电平状态明确抗干扰能力强。建议始终根据你的外部硬件电路为中断引脚选择上拉或下拉输入模式避免浮空。5.5 坑五中断优先级设置不合理导致逻辑错误症状两个中断都能进但它们的执行顺序或打断关系不符合预期。比如低优先级的中断处理到一半被高优先级中断打断但一些共享资源如全局变量、外设访问没有保护导致数据错乱。分析与解决这不是BUG而是设计问题。你需要根据业务逻辑仔细规划中断优先级。无共享资源或处理极快如果两个中断服务函数都非常短只是置标志位且不访问任何共享的全局变量或硬件外设如UART发送那么将它们设为相同的抢占优先级是最简单的不会互相打断。有共享资源或处理耗时如果中断函数需要操作同一个全局变量、同一个SPI总线等或者某个中断函数执行时间较长就必须考虑优先级和临界区保护。方案A设置不同优先级让重要的、需快速响应的中断拥有更高的抢占优先级。但要小心高优先级中断打断低优先级中断时如果两者都操作同一个全局变量g_data必须在操作g_data的代码前后加上临界区保护__disable_irq()和__enable_irq()或者确保操作是“原子”的对于32位机操作8位、16位、32位对齐的变量通常是原子的。方案B保持同优先级在ISR中禁用自身中断在进入耗时较长的中断后先禁用本中断线EXTI-IMR ~EXTI_Linex处理完后再启用。这样可以防止自身中断重入但无法防止其他同优先级中断同时访问共享资源。对于大多数应用我推荐方案A并遵循以下原则中断服务函数只做最紧急、最必要的事如保存数据、清除硬件标志、设置软件标志耗时的处理放到主循环中基于标志位去完成。这样能极大减少中断服务时间降低优先级冲突的风险。6. 使用CubeMX进行图形化配置的注意事项如果你使用STM32CubeMX工具配置过程会直观很多但同样有一些细节需要注意。Pinout视图配置在芯片图上找到PA0和PC13分别将其功能设置为GPIO_EXTI0和GPIO_EXTI13。CubeMX会自动帮你配置GPIO为输入模式通常默认上拉你可以手动修改。NVIC配置切换到NVIC Configuration标签页。你会看到EXTI line0 interrupt和EXTI line[15:10] interrupts两个选项。务必勾选Enabled栏。优先级Preemption Priority可以在这里直接设置。CubeMX会自动生成开启AFIO时钟的代码。代码生成生成代码后你需要在stm32f1xx_it.c中找到对应的中断服务函数EXTI0_IRQHandler和EXTI15_10_IRQHandler添加你的处理逻辑。注意CubeMX生成的代码通常只在main.c中调用HAL_Init()和初始化函数中断服务函数是空的框架你需要自己填写业务代码和清除标志位。对于HAL库清除标志位通常通过调用HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0)和HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_13)来完成这些函数内部会调用你重写的回调函数HAL_GPIO_EXTI_Callback。使用CubeMX的优势是配置直观时钟和引脚冲突检查做得好。但劣势是它隐藏了底层细节当出现异常时你可能更难定位到是AFIO时钟没开还是NVIC优先级设置不对这类底层问题。因此即使使用CubeMX理解本章第2、3节讲的基本原理也至关重要。经过以上从原理到配置再到踩坑经验的完整梳理你应该对STM32F103上配置多个独立的外部中断有了透彻的理解。核心就是为每个中断源分配不同的EXTI线正确映射GPIO配置触发边沿并使能对应的NVIC通道。剩下的就是编写严谨、高效的中断服务函数并处理好它们之间可能存在的优先级和资源共享问题。在实际项目中灵活运用标志位机制坚持“中断快进快出”的原则就能构建出稳定可靠的实时响应系统。