4、中断嵌套与抢占:可嵌套中断控制器(NVIC)、抢占优先级与子优先级
中断嵌套,说白了就是高优先级的中断能把低优先级的中断“踢开”,自己先执行。等它处理完了,再回来继续执行刚才被打断的那个。这个机制在实时系统里太重要了。我刚开始做嵌入式时,就吃过没处理好嵌套的亏——一个关键的中断被另一个不紧急的中断堵住了,整个系统响应直接崩掉。
ARM Cortex-M 系列内核里,负责管理这一切的硬件叫 NVIC(Nested Vectored Interrupt Controller,可嵌套中断控制器)。它不光能处理中断嵌套,还支持中断向量化,也就是说每个中断源都有自己独立的入口地址,不用像老式单片机那样靠软件查询中断标志位。
4.1 NVIC 的核心能力
NVIC 到底能干什么?我列几个关键点:
- 支持最多 240 个中断源(具体数量看芯片型号,比如 STM32F103 只有 60 个)
- 每个中断可独立使能/除能,还能设置挂起和清除挂起
- 中断优先级可编程,支持 8~256 级(取决于实现)
- 自动硬件压栈和出栈,中断切换时 CPU 自动保存 R0-R3、R12、LR、PC、xPSR
- 咬尾中断(Tail-Chaining):如果两个中断连续发生,省去多余的出栈/入栈
- 晚到中断(Late-Arriving):更高优先级中断在压栈阶段到达,直接替换当前要处理的中断
嗯,这里要注意:NVIC 的嵌套是硬件自动完成的,不需要你写额外的代码。但优先级怎么分配,完全由你决定。
4.2 抢占优先级与子优先级
ARM Cortex-M 的优先级系统分两层:抢占优先级(Preempt Priority)和子优先级(Sub Priority)。这个设计我一开始也觉得绕,后来发现它其实很灵活。
抢占优先级决定一个中断能否打断另一个正在执行的中断。子优先级呢?当两个中断的抢占优先级相同时,子优先级高的先执行,但不会发生嵌套。
举个例子:
| 中断源 | 抢占优先级 | 子优先级 |
|---|---|---|
| 定时器 A | 0(最高) | 0 |
| UART 接收 | 1 | 0 |
| GPIO 外部中断 | 1 | 1 |
| DMA 完成 | 2 | 0 |
在这个配置下:
- 定时器 A 可以打断 UART、GPIO、DMA 的中断服务函数
- UART 和 GPIO 抢占优先级相同,所以它们之间不会互相打断
- 如果 UART 和 GPIO 同时挂起,UART 先执行(子优先级更高)
- DMA 优先级最低,谁都能打断它
我个人习惯把最紧急的中断(比如系统心跳、故障检测)放在抢占优先级 0,其他按实时性要求依次排开。
4.3 优先级分组:AIRCR 寄存器
抢占优先级和子优先级的位数分配,由 系统控制块(SCB) 中的 AIRCR(Application Interrupt and Reset Control Register)寄存器的 PRIGROUP 位段决定。Cortex-M3/M4 支持 8 种分组方式:
| PRIGROUP 值 | 抢占优先级位数 | 子优先级位数 | 优先级级数 |
|---|---|---|---|
| 0 | 7 | 1 | 128 级抢占 × 2 级子优先级 |
| 1 | 6 | 2 | 64 × 4 |
| 2 | 5 | 3 | 32 × 8 |
| 3 | 4 | 4 | 16 × 16 |
| 4 | 3 | 5 | 8 × 32 |
| 5 | 2 | 6 | 4 × 64 |
| 6 | 1 | 7 | 2 × 128 |
| 7 | 0 | 8 | 1 × 256(无嵌套) |
实际项目中,我建议用分组 3(4 位抢占 + 4 位子优先级),也就是 16 级抢占优先级,每级下面有 16 个子优先级。这个粒度对大多数应用都够用了。
设置代码很简单,以 STM32 为例:
// 设置优先级分组:4位抢占优先级,4位子优先级
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_3);
// 配置具体中断的优先级
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1; // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0; // 子优先级
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
4.4 中断嵌套的硬件行为
当一个高优先级中断发生时,CPU 正在执行低优先级中断的服务函数,硬件会怎么做?
- CPU 检测到更高优先级中断请求
- 当前指令执行完后,硬件自动压栈(保存现场)
- 从向量表中取出高优先级中断的入口地址
- 跳转执行高优先级中断服务函数
- 高优先级中断执行完毕,执行出栈指令(恢复现场)
- 继续执行刚才被打断的低优先级中断
整个过程是硬件自动完成的,不需要你手动保存寄存器。但要注意:中断服务函数要尽量短,否则嵌套层数多了,栈空间会急剧消耗。
4.5 中断嵌套的优先级逻辑图
下面这张图展示了中断嵌套的完整流程。你可以看到不同优先级的中断如何互相打断,以及硬件如何管理压栈和出栈。
4.6 避坑指南:我踩过的几个坑
中断嵌套看起来简单,实际项目中容易出问题。我分享几个亲身经历:
- 栈溢出:嵌套层数多了,每个中断都要压栈 8 个寄存器(32 字节),再加上局部变量。我曾经在一个项目中嵌套了 4 层,结果栈空间不够,程序跑飞。后来我把每个中断任务的栈深度都算了一遍,才解决。
- 优先级反转:低优先级中断占用了高优先级中断需要的资源,导致高优先级被阻塞。解决办法是让中断服务函数里不要调用可能被阻塞的 API。
- 忘记设置优先级分组:默认分组可能不是你想要的。比如 STM32 上电默认是分组 0(7 位抢占 + 1 位子优先级),如果你没改就直接配优先级,效果可能完全不对。
- 在中断里调用 printf:printf 通常不可重入,而且执行时间很长。在中断里调用它,轻则影响实时性,重则死锁。我一般用环形缓冲区 + 标志位的方式,把打印任务放到主循环里做。
- NVIC 硬件自动管理中断嵌套,你只需要配置好优先级
- 抢占优先级决定能否打断,子优先级决定同级的执行顺序
- 优先级分组在系统初始化时设置一次,之后不要修改
- 中断服务函数要短,避免深层嵌套和不可重入调用