4. 中断系统与NVIC:中断优先级分组、外部中断EXTI配置、中断服务函数编写规范、中断嵌套与抢占优先级实战

中断,说白了就是CPU正在干活,突然被一个紧急事件叫停,转去处理这个事件,处理完再回来接着干。我刚开始学MCU时,觉得中断就是个“高级点的轮询”。后来在项目中吃过亏才明白——中断用不好,系统随时可能崩。

这一章,咱们把中断系统彻底讲透。从NVIC的优先级分组,到外部中断EXTI的配置,再到中断服务函数的写法规范,最后用实战代码演示中断嵌套。嗯,内容不少,但都是硬货。

4.1 NVIC与中断优先级分组

NVIC(Nested Vectored Interrupt Controller),嵌套向量中断控制器。它是Cortex-M内核的中断管家。所有中断的使能、挂起、优先级,都由它管。

我个人习惯,拿到一个新MCU,第一件事就是看它的NVIC支持多少中断源。STM32F103有68个可屏蔽中断通道,F407有82个。这个数字决定了你的系统能挂多少外设中断。

4.1.1 优先级分组:抢占优先级 vs 子优先级

ARM Cortex-M的中断优先级分两组:抢占优先级和子优先级。这个设计,我当年刚接触时觉得挺绕。后来用多了,发现它其实很巧妙。

概念 作用 类比
抢占优先级 决定能否打断正在执行的中断 领导级别:总经理可以打断部门经理
子优先级 同抢占优先级下的响应顺序 同级别领导:按资历排队

抢占优先级高的中断,可以打断抢占优先级低的中断。子优先级只在同抢占优先级时起作用——谁的数字小,谁先响应。

核心规则:抢占优先级决定嵌套能力,子优先级决定同级的排队顺序。两者互不干扰。

4.1.2 优先级分组配置

STM32用NVIC_SetPriorityGrouping()函数来配置分组。它决定了抢占优先级和子优先级各占多少位。以STM32F4为例,优先级寄存器共4位(0-3),分组方式如下:

分组编号 抢占优先级位数 子优先级位数 抢占优先级取值范围 子优先级取值范围
NVIC_PriorityGroup_0 0 4 无 0-15
NVIC_PriorityGroup_1 1 3 0-1 0-7
NVIC_PriorityGroup_2 2 2 0-3 0-3
NVIC_PriorityGroup_3 3 1 0-7 0-1
NVIC_PriorityGroup_4 4 0 0-15 无

我建议,大多数项目用NVIC_PriorityGroup_2。为什么?4个抢占优先级够用,4个子优先级也能满足同级的排队需求。我在一个电机控制项目里用过Group_4,结果发现16个抢占优先级根本用不完,反而把子优先级砍没了,调试时很被动。

我的习惯:系统初始化时只调用一次NVIC_SetPriorityGrouping(),整个项目周期不改。中途改分组,所有中断的优先级含义都会变,容易出bug。

4.2 外部中断EXTI配置

EXTI(External Interrupt/Event Controller),外部中断/事件控制器。它负责把GPIO引脚上的电平变化,转化成中断请求送给NVIC。

配置EXTI,说白了就是三步:选引脚、配触发方式、连NVIC。我见过不少新手在这三步里踩坑,尤其是引脚映射那一步。

4.2.1 EXTI配置步骤

  1. 使能GPIO时钟和SYSCFG时钟——EXTI的引脚映射依赖SYSCFG。
  2. 配置GPIO为输入模式——上拉或下拉根据外部电路定。
  3. 将GPIO引脚连接到EXTI线——通过SYSCFG_EXTILineConfig()函数。
  4. 配置EXTI触发方式——上升沿、下降沿或双边沿。
  5. 使能EXTI中断线——EXTI_IMR寄存器对应位置1。
  6. 配置NVIC优先级并使能中断——NVIC_EnableIRQ()。

这里有个坑:同一EXTI线只能映射一个GPIO引脚。比如EXTI0线只能映射PA0、PB0、PC0中的一个。我曾经在一个项目里,把PA0和PB0都配成了EXTI0,结果两个引脚的中断互相干扰,查了两天才发现。

注意:EXTI线0-15对应GPIO引脚0-15。PA0用EXTI0,PB1用EXTI1,以此类推。同一个EXTI线号,只能选一个引脚。

4.2.2 代码示例:配置PA0为下降沿触发中断

// 1. 使能时钟
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
RCC_APB2PeriphClockCmd(RCC_APB2Periph_SYSCFG, ENABLE);

// 2. 配置GPIO
GPIO_InitTypeDef GPIO_InitStruct;
GPIO_InitStruct.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStruct.GPIO_Mode = GPIO_Mode_IN;
GPIO_InitStruct.GPIO_PuPd = GPIO_PuPd_UP;
GPIO_Init(GPIOA, &GPIO_InitStruct);

// 3. 连接EXTI线
SYSCFG_EXTILineConfig(EXTI_PortSource_GPIOA, EXTI_PinSource_0);

// 4. 配置EXTI
EXTI_InitTypeDef EXTI_InitStruct;
EXTI_InitStruct.EXTI_Line = EXTI_Line0;
EXTI_InitStruct.EXTI_Mode = EXTI_Mode_Interrupt;
EXTI_InitStruct.EXTI_Trigger = EXTI_Trigger_Falling;
EXTI_InitStruct.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStruct);

// 5. 配置NVIC
NVIC_InitTypeDef NVIC_InitStruct;
NVIC_InitStruct.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStruct);

这段代码,我几乎每个项目都会写一遍。你把它封装成一个函数,比如EXTI_PA0_Init(),以后复用就方便了。

4.3 中断服务函数编写规范

中断服务函数(ISR),是中断系统的灵魂。写得好,系统稳如泰山;写得烂,系统随时死机。我见过最离谱的ISR,里面跑了个while(1)等待外部事件——结果中断再也回不来了。

4.3.1 ISR的黄金法则

  • 快进快出——ISR里不要做耗时操作。我一般控制在几十微秒以内。
  • 不要调用printf()——printf()可能重入,而且太慢。
  • 不要用延时函数——delay()会阻塞整个系统。
  • 标志位+主循环——ISR只置标志位,具体处理放主循环。
  • 清除中断标志——不清标志,中断会反复触发。

ISR设计原则:ISR只做最紧急的事——读取数据、置标志位、清除中断标志。其余一切,交给主循环或任务调度器。

4.3.2 标准ISR模板

// 中断标志位
volatile uint8_t g_exti0_flag = 0;

void EXTI0_IRQHandler(void)
{
    // 1. 检查中断源
    if (EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 2. 置标志位(只做最轻量的操作)
        g_exti0_flag = 1;
        
        // 3. 清除中断标志
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

// 主循环中处理
int main(void)
{
    while (1)
    {
        if (g_exti0_flag)
        {
            g_exti0_flag = 0;
            // 在这里处理按键逻辑、通信等耗时操作
        }
    }
}

volatile关键字不能少。编译器可能会优化掉对g_exti0_flag的读取,加上volatile告诉编译器“这个变量随时可能被中断修改”。

4.4 中断嵌套与抢占优先级实战

中断嵌套,就是高优先级中断打断低优先级中断。这个机制用好了,系统响应极快;用不好,低优先级中断可能永远得不到执行。

4.4.1 中断嵌套的条件

  • 两个中断的抢占优先级不同
  • 高抢占优先级的中断触发时,低优先级中断正在执行
  • 高优先级中断执行完后,CPU回到低优先级中断继续执行

如果两个中断的抢占优先级相同,即使子优先级不同,也不会发生嵌套。子优先级只在它们同时挂起时决定谁先响应。

4.4.2 实战:演示中断嵌套

我们配两个中断:EXTI0(按键)抢占优先级1,EXTI1(定时器)抢占优先级2。按键中断可以打断定时器中断。

// EXTI0配置(抢占优先级1)
NVIC_InitStruct.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
NVIC_Init(&NVIC_InitStruct);

// EXTI1配置(抢占优先级2)
NVIC_InitStruct.NVIC_IRQChannel = EXTI1_IRQn;
NVIC_InitStruct.NVIC_IRQChannelPreemptionPriority = 2;
NVIC_InitStruct.NVIC_IRQChannelSubPriority = 0;
NVIC_Init(&NVIC_InitStruct);

当EXTI1正在执行时,如果EXTI0触发,CPU会暂停EXTI1,跳转到EXTI0。EXTI0执行完,再回来继续执行EXTI1。这就是嵌套。

调试技巧:在ISR里翻转GPIO引脚,用示波器看波形。你能直观看到中断嵌套的时序——高优先级中断的脉冲插在低优先级中断的脉冲中间。

4.5 本章知识结构图

下面这张图,把中断系统的核心逻辑串起来了。从外设触发,到NVIC仲裁,再到ISR执行,一目了然。

中断系统知识结构图 中断系统核心逻辑 外部中断EXTI 定时器中断 串口/DMA中断 NVIC 中断控制器 优先级分组 → 抢占优先级 → 子优先级 中断嵌套判断 高抢占优先级 → 打断低优先级 → 执行完返回 中断服务函数 ISR 快进快出 · 置标志位 · 清中断标志 主循环 处理标志 核心流程:外设触发 → NVIC优先级仲裁 → 判断是否嵌套 → 执行ISR → 返回主循环

4.6 避坑指南

中断系统用久了,总会遇到一些奇奇怪怪的问题。我把自己踩过的坑列出来,你遇到了可以直接对照。

坑1:中断标志没清干净

有些外设的中断标志,需要先读状态寄存器再清除。比如USART,要先读SR再写DR,否则标志清不掉。我曾经因为这个,串口中断只进一次就卡死了。

坑2:优先级分组中途修改

项目运行到一半,调用NVIC_SetPriorityGrouping()改分组。所有已配置的中断优先级含义全变了。我建议:上电初始化时一次配好,后面别动。

坑3:ISR里调用可重入函数

printf()、malloc()这些函数,内部可能用了全局变量。中断里调用它们,如果主循环也在用,数据就乱了。我一般只用标志位和环形缓冲区。

我的调试三板斧:1) 示波器看GPIO翻转波形;2) 串口打印中断进入次数;3) 在ISR里累加一个计数器,主循环定期读取。这三个方法,能解决90%的中断问题。

中断系统,是MCU开发的基石。你把它吃透了,后面学定时器、串口、ADC都会轻松很多。嗯,这一章的内容就到这儿。代码该写的都写了,坑该说的都说了。剩下的,就是你自己动手去试了。