3. 中断系统与回调机制:NVIC与优先级分组、外部中断EXTI配置、HAL库中断回调函数机制、设计通用的按键中断驱动

中断,说白了就是CPU正在干活,突然被一个紧急事件打断,跑去处理完再回来继续干活。这个机制在嵌入式系统里太重要了。我刚开始用STM32时,总觉得中断配置很玄乎,后来踩了几个坑才真正搞明白。

3.1 NVIC与优先级分组

NVIC(Nested Vectored Interrupt Controller)是Cortex-M内核的中断控制器。它负责管理所有中断的使能、挂起、优先级等。STM32的NVIC支持最多240个中断,但具体数量看芯片型号。

3.1.1 优先级分组

优先级分组是NVIC最核心的概念。它把优先级分为抢占优先级和子优先级。抢占优先级决定能否打断正在执行的中断,子优先级决定同抢占优先级下的响应顺序。

分组编号 抢占优先级位数 子优先级位数 说明
NVIC_PriorityGroup_0 0 4 不支持抢占,只有子优先级
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级抢占,不支持子优先级

我的习惯:项目里我一般用NVIC_PriorityGroup_2。4级抢占够用,4级子优先级也能满足大部分场景。除非有特别紧急的中断需要更高优先级,否则这个分组最均衡。

3.1.2 优先级配置

配置优先级分组的函数是HAL_NVIC_SetPriorityGrouping()。但注意,这个函数必须在系统初始化时调用一次,而且只能调用一次。为什么?因为分组一旦设定,整个系统的中断优先级规则就固定了,中途改会出大问题。

// 设置优先级分组为Group2
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

// 配置具体中断的优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

我曾经踩过的坑:有一次我在项目中途调用了两次HAL_NVIC_SetPriorityGrouping(),结果中断响应全乱了。后来查手册才发现,这个函数内部会修改SCB->AIRCR寄存器,而该寄存器有写保护,第二次调用可能失败或产生不可预知的行为。所以,切记只调用一次。

3.2 外部中断EXTI配置

EXTI(External Interrupt/Event Controller)负责管理外部引脚的中断和事件。STM32的EXTI支持最多16条线(EXTI0~EXTI15),每条线对应一个GPIO引脚。

3.2.1 EXTI配置步骤

  1. 使能GPIO时钟:配置引脚模式为输入。
  2. 配置GPIO为中断模式:设置触发边沿(上升沿、下降沿或双边沿)。
  3. 配置NVIC:使能对应的中断通道,设置优先级。
  4. 编写中断服务函数:处理中断事件。
// 以PA0为例,配置为下降沿触发的外部中断
void EXTI_Config(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 1. 使能GPIO时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    // 2. 配置PA0为输入,上拉
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    // 3. 配置NVIC
    HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

3.2.2 中断服务函数

中断服务函数的名字是固定的,不能随便改。EXTI0的中断服务函数叫EXTI0_IRQHandler()。在里面调用HAL库的通用处理函数HAL_GPIO_EXTI_IRQHandler()。

void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

关键点:HAL_GPIO_EXTI_IRQHandler()内部会清除中断标志位,然后调用回调函数HAL_GPIO_EXTI_Callback()。你只需要在回调函数里写自己的业务逻辑。

3.3 HAL库中断回调函数机制

HAL库的中断回调机制,说白了就是「你只管写业务逻辑,底层处理交给我」。它把中断处理分成两部分:

  • 底层处理:由HAL库的中断服务函数完成,包括清除标志位、保存上下文等。
  • 上层回调:由用户实现的回调函数完成,只处理业务逻辑。

这种设计模式叫「模板方法模式」。HAL库定义好处理流程,你把具体实现填到回调函数里。

// 用户实现的回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if(GPIO_Pin == GPIO_PIN_0)
    {
        // 处理按键按下事件
        // 注意:这里不能做耗时操作
    }
}

我的建议:回调函数里不要做复杂运算,比如延时、打印、大量数据处理。中断服务函数应该尽快执行完毕,否则会影响系统实时性。如果确实需要处理复杂逻辑,可以用标志位+主循环轮询的方式。

3.4 设计通用的按键中断驱动

现在我们来设计一个通用的按键中断驱动。这个驱动要支持:

  • 任意GPIO引脚配置为按键输入
  • 支持上升沿、下降沿、双边沿触发
  • 支持按键消抖
  • 提供统一的回调接口

3.4.1 驱动结构

// key_driver.h
#ifndef __KEY_DRIVER_H
#define __KEY_DRIVER_H

#include "stm32f1xx_hal.h"

typedef struct {
    GPIO_TypeDef *port;      // GPIO端口
    uint16_t pin;            // GPIO引脚
    uint8_t active_level;    // 有效电平:0-低电平有效,1-高电平有效
    uint32_t debounce_ms;    // 消抖时间(毫秒)
    void (*callback)(void);  // 按键回调函数
} Key_Config_t;

void Key_Init(Key_Config_t *config);
void Key_IRQHandler(uint16_t pin);
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin);

#endif
// key_driver.c
#include "key_driver.h"

static Key_Config_t *key_config = NULL;

void Key_Init(Key_Config_t *config)
{
    key_config = config;
    
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 使能GPIO时钟
    if(config->port == GPIOA) __HAL_RCC_GPIOA_CLK_ENABLE();
    else if(config->port == GPIOB) __HAL_RCC_GPIOB_CLK_ENABLE();
    // ... 其他端口
    
    // 配置GPIO为中断模式
    GPIO_InitStruct.Pin = config->pin;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 默认下降沿
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(config->port, &GPIO_InitStruct);
    
    // 配置NVIC
    // 注意:这里需要根据引脚号计算IRQn
    uint8_t irq_num = config->pin & 0x0F;
    HAL_NVIC_SetPriority(EXTI0_IRQn + irq_num, 1, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn + irq_num);
}

void Key_IRQHandler(uint16_t pin)
{
    if(pin == key_config->pin)
    {
        // 消抖处理
        HAL_Delay(key_config->debounce_ms);
        
        // 再次检测电平
        uint8_t level = HAL_GPIO_ReadPin(key_config->port, key_config->pin);
        if(level == key_config->active_level)
        {
            if(key_config->callback != NULL)
            {
                key_config->callback();
            }
        }
    }
}

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    Key_IRQHandler(GPIO_Pin);
}

3.4.2 使用示例

// main.c
Key_Config_t key1 = {
    .port = GPIOA,
    .pin = GPIO_PIN_0,
    .active_level = 0,      // 低电平有效
    .debounce_ms = 20,      // 20ms消抖
    .callback = Key1_Callback
};

void Key1_Callback(void)
{
    // 按键按下后的处理
    // 比如切换LED状态
    HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13);
}

int main(void)
{
    HAL_Init();
    Key_Init(&key1);
    
    while(1)
    {
        // 主循环做其他事情
    }
}

我曾经踩过的坑:消抖延时HAL_Delay()在中断里调用要小心。如果系统时钟配置不对,或者中断优先级导致SysTick中断被阻塞,HAL_Delay()会卡死。我后来改用定时器轮询或状态机消抖,彻底解决了这个问题。

3.5 中断系统知识结构图

下面这张图展示了中断系统的整体架构和配置流程,方便你理解各个模块之间的关系。

中断系统知识结构图 中断系统知识结构图 NVIC 中断控制器 优先级分组 抢占优先级 + 子优先级 中断使能/禁能 HAL_NVIC_EnableIRQ() 中断挂起/清除 NVIC->ISPR / ICPR EXTI 外部中断 使能GPIO时钟 配置中断模式 配置NVIC 编写ISR HAL库中断回调机制 HAL_GPIO_EXTI_Callback() 用户实现业务逻辑

3.6 总结

中断系统是嵌入式开发的基石。NVIC负责中断管理,EXTI负责外部事件检测,HAL库的回调机制让你专注于业务逻辑。设计通用的按键驱动时,要注意消抖、优先级配置和回调函数的合理使用。

嗯,这里要注意:中断服务函数里千万别做耗时操作。我见过有人直接在中断里做printf,结果系统响应慢得像蜗牛。记住,中断是「紧急处理」,不是「完整处理」。把复杂逻辑放到主循环里,用标志位通信,这才是正道。