4. 中断系统与外部中断:从理论到实战

中断,说白了就是让CPU停下手里的事,先去处理一个紧急任务。我刚开始学STM32时,觉得中断就是个“高级点的轮询”。后来做项目才发现,没有中断,很多实时性要求根本没法满足。

这一章,我们就把中断系统拆开揉碎。从NVIC控制器,到外部中断配置,再到实际写中断服务函数。嗯,每一步我都会结合项目经验来讲。

4.1 NVIC中断控制器:中断的“调度中心”

NVIC(Nested Vectored Interrupt Controller)是Cortex-M内核的中断管家。它负责管理所有中断的使能、挂起、优先级和响应。

我个人习惯把NVIC理解成一个“优先级仲裁器”。当多个中断同时发生时,谁先响应,谁后响应,都由它说了算。

NVIC核心能力:

  • 支持最多240个中断(STM32实际使用约16~80个)
  • 每个中断可独立使能/失能
  • 支持中断优先级分组(抢占优先级 + 子优先级)
  • 硬件自动压栈/出栈,无需软件干预

这里有个关键点:NVIC的优先级是数字越小,优先级越高。很多新手会搞反,我刚开始也犯过这个错。

4.2 中断优先级分组:抢占与响应

STM32把优先级分成两组:抢占优先级和子优先级。抢占优先级决定能否打断正在执行的中断,子优先级决定同抢占级中断的响应顺序。

优先级分组通过 NVIC_PriorityGroupConfig() 函数配置。STM32支持5种分组方式:

分组 抢占优先级位数 子优先级位数 抢占级数量 子优先级数量
NVIC_PriorityGroup_0 0 4 1 16
NVIC_PriorityGroup_1 1 3 2 8
NVIC_PriorityGroup_2 2 2 4 4
NVIC_PriorityGroup_3 3 1 8 2
NVIC_PriorityGroup_4 4 0 16 1

我的建议:大多数项目用 NVIC_PriorityGroup_2 就够了。4级抢占 + 4级子优先级,基本覆盖所有场景。我在一个多传感器采集项目中用过Group_2,效果很好。

配置示例:

// 设置优先级分组:4位抢占优先级,4位子优先级
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_2);

// 配置USART1中断
NVIC_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = USART1_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;  // 抢占优先级
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;         // 子优先级
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

4.3 EXTI外部中断配置:从引脚到中断的链路

EXTI(External Interrupt/Event Controller)负责把GPIO引脚上的电平变化,转换成中断请求。说白了,就是“引脚信号 → 中断信号”的桥梁。

配置EXTI的步骤,我总结为4步:

  1. 使能GPIO时钟 —— 引脚要工作,时钟先到位
  2. 配置GPIO为输入模式 —— 上拉/下拉根据电路决定
  3. 配置EXTI线 —— 选择触发边沿(上升沿/下降沿/双边沿)
  4. 配置NVIC —— 使能中断通道,设置优先级

来看一个按键中断的完整配置:

// 按键中断配置:PA0作为外部中断输入
void EXTI_Key_Config(void)
{
    GPIO_InitTypeDef GPIO_InitStructure;
    EXTI_InitTypeDef EXTI_InitStructure;
    NVIC_InitTypeDef NVIC_InitStructure;

    // 1. 使能GPIOA和AFIO时钟
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE);

    // 2. 配置PA0为浮空输入
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
    GPIO_Init(GPIOA, &GPIO_InitStructure);

    // 3. 将PA0连接到EXTI0线
    GPIO_EXTILineConfig(GPIO_PortSourceGPIOA, GPIO_PinSource0);

    // 4. 配置EXTI0:下降沿触发
    EXTI_InitStructure.EXTI_Line = EXTI_Line0;
    EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
    EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Falling;  // 按键按下时产生下降沿
    EXTI_InitStructure.EXTI_LineCmd = ENABLE;
    EXTI_Init(&EXTI_InitStructure);

    // 5. 配置NVIC
    NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
    NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 2;
    NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
    NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
    NVIC_Init(&NVIC_InitStructure);
}

注意:EXTI线0~15分别对应GPIO的Pin0~Pin15。同一编号的EXTI线只能映射到一个GPIO引脚。比如PA0和PB0不能同时使用EXTI0线。我曾经在一个项目里踩过这个坑,两个按键用了同一个EXTI线,结果只有一个能触发中断。

4.4 编写中断服务函数:ISR的规范写法

中断服务函数(ISR)是中断触发后实际执行的代码。STM32的ISR函数名是固定的,在启动文件里已经定义好。你只需要在C文件中实现它。

ISR的命名规则:XXX_IRQHandler。比如EXTI0的中断服务函数叫 EXTI0_IRQHandler。

// EXTI0中断服务函数
void EXTI0_IRQHandler(void)
{
    // 检查中断标志位
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 执行中断处理逻辑
        // 比如:翻转LED、记录按键次数、唤醒系统等

        // 清除中断标志位 —— 这一步绝对不能忘!
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

ISR里应该写什么?我的原则是:快进快出。中断服务函数里只做最紧急的事,比如设置一个标志位、计数加1、或者把数据存到缓冲区。复杂的处理逻辑放到主循环里做。

ISR编写黄金法则:

  • 代码尽量短小精悍
  • 避免调用延时函数
  • 避免使用printf等阻塞型函数
  • 避免进行复杂运算
  • 必须清除中断标志位

4.5 中断应用:按键中断实战

按键中断是最常见的EXTI应用场景。相比轮询方式,中断方式响应更快,而且不占用CPU时间。

来看一个完整的按键中断示例:

// 全局变量:按键按下标志
volatile uint8_t key_pressed = 0;

// 主函数
int main(void)
{
    // 初始化
    LED_Init();          // LED初始化
    EXTI_Key_Config();   // 按键中断配置

    while(1)
    {
        if(key_pressed)
        {
            key_pressed = 0;  // 清除标志
            LED_Toggle();     // 翻转LED
        }
        // 其他任务...
    }
}

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 消抖处理:延时20ms再检测
        delay_ms(20);
        if(GPIO_ReadInputDataBit(GPIOA, GPIO_Pin_0) == 0)
        {
            key_pressed = 1;  // 设置标志位
        }
        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

这里有个细节:我在ISR里做了软件消抖。虽然中断里加延时不太优雅,但20ms的延时对系统影响很小。如果你对实时性要求极高,可以用定时器来做消抖。

4.6 中断注意事项:标志位清除与重入问题

中断用不好,系统会出各种奇怪问题。我总结几个最常见的坑:

标志位清除

这是新手最容易犯的错误。中断触发后,硬件会置位中断标志位。如果你不清除它,中断会反复触发,导致系统卡死在ISR里。

清除标志位的正确姿势:

// 正确:清除指定中断线标志
EXTI_ClearITPendingBit(EXTI_Line0);

// 或者使用更安全的版本
EXTI_ClearFlag(EXTI_Line0);

我曾经在一个项目中,因为忘记清除标志位,导致系统一上电就卡死在中断里。排查了整整半天才发现问题。从那以后,我写ISR的第一件事就是先写清除标志位的代码。

中断重入问题

中断重入是指一个中断正在执行时,又被同优先级或更高优先级的中断打断。如果处理不当,会导致数据混乱或系统崩溃。

避免重入问题的几个方法:

  • 合理设置优先级:关键中断用高优先级,普通中断用低优先级
  • ISR中关中断:在访问共享资源时,临时关闭中断
  • 使用volatile关键字:ISR和主循环共享的变量,必须用volatile修饰
  • 避免在ISR中调用可重入函数:比如malloc、printf等
// 使用volatile防止编译器优化
volatile uint32_t interrupt_count = 0;

void EXTI0_IRQHandler(void)
{
    if(EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 临时关闭中断,保护共享变量
        __disable_irq();
        interrupt_count++;
        __enable_irq();

        EXTI_ClearITPendingBit(EXTI_Line0);
    }
}

4.7 中断系统知识图谱

下面这张图展示了中断系统的完整链路和核心要素:

STM32中断系统知识图谱 中断源 EXTI外部中断控制器 NVIC中断控制器(优先级仲裁) CPU执行ISR 优先级分组 抢占优先级 子优先级 标志位清除 中断重入保护 volatile关键字 核心要点 • 中断优先级:数字越小优先级越高 • ISR原则:快进快出,只做最紧急的事 • 标志位清除:必须在ISR末尾执行,否则会反复触发中断

4.8 避坑指南:我的血泪教训

做中断开发这些年,我踩过不少坑。分享几个典型的:

坑1:中断优先级设置不当导致系统卡死

我曾经在一个电机控制项目中,把定时器中断优先级设得比系统滴答中断还高。结果定时器频繁打断系统调度,导致系统卡死。后来把定时器优先级降到3(Group_2分组下),问题解决。

坑2:ISR里调用printf导致堆栈溢出

printf函数内部会申请大量栈空间,而且可能触发其他中断。我在调试时习惯在ISR里加printf,结果程序跑着跑着就飞了。后来改用串口DMA + 环形缓冲区,完美解决。

坑3:多个中断共享同一个变量

主循环和ISR同时操作一个变量时,如果不加保护,数据会错乱。我遇到过按键计数不准的问题,就是因为主循环读取变量时,ISR刚好在修改它。加个临界区保护就搞定了。