3. 按键输入与中断:GPIO输入模式、轮询与中断方式读取按键、消抖处理

按键,可以说是嵌入式系统里最基础、也最常用的输入设备了。你想想看,从最简单的开关机,到复杂的菜单选择,都离不开它。但就是这个小东西,处理不好,能让你的产品体验一落千丈。今天我们就来聊聊,怎么把按键这件事做扎实。

3.1 GPIO输入模式:你得先会“听”

要让单片机“听”到按键的动作,首先得把GPIO配置成输入模式。这听起来简单,但里面有几个坑,我一个个说。

输入模式分几种?

一般来说,有三种常见的输入模式:

  • 浮空输入:引脚电平完全由外部决定。如果外部没接上拉或下拉,电平就是不确定的,容易受干扰。我个人习惯,除非有特殊原因,否则尽量不用。
  • 上拉输入:内部有个电阻把电平拉到高。按键没按下时,读到的是高电平;按下后,引脚接地,读到低电平。这是最常用的方式。
  • 下拉输入:和上拉相反,内部电阻拉到低。按键按下后读到高电平。用得相对少一些。

核心要点: 选择哪种模式,取决于你的硬件电路设计。如果外部已经接了上拉电阻,内部就不要再上拉了,否则会形成分压,导致电平不对。

配置代码示例(以STM32为例):

// 配置PA0为输入模式,带上拉
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_INPUT;
GPIO_InitStruct.Pull = GPIO_PULLUP;  // 上拉
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);

嗯,这里要注意,不同的MCU库函数名字不一样,但思路完全一样。配置好之后,你就可以用 HAL_GPIO_ReadPin() 来读取电平了。

3.2 轮询方式:简单但“费CPU”

轮询,说白了就是CPU不停地去问:“按键按下了吗?按下了吗?” 这种方式实现起来最简单,但效率很低。

轮询的典型代码:

while (1) {
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        // 按键被按下
        // 执行相应操作
        HAL_Delay(20); // 简单消抖
        while (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET); // 等待松开
    }
    // 其他任务...
}

你看,CPU大部分时间都在空转。如果系统里还有其他任务,比如要处理传感器数据、要刷新屏幕,这种写法就会拖慢整个系统。我在项目中遇到过,一个同事用轮询处理四个按键,结果系统响应变得很迟钝。后来改成中断,问题就解决了。

避坑指南: 轮询方式只适合非常简单的系统,或者按键不是主要交互手段的场景。如果你的产品有多个任务,千万别这么干。

3.3 中断方式:让CPU“被叫醒”

中断方式就聪明多了。CPU平时该干嘛干嘛,按键按下时,硬件会自动触发一个中断,CPU暂停当前工作,去处理按键事件。处理完再回来继续干活。

配置中断的步骤:

  1. 配置GPIO为输入模式(同上)。
  2. 使能GPIO中断,并设置触发边沿(上升沿、下降沿或双边沿)。
  3. 编写中断服务函数(ISR)。
  4. 在主程序中使能全局中断。

中断配置示例:

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

// 使能中断线
HAL_NVIC_SetPriority(EXTI0_IRQn, 0, 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);
        
        // 这里处理按键事件
        // 注意:中断里不要做耗时操作,比如延时
        // 最好只设置一个标志位,在主循环里处理
        key_pressed_flag = 1;
    }
}

我的经验: 中断服务函数里,千万不要做复杂操作。我曾经见过有人在中断里调用printf,结果系统直接卡死。中断里只做最必要的事,比如置个标志位,或者把数据放到队列里。

3.4 消抖处理:别让按键“抖”个不停

按键是机械结构,按下和松开时,触点会弹跳几次,产生多个脉冲。如果不处理,一次按键可能会被误判成多次。这就是“抖动”。

抖动的波形长什么样?

状态 电平变化 持续时间
按下瞬间 高→低(弹跳) 5~20ms
稳定按下 用户按住的时间
松开瞬间 低→高(弹跳) 5~20ms
稳定松开 直到下次按下

消抖的两种常用方法:

方法一:软件延时消抖

检测到电平变化后,等个10~20ms再读一次。如果电平稳定了,就认为是有效按键。这是最简单的方法。

if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
    HAL_Delay(20); // 延时20ms
    if (HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) == GPIO_PIN_RESET) {
        // 确认按键按下
    }
}

注意: 延时消抖会阻塞CPU,不适合在中断里使用。如果系统对实时性要求高,建议用下面的方法。

方法二:状态机消抖(推荐)

用定时器每隔10ms扫描一次按键,通过状态机判断按键状态。这样不阻塞CPU,而且能处理长按、短按等复杂逻辑。

// 简单的状态机消抖
typedef enum {
    KEY_STATE_IDLE,      // 空闲
    KEY_STATE_DEBOUNCE,  // 消抖中
    KEY_STATE_PRESSED    // 确认按下
} KeyState;

KeyState key_state = KEY_STATE_IDLE;

void Key_Scan(void) {
    uint8_t level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
    
    switch (key_state) {
        case KEY_STATE_IDLE:
            if (level == GPIO_PIN_RESET) {
                key_state = KEY_STATE_DEBOUNCE;
            }
            break;
        case KEY_STATE_DEBOUNCE:
            if (level == GPIO_PIN_RESET) {
                key_state = KEY_STATE_PRESSED;
                // 触发按键事件
                key_event = KEY_PRESSED;
            } else {
                key_state = KEY_STATE_IDLE; // 抖动,回到空闲
            }
            break;
        case KEY_STATE_PRESSED:
            if (level == GPIO_PIN_SET) {
                key_state = KEY_STATE_IDLE; // 松开
            }
            break;
    }
}

这个函数放在定时器中断里,每10ms调用一次。你看,没有阻塞,CPU可以干别的事。

总结一下: 按键处理的核心就三件事——配置好输入模式、选择合适的方式读取(轮询或中断)、做好消抖。我个人更推荐中断+状态机消抖的组合,既高效又可靠。你想想看,一个产品如果按键反应迟钝或者误触发,用户的第一印象就差了。所以,别小看这个基础功能,值得花心思做好。

好了,这一节的内容就到这里。下一节我们会讲更复杂的输入设备——矩阵键盘,到时候会用到今天学的知识。