4. 中断系统与NVIC驱动工程

在轮询方式下,CPU 必须不断读取外设状态寄存器来判断事件是否发生,这种方式在按键、串口接收、定时器溢出等异步事件面前效率极低。STM32 的中断系统让外设能够在事件发生时主动“通知”内核,CPU 暂停当前任务、跳转到对应的中断服务函数处理事件,处理完再返回原位置继续执行。本节围绕 EXTI 外部中断、NVIC 优先级分组、中断服务函数编写规范,以及按键中断的 BSP 注册机制展开。

一、EXTI 与 NVIC 的协作关系

STM32 的中断链路可以概括为:外设或 GPIO 检测到事件 → EXTI 边沿检测电路捕获上升沿/下降沿 → 置位挂起寄存器 EXTI_PR → 向 NVIC 发出中断请求 → NVIC 根据优先级分组和使能状态决定是否提交给 Cortex-M 内核 → 内核响应中断,读取向量表跳转到 ISR。

EXTI 负责“事件检测”,NVIC 负责“优先级仲裁与使能”。EXTI 的每条线可独立配置触发边沿(上升沿、下降沿或双边沿)、屏蔽(IMR)和挂起(PR)。以 STM32F103 为例,EXTI0~EXTI15 分别对应 GPIO 的 Px0~Px15,同一编号的引脚(如 PA0、PB0、PC0)共享一条 EXTI 线,因此不能同时把 PA0 和 PB0 都配置为 EXTI0 中断源,这是初学者常见的配置冲突。

EXTI 外部中断信号链与 NVIC 仲裁 GPIO 引脚 PA0 / PC13 按键按下 → 电平变化 EXTI 边沿检测 RTSR 上升沿 FTSR 下降沿 IMR 屏蔽 / PR 挂起 NVIC 仲裁 优先级分组 抢占 / 子优先级 ISER 使能 Cortex-M 内核 取向量 → 跳转 ISR 压栈 / 出栈 NVIC 优先级分组(4 位优先级位) Group 0 0 位抢占 / 4 位子 Group 2 2 位抢占 / 2 位子 Group 3 3 位抢占 / 1 位子 Group 4 4 位抢占 / 0 位子 抢占优先级决定能否嵌套;子优先级仅在同时挂起时决定响应顺序

二、NVIC 优先级分组与配置

Cortex-M3/M4 的每个中断拥有 4 位优先级位,通过 NVIC 的 AIRCR 寄存器中的 PRIGROUP 字段把这 4 位划分为“抢占优先级”和“子优先级”两部分。抢占优先级高的中断可以打断正在执行的低抢占优先级 ISR,形成嵌套;子优先级只在两个中断同时挂起时决定谁先响应,不能嵌套。

常用的分组是 NVIC_PRIORITYGROUP_2,即 2 位抢占 + 2 位子优先级,抢占和子优先级各 0~3 共 4 级。配置时先调用 HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2),再对每个中断设置优先级和使能:

HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

// EXTI0 中断:抢占 1,子优先级 0
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

// EXTI15_10 中断:抢占 2,子优先级 1
HAL_NVIC_SetPriority(EXTI15_10_IRQn, 2, 1);
HAL_NVIC_EnableIRQ(EXTI15_10_IRQn);

数值越小优先级越高。上例中 EXTI0 抢占优先级为 1,高于 EXTI15_10 的 2,因此 EXTI0 的 ISR 可以打断 EXTI15_10 的 ISR。若两个中断抢占优先级相同,则不能互相打断,只能按子优先级顺序排队。

三、中断服务函数编写规范

ISR 是中断响应的入口,编写时有几条硬性规范:

  • 函数名必须与启动文件中的向量表一致,例如 EXTI0_IRQHandler、EXTI15_10_IRQHandler,拼写错误会导致中断触发后跳转到默认死循环。
  • 执行时间尽量短,只做标志清除、数据搬运、置位事件标志等必要动作,耗时逻辑放到主循环处理。
  • 必须清除中断挂起标志,EXTI 通过写 1 到 EXTI_PR 对应位清除,HAL 库中调用 __HAL_GPIO_EXTI_CLEAR_IT() 或 HAL_GPIO_EXTI_IRQHandler() 完成。
  • 共享变量加 volatile,防止编译器优化导致主循环读不到 ISR 的修改。
  • 避免在 ISR 中调用阻塞函数,如 HAL_Delay、带超时的阻塞发送等。

使用 HAL 库时,标准写法是在 ISR 中调用 HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_x),它会自动清除标志并回调 HAL_GPIO_EXTI_Callback()。用户只需重写回调函数,把业务逻辑放在回调里,这样中断入口与业务处理解耦,便于 BSP 层统一管理。

四、按键中断触发事件处理

以 PC13 按键为例,配置流程如下:

  1. 使能 GPIOC 时钟,把 PC13 配置为输入模式,并开启上拉(按键另一端接 GND,按下为低电平)。
  2. 把 PC13 复用为 EXTI13,选择下降沿触发(按下瞬间产生下降沿)。
  3. 配置 NVIC:设置优先级分组、EXTI15_10_IRQn 的抢占/子优先级并使能。
  4. 在 EXTI15_10_IRQHandler 中调用 HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_13)。
  5. 重写 HAL_GPIO_EXTI_Callback(),判断引脚号后置位按键事件标志或翻转 LED。

需要注意机械按键的抖动问题。按下和释放瞬间会产生 5~20 ms 的抖动,若不加处理,一次按键可能触发多次中断。常见做法是在回调中记录时间戳,与上次触发时间比较,小于 20 ms 则忽略;或者只置位标志,由主循环做软件消抖。前者响应快但占用中断时间,后者把消抖放到主循环,ISR 更短,工程中更常用。

若按键接在 PA0 上,则使用 EXTI0_IRQn 和 EXTI0_IRQHandler;若接在 PC13 上,则使用 EXTI15_10_IRQn 和 EXTI15_10_IRQHandler。EXTI 线号由引脚号决定,与端口无关,但同一线号只能选一个端口。

五、BSP 中断注册机制

在 BSP 工程中,不建议把每个按键的业务逻辑直接写死在 ISR 或回调里,而是建立一层“中断注册”机制:BSP 层维护一张回调表,按键初始化时把处理函数注册进去,ISR 触发后根据引脚号查表调用对应回调。这样新增按键只需注册,不必修改中断入口代码。

一种简洁的实现方式是定义结构体数组:

typedef void (*key_callback_t)(uint16_t pin);

typedef struct {
    uint16_t pin;
    key_callback_t cb;
} key_isr_item_t;

static key_isr_item_t key_isr_table[KEY_MAX];

void BSP_Key_Register(uint16_t pin, key_callback_t cb)
{
    for (int i = 0; i < KEY_MAX; i++) {
        if (key_isr_table[i].pin == 0) {
            key_isr_table[i].pin = pin;
            key_isr_table[i].cb  = cb;
            return;
        }
    }
}

void HAL_GPIO_EXTI_Callback(uint16_t pin)
{
    for (int i = 0; i < KEY_MAX; i++) {
        if (key_isr_table[i].pin == pin && key_isr_table[i].cb) {
            key_isr_table[i].cb(pin);
            return;
        }
    }
}

应用层只需调用 BSP_Key_Register(GPIO_PIN_13, Key_ShortPress_Handler) 即可完成绑定。回调中通常只做“置位事件标志 + 记录时间戳”,主循环轮询标志后执行具体业务,从而保证 ISR 短小、业务可扩展、中断与逻辑分层清晰。

注册表容量要预先定义,注册前检查是否已存在相同引脚,避免重复注册覆盖;回调函数内部不要再次调用注册接口,防止在中断上下文中修改表结构。

掌握 EXTI 与 NVIC 的协作、优先级分组规则、ISR 编写规范和 BSP 注册机制后,按键、编码器、外部传感器等异步事件都能以统一、可维护的方式接入工程,为后续串口、定时器等中断外设打下基础。