4、按键输入与中断:机械按键消抖原理、外部中断配置、使用按键控制 LED 开关

按键,是嵌入式系统里最基础的人机交互方式。

别看它简单,我刚开始做项目时,就在按键上栽过跟头。一个智能家居面板,按一下灯亮,再按一下灯灭——结果用户反映「按一下有时候闪两下」。排查了半天,就是没做消抖处理。

这节课,咱们就把按键这件事彻底讲透。

4.1 机械按键的物理特性与抖动

机械按键的本质,是两片金属簧片的接触与分离。

你想想看,金属片碰撞时会发生什么?

它会弹跳。就像你扔一个乒乓球到地上,它不会立刻停住,而是会弹几下才稳定。

按键按下时,电平信号不是干净地从高到低跳变,而是会在几十毫秒内反复跳变。这就是「抖动」。释放时也一样。

典型抖动时间:

  • 按下抖动:5~20ms
  • 释放抖动:5~10ms
  • 稳定闭合时间:>50ms(取决于按键力度)

如果不处理抖动,MCU会认为你按了好几次。我在一个温控器项目里遇到过,用户明明只按了一次「+」键,温度却跳了3度。嗯,这就是没消抖的后果。

4.2 消抖的两种主流方法

消抖说白了就一个思路:等信号稳定了再读取。

4.2.1 硬件消抖

用RC低通滤波器。一个电阻加一个电容,把毛刺滤掉。

电路示意:
VCC —— 按键 —— R(10kΩ) —— IO口
                       |
                      C(0.1μF)
                       |
                      GND

RC时间常数 τ = R × C = 10kΩ × 0.1μF = 1ms。这个值能滤掉大部分抖动。

我个人习惯是:如果PCB空间允许,尽量加硬件消抖。它不占CPU,稳定可靠。

4.2.2 软件消抖

这是最常用的方法。核心代码就几句话:

// 伪代码:软件消抖
if (GPIO_ReadPin(KEY_PIN) == 0) {      // 第一次检测到按下
    Delay_ms(20);                       // 等待20ms,跳过抖动期
    if (GPIO_ReadPin(KEY_PIN) == 0) {   // 再次确认
        // 按键确实被按下了
        // 执行按键处理
    }
}

为什么是20ms?

因为绝大多数机械按键的抖动不会超过20ms。我测试过十几种不同厂家的按键,最长的抖动也就18ms左右。所以20ms是个安全值。

小技巧:Delay_ms(20) 在裸机程序里没问题。但如果用了RTOS,千万别用阻塞延时。改用定时器或vTaskDelay(),否则整个系统都会被卡住。

4.3 ESP32的外部中断配置

用轮询方式读按键,CPU得一直盯着IO口,太浪费了。

中断才是正经做法。按键按下时,让CPU「被叫过来」处理,平时该干嘛干嘛。

ESP32的GPIO中断,配置起来其实很简单。我直接上代码:

#include <driver/gpio.h>

#define KEY_GPIO      GPIO_NUM_0    // 开发板上的BOOT按键
#define LED_GPIO      GPIO_NUM_2    // 板载LED

// 中断服务函数
static void IRAM_ATTR key_isr_handler(void* arg) {
    // 注意:中断里不能做复杂操作
    // 这里只设置一个标志位
    uint32_t *flag = (uint32_t *)arg;
    *flag = 1;
}

void app_main(void) {
    uint32_t key_pressed = 0;

    // 配置按键GPIO
    gpio_config_t io_conf = {};
    io_conf.intr_type = GPIO_INTR_NEGEDGE;  // 下降沿触发(按下)
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pin_bit_mask = (1ULL << KEY_GPIO);
    io_conf.pull_up_en = 1;                 // 内部上拉
    gpio_config(&io_conf);

    // 配置LED GPIO
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(LED_GPIO, 0);

    // 安装中断服务
    gpio_install_isr_service(0);
    gpio_isr_handler_add(KEY_GPIO, key_isr_handler, (void*)&key_pressed);

    while (1) {
        if (key_pressed) {
            key_pressed = 0;  // 清除标志

            // 注意:消抖不能在中断里做
            // 所以我们在主循环里做延时消抖
            vTaskDelay(pdMS_TO_TICKS(20));

            if (gpio_get_level(KEY_GPIO) == 0) {
                // 按键确实被按下了
                int level = gpio_get_level(LED_GPIO);
                gpio_set_level(LED_GPIO, !level);
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

这里有几个关键点,我得强调一下:

配置项 说明 我的建议
GPIO_INTR_NEGEDGE 下降沿触发 按键按下时IO从高变低,用这个最合适
IRAM_ATTR 中断函数放在IRAM中 不加这个,中断响应会慢,极端情况会崩溃
gpio_install_isr_service 安装中断服务 整个程序只调用一次

避坑指南:我曾经在中断里直接调用gpio_set_level()来控制LED。结果发现有时候LED不响应,有时候程序跑飞。后来才意识到——中断里不能做耗时操作。GPIO操作虽然快,但如果有多个中断嵌套,就会出问题。正确做法是:中断里只设标志,主循环里处理逻辑。

4.4 完整示例:按键控制LED开关

把上面的代码整合一下,加上完整的消抖逻辑:

#include <stdio.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <driver/gpio.h>

#define KEY_GPIO      GPIO_NUM_0
#define LED_GPIO      GPIO_NUM_2

static volatile uint32_t g_key_flag = 0;

static void IRAM_ATTR key_isr(void* arg) {
    g_key_flag = 1;
}

void app_main(void) {
    // 配置按键
    gpio_config_t io_conf = {
        .intr_type = GPIO_INTR_NEGEDGE,
        .mode = GPIO_MODE_INPUT,
        .pin_bit_mask = (1ULL << KEY_GPIO),
        .pull_up_en = 1,
        .pull_down_en = 0
    };
    gpio_config(&io_conf);

    // 配置LED
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);
    gpio_set_level(LED_GPIO, 0);

    // 中断初始化
    gpio_install_isr_service(0);
    gpio_isr_handler_add(KEY_GPIO, key_isr, NULL);

    printf("按键控制LED示例已启动\n");

    while (1) {
        if (g_key_flag) {
            g_key_flag = 0;

            // 软件消抖:延时20ms后再次检测
            vTaskDelay(pdMS_TO_TICKS(20));

            if (gpio_get_level(KEY_GPIO) == 0) {
                // 按键有效,翻转LED
                int level = gpio_get_level(LED_GPIO);
                gpio_set_level(LED_GPIO, !level);
                printf("LED状态切换为: %s\n", level ? "OFF" : "ON");
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

这段代码跑在ESP32-DevKitC上,按下BOOT键,板载LED就会翻转状态。

4.5 进阶:长按与短按的区分

实际产品中,一个按键往往要承担多个功能。比如短按开关灯,长按调亮度。

怎么实现?

思路很简单:记录按键按下的时间。

// 伪代码:长按短按检测
uint32_t press_time = 0;
uint8_t key_state = 0;  // 0:释放 1:按下

if (gpio_get_level(KEY_GPIO) == 0 && key_state == 0) {
    // 刚按下
    key_state = 1;
    press_time = xTaskGetTickCount();
}

if (gpio_get_level(KEY_GPIO) == 1 && key_state == 1) {
    // 刚释放
    key_state = 0;
    uint32_t duration = xTaskGetTickCount() - press_time;

    if (duration < pdMS_TO_TICKS(1000)) {
        // 短按(小于1秒)
        short_press_handler();
    } else {
        // 长按(大于等于1秒)
        long_press_handler();
    }
}

我在一个智能插座项目里就用到了这个方案。短按控制通断,长按进入配网模式。用户反馈说操作很直观。

4.6 总结一下这节课的核心

  • 机械按键一定会抖动,抖动时间5~20ms,必须消抖
  • 软件消抖最常用:检测到按下→延时20ms→再次确认
  • 中断比轮询高效:ESP32用gpio_isr_handler_add注册中断,中断里只设标志
  • 消抖放在主循环,不要在中断里做延时
  • 长按短按通过计时实现,灵活扩展功能

按键看似简单,但做好它,是嵌入式工程师的基本功。下次遇到按键不灵的情况,你知道该查哪里了吧?