3. GPIO输入检测:按键抖动现象与消抖原理

按键检测,听起来很简单对吧?不就是读个电平高低嘛。但实际做产品时,这个看似简单的操作,坑可不少。我最早做的一个小项目,按键控制LED灯,按下就亮,松开就灭。结果调试时发现,按一次灯闪了好几下。当时我还以为是硬件坏了,查了半天……后来才知道,这就是经典的按键抖动问题。

3.1 按键抖动现象

机械按键的内部结构,说白了就是两个金属簧片。按下时它们接触,松开时它们分离。但问题在于,金属接触的瞬间不是干净利落的——它会弹跳几次才能稳定下来。

抖动时间:通常持续 5~20ms,具体取决于按键的机械品质。

你想想看,CPU 的时钟是 MHz 级别的,1ms 能执行几千条指令。在这 5~20ms 的抖动期内,如果你连续读取 GPIO 电平,就会读到一串 0 和 1 交替的信号。这就是为什么按一次键,程序却认为你按了好几次。

阶段 电平状态 持续时间
按下前 高电平(上拉) 稳定
按下抖动 高低电平交替 5~20ms
按下稳定 低电平 持续
松开抖动 高低电平交替 5~20ms
松开后 高电平(上拉) 稳定

3.2 消抖原理

消抖的核心思路就一个:避开抖动期,在稳定期采样。具体做法分两种:硬件消抖和软件消抖。

硬件消抖——在按键引脚上加一个 RC 低通滤波器。电容充放电会平滑掉电平的跳变,让抖动被滤除。优点是 CPU 不用操心,缺点是增加了 BOM 成本和 PCB 面积。

软件消抖——这是嵌入式开发中最常用的方式。我个人的习惯是:检测到电平变化后,延时 10~20ms 再读一次。如果两次电平一致,就认为是有效按键事件。

我的经验:延时 10ms 通常就够了。如果按键质量特别差,可以延长到 20ms。但别超过 30ms,否则用户会觉得按键反应迟钝。

软件消抖的流程,我画了一张图,你可以对照着理解:

按键消抖处理流程图 开始 读取 GPIO 电平值 是否按下? 否 是 延时 10ms 再次读取 GPIO 电平值

3.3 CubeMX 配置 GPIO 输入

接下来我们实际操作一下。打开 CubeMX,新建一个 STM32F103C8T6 的工程。我以这个型号为例,其他型号操作类似。

  1. 选择引脚:在 Pinout 视图中,找到 PA0 引脚。点击它,选择 GPIO_Input 模式。
  2. 配置上拉电阻:在 GPIO 配置界面,将 GPIO Pull-up/Pull-down 设置为 Pull-up。这样按键未按下时,引脚为高电平;按下后接地,引脚为低电平。
  3. 配置标签:在 User Label 一栏,输入 KEY。这样生成的代码中,引脚宏定义就是 KEY_Pin 和 KEY_GPIO_Port,可读性更好。

注意:如果你用的是开发板,先查一下原理图,确认按键接在哪个引脚上,以及按下后是低电平还是高电平。我曾经因为没看原理图,把上拉和下拉搞反了,折腾了半小时才发现。

3.4 HAL 库 GPIO 读函数

HAL 库提供了两个读取 GPIO 电平的函数:

函数 原型 说明
HAL_GPIO_ReadPin GPIO_PinState HAL_GPIO_ReadPin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin) 读取指定引脚的电平,返回 GPIO_PIN_SET(高)或 GPIO_PIN_RESET(低)
HAL_GPIO_WritePin void HAL_GPIO_WritePin(GPIO_TypeDef* GPIOx, uint16_t GPIO_Pin, GPIO_PinState PinState) 设置指定引脚的电平

用法很简单:

// 读取按键状态
GPIO_PinState keyState = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);

if (keyState == GPIO_PIN_RESET) {
    // 按键按下(低电平有效)
    HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_SET);  // 点亮LED
} else {
    HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, GPIO_PIN_RESET); // 熄灭LED
}

3.5 实战:按键控制 LED 灯

好了,理论讲完了,我们直接上手写代码。这个实战的目标是:按一下按键,LED 状态翻转一次。注意,是翻转,不是按下亮松开灭。这样更能体现消抖的效果。

完整的代码框架如下:

/* main.c 中的 while 循环 */
while (1)
{
    /* 读取按键状态 */
    if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
    {
        /* 延时消抖 */
        HAL_Delay(10);

        /* 再次确认 */
        if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET)
        {
            /* 等待按键松开,避免一次按下触发多次翻转 */
            while (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET);

            /* 翻转 LED 状态 */
            HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        }
    }
}

关键点:

  • 两次读取:第一次检测到按下后,延时 10ms 再读一次。两次一致才认为是有效按下。
  • 等待松开:用 while 循环等待按键释放。如果不加这一步,按住按键不放时,LED 会一直翻转。
  • 使用 TogglePin:HAL_GPIO_TogglePin 是 HAL 库提供的翻转函数,比先读再写方便多了。

我的建议:实际项目中,我一般不会在 while 循环里用 HAL_Delay 做消抖,因为它会阻塞整个程序。更好的做法是用定时器或状态机来做非阻塞消抖。不过对于初学者,先理解这个基础版本就够了。

3.6 常见问题与避坑

  • 按键没反应:先检查 GPIO 配置是否正确。用万用表量一下引脚电平,按下时是否变化。如果没变化,八成是引脚选错了。
  • 按一次触发多次:消抖延时太短,或者根本没做消抖。把延时加到 15~20ms 试试。
  • LED 亮度异常:检查 LED 限流电阻。STM32 GPIO 输出电流有限,直接驱动 LED 要加 330Ω~1kΩ 的电阻。

我曾经踩过的坑:有一次我把按键接在 PB3 引脚上,怎么配置都不对。后来查手册才发现,PB3 是 JTDO 功能引脚,默认被调试器占用了。在 CubeMX 里需要先把调试接口配置为 Serial Wire,才能正常使用这个引脚做 GPIO。

嗯,这一节的内容就到这里。按键消抖虽然基础,但它是嵌入式开发中第一个真正需要你思考「硬件特性 vs 软件处理」的地方。把这个搞明白了,后面学中断、定时器、状态机都会轻松很多。