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 的完整路径。

NVIC 中断控制器核心结构图 NVIC 中断控制器核心结构 外设中断源 外设中断源 外设中断源 挂起寄存器 使能寄存器 优先级寄存器 优先级比较器 抢占 vs 当前 CPU 说明: 1. 外设中断信号先到达挂起寄存器,标记中断请求。 2. 使能寄存器决定该中断是否被允许响应。 3. 优先级寄存器提供该中断的抢占优先级和子优先级。 4. 优先级比较器判断是否打断当前正在执行的中断。 5. 如果优先级更高,CPU 暂停当前任务,响应新中断。

中断响应流程

一个完整的中断响应,大致走这几步:

  1. 外设产生中断信号:比如串口接收到一个字节,硬件将对应的中断标志位置 1。
  2. NVIC 挂起该中断:挂起寄存器对应位置 1,表示有一个中断请求在等待。
  3. 使能检查:NVIC 检查 ISER 寄存器,确认该中断是否被使能。如果没使能,挂起状态保持,但不会触发 CPU。
  4. 优先级比较:如果使能了,NVIC 把该中断的优先级和当前正在执行的中断优先级做比较。如果新中断的抢占优先级更高,就触发抢占。
  5. CPU 响应:CPU 自动压栈(保存当前上下文),从向量表中取出中断服务函数的地址,跳转执行。
  6. 清除挂起:中断服务函数执行完毕后,硬件自动清除挂起位。但有些外设的中断标志位需要软件手动清除。

经验之谈:我在做低功耗项目时,经常用挂起寄存器来判断中断是否在睡眠期间被触发。比如进入 Stop 模式前,先读一下挂起状态,如果有未处理的中断,就先处理掉再睡,避免一睡下去就被唤醒。

常见避坑指南

  • 优先级分组必须在系统初始化时设置一次,之后不要再改。因为改变分组会导致所有中断的优先级重新解释,很容易出问题。
  • 中断服务函数要尽量短。我见过有人把几百行的业务逻辑写在中断里,结果系统响应越来越慢,最后死机。中断里只做标志位设置或数据搬运,具体处理放到主循环或任务里。
  • 注意中断标志位的清除方式。有些外设是写 1 清除,有些是读寄存器自动清除。搞错了会导致中断反复触发,系统卡死。
  • 嵌套中断时,注意栈空间。每个中断嵌套都会消耗栈空间,如果嵌套层数太多,栈溢出是迟早的事。我一般把主栈设大一些,至少 1KB 起步。

NVIC 这部分内容,看起来寄存器不多,但每个位都有讲究。理解了它的工作原理,后面写驱动封装的时候,你就能知道什么时候该用抢占,什么时候该用子优先级,什么时候需要软件挂起。这些细节,才是嵌入式开发的硬功夫。