4. 中断系统与NVIC:中断优先级分组、外部中断EXTI、模块化封装中断驱动、实战:按键中断控制LED

中断,是嵌入式系统的灵魂。没有中断的MCU,就像一个人只能排队做事情,效率极低。我刚开始学STM32时,对中断的理解就是“一个函数被自动调用”。后来踩了不少坑才明白——中断的配置、优先级管理、响应时序,每一个细节都藏着雷。

这一章,我们就把中断系统彻底讲透。从NVIC的优先级分组,到外部中断EXTI的配置,再到如何写出可复用的中断驱动模块。最后,用一个按键中断控制LED的实战,把知识点串起来。

4.1 NVIC与中断优先级分组

NVIC(Nested Vectored Interrupt Controller),嵌套向量中断控制器。它是Cortex-M3内核的中断管家。STM32的所有中断,最终都由NVIC来调度。

优先级分组,是NVIC最核心的概念。说白了,就是决定“抢占优先级”和“子优先级”各占多少位。

分组编号 抢占优先级位数 子优先级位数 优先级总数
NVIC_PriorityGroup_0 0 bit 4 bits 16级子优先级
NVIC_PriorityGroup_1 1 bit 3 bits 2级抢占 × 8级子优先级
NVIC_PriorityGroup_2 2 bits 2 bits 4级抢占 × 4级子优先级
NVIC_PriorityGroup_3 3 bits 1 bit 8级抢占 × 2级子优先级
NVIC_PriorityGroup_4 4 bits 0 bit 16级抢占优先级
我曾经踩过的坑:整个工程中,优先级分组只能设置一次!我见过有人在不同模块里反复调用NVIC_PriorityGroupConfig(),结果中断行为完全乱掉。建议在main()函数一开始就设置好,之后不要再改。

我个人习惯使用NVIC_PriorityGroup_2。4级抢占优先级,4级子优先级,够用且灵活。高实时性的中断(比如定时器、通信)给高抢占优先级,普通中断给低抢占优先级。

4.2 外部中断EXTI详解

EXTI(External Interrupt/Event Controller),外部中断/事件控制器。它负责检测GPIO引脚上的信号变化,并产生中断请求。

EXTI的配置流程,其实不复杂。但有几个关键点,容易出错。

  1. 选择中断线:EXTI有16条中断线(EXTI0~EXTI15),每条线对应一组GPIO引脚。比如PA0、PB0、PC0都映射到EXTI0。同一时间,只能有一个引脚连接到某条中断线。
  2. 配置触发方式:上升沿触发、下降沿触发、或者双边沿触发。
  3. 使能中断线:在EXTI寄存器中使能对应的中断线。
  4. 配置NVIC:使能对应的中断通道,设置优先级。

关键点:GPIO的AFIO时钟必须使能!我见过有人配置了EXTI,但忘了开AFIO时钟,中断死活不触发。AFIO时钟在RCC_APB2PeriphClockCmd()中使能。

举个例子,配置PA0为下降沿触发的外部中断:

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

// 2. 配置PA0为输入模式
GPIO_InitTypeDef GPIO_InitStructure;
GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU; // 上拉输入
GPIO_Init(GPIOA, &GPIO_InitStructure);

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

// 4. 配置EXTI
EXTI_InitTypeDef EXTI_InitStructure;
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_InitTypeDef NVIC_InitStructure;
NVIC_InitStructure.NVIC_IRQChannel = EXTI0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);

4.3 模块化封装中断驱动

裸机编程最容易出现的问题,就是中断服务函数里塞了太多逻辑。你想想看,中断里做延时、做复杂计算、甚至调用printf——系统不崩才怪。

我的做法是:中断服务函数只做一件事——设置一个标志位,或者把数据放到一个环形缓冲区。真正的处理逻辑,放在主循环里。

下面是一个模块化的按键中断驱动示例:

// key_interrupt.h
#ifndef __KEY_INTERRUPT_H
#define __KEY_INTERRUPT_H

#include "stm32f10x.h"

// 按键中断回调函数类型
typedef void (*KeyInterruptCallback)(void);

// 初始化按键中断
void KeyInterrupt_Init(void);

// 注册按键按下回调函数
void KeyInterrupt_RegisterCallback(KeyInterruptCallback callback);

#endif

// key_interrupt.c
#include "key_interrupt.h"

static KeyInterruptCallback key_press_callback = NULL;

void KeyInterrupt_Init(void)
{
    // 配置GPIO、EXTI、NVIC(代码同上)
    // ...
}

void KeyInterrupt_RegisterCallback(KeyInterruptCallback callback)
{
    key_press_callback = callback;
}

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    if (EXTI_GetITStatus(EXTI_Line0) != RESET)
    {
        // 清除中断标志位
        EXTI_ClearITPendingBit(EXTI_Line0);
        
        // 调用回调函数
        if (key_press_callback != NULL)
        {
            key_press_callback();
        }
    }
}

我的习惯:回调函数里不要做耗时操作。如果需要在中断里处理数据,用环形缓冲区。主循环定期检查缓冲区,处理数据。这样既保证了中断的实时性,又避免了中断嵌套过深。

4.4 实战:按键中断控制LED

理论说完了,我们来写一个完整的例子。按下按键,LED状态翻转。

硬件连接:

  • 按键:PA0,外部中断下降沿触发
  • LED:PC13,低电平点亮
// main.c
#include "stm32f10x.h"
#include "key_interrupt.h"

// LED控制函数
void LED_Toggle(void)
{
    static uint8_t led_state = 0;
    if (led_state == 0)
    {
        GPIO_ResetBits(GPIOC, GPIO_Pin_13);
        led_state = 1;
    }
    else
    {
        GPIO_SetBits(GPIOC, GPIO_Pin_13);
        led_state = 0;
    }
}

int main(void)
{
    // 配置LED引脚
    RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE);
    GPIO_InitTypeDef GPIO_InitStructure;
    GPIO_InitStructure.GPIO_Pin = GPIO_Pin_13;
    GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
    GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
    GPIO_Init(GPIOC, &GPIO_InitStructure);
    
    // 初始化按键中断
    KeyInterrupt_Init();
    
    // 注册回调函数
    KeyInterrupt_RegisterCallback(LED_Toggle);
    
    while (1)
    {
        // 主循环可以干其他事情
    }
}

这个例子看起来简单,但背后涉及了中断配置、回调机制、模块化设计。你想想看,如果以后要增加第二个按键,只需要再注册一个回调函数,完全不需要修改中断服务函数。

注意:按键存在机械抖动。按下和释放的瞬间,会产生多个边沿跳变。如果直接触发中断,LED可能会闪烁多次。解决办法:硬件上并联一个100nF电容,或者软件上做10~20ms的延时消抖。我个人推荐硬件消抖,简单可靠。

4.5 中断系统的知识结构

为了让你更直观地理解中断系统的整体架构,我画了一张图:

STM32中断系统知识结构 中断源 外部中断 (EXTI) 外设中断 (TIM/USART等) 软件中断 (SWI) NVIC 中断控制器 优先级分组 · 使能 · 挂起 · 清除 Cortex-M3 CPU 中断服务函数 (ISR) ISR中只做标志位设置或数据入队,主循环中处理实际逻辑

从这张图可以看得很清楚:中断源产生请求,NVIC根据优先级决定是否响应,CPU执行对应的ISR。ISR里只做最轻量级的操作,真正的业务逻辑在主循环中处理。这个架构,我在多个项目中验证过,稳定可靠。

4.6 中断调试的常见问题

最后,分享几个我在调试中断时遇到的典型问题:

  • 中断不触发:检查AFIO时钟是否使能,检查NVIC是否使能,检查中断线是否被其他引脚占用。
  • 中断频繁触发:检查中断标志位是否在ISR中清除,检查外部信号是否有抖动。
  • 中断响应慢:检查是否有更高优先级的中断在频繁执行,检查ISR中是否有耗时操作。
  • 中断嵌套导致栈溢出:检查中断嵌套深度,适当增大栈空间。

调试技巧:在ISR中设置一个GPIO引脚,用示波器观察引脚电平变化。如果引脚电平变化频率异常,说明中断触发有问题。这个方法比printf高效得多。

中断系统是STM32开发的基础,也是难点。把这一章的内容吃透,后面的定时器、通信、ADC等外设驱动,都会轻松很多。