3、GPIO与中断:GPIO配置模式(推挽、开漏、上拉下拉),外部中断触发机制与优先级设置

好,咱们今天聊点硬核的。GPIO和中断,这俩东西你看着简单,但真到了项目里,坑一个接一个。我这些年调试过的板子,少说也有几十种了,每次翻车,十有八九都跟GPIO配置或者中断优先级有关。

说白了,GPIO就是芯片跟外界打招呼的手。而中断,就是芯片被外界拍了一下肩膀,得停下来看看谁在叫它。这两者配合好了,你的系统就稳如老狗;配合不好,那就是各种灵异现象。

3.1 GPIO配置模式:推挽、开漏、上拉下拉

先说说GPIO的模式。很多人觉得这有啥好讲的,不就是输入输出吗?嗯,你要是这么想,那后面肯定要吃亏。

3.1.1 推挽输出(Push-Pull)

推挽输出,说白了就是芯片内部有两个管子,一个负责推(输出高电平),一个负责挽(输出低电平)。你让它输出高,它就给你怼到VCC;你让它输出低,它就给你拉到GND。

特点:

  • 输出能力强,驱动电流大
  • 高低电平都很干脆,没有中间状态
  • 不能多个输出直接连在一起(会打架)

我个人的习惯:控制LED、蜂鸣器这类负载,我基本都用推挽输出。简单粗暴,效果直接。

举个例子,驱动一个LED:

// 推挽输出配置(以STM32为例)
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;  // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

// 点亮LED
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_0, GPIO_PIN_SET);

3.1.2 开漏输出(Open-Drain)

开漏输出就有点意思了。它只有拉低的能力,没有拉高的本事。你想让它输出高电平?对不起,它只能把管子断开,让外部上拉电阻去拉高。

为什么要这么设计?我遇到过这样一个场景:两个设备要共用一个信号线,一个说高,一个说低,那不就短路了吗?开漏输出就能解决这个问题——谁想拉低就拉低,谁想释放就释放,大家和平共处。

典型应用场景:

  • I2C总线(SDA和SCL都是开漏)
  • 多个设备共享的中断信号线
  • 电平转换(3.3V和5V系统互联)

小提示:用开漏输出时,千万别忘了外部上拉电阻。我见过有人忘了加上拉,结果总线一直浮空,数据全乱套了。电阻值一般选4.7kΩ到10kΩ,具体看总线速度和负载电容。

// 开漏输出配置
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_OD;  // 开漏输出
GPIO_InitStruct.Pull = GPIO_NOPULL;          // 外部上拉,内部不拉
HAL_GPIO_Init(GPIOB, &GPIO_InitStruct);

3.1.3 上拉与下拉电阻

这个其实不算是输出模式,而是输入模式的配置。但很多人把它跟输出模式搞混了,我单独拎出来讲。

上拉电阻,就是把引脚通过一个电阻接到VCC。下拉电阻,就是接到GND。它们的作用只有一个:防止引脚浮空

你想想看,一个输入引脚啥都没接,它的电平是多少?鬼知道。可能是高,可能是低,还可能随着周围的电磁场乱跳。这就是浮空,是很多奇怪bug的根源。

配置 默认电平 适用场景
上拉输入 高电平 按键检测(按下为低)
下拉输入 低电平 按键检测(按下为高)
浮空输入 不确定 外部已有上拉/下拉的场合

注意:内部上拉电阻的阻值一般在30kΩ~50kΩ之间,比较弱。如果你的环境干扰很强,建议外部再加一个10kΩ的上拉。我曾经在一个电机驱动项目里,就因为偷懒用了内部上拉,结果信号被电机产生的EMI干扰得一塌糊涂。

3.2 外部中断触发机制

好,GPIO讲完了,咱们聊聊中断。中断这东西,说白了就是让CPU别傻等着,有事再叫它。

3.2.1 触发方式

外部中断的触发方式,常见的有这么几种:

  • 上升沿触发:信号从低变高的那一刻触发
  • 下降沿触发:信号从高变低的那一刻触发
  • 双边沿触发:不管上升还是下降,变了就触发
  • 低电平触发:只要信号是低电平就一直触发
  • 高电平触发:只要信号是高电平就一直触发

你可能会问,边沿触发和电平触发有啥区别?我举个例子你就明白了。

假设你用一个按键来控制灯的开关。如果用下降沿触发,按一下触发一次,灯亮;再按一下,又触发一次,灯灭。干净利落。

但如果用低电平触发呢?你按下去,灯亮;你一直按着,CPU就一直响应中断,别的活都干不了。等你松手,灯灭。这体验就很糟糕了。

我的经验:按键检测用下降沿触发,配合软件消抖。传感器信号变化用边沿触发。需要持续响应的场景(比如紧急停止)才用电平触发。

3.2.2 硬件消抖

说到按键,就不得不提抖动。机械按键按下和释放的瞬间,会有一个短暂的抖动期,大概5-20ms。如果不处理,一次按键可能触发好几次中断。

硬件消抖的方法很简单:加一个RC低通滤波器。电阻选10kΩ,电容选0.1μF,时间常数τ=RC=1ms,基本能把抖动滤掉。

// 软件消抖示例(在中断服务函数中)
void EXTI0_IRQHandler(void)
{
    // 检查中断标志
    if(__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET)
    {
        // 延时10ms消抖
        HAL_Delay(10);
        
        // 再次读取引脚状态,确认确实是按下
        if(HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET)
        {
            // 执行按键处理逻辑
            key_press_handler();
        }
        
        // 清除中断标志
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);
    }
}

小技巧:中断服务函数里尽量少做事,尤其是别用HAL_Delay这种阻塞函数。我一般只在中断里设置一个标志位,真正的处理放到主循环里去做。这样既不会错过中断,也不会影响系统响应。

3.3 中断优先级设置

中断优先级,这是个容易让人头大的东西。但搞懂了,你的系统就能有条不紊地工作。

3.3.1 抢占优先级与子优先级

大多数ARM Cortex-M内核的MCU,中断优先级分为两部分:

  • 抢占优先级:数值越小,优先级越高。高抢占优先级的中断可以打断低优先级的中断。
  • 子优先级:当抢占优先级相同时,子优先级高的先执行。但子优先级不能打断同级的中断。

举个例子,你正在处理一个串口中断(抢占优先级2),突然来了一个定时器中断(抢占优先级1)。因为定时器的抢占优先级更高,它会打断串口中断,先执行完再回来继续处理串口。

中断源 抢占优先级 子优先级 能否打断
定时器 0 0 能打断所有
外部中断 1 0 能打断串口
串口 2 0 不能打断别人

3.3.2 优先级分组

STM32里有个叫NVIC的东西,它允许你把优先级分成不同的组。比如:

  • 分组0:所有位都是子优先级(没有抢占)
  • 分组1:1位抢占,3位子优先级
  • 分组2:2位抢占,2位子优先级
  • 分组3:3位抢占,1位子优先级
  • 分组4:所有位都是抢占优先级

我个人习惯用分组2,也就是2位抢占、2位子优先级。这样最多有4级抢占,4级子优先级,够用了。

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

// 配置外部中断优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 1, 0);  // 抢占1,子优先级0
HAL_NVIC_EnableIRQ(EXTI0_IRQn);

// 配置定时器中断优先级
HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0);   // 抢占0,最高优先级
HAL_NVIC_EnableIRQ(TIM2_IRQn);

避坑指南:我曾经在一个项目中,把所有中断的抢占优先级都设成了0。结果一个高频中断把系统拖死了,低优先级的任务永远得不到执行。后来我学乖了:实时性要求高的(比如电机控制)给高优先级,非实时的(比如按键、串口)给低优先级。

3.3.3 中断嵌套的注意事项

中断嵌套虽然好用,但也要小心。嵌套太深,栈空间可能不够用。我见过有人嵌套了5层中断,结果栈溢出,程序跑飞了。

另外,中断服务函数里尽量不要调用那些可能引起阻塞的函数。比如printf、HAL_Delay、malloc这些,能不用就不用。实在要用,确保中断优先级足够低,不会被其他中断打断。

嗯,GPIO和中断这块,内容其实挺多的。但只要你理解了推挽和开漏的区别,掌握了上拉下拉的用法,搞清楚了中断触发方式和优先级设置,大部分项目都能应付了。剩下的,就是在实际项目中慢慢积累了。