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的三个核心模块——向量表、优先级分组、寄存器编程——串在了一起。你写代码时,脑子里要有这张图:先配分组,再设优先级,最后使能中断。顺序别搞反。
4.8 常见陷阱与工程建议
陷阱1:优先级分组只在复位后配置一次。运行中改分组,所有中断的优先级含义都变了,嵌套关系瞬间乱掉。我见过有人在任务切换时改分组,结果系统跑几个小时就死一次,查了两个月。
陷阱2:中断服务函数要短。NVIC支持嵌套,但不代表你可以把大循环放ISR里。高优先级中断会饿死低优先级中断。我的原则:ISR里只做标志位、数据搬运、唤醒任务,具体处理放主循环或RTOS任务里。
建议1:调试时用NVIC的挂起寄存器手动触发中断,验证ISR逻辑。比等硬件事件快得多。
建议2:写一个中断优先级分配表,把每个中断的抢占级和子优先级列清楚。团队协作时,这个表能避免两个人把同一个优先级用了两遍。
建议3:如果芯片支持VTOR,把向量表放到RAM。这样可以在运行时动态修改中断入口,做热补丁或动态加载驱动时特别方便。