3、GPIO输入驱动:按键输入实验、机械按键消抖、外部中断EXTI、中断优先级管理

按键输入,是嵌入式开发里最基础、也最容易踩坑的环节。很多新手觉得按键不就是读个电平嘛,有什么难的?结果一上电,按键按下一次,灯闪了七八下——这就是典型的没做消抖。

我刚开始做项目时也吃过这个亏。那是一个工业控制面板,操作员按一次启动键,机器抖动了三次才启动。后来查了半天,发现就是按键消抖没处理好。从那以后,我对按键输入的处理就格外上心。

这一章,咱们就把GPIO输入驱动讲透。从最基础的按键电平读取,到机械按键消抖,再到外部中断EXTI,最后聊聊中断优先级管理。一步步来,别急。

3.1 按键输入的基本原理

按键的本质,就是一个机械开关。按下时导通,松开时断开。我们通过GPIO读取这个开关的电平状态,就能判断按键是否被按下。

但这里有个关键点:按键的电路连接方式。常见的有两种:

连接方式 电路结构 按下时电平 松开时电平
上拉输入 按键一端接GND,另一端接GPIO,GPIO内部上拉至VCC 低电平(0) 高电平(1)
下拉输入 按键一端接VCC,另一端接GPIO,GPIO内部下拉至GND 高电平(1) 低电平(0)

我个人习惯使用上拉输入。为什么?因为大多数MCU的GPIO默认就支持内部上拉,省一个外部电阻。而且GND比VCC更稳定,抗干扰能力更强。

小提示:STM32的GPIO内部上拉电阻约40kΩ,下拉电阻也差不多。如果对功耗敏感,可以改用外部10kΩ上拉,响应速度更快。

3.2 机械按键消抖

机械按键有个天生的毛病——抖动。你按下一次,实际上触点在极短时间内会反复通断好几次,持续时间大约5~20ms。如果不处理,MCU会误判为按了多次。

消抖的方法主要有两种:

  • 硬件消抖:在按键两端并联一个电容(通常0.1μF),利用电容的充放电特性滤除抖动。简单粗暴,但增加BOM成本。
  • 软件消抖:检测到电平变化后,延时10~20ms再读一次。如果电平稳定,才确认按键状态。不花钱,但占用CPU时间。

我在项目中通常用软件消抖。原因很简单——省钱、灵活。而且RTOS环境下,可以用任务延时让出CPU,不影响其他任务。

下面是一个典型的软件消抖代码:

// 按键消抖函数
// 返回值:0-未按下,1-按下
uint8_t Key_Scan(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin)
{
    if (HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == GPIO_PIN_RESET)  // 第一次检测到按下
    {
        HAL_Delay(10);  // 延时10ms,避开抖动区间
        if (HAL_GPIO_ReadPin(GPIOx, GPIO_Pin) == GPIO_PIN_RESET)  // 再次确认
        {
            return 1;  // 确认按下
        }
    }
    return 0;  // 未按下或抖动误判
}
注意:HAL_Delay()在中断服务函数里不能使用!如果你在中断里做消抖,需要用定时器或SysTick做非阻塞延时。我曾经见过有人直接在EXTI中断里调HAL_Delay,结果系统直接卡死——因为HAL_Delay依赖SysTick中断,而SysTick中断优先级低于EXTI,死锁了。

3.3 外部中断EXTI

轮询读按键太浪费CPU了。你想啊,CPU每秒几百万条指令,却要不停地去查一个按键有没有被按——这效率太低了。更好的方式是:让按键主动通知CPU。这就是外部中断EXTI干的事。

STM32的EXTI支持16个外部中断线(EXTI0~EXTI15),每个GPIO引脚都可以配置为中断源。配置流程大致如下:

  1. 使能GPIO时钟和SYSCFG时钟
  2. 配置GPIO为输入模式(上拉/下拉)
  3. 将GPIO引脚连接到EXTI线(通过SYSCFG)
  4. 配置EXTI触发方式(上升沿、下降沿、双边沿)
  5. 配置NVIC中断优先级并使能中断
  6. 编写中断服务函数

下面是一个完整的EXTI配置示例:

// 按键中断初始化(PA0,下降沿触发)
void Key_EXTI_Init(void)
{
    GPIO_InitTypeDef GPIO_InitStruct = {0};
    
    // 1. 使能时钟
    __HAL_RCC_GPIOA_CLK_ENABLE();
    __HAL_RCC_SYSCFG_CLK_ENABLE();
    
    // 2. 配置GPIO
    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, 2, 0);  // 抢占优先级2,子优先级0
    HAL_NVIC_EnableIRQ(EXTI0_IRQn);
}

// 中断服务函数
void EXTI0_IRQHandler(void)
{
    if (__HAL_GPIO_EXTI_GET_IT(GPIO_PIN_0) != RESET)
    {
        // 这里放你的按键处理逻辑
        // 注意:中断里不要做耗时操作!
        
        __HAL_GPIO_EXTI_CLEAR_IT(GPIO_PIN_0);  // 清除中断标志
    }
}
核心要点:EXTI中断服务函数里绝对不能做延时、打印、复杂计算等耗时操作。中断服务的原则是“快进快出”——只做标志位设置或数据接收,具体处理放到主循环或任务里。

3.4 中断优先级管理

当系统里有多个中断源时,谁先响应、谁可以打断谁,这就是中断优先级管理的事。STM32使用NVIC(嵌套向量中断控制器)来管理中断优先级。

STM32的中断优先级分为两部分:

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

优先级分组由SCB->AIRCR寄存器决定。STM32通常支持4位优先级,可以灵活分配抢占位和子位:

分组 抢占位 子位 说明
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级子优先级也够灵活。你想想看,一个典型的物联网项目里,中断优先级大概这么分配:

  • 抢占0:系统时钟(SysTick)——最高,保证系统心跳
  • 抢占1:通信中断(UART、SPI)——数据不能丢
  • 抢占2:外部中断(按键、传感器)——响应要快
  • 抢占3:普通外设(定时器、ADC)——优先级最低
避坑指南:我曾经在一个项目里把SysTick中断优先级设成了最低,结果HAL_Delay()在中断里调用时直接死机。原因就是SysTick中断被其他中断阻塞了,无法产生滴答计数。记住:SysTick的优先级一定要高于所有调用HAL_Delay()的中断!

3.5 知识体系总览

这一章的内容比较多,我画了一张结构图,帮你理清思路:

GPIO输入驱动知识体系 GPIO输入驱动 按键电平读取 机械按键消抖 外部中断EXTI 上拉输入 vs 下拉输入 GPIO模式配置(推挽/开漏) 硬件消抖(RC滤波) 软件消抖(延时+重读) 触发方式配置 中断服务函数编写 中断优先级管理(NVIC) 抢占优先级 子优先级 优先级分组配置 SysTick优先级必须高于所有调用HAL_Delay()的中断!

从这张图可以看得很清楚:GPIO输入驱动不是孤立的知识点。按键电平读取是基础,消抖是工程实践,EXTI是效率提升,中断优先级管理是系统整合。四者环环相扣,缺一不可。

嗯,这一章的内容就到这里。代码示例可以直接拿去用,但建议你动手改一改——比如把延时消抖改成定时器轮询,或者试试不同的优先级分组。只有亲手踩过坑,才能真正记住这些知识点。