2026/10/18 1:29:54

STM32中断与NVIC实战:从寄存器到嵌套优先级全解析

STM32中断与NVIC实战:从寄存器到嵌套优先级全解析 1. 为什么第三章值得单独拎出来讲如果你是从第一章、第二章一路看过来的那说明你已经迈过了点亮一颗LED和看懂时钟树这两道坎。到了第三章事情开始变得有意思了——我们不再满足于让某个外设能动而是要搞清楚它为什么能动、动得多快、动的时候CPU在干嘛。这一章的核心就是中断与事件系统以及它背后那套让无数新手抓狂的NVIC嵌套向量中断控制器。我见过太多人卡在这一章。前两章配寄存器配得挺顺一到中断就懵明明使能了中断为什么进不去明明写了中断服务函数为什么跳到了HardFault明明优先级设了为什么嵌套行为跟想象的不一样这些问题不是因为你笨而是因为寄存器手册在这一块写得极其反人类——它默认你已经理解了Cortex-M内核的中断模型而绝大多数教程又只告诉你这样配就行不告诉你为什么必须这样配。所以这一章我打算换个讲法。不按手册的寄存器顺序念而是按一个中断从触发到返回的完整生命周期来拆。你跟着这条时间线走一遍每个寄存器在哪个环节起作用、为什么非它不可自然就串起来了。这一章的内容量比较大我建议你手边开着参考手册的NVIC章节和向量表章节对照着看。先给个全局印象STM32的中断系统分两层——内核层的NVIC管优先级、使能、挂起和嵌套外设层的中断标志位管谁触发了中断。很多人搞混这两层以为在NVIC里使能了就能进中断结果外设那边的标志位根本没清或者根本没使能自然进不去。这个两层模型是理解整章的地基后面所有细节都挂在这上面。2. 中断触发的完整链路从外设到CPU取向量2.1 外设侧标志位才是真正的触发源先明确一件事NVIC本身不产生中断它只是中转站。真正产生中断请求的是外设。以EXTI外部中断/事件控制器为例一个按键按下EXTI的挂起寄存器EXTI_PR里对应的位被硬件置1这个1就是中断请求的源头。这里有个新手最容易踩的坑EXTI_PR是写1清零的不是写0清零也不是读清零。你写0进去它纹丝不动写1才清。我第一次调EXTI的时候中断服务函数里写了EXTI-PR 0;结果中断疯狂重入CPU直接卡死。后来才反应过来应该是EXTI-PR EXTI_PR_PR0;对应位写1。这个设计是为了避免读-改-写竞争——你只想清某一位不想影响其他位写1清零天然支持这种原子操作。除了EXTI_PR还有两个寄存器必须配对使用IMR中断屏蔽寄存器和EMR事件屏蔽寄存器。IMR控制是否向NVIC发中断请求EMR控制是否向其他外设发事件脉冲。这两个是独立的你可以只开中断不开事件也可以只开事件不开中断。事件机制在后面讲DMA和定时器联动时会大放异彩这里先记住它存在。配置一个EXTI通道的标准流程是这样的// 1. 使能SYSCFG时钟EXTI配置依赖它 RCC-APB2ENR | RCC_APB2ENR_SYSCFGEN; // 2. 把GPIO引脚映射到EXTI线以PA0为例 SYSCFG-EXTICR[0] ~SYSCFG_EXTICR1_EXTI0; // 清空 SYSCFG-EXTICR[0] | SYSCFG_EXTICR1_EXTI0_PA; // 选PA // 3. 配置触发边沿 EXTI-RTSR | EXTI_RTSR_TR0; // 上升沿触发 EXTI-FTSR ~EXTI_FTSR_TR0; // 关闭下降沿 // 4. 解除屏蔽 EXTI-IMR | EXTI_IMR_IM0; // 5. 清一次挂起位防止配置过程中产生的假触发 EXTI-PR EXTI_PR_PR0;注意第5步很多人省了这一步结果一使能中断立刻进一次还以为是上电抖动。其实是你配置过程中某个瞬间满足了触发条件硬件把PR置1了。养成配置完先清一次的习惯能省掉大量莫名其妙的调试时间。2.2 内核侧NVIC的四个关键寄存器外设把请求送到NVIC之后NVIC要做四件事判断是否使能、比较优先级、决定是否抢占当前中断、把向量地址送给CPU。对应四个寄存器组ISER中断使能寄存器写1使能写0无效不是写0禁止。要禁止得写ICER。这个写1使能、写0无效的设计跟EXTI_PR类似都是为了原子操作。ISPR/ICPR挂起/解挂寄存器可以软件触发一个中断写ISPR也可以清除一个挂起的中断写ICPR。调试时特别有用——你可以不接任何硬件直接写ISPR软件触发中断验证你的中断服务函数逻辑对不对。IPR优先级寄存器每个中断占8位但STM32只实现了高4位低4位读回是0。这意味着优先级只有16级0-15数值越小优先级越高。IABR活跃位寄存器只读告诉你当前哪些中断正在执行。调试嵌套问题时读这个寄存器能一眼看出谁在跑。这里要重点说优先级。STM32的优先级分抢占优先级和子优先级通过SCB-AIRCR的PRIGROUP字段划分。比如PRIGROUP设为5那么高5位是抢占优先级低3位是子优先级在4位有效位里切。抢占优先级决定能不能嵌套子优先级只在同时挂起时决定谁先执行不能嵌套。我个人的建议是除非你有非常明确的嵌套需求否则全部设成抢占优先级子优先级全给0。这样行为最直观——高抢占优先级的随时能打断低的同抢占优先级的排队执行。混用子优先级只会让调试复杂度指数上升收益却很小。2.3 向量表CPU怎么找到你的函数中断触发后CPU从向量表里取地址。向量表默认在Flash起始地址0x08000000但可以通过SCB-VTOR重定位到RAM或其他地址。重定位在Bootloader场景下是必须的——App的向量表不在0x08000000你不重定位中断一来CPU就跳到Bootloader的向量表里去了直接跑飞。向量表的排列顺序是固定的前16个是内核异常Reset、NMI、HardFault等从第16个开始是外设中断顺序由芯片型号决定不能改。你写中断服务函数时函数名必须和启动文件里的弱定义符号一致否则链接器找不到你的函数永远不会被调用。// 启动文件里的弱定义以EXTI0为例 void EXTI0_IRQHandler(void) __attribute__((weak, alias(Default_Handler))); // 你的实现名字必须一模一样 void EXTI0_IRQHandler(void) { if (EXTI-PR EXTI_PR_PR0) { // 处理逻辑 EXTI-PR EXTI_PR_PR0; // 清标志 } }注意中断服务函数里一定要先判断标志位再处理最后清标志。判断标志位是因为多个中断源可能共用一个向量比如EXTI0到EXTI4各自独立但EXTI5到EXTI9共用一个向量你不判断就处理可能处理了不该处理的中断。3. 优先级分组与嵌套那些手册不会告诉你的行为细节3.1 PRIGROUP的划分逻辑与常见误配Cortex-M的优先级寄存器有8位STM32只用了高4位。PRIGROUP决定这4位里哪几位是抢占、哪几位是子优先级。手册给了一张表但那张表容易看晕。我用一句话总结PRIGROUP的值等于子优先级占的位数。比如PRIGROUP3子优先级占3位抢占优先级占1位4-31那么抢占优先级只有0和1两级子优先级有0-7八级。这里有个反直觉的点PRIGROUP越大抢占优先级能分的级数越少。很多人第一次配的时候想我要精细控制抢占结果把PRIGROUP设得很大反而只剩一两级抢占嵌套行为完全不是想要的。我的经验是大多数项目用PRIGROUP4即2位抢占、2位子优先级或者干脆PRIGROUP7全子优先级无嵌套。前者适合有少量嵌套需求的场景后者适合我不需要嵌套只要排队的场景。真正需要4位全抢占16级嵌套的项目极少因为嵌套太深会导致栈溢出而且中断延迟不可控。配置代码很简单但要注意AIRCR有写保护// 设置PRIGROUP42位抢占2位子优先级 SCB-AIRCR (0x5FA SCB_AIRCR_VECTKEY_Pos) | (4 SCB_AIRCR_PRIGROUP_Pos);那个0x5FA是密钥不写这个值写不进去。我第一次配的时候忘了密钥读回AIRCR发现没变还以为是芯片坏了。3.2 嵌套发生的精确条件嵌套不是高优先级一定能打断低优先级它有三个条件同时满足才行新中断的抢占优先级严格高于当前正在执行的中断的抢占优先级。新中断的NVIC使能位为1。当前中断没有把PRIMASK置1即没有关中断。子优先级不参与嵌套判断。两个中断抢占优先级相同、子优先级不同后到的不会打断先到的只会等先到的执行完再执行。这个行为在手册里写得很隐晦但实际项目中经常遇到——你以为设了更小的子优先级就能抢占结果并没有。还有一个细节中断返回时的尾链tail-chaining。当一个中断执行完如果此时有另一个挂起的中断CPU不会先出栈再入栈而是直接取新向量继续执行省掉了出栈入栈的12个周期。这个优化是硬件自动做的你不需要配置但理解它能帮你解释为什么某些场景下中断响应比预期快。3.3 优先级反转与临界区保护中断嵌套带来一个经典问题优先级反转。低优先级任务持有锁高优先级中断来了想拿同一把锁结果高优先级被低优先级阻塞。在裸机环境里这个问题表现为主循环正在操作一个链表中断来了也要操作同一个链表链表操作到一半被中断打断中断里看到的是不完整的状态。解决方案是临界区。Cortex-M提供了PRIMASK、FAULTMASK、BASEPRI三个屏蔽寄存器PRIMASK置1屏蔽所有可配置优先级的中断NMI和HardFault除外。用__disable_irq()和__enable_irq()操作。BASEPRI屏蔽优先级数值大于等于某个值的中断。比如设BASEPRI5那么优先级5到15的中断都被屏蔽0到4的还能进来。这个比PRIMASK精细适合我只想屏蔽低优先级中断高优先级的实时任务不能停的场景。FAULTMASK置1连HardFault都屏蔽只有NMI能进。一般只在极端错误处理时用平时别碰。// 用BASEPRI保护临界区屏蔽优先级5的中断 __set_BASEPRI(5 (8 - __NVIC_PRIO_BITS)); // ... 临界区代码 ... __set_BASEPRI(0); // 解除屏蔽注意BASEPRI的值要左移(8 - __NVIC_PRIO_BITS)位因为STM32只用了高4位。直接写5进去是错的得写5480。这个移位我第一次写的时候漏了结果屏蔽范围完全不对调了半天。4. 中断服务函数的编写规范与性能陷阱4.1 函数签名与编译器行为中断服务函数的签名必须是void func(void)不能有参数、不能有返回值。这不是约定是硬件要求——CPU取向量后直接跳转没有地方给你传参。如果你写成void func(int x)编译器可能不报错因为弱符号匹配只看名字但运行时栈会乱行为不可预测。另外中断服务函数不需要手动保存现场硬件自动压栈R0-R3、R12、LR、PC、xPSR这8个寄存器。但如果你在函数里用了R4-R11编译器会自动插入压栈指令。这就是为什么中断服务函数里用的局部变量越多中断延迟越大——压栈的寄存器多了。我见过有人在中断里写了一个复杂的浮点运算结果中断延迟飙到几百个周期主循环的实时性直接崩了。中断服务函数的原则是能短则短能不在中断里做的就不在中断里做。把数据存到一个缓冲区置个标志主循环去处理。这就是上半部/下半部的思想虽然STM32没有像Linux那样正式的下半部机制但思路是一样的。4.2 共享变量的volatile与原子性中断和主循环共享的变量必须加volatile。不加的话编译器优化时可能把这个变量缓存到寄存器里主循环永远看不到中断的修改。这个坑太经典了几乎每个学STM32的人都踩过。volatile uint8_t g_flag 0; // 中断里置1主循环轮询 void EXTI0_IRQHandler(void) { g_flag 1; EXTI-PR EXTI_PR_PR0; } int main(void) { while (1) { if (g_flag) { g_flag 0; // 处理逻辑 } } }但volatile只保证每次都从内存读不保证原子性。如果共享的是一个32位变量在32位MCU上读写是原子的没问题。但如果是一个64位变量或者一个结构体中断里改一半、主循环读一半就会读到撕裂的数据。这时候要么用临界区保护要么用双缓冲——中断写A缓冲主循环读B缓冲通过一个指针原子切换。4.3 中断延迟的组成与优化中断延迟 硬件响应时间 压栈时间 取向量时间 你的代码执行时间。前三个是固定的Cortex-M3/M4大约12个周期压栈取向量你能优化的是最后一个。优化手段有几个一是减少中断里的局部变量降低压栈量二是把不紧急的处理挪到主循环三是如果多个中断源共用一个向量用查表代替if-else链四是把中断服务函数放到RAM里执行Flash等待周期在高速下会拖慢取指。最后一条在STM32F4以上带ART加速器效果不明显但在F1系列上把中断服务函数搬到RAM能省不少周期。方法是用__attribute__((section(.RamFunc)))把函数放到RAM段启动时从Flash拷贝过去。5. 调试中断问题的实战排查链路5.1 进不去中断的五种可能中断进不去按这个顺序排查基本能覆盖95%的情况外设时钟没开EXTI依赖SYSCFGSYSCFG在APB2上。你只开了GPIO时钟没开SYSCFGEXTI配置写不进去。NVIC使能位没置NVIC_EnableIRQ(EXTI0_IRQn)漏了。这个最隐蔽因为外设侧看起来都配好了。优先级分组没设AIRCR的PRIGROUP默认值可能不是你想要的导致优先级判断异常。全局中断没开__enable_irq()漏了或者某处调了__disable_irq()没恢复。向量表没重定位用了Bootloader但忘了设VTOR中断跳到Bootloader的向量表去了。排查方法先在NVIC的IABR寄存器看中断有没有变成活跃如果没有说明请求根本没到NVIC问题在外设侧如果活跃了但没进函数问题在向量表或函数名。5.2 中断反复进入的根因分析中断反复进入99%是标志位没清。但没清分几种情况清错了寄存器清了NVIC的挂起位ICPR没清外设的标志位EXTI_PR。NVIC的挂起位是请求排队用的外设标志位才是请求源。你清了ICPR外设标志还在NVIC立刻又收到请求又进中断。清标志的时机不对在中断服务函数开头清然后处理逻辑里又触发了同一个中断源导致丢失一次。正确做法是先清标志再处理或者先处理最后清取决于你的逻辑是否会产生新触发。多个中断源共用向量EXTI5到EXTI9共用一个向量你只清了EXTI5的标志EXTI9的标志还在出去立刻又进来。必须在函数里遍历所有可能的源逐个判断清除。5.3 HardFault的定位技巧中断里跑飞进HardFault最常见的原因是栈溢出或访问了非法地址。定位方法首先看LR寄存器的值。如果LR是0xFFFFFFF9说明是从线程模式进的中断如果是0xFFFFFFFD说明是从中断模式进的。然后看压栈的PC值那个地址就是跑飞的位置。在调试器里HardFault发生时查看栈帧void HardFault_Handler(void) { __asm volatile ( tst lr, #4\n ite eq\n mrseq r0, msp\n mrsne r0, psp\n b hardfault_report\n ); }hardfault_report里把r0指向的栈帧打印出来PC、LR、xPSR一目了然。PC指向的指令就是罪魁祸首。我靠这招定位过好几次中断里调用了未初始化函数指针的问题。提示栈溢出在中断场景下特别常见因为中断嵌套会叠加栈使用。如果你的栈设得比较小比如1KB又开了多层嵌套很容易溢出。建议至少留2KB给中断栈嵌套深的场景留4KB。6. 从寄存器操作到实际项目的映射6.1 一个完整的按键中断案例把前面的知识串起来做一个按键控制LED的完整例子。需求PA0按键按下PC13的LED翻转。用寄存器操作不用库函数。#include stm32f10x.h volatile uint8_t g_key_pressed 0; void EXTI0_IRQHandler(void) { if (EXTI-PR EXTI_PR_PR0) { g_key_pressed 1; EXTI-PR EXTI_PR_PR0; } } int main(void) { // 1. 时钟GPIOA、GPIOC、AFIO、SYSCFG RCC-APB2ENR | RCC_APB2ENR_IOPAEN | RCC_APB2ENR_IOPCEN | RCC_APB2ENR_AFIOEN; // 2. PA0输入浮空 GPIOA-CRL ~(0xF 0); GPIOA-CRL | (0x4 0); // 浮空输入 // 3. PC13推挽输出 GPIOC-CRH ~(0xF 20); GPIOC-CRH | (0x2 20); // 推挽输出2MHz // 4. AFIO映射EXTI0到PA0 AFIO-EXTICR[0] ~AFIO_EXTICR1_EXTI0; AFIO-EXTICR[0] | AFIO_EXTICR1_EXTI0_PA; // 5. EXTI0下降沿触发 EXTI-FTSR | EXTI_FTSR_TR0; EXTI-RTSR ~EXTI_RTSR_TR0; EXTI-IMR | EXTI_IMR_IM0; EXTI-PR EXTI_PR_PR0; // 清一次 // 6. NVIC配置 NVIC_SetPriorityGrouping(4); NVIC_SetPriority(EXTI0_IRQn, 5); NVIC_EnableIRQ(EXTI0_IRQn); // 7. 开全局中断 __enable_irq(); while (1) { if (g_key_pressed) { g_key_pressed 0; GPIOC-ODR ^ (1 13); // 翻转LED } } }这个例子里有几个细节值得说。第一AFIO时钟必须开否则EXTICR写不进去。第二NVIC_SetPriorityGrouping(4)要在设置优先级之前调用否则优先级值会被错误解释。第三EXTI-PR的清零放在配置最后防止配置过程中的假触发。6.2 中断与主循环的分工原则什么时候该用中断什么时候该用轮询我的判断标准是事件发生的时刻不可预测且响应延迟有要求用中断事件发生频率极高且处理逻辑简单用轮询。比如按键按下时刻不可预测用中断。比如串口接收数据到达时刻不可预测用中断。比如ADC连续采样频率固定且高用DMA轮询不用中断。比如LED闪烁定时器中断里翻转或者主循环里延时翻转都行看你对时间精度的要求。中断服务函数里只做必须立刻做的事。比如串口接收中断你只需要把数据从DR寄存器读出来存到缓冲区解析协议的事放到主循环。这样中断执行时间短不会阻塞其他中断。6.3 中断优先级的实际分配策略一个典型项目的优先级分配我一般这么排数值越小优先级越高优先级用途理由0系统滴答定时器提供时基必须最准时1紧急故障处理过流、过压等保护2-3高速通信串口、SPI防止数据丢失4-5普通外设按键、普通定时器6-7低速外设温湿度传感器等8-15非关键任务状态指示、日志输出这个分配不是死的但原则是越不能容忍延迟的优先级越高。同时要注意高优先级中断的执行时间要尽可能短否则会阻塞所有低优先级中断。还有一个经验不要把所有中断都设成最高优先级。我见过一个项目所有中断都是优先级0结果中断之间不能嵌套一个慢中断执行时其他中断全在排队实时性反而更差。优先级的意义在于区分轻重缓急全设成一样就失去了意义。7. 一些手册上找不到的实操心得7.1 软件触发中断的调试妙用调试中断逻辑时不一定要接硬件。直接写NVIC的ISPR寄存器就能软件触发一个中断NVIC-ISPR[0] (1 6); // 触发EXTI0中断假设EXTI0是第6号中断这样你可以验证中断服务函数的逻辑对不对不用去按按键或者等外部信号。等逻辑调通了再接硬件验证触发路径。这个技巧在调试复杂中断嵌套时特别有用因为你可以精确控制触发时机。7.2 中断向量表的RAM重定位有些场景下需要动态修改中断服务函数比如Bootloader跳转到App后App想用自己的中断处理。这时候把向量表重定位到RAM然后修改RAM里的向量表// 把向量表从Flash拷到RAM extern uint32_t _svector_table; extern uint32_t _evector_table; uint32_t *ram_vectors (uint32_t *)0x20000000; memcpy(ram_vectors, _svector_table, (uint32_t)_evector_table - (uint32_t)_svector_table); // 重定位 SCB-VTOR (uint32_t)ram_vectors; // 动态修改某个中断的处理函数 ram_vectors[16 EXTI0_IRQn] (uint32_t)my_new_handler;这个技巧在实现可插拔的中断处理时很有用但要注意RAM向量表要按128字节对齐VTOR的低7位保留。7.3 中断延迟的实测方法想知道你的中断延迟到底是多少用GPIO翻转法测在中断服务函数第一行翻转一个GPIO用示波器看触发信号和GPIO翻转之间的时间差。这个时间差就是总中断延迟。我实测过STM32F103在72MHz下的EXTI中断延迟从引脚信号跳变到GPIO翻转大约1.2微秒。其中硬件响应和压栈占了大头我的中断服务函数本身只花了不到0.2微秒。这个数据帮你建立直觉中断延迟不是零微秒级的延迟在高速控制场景下是要考虑的。如果要进一步降低延迟可以把中断服务函数放到RAM执行F1系列上能省大概0.3微秒。F4系列有ART加速器放RAM的收益很小不值得折腾。7.4 嵌套中断的栈深度估算中断嵌套的栈深度 主程序栈深度 每层中断的栈帧。每层中断的栈帧至少是8个寄存器32字节加上你函数里的局部变量。如果嵌套3层每层用50字节那就是150字节。加上主程序的栈使用你就能估算出最小栈大小。我的习惯是栈大小 主程序最大栈深度 中断层数 × 每层最大栈帧 × 1.5安全系数。比如主程序用200字节最多3层中断每层80字节那就是200 3×80×1.5 560字节。取个整给1KB留足余量。栈溢出是嵌入式系统最隐蔽的bug之一因为它不一定立刻崩溃可能只是覆盖了其他变量导致行为诡异。在栈的末尾放一个魔数比如0xDEADBEEF定期检查这个魔数有没有被改写能提前发现栈溢出。7.5 中断与低功耗的配合STM32的低功耗模式和中断是深度绑定的。WFIWait For Interrupt指令让CPU进入睡眠任何中断都能唤醒。WFEWait For Event则是等事件事件可以是中断也可以是其他外设发的事件脉冲。在低功耗场景下中断服务函数执行完后如果没有任何挂起的中断CPU会再次进入睡眠。这个行为是自动的不需要你手动再执行WFI。但要注意如果中断服务函数里清了某个标志导致没有新的中断挂起CPU就会一直睡下去直到下一个中断到来。调试低功耗时我经常用GPIO翻转来观察CPU的睡眠时间。在进入WFI前翻转一个GPIO在中断服务函数第一行翻转另一个GPIO两个GPIO之间的时间就是睡眠时间。这个方法能直观地看到低功耗策略有没有生效。8. 从这一章往后你该往哪走第三章的内容到这里就差不多了。中断和事件系统是STM32里最内核的部分把它吃透后面学DMA、学RTOS、学通信协议都会轻松很多。因为DMA的传输完成中断、RTOS的任务切换本质是PendSV异常、通信协议的超时处理全都建立在这一章的基础上。我个人的建议是不要急着往后翻。把这一章的每个寄存器都亲手写一遍把按键中断的例子跑通然后故意制造几个错误——比如不清标志位、不设优先级分组、不重定位向量表——看看现象是什么用调试器定位问题。这种故意踩坑的练习比顺顺利利跑通一个例子有价值得多。下一章我们会讲DMA与总线矩阵那是另一个让人又爱又恨的话题。但有了这一章的中断基础DMA的中断配置对你来说就是小菜一碟了。