4. 异常与中断系统(NVIC):嵌套向量中断控制器原理、中断优先级分组与寄存器编程

中断,是嵌入式系统的灵魂。没有中断,CPU就得不停地轮询——说白了就是傻等。我刚开始做Cortex-M项目时,对NVIC的理解也仅限于“配个优先级、开个使能”。直到有一次产品在现场频繁死机,查了三天才发现是中断优先级配错了,高优先级中断把低优先级的临界区给冲了。从那以后,我对NVIC的每一个寄存器都格外较真。

这一章,咱们就把NVIC的里里外外掰开揉碎。你跟着我走一遍,保证以后配中断心里有底。

4.1 嵌套向量中断控制器(NVIC)原理

NVIC是Cortex-M内核的中断管家。它管三件事:中断的使能/除能、中断的挂起/清除、中断的优先级裁决。

和传统ARM7/ARM9不同,Cortex-M的中断是向量化的。每个中断源都有一个固定的中断号,CPU响应中断时,硬件自动从向量表中取出对应的中断服务函数地址,直接跳转。不需要软件查中断源,也不需要汇编跳转表。这就是“向量”二字的含义。

核心特点:

  • 支持最多240个外部中断(具体由芯片厂商决定)
  • 每个中断可独立使能/除能
  • 每个中断可独立设置优先级(8位优先级寄存器,实际使用高几位)
  • 支持中断嵌套——高优先级中断可以打断低优先级中断
  • 支持尾链技术(Tail-Chaining)——连续中断切换时省去压栈出栈开销

尾链技术我多说一句。两个中断连续到来时,传统ARM会先出栈再进栈,白白浪费十几个周期。Cortex-M直接跳过冗余的栈操作,从一个ISR尾巴链到下一个ISR头部。我在一个高速数据采集项目里实测过,尾链能让中断延迟降低30%以上。

4.2 中断优先级分组

这是NVIC里最容易搞混的地方。Cortex-M把优先级分为两段:抢占优先级和子优先级。抢占优先级决定能否打断,子优先级决定同抢占级时的响应顺序。

怎么切分这两段的位数?靠的是优先级分组寄存器——SCB->AIRCR(Application Interrupt and Reset Control Register)。

具体来说,AIRCR的[10:8]位(PRIGROUP字段)决定分组方式:

PRIGROUP值 抢占优先级位数 子优先级位数 优先级等级数
0 7 0 128级抢占,无子优先级
1 6 1 64级抢占,2级子优先级
2 5 2 32级抢占,4级子优先级
3 4 3 16级抢占,8级子优先级
4 3 4 8级抢占,16级子优先级
5 2 5 4级抢占,32级子优先级
6 1 6 2级抢占,64级子优先级
7 0 7 无抢占,128级子优先级

注意:大多数Cortex-M芯片只实现了优先级寄存器的高几位(比如STM32只用了高4位)。所以实际可用的优先级等级数要查芯片手册,别被理论值忽悠了。

我个人习惯用分组3(4位抢占 + 4位子优先级)。这样抢占级有16级,够用;子优先级也有16级,同抢占级的中断还能排个先后。你想想看,如果所有中断都用同一个抢占级,那就没有嵌套了,和裸机轮询没区别。

4.3 中断向量表

中断向量表是CPU响应中断时的“导航地图”。Cortex-M规定向量表必须从地址0开始(或者从SCB->VTOR指定的地址开始)。

向量表的布局是这样的:

偏移地址 异常/中断号 内容
0x0000_0000 - 主栈指针(MSP)初始值
0x0000_0004 1 复位向量(Reset_Handler)
0x0000_0008 2 NMI中断服务函数地址
0x0000_000C 3 硬错误(HardFault_Handler)
... ... ...
0x0000_0040 16 系统服务调用(SVC_Handler)
0x0000_0044 17 调试监控(DebugMon_Handler)
0x0000_0048 18 PendSV(PendSV_Handler)
0x0000_004C 19 SysTick(SysTick_Handler)
0x0000_0050 20 外部中断0(IRQ0)
... ... ...

每个向量表项占4字节,存的是对应中断服务函数的地址。注意,Cortex-M用的是函数地址,不是跳转指令。硬件自动完成地址加载和跳转。

如果芯片支持VTOR(向量表偏移寄存器),你可以把向量表搬到RAM里。这在做IAP(在应用编程)或RTOS动态切换中断时特别有用。我曾经在一个Bootloader项目里,把向量表重定位到RAM,运行时动态修改中断入口,实现了“热补丁”效果。

4.4 中断使能与挂起寄存器编程

NVIC的寄存器操作,说白了就是读写几个内存映射的寄存器。Cortex-M把NVIC的寄存器映射到系统总线地址空间,用C语言指针直接操作。

常用的NVIC寄存器组:

  • ISER(Interrupt Set-Enable Register):写1使能中断,读返回当前使能状态
  • ICER(Interrupt Clear-Enable Register):写1除能中断
  • ISPR(Interrupt Set-Pending Register):写1挂起中断(软件触发中断)
  • ICPR(Interrupt Clear-Pending Register):写1清除挂起状态
  • IABR(Interrupt Active Bit Register):只读,指示当前正在服务的中断
  • IPR(Interrupt Priority Register):设置中断优先级,每8位一个中断

每个寄存器组有8个32位寄存器(编号0~7),每个寄存器管理32个中断。比如ISER[0]的bit0对应中断号0,bit1对应中断号1,以此类推。

直接上代码,咱们用CMSIS标准库的方式操作:

/* 使能中断号10(假设对应UART1) */
NVIC_EnableIRQ(UART1_IRQn);

/* 除能中断号10 */
NVIC_DisableIRQ(UART1_IRQn);

/* 软件触发中断号10(挂起) */
NVIC_SetPendingIRQ(UART1_IRQn);

/* 清除中断号10的挂起状态 */
NVIC_ClearPendingIRQ(UART1_IRQn);

/* 获取中断号10的挂起状态 */
uint32_t pending = NVIC_GetPendingIRQ(UART1_IRQn);

/* 设置中断号10的优先级(抢占级2,子优先级1) */
/* 假设分组3:高4位抢占,低4位子优先级 */
NVIC_SetPriority(UART1_IRQn, (2 << 4) | 1);

如果你不想用CMSIS库,直接操作寄存器也行:

/* 使能中断号10 */
#define NVIC_ISER0  (*(volatile uint32_t *)0xE000E100)
NVIC_ISER0 = (1 << 10);

/* 设置中断号10的优先级 */
/* IPR寄存器每32位管理4个中断,中断号10对应IPR[2]的[23:16]位 */
#define NVIC_IPR2   (*(volatile uint32_t *)0xE000E408)
NVIC_IPR2 = (NVIC_IPR2 & ~(0xFF << 16)) | (0x21 << 16);  // 0x21 = 抢占2 + 子优先级1

经验之谈:操作IPR寄存器时,一定要用“读-改-写”模式,别把其他中断的优先级给冲了。我见过有人直接赋值,结果所有中断优先级都变成一样的了——调试时那个酸爽。

4.5 中断优先级分组配置实战

配置优先级分组,必须在系统启动早期完成。一旦配置好,整个运行期间不要再改。为什么?因为分组改变后,所有已配置的优先级数值含义都变了,中断嵌套逻辑会乱套。

配置代码:

/* 设置优先级分组为分组3:4位抢占 + 4位子优先级 */
/* AIRCR寄存器地址:0xE000_ED0C */
/* 写AIRCR需要KEY:0x5FA */
#define SCB_AIRCR   (*(volatile uint32_t *)0xE000ED0C)
SCB_AIRCR = (0x5FA << 16) | (3 << 8);  // PRIGROUP = 3

配置完成后,每个中断的优先级字节就拆成两段:高4位是抢占优先级(0~15),低4位是子优先级(0~15)。数值越小,优先级越高。

举个例子:

  • 中断A:优先级 = 0x10(抢占1,子优先级0)
  • 中断B:优先级 = 0x21(抢占2,子优先级1)
  • 中断C:优先级 = 0x11(抢占1,子优先级1)

中断A和C抢占优先级相同(都是1),所以它们之间不嵌套。但中断A的子优先级是0,C的子优先级是1,所以如果A和C同时挂起,A先响应。中断B的抢占优先级是2,比A和C都低,所以B不能打断A或C。

避坑指南:我曾经在一个多中断项目里,把TIM2和UART1的抢占优先级设成了相同值,但子优先级没注意。结果TIM2中断里调了一个延时函数,UART1中断来了只能排队等。串口数据就丢了。后来我把UART1的抢占优先级提高一级,问题解决。记住:实时性要求高的中断,抢占优先级一定要高。

4.6 中断使能与挂起的底层逻辑

使能中断,本质是往ISER寄存器的对应bit写1。挂起中断,本质是往ISPR寄存器的对应bit写1。这两个操作是独立的——你可以挂起一个还没使能的中断,等使能后立即响应。

挂起寄存器的一个妙用是软件触发中断。我在做协议栈时,经常用软件触发中断来模拟硬件事件,方便单元测试。比如:

/* 软件触发SPI传输完成中断 */
NVIC_SetPendingIRQ(SPI1_IRQn);

这样不用真的等硬件发数据,就能验证中断服务函数的逻辑。

另外,清除挂起状态也很关键。如果中断服务函数执行完了,但挂起位没清,CPU会再次进入中断——死循环。好在Cortex-M硬件会在进入ISR时自动清除挂起位,但如果你在ISR里又手动挂起了同中断,就得小心处理。

4.7 知识结构总览

下面这张图把NVIC的核心知识点串起来了。你对照着看,心里就有谱了。

NVIC 核心知识结构 NVIC 嵌套向量中断控制器 中断向量表 地址0开始 / VTOR重定位 每个表项4字节 = 函数地址 硬件自动取址跳转 优先级分组 AIRCR.PRIGROUP 配置 抢占优先级 + 子优先级 数值越小优先级越高 寄存器编程 ISER(使能) | ICER(除能) | ISPR(挂起) | ICPR(清除挂起) | IPR(优先级) 每个寄存器组8个32位寄存器,管理0~255号中断 CMSIS库函数:NVIC_EnableIRQ / NVIC_SetPriority / NVIC_SetPendingIRQ

这张图把NVIC的三个核心模块——向量表、优先级分组、寄存器编程——串在了一起。你写代码时,脑子里要有这张图:先配分组,再设优先级,最后使能中断。顺序别搞反。

4.8 常见陷阱与工程建议

陷阱1:优先级分组只在复位后配置一次。运行中改分组,所有中断的优先级含义都变了,嵌套关系瞬间乱掉。我见过有人在任务切换时改分组,结果系统跑几个小时就死一次,查了两个月。

陷阱2:中断服务函数要短。NVIC支持嵌套,但不代表你可以把大循环放ISR里。高优先级中断会饿死低优先级中断。我的原则:ISR里只做标志位、数据搬运、唤醒任务,具体处理放主循环或RTOS任务里。

建议1:调试时用NVIC的挂起寄存器手动触发中断,验证ISR逻辑。比等硬件事件快得多。

建议2:写一个中断优先级分配表,把每个中断的抢占级和子优先级列清楚。团队协作时,这个表能避免两个人把同一个优先级用了两遍。

建议3:如果芯片支持VTOR,把向量表放到RAM。这样可以在运行时动态修改中断入口,做热补丁或动态加载驱动时特别方便。