第3章:外部中断实战——GPIO外部中断配置与回调机制

外部中断,说白了就是让芯片对外部信号变化做出即时响应。你按下一个按键,传感器输出一个跳变沿,系统必须立刻处理——这就是外部中断的用武之地。

我刚开始用STM32时,总觉得中断配置挺麻烦的。后来做多了才发现,只要理清几个关键点,其实就那么回事。这一章咱们就把GPIO外部中断的配置、触发方式选择、还有那个回调函数HAL_GPIO_EXTI_Callback,一次性讲透。

本章核心目标:掌握STM32 HAL库外部中断的完整配置流程,理解中断触发方式的差异,并能熟练使用回调函数处理中断事件。

3.1 外部中断的整体架构

先看一张图,把整个外部中断的路径理清楚。从引脚到CPU,中间经过了哪些环节?

STM32 GPIO外部中断路径 GPIO引脚 EXTI边沿检测 NVIC中断控制器 CPU内核 中断服务函数 HAL_GPIO_EXTI_Callback 每个GPIO引脚可配置为 外部中断输入 最多16个EXTI线(PA0~Px0) 支持上升沿/下降沿/双边沿 可软件触发 支持中断/事件两种模式 配置步骤: 1. GPIO初始化 2. EXTI配置 3. NVIC使能

从图上能看出来,一个外部中断信号从引脚到你的回调函数,要经过四道关卡:GPIO引脚配置、EXTI边沿检测、NVIC中断使能、最后才是中断服务函数里调用回调函数。每一关都不能少。

3.2 GPIO外部中断配置步骤

我个人习惯把配置分成三步走。你按这个顺序来,基本不会漏东西。

3.2.1 第一步:GPIO初始化

这一步和普通GPIO配置差不多,但有个关键区别——模式要选对。

GPIO_InitTypeDef GPIO_InitStruct = {0};

/* 使能GPIO时钟 */
__HAL_RCC_GPIOA_CLK_ENABLE();

/* 配置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);

注意Mode参数。这里用的是GPIO_MODE_IT_FALLING,表示下降沿触发中断。HAL库里还有几种模式可选:

模式宏定义 触发方式 典型应用场景
GPIO_MODE_IT_RISING 上升沿触发 检测信号从低到高
GPIO_MODE_IT_FALLING 下降沿触发 按键按下、信号结束
GPIO_MODE_IT_RISING_FALLING 双边沿触发 编码器、脉冲计数

我的经验:按键检测一般用下降沿触发,配合内部上拉。这样按键按下时引脚从高电平拉到低电平,正好触发中断。省掉了外部上拉电阻。

3.2.2 第二步:配置NVIC中断优先级

GPIO初始化只是告诉芯片“我要用中断模式”,但真正让中断能响应,还得打开NVIC。这一步很多人会忘。

/* 配置NVIC中断优先级 */
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

这里有两个参数:抢占优先级和子优先级。我一般把实时性要求高的中断设低优先级数值(数值越小优先级越高)。具体怎么分配,得看你项目里有多少中断源。

我曾经踩过的坑:只配了GPIO,忘了使能NVIC。结果折腾了半天,中断死活不触发。后来用调试器一看,NVIC里根本没使能这个中断线。嗯,从那以后我每次都会检查NVIC配置。

3.2.3 第三步:编写中断服务函数

中断来了,CPU会跳转到对应的中断向量表。STM32为每个EXTI线都分配了独立的中断服务函数名。比如EXTI0对应EXTI0_IRQHandler。

void EXTI0_IRQHandler(void)
{
    /* HAL库的中断处理入口 */
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

这个函数里就做一件事——调用HAL库的中断处理函数。它会帮我们清除中断标志位,然后调用回调函数。

3.3 中断回调函数HAL_GPIO_EXTI_Callback

这才是我们真正写业务逻辑的地方。HAL库的设计思路是:中断服务函数里不做具体业务,而是通过回调函数把处理逻辑暴露给用户。

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if(GPIO_Pin == GPIO_PIN_0)
    {
        /* 处理PA0的中断 */
        // 你的业务代码写在这里
        // 比如:翻转LED、记录按键次数、唤醒系统等
    }
}

注意这个回调函数是弱定义的(__weak),你在用户代码里重新实现它,就会覆盖HAL库的默认实现。所有EXTI线共用一个回调函数,通过GPIO_Pin参数区分是哪个引脚触发的。

关键点:回调函数运行在中断上下文中。这意味着它要尽量短小精悍,不能做耗时操作。我见过有人直接在回调里做printf打印,结果系统卡死——串口打印太慢了。

3.4 完整代码示例

把上面三步串起来,就是一个完整的外部中断工程。下面是我常用的模板:

/* main.c */
int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();
    
    while(1)
    {
        // 主循环做其他事情
        // 按键中断由回调处理
    }
}

/* gpio.c */
void MX_GPIO_Init(void)
{
    __HAL_RCC_GPIOA_CLK_ENABLE();
    
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    GPIO_InitStruct.Pin = GPIO_PIN_0;
    GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING;
    GPIO_InitStruct.Pull = GPIO_PULLUP;
    HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
    
    HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

/* stm32f1xx_it.c */
void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

/* user_code.c */
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if(GPIO_Pin == GPIO_PIN_0)
    {
        // 按键按下,执行动作
        HAL_GPIO_TogglePin(GPIOB, GPIO_PIN_5);  // 翻转LED
    }
}

3.5 中断触发方式的选择策略

你想想看,触发方式选不对,轻则误触发,重则丢中断。我根据项目经验总结了几条原则:

  • 按键检测:下降沿触发 + 内部上拉。按键按下时引脚从高变低,触发一次中断。配合10~20ms的软件消抖。
  • 传感器信号:看传感器输出类型。如果是开漏输出,用上升沿触发;如果是推挽输出,根据有效电平选择。
  • 编码器:双边沿触发。A相和B相都要检测,才能判断方向和计数。
  • 唤醒信号:一般用上升沿或下降沿,取决于休眠时的引脚电平状态。

我的习惯:不确定用哪种触发方式时,先用逻辑分析仪抓一下信号波形。看一眼就知道该用上升沿还是下降沿了。比瞎猜靠谱得多。

3.6 避坑指南

做外部中断,有几个坑我几乎每次都会遇到。列出来给你提个醒:

  1. 中断标志位没清除:HAL_GPIO_EXTI_IRQHandler会自动清除,但如果你自己写中断服务函数,别忘了手动清标志位。
  2. 优先级分组没设置:HAL_Init()里默认设置了4位抢占优先级。如果你改了分组,记得所有中断优先级都要重新评估。
  3. 回调函数里做延时:中断里绝对不能调用HAL_Delay()。它依赖SysTick中断,而SysTick中断优先级可能比当前中断低,直接死锁。
  4. 多个引脚共用一条EXTI线:比如PA0和PB0不能同时作为EXTI0使用。同一时间只能有一个引脚映射到某条EXTI线。

我曾经犯过的错:在一个项目里,我把PA0和PB0都配成了EXTI0中断。结果两个引脚的电平变化都会触发同一个中断,根本分不清是哪个引脚。后来查手册才发现,EXTI线是共享的,同一时间只能映射一个引脚。

3.7 小结

外部中断其实不复杂。记住三步走:GPIO初始化配模式、NVIC使能开中断、回调函数写业务。触发方式根据信号特点选,回调函数里别做耗时操作。做到这几点,基本不会出大问题。