4. 按键输入与中断:数字输入原理、按键消抖、外部中断配置、中断服务函数编写

按键,是嵌入式系统里最基础、也最容易被忽视的输入方式。你想想看,一个设备怎么知道用户想让它干什么?大部分时候,就是靠按键按下这个简单的动作。但就是这个“按下”,背后藏着不少门道。

我个人习惯把按键处理分成三个层次:读对电平、消掉抖动、用中断还是轮询。这一章,咱们就把这三件事彻底讲透。

4.1 数字输入原理:GPIO怎么知道按键被按下了?

ESP32的GPIO引脚,在输入模式下,就是一个数字电平检测器。它只能识别两种状态:高电平(3.3V)和低电平(0V)。

按键的典型接法有两种:

  • 上拉输入:按键一端接GND,另一端接GPIO,GPIO外部或内部上拉到VCC。按键按下 → GPIO读到低电平。
  • 下拉输入:按键一端接VCC,另一端接GPIO,GPIO外部或内部下拉到GND。按键按下 → GPIO读到高电平。

ESP32内部有可配置的上拉/下拉电阻,省掉了外部电阻。但我建议:对长线连接的按键,还是加外部10kΩ上拉电阻更可靠。我在一个工业项目里遇到过,只用内部上拉,结果线缆一长,干扰信号让按键频繁误触发。

配置方式 常态电平 按下电平 内部电阻可用性
上拉输入 高(3.3V) 低(0V) 可用,约45kΩ
下拉输入 低(0V) 高(3.3V) 可用,约45kΩ
我的习惯: 按键接GND + 内部上拉。这样常态是高电平,按下是低电平。万一按键线断了,电平还是高,不会误判为按下。

4.2 按键消抖:为什么不能直接读电平?

你按下按键的瞬间,金属触点不是“啪”一下就稳定接触的。它会弹跳几次,在几毫秒到几十毫秒内,产生一连串的脉冲。直接读这个信号,程序会认为你按了好多次。

消抖的本质就是:等信号稳定了再读取。

常用的消抖方法有两种:

  1. 硬件消抖:在按键两端并联一个0.1μF电容,利用电容的充放电特性滤除毛刺。简单粗暴,但增加BOM成本。
  2. 软件消抖:检测到电平变化后,延时10~20ms,再读一次。如果电平一致,才确认按键状态。

我几乎只用软件消抖。原因很简单:改延时比改电路容易多了。

一个经典的软件消抖代码片段:

// 非阻塞消抖函数
int debounce_read(gpio_num_t pin) {
    if (gpio_get_level(pin) == 0) {          // 第一次读到低电平
        vTaskDelay(pdMS_TO_TICKS(20));       // 等待20ms
        if (gpio_get_level(pin) == 0) {      // 再次确认
            return 1;                        // 确认按下
        }
    }
    return 0;                                // 未按下或抖动
}
我曾经踩过的坑: 用 delay(20) 这种阻塞延时做消抖。在单任务系统里没问题,但在FreeRTOS下,这会让整个系统卡住20ms。一定要用 vTaskDelay 或定时器轮询。

4.3 外部中断配置:让CPU别傻等

轮询方式读取按键,CPU得不停地去查电平。浪费电,也浪费算力。中断的思路是:让按键主动通知CPU。

ESP32的每个GPIO都可以配置为外部中断源。中断触发模式有四种:

  • 上升沿触发:电平从低变高时触发。
  • 下降沿触发:电平从高变低时触发。
  • 任意沿触发:电平变化就触发。
  • 低电平触发:电平为低时持续触发(ESP32部分引脚支持)。

配置外部中断的步骤,我总结为三步:

  1. 配置GPIO为输入模式:设置上拉/下拉。
  2. 注册中断服务:告诉ESP32,这个引脚的中断由哪个函数处理。
  3. 设置触发条件:选择上升沿还是下降沿。

代码示例:

// 按键中断初始化
void button_interrupt_init(gpio_num_t pin) {
    // 1. 配置GPIO
    gpio_config_t io_conf = {};
    io_conf.intr_type = GPIO_INTR_NEGEDGE;      // 下降沿触发
    io_conf.pin_bit_mask = (1ULL << pin);
    io_conf.mode = GPIO_MODE_INPUT;
    io_conf.pull_up_en = GPIO_PULLUP_ENABLE;    // 内部上拉
    gpio_config(&io_conf);

    // 2. 安装GPIO中断服务
    gpio_install_isr_service(0);

    // 3. 注册中断处理函数
    gpio_isr_handler_add(pin, isr_handler, (void*)pin);
}
关键点: gpio_install_isr_service 在整个程序中只需要调用一次。重复调用会导致错误。

4.4 中断服务函数编写:短、平、快

中断服务函数(ISR)是运行在中断上下文里的。它有几个铁律:

  • 不能调用阻塞函数:比如 vTaskDelay、printf、malloc。
  • 不能做复杂运算:浮点运算、大循环都不行。
  • 执行时间要短:最好在微秒级完成。

那ISR里到底该干什么?我的做法是:只做一件事——发信号。

在FreeRTOS下,ISR里最常用的机制是:

  • 二值信号量:通知任务按键发生了。
  • 队列:把按键值发给任务处理。
  • 任务通知:轻量级信号量,效率更高。

一个典型的ISR写法:

// 中断服务函数
static void IRAM_ATTR isr_handler(void* arg) {
    uint32_t gpio_num = (uint32_t)arg;
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    // 发送信号量给处理任务
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);

    // 如果需要上下文切换
    if (xHigherPriorityTaskWoken) {
        portYIELD_FROM_ISR();
    }
}

// 任务中等待信号量
void button_task(void* pvParameters) {
    while (1) {
        if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 在这里做消抖和按键处理
            vTaskDelay(pdMS_TO_TICKS(20));
            if (gpio_get_level(BUTTON_PIN) == 0) {
                // 执行按键动作
                printf("Button pressed!\n");
            }
        }
    }
}
注意: ISR函数前必须加 IRAM_ATTR 属性,把代码放到IRAM中运行。否则,如果Flash缓存未命中,会导致崩溃。

4.5 本章知识体系

下面这张图,把按键输入与中断的完整流程串起来了。从硬件电平到ISR,再到任务处理,每一步都有对应的技术点。

按键输入与中断知识体系 按键输入与中断知识体系 硬件基础 上拉/下拉电阻 GPIO配置 输入模式 / 内部上拉 电平检测 高/低电平识别 按键消抖 硬件消抖(电容) 软件消抖(延时+重读) 读取策略 轮询 vs 中断 非阻塞延时 中断配置 触发模式选择 中断服务注册 ISR编写 短平快 / IRAM_ATTR 不可阻塞 任务通信 信号量 / 队列 任务通知 应用层处理 按键状态机 / 长按短按识别 / 组合键 核心原则:硬件可靠 → 软件消抖 → 中断通知 → 任务处理

从这张图可以看得很清楚:硬件是基础,消抖是保障,中断是效率,任务是归宿。每一层都有明确的职责,不能跳过,也不能混淆。

嗯,这一章的内容就到这里。按键看似简单,但处理不好,会让整个系统显得很不专业。把消抖和中断的细节做到位,你的设备用起来才会有“高级感”。