NVIC 中断控制器详解
NVIC,全称 Nested Vectored Interrupt Controller,嵌套向量中断控制器。它是 Cortex-M 内核里管理中断的核心模块。说白了,所有外设的中断信号,最终都要经过 NVIC 的调度,才能到达 CPU 去执行对应的中断服务函数。
我刚开始用 STM32 的时候,总觉得 NVIC 就是个开关,打开就能用。后来踩了几个坑才发现,优先级分组、挂起、清除这些细节,任何一个没搞对,系统都可能跑飞。这一节,咱们就把 NVIC 的里里外外彻底捋一遍。
NVIC 功能概述
NVIC 在 STM32 里负责以下几件事:
- 中断使能与除能:决定哪个中断源可以被响应。
- 优先级管理:支持最多 16 级可编程优先级(4 位优先级位)。
- 中断挂起与清除:可以软件触发或清除中断请求。
- 向量表管理:中断服务函数的入口地址表。
- 尾链(Tail-Chaining):连续中断处理时,省去多余的入栈出栈操作。
NVIC 支持的中断数量,取决于具体的芯片型号。比如 STM32F103 系列支持 60 个可屏蔽中断通道,而 F407 系列支持 82 个。这个数字在参考手册的「Interrupt vector table」里都能查到。
中断优先级分组
这是 NVIC 里最容易搞混的地方。STM32 用 4 位二进制数来表示优先级,但这 4 位怎么分配,由 优先级分组 决定。
分组规则由 SCB->AIRCR 寄存器的 PRIGROUP 位段控制。一共支持 5 种分组方式:
| 分组编号 | 抢占优先级位数 | 子优先级位数 | 抢占优先级范围 | 子优先级范围 |
|---|---|---|---|---|
| 0 | 0 | 4 | 无 | 0~15 |
| 1 | 1 | 3 | 0~1 | 0~7 |
| 2 | 2 | 2 | 0~3 | 0~3 |
| 3 | 3 | 1 | 0~7 | 0~1 |
| 4 | 4 | 0 | 0~15 | 无 |
我个人习惯用分组 2,也就是 2 位抢占 + 2 位子优先级。这样抢占优先级有 4 级,子优先级也有 4 级,够用且均衡。你想想看,如果选分组 0,所有中断都没有抢占能力,那就退化成纯查询模式了,中断嵌套的优势全没了。
关键规则:抢占优先级高的中断,可以打断抢占优先级低的中断。子优先级只在同抢占优先级时,决定谁先被响应。
优先级配置寄存器
每个中断通道都有一个对应的优先级寄存器,叫 NVIC->IPRx。每个寄存器 8 位,但 STM32 只用了高 4 位。低 4 位读回来是 0,写进去会被忽略。
配置优先级时,需要把抢占优先级和子优先级合并成一个 8 位值。合并公式很简单:
// 假设分组为 2(2位抢占 + 2位子优先级)
// 抢占优先级 = 2,子优先级 = 1
uint8_t priority = (2 << 2) | 1; // 结果 = 0x09
// 写入 NVIC->IPR 寄存器
NVIC->IPR[USART1_IRQn] = priority;
这里要注意,USART1_IRQn 是中断号,在 stm32fxxx.h 头文件里定义。不同系列的中断号可能不一样,移植代码时一定要核对。
小技巧:我一般封装一个函数,传入抢占优先级和子优先级,内部自动根据当前分组计算最终值。这样代码可读性高,也不容易算错。
中断使能与除能
使能中断用 NVIC->ISER 寄存器,除能用 NVIC->ICER 寄存器。这两个寄存器都是 32 位,每个位对应一个中断通道。
举个例子,使能 USART1 中断:
// USART1_IRQn = 37
NVIC->ISER[37 / 32] |= (1 << (37 % 32));
// 等价于 NVIC->ISER[1] |= (1 << 5);
除能就是写 ICER:
NVIC->ICER[37 / 32] |= (1 << (37 % 32));
为什么使能和除能要用不同的寄存器?因为如果都用同一个寄存器,你读-改-写的时候,可能被中断打断,导致状态不一致。而 ISER 和 ICER 是「写 1 有效」的,写 0 不影响其他位,所以不需要读-改-写,直接写 1 就行。这是硬件设计上的一个巧妙之处。
注意:使能中断前,一定要先配置好优先级。否则中断一旦使能,可能以默认优先级(通常是 0,最高)立即响应,导致不可预期的行为。我曾经在调试一个电机驱动项目时,先使能了定时器中断,后配优先级,结果中断一使能就触发,把正在初始化的 PWM 输出搞乱了,电机猛地转了一下,吓我一跳。
中断挂起与清除挂起
有时候,我们需要软件触发一个中断,或者手动清除一个已经挂起的中断请求。NVIC 提供了两组寄存器:
- 中断挂起寄存器:
NVIC->ISPR,写 1 将对应中断挂起。 - 中断清除挂起寄存器:
NVIC->ICPR,写 1 清除对应中断的挂起状态。
用法和 ISER/ICER 完全一样:
// 软件挂起 USART1 中断
NVIC->ISPR[37 / 32] |= (1 << (37 % 32));
// 清除 USART1 中断挂起
NVIC->ICPR[37 / 32] |= (1 << (37 % 32));
挂起状态还可以通过 NVIC->ISPR 读取,判断某个中断是否正在等待响应。
实用场景:在调试时,我经常用软件挂起来模拟中断触发,验证中断服务函数是否正常。比如写一个按键驱动,先用软件挂起中断,看回调函数能不能正确执行,省得每次都要物理按键。
NVIC 核心结构图
下面这张图,把 NVIC 的核心模块和它们之间的关系画出来了。你可以看到中断信号从外设到 CPU 的完整路径。
中断响应流程
一个完整的中断响应,大致走这几步:
- 外设产生中断信号:比如串口接收到一个字节,硬件将对应的中断标志位置 1。
- NVIC 挂起该中断:挂起寄存器对应位置 1,表示有一个中断请求在等待。
- 使能检查:NVIC 检查 ISER 寄存器,确认该中断是否被使能。如果没使能,挂起状态保持,但不会触发 CPU。
- 优先级比较:如果使能了,NVIC 把该中断的优先级和当前正在执行的中断优先级做比较。如果新中断的抢占优先级更高,就触发抢占。
- CPU 响应:CPU 自动压栈(保存当前上下文),从向量表中取出中断服务函数的地址,跳转执行。
- 清除挂起:中断服务函数执行完毕后,硬件自动清除挂起位。但有些外设的中断标志位需要软件手动清除。
经验之谈:我在做低功耗项目时,经常用挂起寄存器来判断中断是否在睡眠期间被触发。比如进入 Stop 模式前,先读一下挂起状态,如果有未处理的中断,就先处理掉再睡,避免一睡下去就被唤醒。
常见避坑指南
- 优先级分组必须在系统初始化时设置一次,之后不要再改。因为改变分组会导致所有中断的优先级重新解释,很容易出问题。
- 中断服务函数要尽量短。我见过有人把几百行的业务逻辑写在中断里,结果系统响应越来越慢,最后死机。中断里只做标志位设置或数据搬运,具体处理放到主循环或任务里。
- 注意中断标志位的清除方式。有些外设是写 1 清除,有些是读寄存器自动清除。搞错了会导致中断反复触发,系统卡死。
- 嵌套中断时,注意栈空间。每个中断嵌套都会消耗栈空间,如果嵌套层数太多,栈溢出是迟早的事。我一般把主栈设大一些,至少 1KB 起步。
NVIC 这部分内容,看起来寄存器不多,但每个位都有讲究。理解了它的工作原理,后面写驱动封装的时候,你就能知道什么时候该用抢占,什么时候该用子优先级,什么时候需要软件挂起。这些细节,才是嵌入式开发的硬功夫。