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

按键,可以说是嵌入式开发里最基础、也最容易被忽视的输入设备了。我刚开始做项目那会儿,总觉得按键嘛,读个电平就行了,能有多复杂?结果第一次做产品样机,十个按键有八个按下去乱跳,被硬件同事笑了好几天。嗯,从那以后我再也不敢小看按键处理了。

今天咱们就把按键这件事彻底聊透。从GPIO输入模式怎么配,到轮询和中断两种方式的取舍,再到那个让人头疼的消抖问题,我一个一个说清楚。

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

在ESP32上,要把一个GPIO设成输入模式,其实就一行代码的事。但这里有个坑——你想想看,如果引脚悬空,电平是不确定的。所以内部上拉或下拉电阻就派上用场了。

// 配置GPIO0为输入模式,并启用内部上拉
gpio_config_t io_conf = {
    .pin_bit_mask = (1ULL << GPIO_NUM_0),
    .mode = GPIO_MODE_INPUT,
    .pull_up_en = GPIO_PULLUP_ENABLE,
    .pull_down_en = GPIO_PULLDOWN_DISABLE,
    .intr_type = GPIO_INTR_DISABLE
};
gpio_config(&io_conf);

我个人习惯,按键电路一般用外部上拉电阻(10kΩ左右),内部上拉只在实验板上临时用用。为什么?因为内部上拉的阻值偏大(约45kΩ),抗干扰能力弱一些。我在一个工控项目里吃过这个亏——现场电机一启动,按键电平就乱跳,后来换成外部上拉才稳住。

要点总结:
  • 输入模式下,引脚是高阻态,必须要有确定的电平
  • 内部上拉/下拉可以通过软件配置,但阻值较大
  • 外部上拉电阻更可靠,推荐10kΩ

3.2 轮询方式:简单但费CPU

轮询,说白了就是主循环里不停地读引脚电平。代码写起来最简单,但代价是CPU被占着啥也干不了。

void app_main(void)
{
    gpio_config_input(GPIO_NUM_0);
    
    while (1) {
        int level = gpio_get_level(GPIO_NUM_0);
        if (level == 0) {  // 假设按键按下为低电平
            printf("按键按下\n");
            vTaskDelay(pdMS_TO_TICKS(200)); // 简单延时防抖
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

你看,这个写法有个明显的问题:vTaskDelay(10) 意味着每10ms才检测一次。如果按键按下去只有5ms,那就漏掉了。反过来,如果延时设得太短,CPU又忙不过来。

轮询适合什么场景?我个人觉得,只有一种情况适合——系统里就一个按键,而且没有其他实时任务。但凡任务多一点,轮询就会变成噩梦。我曾经在一个四按键的遥控器项目里用轮询,结果按键响应时快时慢,用户体验极差。

我的建议: 轮询只用于原型验证或极简系统。正式产品,尤其是多按键场景,请用中断。

3.3 中断方式:让硬件来通知你

中断的思路正好反过来——平时CPU该干嘛干嘛,按键按下时硬件主动通知你。这样既省CPU,响应也快。

// 中断服务函数
static void IRAM_ATTR gpio_isr_handler(void* arg)
{
    uint32_t gpio_num = (uint32_t) arg;
    // 注意:ISR里不能调用printf等阻塞函数
    // 通常的做法是发一个信号量给任务
    xSemaphoreGiveFromISR(sem_button, NULL);
}

// 配置中断
gpio_config_t io_conf = {
    .pin_bit_mask = (1ULL << GPIO_NUM_0),
    .mode = GPIO_MODE_INPUT,
    .pull_up_en = GPIO_PULLUP_ENABLE,
    .intr_type = GPIO_INTR_NEGEDGE  // 下降沿触发
};
gpio_config(&io_conf);

// 安装中断服务
gpio_install_isr_service(0);
gpio_isr_handler_add(GPIO_NUM_0, gpio_isr_handler, (void*)GPIO_NUM_0);

这里有个关键点:中断服务函数里不要做复杂操作。我见过有人直接在ISR里写延时、打印、甚至调用WiFi API,结果系统直接崩溃。正确的做法是——ISR里只做最轻量的事,比如发信号量、置标志位,真正的处理交给任务。

注意: ESP32的中断服务函数必须加 IRAM_ATTR 属性,否则代码可能放在flash里,中断响应会变慢甚至出错。这个坑我踩过,排查了一整天才发现是IRAM的问题。

3.4 按键消抖:硬件和软件两手抓

按键消抖,说白了就是解决按键按下和松开瞬间的电平抖动。你想想看,机械触点在接触的瞬间会弹跳几次,时间大概5-20ms。如果不处理,一次按键可能被识别成多次。

硬件消抖最简单——加个RC低通滤波器。一个10kΩ电阻加一个0.1μF电容,时间常数约1ms,效果不错。但硬件方案会增加BOM成本,所以大多数时候我们用软件消抖。

软件消抖我常用的有两种方法:

方法一:延时消抖(最简单)

if (gpio_get_level(BUTTON_PIN) == 0) {
    vTaskDelay(pdMS_TO_TICKS(20)); // 等20ms
    if (gpio_get_level(BUTTON_PIN) == 0) {
        // 确认按键按下
    }
}

这个方法虽然简单,但有个问题——延时期间CPU被阻塞了。如果系统里还有其他任务,这个延时就会拖慢整体响应。

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

typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_PRESSED
} button_state_t;

button_state_t state = STATE_IDLE;
uint32_t last_tick = 0;

void button_task(void *arg)
{
    while (1) {
        int level = gpio_get_level(BUTTON_PIN);
        uint32_t now = xTaskGetTickCount();
        
        switch (state) {
            case STATE_IDLE:
                if (level == 0) {
                    state = STATE_DEBOUNCE;
                    last_tick = now;
                }
                break;
                
            case STATE_DEBOUNCE:
                if ((now - last_tick) > pdMS_TO_TICKS(20)) {
                    if (level == 0) {
                        state = STATE_PRESSED;
                        printf("按键按下\n");
                    } else {
                        state = STATE_IDLE; // 抖动,回到空闲
                    }
                }
                break;
                
            case STATE_PRESSED:
                if (level == 1) {
                    state = STATE_IDLE; // 松开
                }
                break;
        }
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

状态机的好处是不阻塞,而且能准确区分按下和松开。我在一个需要长按/短按识别的项目里就用这个方案,效果很好。

消抖经验总结:
  • 延时20ms是通用值,但不同按键可能不同,最好实测调整
  • 状态机消抖比延时消抖更优雅,适合多任务系统
  • 如果按键数量多,可以考虑用定时器统一扫描
  • 硬件消抖+软件消抖双保险,但一般软件就够了

3.5 实战建议:按键处理的最佳实践

说了这么多,最后给几个我实际项目里总结出来的经验:

  1. 中断+任务处理:中断里只发信号量,真正的按键逻辑放在一个独立任务里。这样既响应快,又不会阻塞其他任务。
  2. 按键事件队列:如果系统里有多个按键,建议用队列把按键事件串起来。任务从队列里取事件,按顺序处理。
  3. 长按和短按识别:在状态机里加一个计时器,按下时间超过某个阈值就判定为长按。这个在菜单系统里特别实用。
  4. 调试时先看波形:用示波器抓一下按键引脚的电平,看看抖动时间到底多长。我每次换新按键型号都会先测一下,省得后面出问题。

嗯,按键这部分就聊到这儿。说白了,按键处理的核心就三件事:电平读取、消抖处理、事件分发。把这三件事理清楚了,不管多少个按键你都能搞定。下一章咱们聊聊更高级的输入设备——矩阵键盘和触摸按键,到时候再细说。