3. 按键输入与中断:数字输入、内部上拉/下拉电阻、外部中断、消抖处理

按键,是嵌入式系统里最基础、也最容易被轻视的输入方式。很多新手觉得按键不就是读个高低电平吗?嗯,理论上是这样。但实际项目中,按键带来的坑可不少——抖动、误触发、引脚电平不确定……我最早做产品时就在这上面吃过亏。

这一章,咱们把按键这件事彻底讲透。从硬件原理到软件消抖,从轮询读到中断触发,一步到位。

3.1 数字输入的本质

ESP32 的 GPIO 做数字输入时,其实就是在读一个寄存器的值——引脚电压高于某个阈值(约 0.75×VDD),读到 1;低于阈值(约 0.25×VDD),读到 0。说白了,就是个 1 或 0 的判断。

但这里有个关键问题:引脚悬空时,电平是不确定的。你想想看,一个没接任何东西的引脚,外界电磁干扰就能让它乱跳。这就是为什么我们需要上拉或下拉电阻。

状态 引脚电平 典型场景
高电平(1) VIH ≥ 2.475V 按键未按下(上拉)
低电平(0) VIL ≤ 0.825V 按键按下(上拉)
悬空 不确定 引脚未连接任何电路

3.2 内部上拉/下拉电阻

ESP32 的 GPIO 内部集成了可编程的上拉和下拉电阻。我个人习惯是:能用内部电阻,绝不外加。省一颗电阻事小,少一个焊点就少一个故障点。

内部上拉电阻的典型值在 45kΩ 左右,下拉约 45kΩ。这个阻值足够让按键在未按下时保持确定电平,同时按下时电流也很小——微安级别,不用担心功耗。

关键配置函数:

  • gpio_set_pull_mode(GPIO_NUM_X, GPIO_PULLUP_ONLY) — 仅上拉
  • gpio_set_pull_mode(GPIO_NUM_X, GPIO_PULLDOWN_ONLY) — 仅下拉
  • gpio_set_pull_mode(GPIO_NUM_X, GPIO_FLOATING) — 浮空(不推荐)

我曾经在一个低功耗项目里,为了省电把按键引脚设成了浮空。结果产品在实验室跑得好好的,一到客户现场就频繁误触发。查了两天才发现是工频干扰导致的。从那以后,我再也不敢让按键引脚浮空了。

3.3 外部中断:让 CPU 别再傻等

轮询读按键,CPU 得一直跑循环检查电平变化。这在简单程序里没问题,但一旦系统复杂了——比如同时要处理 Wi-Fi、传感器、显示——轮询就成了性能杀手。

中断的思路是:让 CPU 该干嘛干嘛,按键按下时主动通知它。ESP32 的 GPIO 支持四种中断触发方式:

触发方式 宏定义 说明
上升沿触发 GPIO_INTR_POSEDGE 电平从低变高时触发
下降沿触发 GPIO_INTR_NEGEDGE 电平从高变低时触发
任意沿触发 GPIO_INTR_ANYEDGE 电平变化就触发
低电平触发 GPIO_INTR_LOW_LEVEL 低电平持续时触发

实际项目中,按键最常用的是下降沿触发——按键按下时电平从高变低,正好触发中断。但注意,中断服务函数(ISR)里不能做耗时操作,比如打印、延时。我一般只在 ISR 里设一个标志位,具体处理放到主循环里做。

我的习惯做法:

ISR 里只做两件事——清除中断标志、置位一个 volatile 变量。主循环检测到这个变量变化后,再执行消抖和业务逻辑。这样既保证了响应速度,又不会阻塞系统。

3.4 消抖处理:机械按键的必修课

机械按键按下和释放的瞬间,触点会弹跳几次,持续时间通常 5~20ms。如果不做消抖,一次按键可能被误判成多次。我见过最夸张的一次,一个劣质按键弹跳了 30ms,程序里没消抖,按一次触发了 7 次中断。

消抖方法主要有三种:

  1. 硬件消抖——在按键两端并联一个 0.1μF 电容。简单粗暴,但增加 BOM 成本。
  2. 软件延时消抖——检测到电平变化后,延时 10~20ms 再读一次。代码简单,但会阻塞。
  3. 状态机消抖——用状态机记录按键状态,不阻塞、不延时。我强烈推荐这种方式。

注意:在 ISR 里做延时消抖是禁止的!中断服务函数必须快速返回。延时消抖请放在主循环或任务中处理。

下面是我常用的状态机消抖代码,你可以直接拿去用:

typedef enum {
    KEY_STATE_IDLE,
    KEY_STATE_PRESS_DEBOUNCE,
    KEY_STATE_PRESSED,
    KEY_STATE_RELEASE_DEBOUNCE
} key_state_t;

key_state_t key_state = KEY_STATE_IDLE;
uint32_t last_tick = 0;
const uint32_t DEBOUNCE_MS = 20;

void key_task(void *arg) {
    while (1) {
        int level = gpio_get_level(KEY_GPIO);
        uint32_t now = xTaskGetTickCount();
        
        switch (key_state) {
            case KEY_STATE_IDLE:
                if (level == 0) {  // 检测到按下
                    key_state = KEY_STATE_PRESS_DEBOUNCE;
                    last_tick = now;
                }
                break;
                
            case KEY_STATE_PRESS_DEBOUNCE:
                if ((now - last_tick) >= pdMS_TO_TICKS(DEBOUNCE_MS)) {
                    if (gpio_get_level(KEY_GPIO) == 0) {
                        key_state = KEY_STATE_PRESSED;
                        // 按键有效按下,处理业务
                        printf("Key pressed!\n");
                    } else {
                        key_state = KEY_STATE_IDLE;  // 抖动误判
                    }
                }
                break;
                
            case KEY_STATE_PRESSED:
                if (level == 1) {  // 检测到释放
                    key_state = KEY_STATE_RELEASE_DEBOUNCE;
                    last_tick = now;
                }
                break;
                
            case KEY_STATE_RELEASE_DEBOUNCE:
                if ((now - last_tick) >= pdMS_TO_TICKS(DEBOUNCE_MS)) {
                    if (gpio_get_level(KEY_GPIO) == 1) {
                        key_state = KEY_STATE_IDLE;
                        printf("Key released!\n");
                    } else {
                        key_state = KEY_STATE_PRESSED;  // 抖动误判
                    }
                }
                break;
        }
        vTaskDelay(pdMS_TO_TICKS(5));  // 每5ms检查一次
    }
}

这段代码的核心思路是:不信任单次采样,用时间窗口过滤抖动。20ms 的消抖时间是我试过很多按键后觉得最稳妥的值。如果你用的按键质量特别好,可以缩短到 10ms;如果是那种几毛钱的轻触开关,建议留到 30ms。

3.5 知识体系总览

下面这张图把按键输入与中断的完整链路画出来了。从硬件引脚配置,到中断触发,再到消抖处理,最后到业务响应——每一步都有坑,每一步也都有标准解法。

按键输入与中断知识体系 硬件配置 GPIO模式设置 上拉/下拉电阻 中断配置 触发方式选择 ISR注册 消抖处理 硬件消抖 软件状态机 业务响应 标志位检测 → 按键事件分发 → 功能执行 核心原则 ISR 只做标志位,不做耗时操作 消抖时间 10~30ms,根据按键质量调整 常见问题 引脚浮空 → 误触发 → 使用内部上拉/下拉 ISR 内延时 → 系统卡死 → 用状态机替代

3.6 完整配置示例

最后,给一个完整的按键中断配置代码。你可以把它当成模板,改改引脚号就能用:

#include "driver/gpio.h"

#define KEY_GPIO      GPIO_NUM_0
#define KEY_PRESSED   0  // 上拉,按下为低

static volatile bool key_pressed_flag = false;

static void IRAM_ATTR key_isr_handler(void *arg) {
    key_pressed_flag = true;
}

void key_init(void) {
    // 配置GPIO
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << KEY_GPIO),
        .mode = GPIO_MODE_INPUT,
        .pull_up_en = GPIO_PULLUP_ENABLE,  // 内部上拉
        .pull_down_en = GPIO_PULLDOWN_DISABLE,
        .intr_type = GPIO_INTR_NEGEDGE     // 下降沿触发
    };
    gpio_config(&io_conf);
    
    // 安装GPIO中断服务
    gpio_install_isr_service(0);
    gpio_isr_handler_add(KEY_GPIO, key_isr_handler, NULL);
}

void app_main(void) {
    key_init();
    
    while (1) {
        if (key_pressed_flag) {
            key_pressed_flag = false;
            // 这里放消抖和业务处理
            vTaskDelay(pdMS_TO_TICKS(20));
            if (gpio_get_level(KEY_GPIO) == KEY_PRESSED) {
                printf("Key event detected!\n");
            }
        }
        // 其他任务...
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

这段代码里,ISR 只做了一件事——置标志位。主循环检测到标志位后,先延时 20ms 消抖,再确认电平是否真的变了。这种做法我在五六个项目里验证过,稳定可靠。

小技巧:如果你的按键需要区分短按和长按,可以在状态机里加一个计时器。按下后开始计时,超过 1 秒算长按,否则算短按。这个扩展留给你自己实现,不难。