开始用寄存器写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按键的中断对应EXTI13,PB5对应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的来源为PA0,PA0对应值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,放到四比特位域里正好是CNF=10、MODE=00,输入模式里的上拉/下拉输入。
那到底上拉还是下拉?由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_IRQHandler,EXTI5到EXTI9共用EXTI9_5_IRQHandler,EXTI10到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会被重新置1,NVIC也会把新的挂起叠加上去。你在函数末尾清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或电路。这个思路帮我在好几个项目里省了大把时间,希望对你也有用。