
开始用寄存器写STM32之后我最大的感受是库函数把外设包装得再漂亮最后真正一起配合工作的还是那一堆寄存器。上一章聊过GPIO这一章继续走寄存器路线把外部中断这一件事用大白话讲透。外部中断入门简单但真正踩坑的人非常多因为它和GPIO不同不再是你写一个寄存器位就能完事的东西而是横跨GPIO、复用功能、中断控制器、内核向量表四块内容任何一个环节断了现象都是“按键没反应”。这篇内容适合两类人刚把GPIO点灯跑通、想进一步理解芯片底层到底怎么响应外部信号的初学者以及用固件库写了好几个项目、却说不清中断服务函数为什么能被执行的老工程师。这一章按我的习惯来先讲清楚整条链路再给寄存器代码最后把我实际调过的坑全部摆出来。1. 为什么第二章讲外部中断先建立一个整体框图1.1 中断不是外设而是一条完整的事件管道很多新手把外部中断当成一个独立外设来学这是个误区。外部中断没有自己的状态机它实际上是一套“检测并传递事件”的机制引脚电平发生变化先送到边沿检测电路边沿检测电路把结果记录到一个挂起标志里然后由中断控制器决定要不要打断CPU正在执行的代码CPU被允许打断后跳到一个固定地址执行中断服务函数执行完再回到原来的位置继续干活。这个链条里每一环都有自己的寄存器而且分散在芯片的不同地址区域。我在第一章讲GPIO时说过点灯的本质就是往ODR寄存器里写0或1。但外部中断的整个链路要复杂得多GPIO负责把引脚状态送进来AFIO负责选择“这个引脚连接到哪一条EXTI线”EXTI寄存器负责检测上升沿或下降沿并产生请求NVIC负责仲裁这个请求的优先级最后CPU响应后跳转到向量表指向的中断函数。写中断代码本质上就是在配置这条管道上的每一个阀门。1.2 这一章学完你能做什么当你把这条管道全部用寄存器串起来你会获得几个非常实用的能力。第一手动配置外部中断初始化不再依赖图形化配置工具生成的那堆代码出了问题自己看得懂。第二真正理解“清标志位”为什么那么重要以及误操作会导致什么现象。第三掌握一套通用的中断调试思路以后学串口、定时器、ADC时处理中断相关问题的逻辑是一样的只是换了个外设名而已。我见过不少开发者固件库用了两三年遇到“中断只进一次”依然只能靠网上搜答案。原因很简单他们把中断当函数调用没有理解硬件挂起和软件清除的关系。这一章把这些东西用寄存器层面对齐你会发现自己以后写任何中断外设都有底气了。2. 动手写代码前先把“谁管谁”搞清楚2.1 EXTI、NVIC、CPU三者的分工拿到参考手册你会看到外部中断相关的寄存器主要集中在“EXTI”这一章但真正用起来还牵扯到GPIO、AFIO、NVIC甚至启动文件里的向量表。我第一次接触时也觉得头大后来用一个比喻才理清EXTI是门铃按钮NVIC是处理门铃声音的控制面板CPU是正在书房里干活的屋主。门铃按钮被按下只是产生了一个“有人来了”的电信号控制面板判断这个信号是否重要、是否要打扰屋主以及把哪一路门铃排在最优先的位置屋主听到铃声后停下手头的工作走到门口处理事情处理完再回书房继续干活。所以当你写外部中断初始化时至少要做四件事把引脚配置成输入让GPIO能读到外部电平把引脚通过AFIO连接到对应的EXTI线配置EXTI检测边沿并使能中断请求最后配置NVIC让CPU“愿意”响应这个请求。绝大多数“不触发”的问题都出在这四件事里漏了一件。2.2 中断模式和事件模式不是一回事EXTI单元本身提供两种输出中断请求和事件请求。中断请求意味着向NVIC发出信号NVIC再决定是否让CPU停下来执行中断服务函数。事件请求则不走NVIC它直接把一个脉冲信号送给其他外设比如触发ADC开始一次转换、触发定时器捕捉输入全程不需要CPU参与。这两种模式对应的使能寄存器不同中断模式用EXTI_IMR事件模式用EXTI_EMR边沿检测配置用的都是RTSR和FTSR。初学阶段我建议先把中断模式用熟因为事件模式的收益需要配合其他外设才体现得出来比如用外部事件让ADC进行高速采样能省掉频繁进入中断的开销。但要心里有数如果哪天你看到别人代码里只开了EMR、没开IMR还跟你说“能采样”那不是写错了而是用了事件模式。2.3 每条EXTI线只能接一个引脚EXTI线的编号是固定的从EXTI0到EXTI15一共16根。GPIO也有16个引脚PA0、PB0、PC0这些引脚编号相同的引脚共享同一条EXTI线。也就是说PB0和PC0如果想用外部中断都得通过EXTI0这条线但同一时刻EXTI0只能从它们中间选一个作为输入源。这个选择就是AFIO寄存器组里EXTICR系列寄存器干的事它的每一个四比特位域对应的就是一条EXTI线值0代表PA组1代表PB组2代表PC组以此类推。这也是初学者最容易被绕晕的地方GPIOA端口号也好GPIOB端口号也好不管哪个端口只要引脚编号是0走的就是EXTI0。换句话说外部中断的线编号跟着引脚号走不跟着端口号走。PC13按键的中断对应EXTI13PB5对应EXTI5非常机械。3. 从零配置一个按键中断初始化代码逐步拆解3.1 实验场景与接线方式拿一个最常见也最稳妥的实验来说一个按键接在PA0引脚按键另一端接GND板子上PA0不接其他东西。芯片内部配置成上拉输入这样按键没按下时PA0读到高电平按下时PA0被拉到GND读到低电平松手后又回到高电平产生一个下降沿。我们的目标是在这个下降沿触发EXTI0中断在中断里翻转PA1上的LED状态。如果你的按键接法刚好相反一端接电源、一端接PA0那触发沿要改成上升沿内部下拉也通过ODR位写0来实现除此之外其他逻辑不变。记住一个原则边沿方向要和外部电平变化方向一致否则中断永远不会来。3.2 初始化顺序与完整代码先说顺序这个顺序我建议第一次写的时候严格遵守开GPIO时钟和AFIO时钟把引脚配置成合适的输入模式配置AFIO选择EXTI线来源配置EXTI边沿检测使能EXTI中断请求配置NVIC优先级最后写中断服务函数。顺序乱了比如先配NVIC再开时钟代码也能跑但排查问题时会多绕很多弯。完整的初始化代码和中断服务函数如下我直接给出可以复制到工程里验证的版本。void EXTI0_Init(void) { // 1. 打开GPIOA和AFIO的时钟 RCC-APB2ENR | RCC_APB2ENR_IOPAEN; RCC-APB2ENR | RCC_APB2ENR_AFIOEN; // 2. PA0配置为上拉输入注意是输入模式不是输出模式 GPIOA-CRL ~((uint32_t)0x0F 0); GPIOA-CRL | ((uint32_t)0x08 0); GPIOA-ODR | (uint32_t)1 0; // 3. AFIO选择EXTI0的来源为PA0PA0对应值0默认也是0 AFIO-EXTICR[0] ~((uint32_t)0x0F 0); // 4. 下降沿触发上升沿禁止边沿使能 EXTI-FTSR | (uint32_t)1 0; EXTI-RTSR ~((uint32_t)1 0); // 5. 使能EXTI0的中断请求不是事件请求 EXTI-IMR | (uint32_t)1 0; // 6. 配置NVIC这里用分组2抢占优先级1子优先级0 SCB-AIRCR 0x05FA0000 | (0x500); NVIC-IP[6] (uint8_t)((1 4) | (0 0x0F)); NVIC-ISER[0] | (uint32_t)1 6; } void EXTI0_IRQHandler(void) { if (EXTI-PR (uint32_t)1 0) { // 先清除挂起标志再执行实际功能 EXTI-PR (uint32_t)1 0; GPIOA-ODR ^ (uint32_t)1 1; } }这里面第6步的NVIC配置比较有讲究。SCB-AIRCR那行设置优先级分组0x500在常用的固件库里对应“2位抢占优先级2位子优先级”也就是总共4位优先级寄存器拆成前两位和后两位。NVIC-IP[6]是配置EXTI0中断优先级的地方高4位有效我写的是抢占优先级1、子优先级0两个数值都只在各自范围内有意义。最后NVIC-ISER[0]的bit6对应的就是EXTI0中断编号。3.3 GPIO配置成输入为什么还要搞四个位在CRL寄存器里PA0对应最低一个四比特位域其中低两位是MODE高两位是CNF。如果你直接对着手册看会觉得繁琐但组合起来很有规律MODE为00表示输入CNF为10表示上拉或下拉输入CNF为01表示浮空输入CNF为00表示模拟输入。我上面写0x08二进制就是1000放到四比特位域里正好是CNF10、MODE00输入模式里的上拉/下拉输入。那到底上拉还是下拉由ODR寄存器的同一位决定。ODR位写1是上拉写0是下拉。所以第3行GPIOA-ODR | 1 0;就是为了把PA0内部上拉打开。很多人把ODR当成输出数据寄存器觉得在输入模式下没用实际上它在输入模式下就是上下拉的开关这点特别容易漏。3.4 中断函数名和向量表是绑死的EXTI0的中断服务函数只能叫EXTI0_IRQHandler这个名字不是随便起的它定义在启动文件的中断向量表里。启动文件给每个中断安排了一个固定地址里面放了一个函数指针这个指针默认指向一个空处理函数。你写了一个同名函数链接器就会把它放到向量表对应的位置你写错了名字比如写成EXTI0_Handler链接器会认为向量表里还是那个空处理函数触发中断后程序会跳进一个空循环里看起来就是“没反应”。如果按键触发时程序跑飞或者卡死先查一下中断服务函数名字和向量表是否一致。常见的外部中断函数名我列在下面方便对照EXTI0到EXTI4是对应中断号6到10的EXTI0_IRQHandler、EXTI1_IRQHandler直到EXTI4_IRQHandlerEXTI5到EXTI9共用EXTI9_5_IRQHandlerEXTI10到EXTI15共用EXTI15_10_IRQHandler。4. 中断触发与清挂起最容易翻车的细节4.1 写1清除挂起为什么这么反直觉外部中断请求产生后硬件会在EXTI_PR寄存器里把对应位置1这就是“挂起标志”。只要挂起标志还是1中断控制器就会一直看到这个请求存在但它不会无休止地进入同一个中断因为NVIC在同一优先级下会合并挂起状态。关键问题在于这个标志怎么清除很多人第一次写会写成EXTI-PR ~(1 0);想把位置0。结果发现中断要么不触发要么永远卡在中断里不出来。原因很简单EXTI_PR是一个写1清除寄存器硬件约定“软件往这个位写1表示确认处理完这次事件硬件随即把该位清0”。它和普通读写寄存器完全反着来你写0对它来说相当于没操作。我推荐直接写EXTI-PR (1 0);而不是用或赋值。原因是在高速触发场景下读改写操作存在竞态风险你在读PR的瞬间另一个外部事件已经置位了另一个挂起位你改完再写回去可能会把这个新事件也一并处理掉。直接写自己那一位既完成清除又不影响其他位。4.2 挂起不止一层NVIC里还有一个Pend很多人在清完EXTI_PR之后发现中断还是会连续进入两次于是怀疑清标志的代码没生效。其实这里藏着一层更深的机制NVIC自己也有一个挂起状态叫Pending。外部中断触发时EXTI_PR置1同时NVIC收到请求也会在内部记录一个挂起。如果你的中断服务函数正在执行此时同一条EXTI线又来了一次触发EXTI_PR会被重新置1NVIC也会把新的挂起叠加上去。你在函数末尾清EXTI_PR只能清除外设级的挂起NVIC内部可能还压着一条请求函数一退出它又立刻进入一次。这种“清不干净”的典型表现就是一次机械按键按下去中断函数连续跑了两遍第二遍看起来莫名其妙。如果遇到这个问题除了检查按键硬件抖动还可以在中断服务函数里清PR之后把NVIC的挂起也清一下写NVIC-ICPR[0] | (1 6);。这个寄存器同样是写1清除它的工作就是把NVIC里残留的挂起请求清掉。当然这只是一种兜底更关键的是理解你的程序里是否真的会产生多余的NVIC挂起。4.3 优先级分组决定了谁能打断谁有个高频误解我把外部中断优先级写成了数字1怎么还是被打断了你得先搞清这个数字的哪几位是抢占优先级、哪几位是子优先级这完全由优先级分组决定。我上面代码里用的是分组2也就是2位抢占、2位子优先级此时优先级寄存器里高两位是抢占优先级低两位是子优先级。我配置NVIC-IP[6]时写入(1 4)意思是抢占优先级为1子优先级为0注意写入时要左移4位把值放到高四位去。抢占优先级才是决定能否打断别人的关键。如果串口中断的抢占优先级是2外部中断是1那么外部中断发生时会打断串口中断。如果两者抢占优先级都是1那么子优先级决定谁先响应如果抢占和子优先级都一样NVIC会按中断编号的自然顺序来决定编号小的先响应。在项目里配置多个中断时一定要先分好组再给每个中断定抢占优先级否则会出现两个中断互相等待或者响应顺序混乱的问题。5. 实际项目里常见的五个坑和排查思路5.1 怎么按都不进中断按键按到手酸中断一次都不进这是最常见的开局。我建议不要乱改代码按顺序做一次静态检查时钟GPIO模式AFIO映射EXTI边沿EXTI使能NVIC中断函数名。这七项里任何一项错了都进不去。其中有两个坑最容易藏一个是忘记打开AFIO时钟导致EXTI始终选不到想要的引脚另一个是把GPIO配置成了输出模式输出模式下引脚不读外部电平中断自然永远不触发。如果静态检查没问题打开调试器看寄存器实时值。程序全速跑在中断服务函数第一行断点按一下按键如果停不下来看EXTI_PR寄存器按键按下后如果它变成1说明外部信号已经到达EXTI问题出在NVIC或者函数名上如果它一直是0问题出在GPIO配置或者外部电路上。这一招能把排查范围直接砍掉一半。5.2 触发一次以后再也没动静和“完全不动”不同能进一次说明大链路是通的后面不再动常见原因有五个。第一个也是最常见的清PR时用了写0操作其实标志一直没被清掉程序可能已经卡在中断里或者逻辑异常。第二个按键按下时电平确实变化了但松开时又产生了一个反方向的沿而你可能两个沿都开着了进了两次之后状态又翻转回去看起来像“再也没反应”。第三个EXTI线被其他程序段重新改成了别的引脚AFIO配置被覆盖。第四个老代码里把GPIO重新配置成了复用功能导致引脚不再是一根普通输入脚。第五个使用中断函数的同时代码里又定义了一个同名的中断处理函数在链接时把真正要用的覆盖了。排查手段还是看PR但这次要分两个时间点按下时看PR松开时再看PR。如果松开时PR也置1了那就是上升沿也被触发了去RTSR里把那一位清掉就行。5.3 一次按键触发了两次甚至三四次按键机械抖动是首要嫌疑。按键按下的瞬间金属弹片会连续接触断开几次时间短则几毫秒长则几十毫秒也就是说你按下一次边沿检测可能看到好几个有效沿。硬件上可以给按键并联一个RC低通滤波器或者直接加一个施密特触发器芯片但最简单可靠的办法是软件消抖。我不推荐在中断服务函数里Delay因为延时会让这条中断的优先级变得形同虚设其他紧急事情全部被堵住。推荐做法是中断里只置一个标志位主循环检测到标志后用状态计数消抖。比如每次扫描都读按键电平如果连续读到同一个电平均多少次以上才认为状态确认为改变了。这个做法不会浪费CPU响应也足够快。uint8_t btn_count 0; uint8_t btn_state 1; void KeyScan(void) { uint8_t level ((GPIOA-IDR (1 0)) 0) ? 1 : 0; if (level btn_state) { btn_count 0; } else { btn_count; if (btn_count 5) { btn_state level; btn_count 0; } } }这段代码在主循环里反复执行levle为1表示按下状态连续5次都读到1才确认按键真的按下。这个方案比在中断里Delay要健康得多原因是主循环哪怕稍微慢了一点也不会阻塞其他中断的及时响应。5.4 中断服务函数里干了太多活系统卡顿这是新手最容易又最隐蔽的坑。外部中断触发了你想在中断里打印日志、刷新屏幕、写Flash然后把整个逻辑都放进ISR。中断服务函数执行期间CPU处于特权状态大部分其他中断都在等它如果ISR执行了10毫秒这10毫秒内连系统滴答定时器都得不到及时处理表现出来的就是系统卡顿、界面停顿。正确的做法是让ISR短小精悍先判断挂起位清挂起置一个全局标志或者读取需要的状态然后立即退出。真正耗时的工作放到主循环里处理。ISR里只做“贴封条”的动作别在里面搞“施工”。如果确实需要快速响应外部信号并保存波形数据那也要保证每个操作都是必要的不能出现打印这种慢速外设操作。5.5 读寄存器发现写的值和读出来的不一样写寄存器后回读不对这种事我遇到过好几次大部分时候不是芯片坏了。最常见的原因是外设时钟没开。你在RCC里没打开GPIO时钟往GPIO寄存器写值写操作就像打到了空气墙上硬件外设根本没有工作时钟寄存器不状态。调试器的外设窗口还会显示你写入的值因为调试接口模拟了总线写入但实际内部电路没跑起来。所以一开始我总会强调开时钟永远是第一步。另一个常见原因是读改写之间的残留。比如你想把PA0模式改成输入只在CRL寄存器用了按位或而没有先清除原有四位的值这就会和之前的配置叠加出奇怪的模式。正确做法是先与一个反掩码清零再或上目标值。代码习惯上我把这两句写在一起宁可多花一遍读写也不要让状态残留。6. 调试验证与进阶思路6.1 最实用的五步调试法我把外部中断的调试总结成五步每一步都在回答“信号走到了哪”。第一步在中断服务函数第一行打断点全速运行按按键看能不能停住。第二步如果停不住看EXTI_PR有没有因为按键而变成1以此判断外部信号是否进入了EXTI。第三步如果EXTI_PR是1但仍没进中断去查NVIC的使能位和挂起状态看中断请求是否到达了NVIC。第四步如果NVIC里一切都对查中断服务函数名和向量表是否匹配。第五步如果中断函数确实进去了但功能没实现再查ISR内部的清标志和执行逻辑。这套思路最大的好处是每一步都对应一个独立的硬件模块不会出现“改一下AFIO、改一下NVIC、再改一下GPIO”这种乱枪打鸟式的操作。我调试寄存器外设时基本都是这么一层一层剥开的。6.2 不同型号的寄存器差异要注意上面所有代码基于STM32F1系列。如果你用的是其他系列的芯片寄存器名字和结构会有变化。F4系列里AFIO这个模块被SYSCFG替代引脚的EXTI选择寄存器不叫AFIO_EXTICR而是放在SYSCFG里名字也变成了SYSCFG_EXTICR写代码时如果照着F1的寄存器名找编译都过不了。EXTI本身的功能大同小异但IMR、EMR、RTSR、FTSR这些寄存器的偏移和位数也可能在不同系列里有细微差别。所以我强调一个习惯拿到新板子先打开对应型号的参考手册找到EXTI章节对照寄存器地址和位定义写代码不要直接抄其他型号的程序。固件库和HAL库里的寄存器结构体定义已经帮你把地址算好了但不代表寄存器含义也一定一样查手册永远是最终答案。6.3 三个可以继续练下去的小实验基础按键中断跑通之后建议做三个扩展实验把这块真正吃透。第一个实验把按键从PA0挪到PB0只改AFIO_EXTICR的配置体会端口不同但EXTI线编号不变的逻辑。第二个实验试着同时使用两个不同的EXTI线比如PA0和PC13分别进不同的中断服务函数看看NVIC的两个使能位怎么独立工作。第三个实验再看一眼EXTI_EMR把它使能后触发事件配合ADC采样或者定时器输入捕获体会一下“事件”和“中断”的区别到底在哪。实验做到第三步你会对“外部中断”这个词有一个完全不一样的理解它不只是让CPU停下工作去处理按键更是一套通用的信号分发机制可以把外部脉冲送给不同的外设而不一定非要CPU介入。这种理解一旦建立后面学串口空闲中断、定时器捕获比较中断都能轻松迁移。最后说点我在实际调试里的体会。外部中断这类东西十个新手九个栽在清标志上还有一个栽在AFIO时钟上。遇到怪现象别急着改代码先开调试器的外设寄存器窗口把EXTI-PR盯住按一下按键看看它有没有变化。这一位变了问题就在后面的NVIC或中断函数这一位不变问题就在前面的GPIO或电路。这个思路帮我在好几个项目里省了大把时间希望对你也有用。