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

按键,是嵌入式系统里最基础、也最容易被轻视的输入方式。我见过不少新手,上来就写个 while(1) 轮询按键,结果产品一量产,用户按一下灯闪三下,投诉电话被打爆。说白了,按键看似简单,但背后涉及数字输入原理、信号完整性和实时响应策略,每一步都有坑。

这一章,我们就从最底层的数字输入讲起,把按键消抖、外部中断和中断服务函数(ISR)这些硬骨头啃下来。嗯,跟着我的节奏走,保证你学完就能写出稳定可靠的按键驱动。

3.1 数字输入原理

ESP32 的 GPIO 做输入时,内部其实是一个施密特触发器。它有两个阈值:上升阈值和下降阈值。信号从低到高,必须超过上升阈值才判为高电平;从高到低,必须低于下降阈值才判为低电平。这能有效抑制信号边缘的毛刺。

但施密特触发器不是万能的。按键按下时,金属触点会物理弹跳,产生一连串的脉冲。这就是我们常说的“抖动”。

核心概念:按键抖动时间通常持续 5~20ms。在这段时间内,电平会反复跳变。如果不做处理,一次按下会被误判成多次。

我在项目中遇到过最离谱的情况:一个智能灯控面板,用户按一次开关,灯会随机切换 3~5 次模式。拆开一看,按键消抖代码根本没写。嗯,这就是典型的“能用但不好用”的教训。

3.2 按键消抖

消抖的方法主要有两种:硬件消抖和软件消抖。在嵌入式开发中,软件消抖是主流,因为成本低、灵活度高。

3.2.1 硬件消抖

硬件消抖通常是在按键两端并联一个电容(0.1μF 左右),利用电容的充放电特性平滑信号跳变。但电容会增加 BOM 成本,而且 PCB 布局时电容要尽量靠近按键引脚,否则效果打折。

消抖方式 优点 缺点
硬件电容消抖 不占用 CPU 资源,响应快 增加 BOM 成本,PCB 布局受限
软件延时消抖 零成本,实现简单 阻塞 CPU,不适合多任务
软件状态机消抖 非阻塞,适合 RTOS 或事件驱动 代码稍复杂,需要维护状态

3.2.2 软件消抖——延时法

最直观的方法:检测到电平变化后,延时 20ms 再读一次。如果电平稳定,就认为是有效按下。但 delay(20) 会阻塞 CPU,在实时系统中是大忌。

// 简单的延时消抖(不推荐在中断或 RTOS 中使用)
int read_key_debounced(int gpio_num) {
    if (gpio_get_level(gpio_num) == 0) {  // 假设低电平有效
        vTaskDelay(pdMS_TO_TICKS(20));    // 延时 20ms
        if (gpio_get_level(gpio_num) == 0) {
            return 1;  // 确认按下
        }
    }
    return 0;
}

3.2.3 软件消抖——状态机法

我个人习惯用状态机法。它不阻塞 CPU,适合在定时中断或 RTOS 任务中周期性调用。核心思想是:连续采样 N 次,如果 N 次中有 M 次(M > N/2)电平一致,就判定为稳定状态。

// 状态机消抖示例(每 5ms 调用一次)
#define DEBOUNCE_THRESHOLD  4  // 连续 4 次采样一致才算稳定

typedef enum {
    STATE_IDLE,
    STATE_PRESSING,
    STATE_PRESSED,
    STATE_RELEASING
} debounce_state_t;

int debounce_filter(int raw_level, debounce_state_t *state) {
    static int stable_level = 1;  // 假设默认高电平
    static int count = 0;

    switch (*state) {
        case STATE_IDLE:
            if (raw_level == 0) {
                count = 1;
                *state = STATE_PRESSING;
            }
            break;
        case STATE_PRESSING:
            if (raw_level == 0) {
                count++;
                if (count >= DEBOUNCE_THRESHOLD) {
                    stable_level = 0;
                    *state = STATE_PRESSED;
                    return 1;  // 检测到按下事件
                }
            } else {
                *state = STATE_IDLE;  // 抖动,复位
            }
            break;
        case STATE_PRESSED:
            if (raw_level == 1) {
                count = 1;
                *state = STATE_RELEASING;
            }
            break;
        case STATE_RELEASING:
            if (raw_level == 1) {
                count++;
                if (count >= DEBOUNCE_THRESHOLD) {
                    stable_level = 1;
                    *state = STATE_IDLE;
                    return 2;  // 检测到释放事件
                }
            } else {
                *state = STATE_PRESSED;  // 抖动,回到按下状态
            }
            break;
    }
    return 0;  // 无事件
}

我的经验:采样间隔 5ms,阈值设为 4,总消抖时间约 20ms。这个参数在大多数机械按键上表现稳定。如果按键质量差(比如廉价薄膜开关),可以适当提高到 6~8 次采样。

3.3 外部中断配置

轮询按键浪费 CPU,而且响应延迟不可控。外部中断才是正经做法。ESP32 的 GPIO 几乎都可以配置为外部中断源,支持上升沿、下降沿、低电平、高电平四种触发方式。

配置步骤很简单,但有几个细节容易翻车。我曾经在量产前夜发现中断不触发,查了半天,结果是 GPIO 内部上拉电阻没使能,引脚悬空导致电平不稳定。

3.3.1 配置流程

  1. 配置 GPIO 模式:设置为输入模式,使能内部上拉或下拉电阻。
  2. 注册中断服务:调用 gpio_install_isr_service() 初始化中断服务。
  3. 添加中断回调:调用 gpio_isr_handler_add() 绑定 GPIO 和中断处理函数。
  4. 设置触发方式:调用 gpio_set_intr_type() 选择触发边沿或电平。
// 外部中断配置示例
#define KEY_GPIO  0  // 假设按键接在 GPIO0

static void IRAM_ATTR key_isr_handler(void *arg) {
    // 中断服务函数,注意:不能调用 printf 等阻塞函数
    int gpio_num = (int)arg;
    // 这里只做标记,实际处理放到任务中
    xQueueSendFromISR(key_event_queue, &gpio_num, NULL);
}

void key_interrupt_init(void) {
    // 1. 配置 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;  // 使能内部上拉
    io_conf.pull_down_en = 0;
    gpio_config(&io_conf);

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

    // 3. 注册中断处理
    gpio_isr_handler_add(KEY_GPIO, key_isr_handler, (void *)KEY_GPIO);
}

注意:中断服务函数必须用 IRAM_ATTR 修饰,确保代码放在 IRAM 中。否则,如果 Flash 缓存未命中,中断响应会延迟甚至丢失。

3.4 中断服务函数编写

中断服务函数(ISR)是嵌入式开发的高危地带。写不好,轻则系统卡死,重则烧硬件。我见过有人直接在 ISR 里调用 printf,结果串口输出乱码,系统频繁复位。

ISR 的黄金法则是:快进快出。不要在 ISR 里做复杂运算、延时、打印或动态内存分配。正确的做法是:ISR 只做标记或通过队列发送数据,真正的处理逻辑放到任务循环中。

3.4.1 ISR 编写规范

  • 使用 IRAM_ATTR:确保 ISR 代码在 IRAM 中执行。
  • 避免阻塞调用:禁止使用 vTaskDelay()、printf()、malloc() 等。
  • 使用中断安全 API:FreeRTOS 提供 xQueueSendFromISR()、xSemaphoreGiveFromISR() 等。
  • 清除中断标志:部分外设需要手动清除中断标志位。
// 正确的 ISR 写法:只发送事件,不处理逻辑
static void IRAM_ATTR key_isr_handler(void *arg) {
    int gpio_num = (int)arg;
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(key_event_queue, &gpio_num, &xHigherPriorityTaskWoken);
    if (xHigherPriorityTaskWoken) {
        portYIELD_FROM_ISR();
    }
}

// 任务中处理按键事件
void key_process_task(void *pvParameters) {
    int gpio_num;
    while (1) {
        if (xQueueReceive(key_event_queue, &gpio_num, portMAX_DELAY)) {
            // 在这里做消抖和按键逻辑处理
            int key_state = debounce_filter(gpio_get_level(gpio_num), &key_state_machine);
            if (key_state == 1) {
                printf("Key %d pressed!\n", gpio_num);
                // 执行按键对应的操作
            }
        }
    }
}

3.5 本章知识体系

下面这张图梳理了按键输入与中断的完整知识链路。从物理按键到 CPU 响应,每一步都有对应的处理策略。

按键输入与中断知识体系 按键输入与中断知识体系 物理按键 信号抖动 (5~20ms) 硬件消抖(电容) 软件消抖(延时法) 软件消抖(状态机) 外部中断配置 ISR:快进快出 核心原则:物理层处理抖动 → 驱动层配置中断 → 应用层处理事件

避坑指南:我曾经在一个低功耗项目中,把按键中断配置为上升沿和下降沿都触发,结果按键按下和释放各触发一次中断,导致逻辑混乱。后来改成只触发下降沿(按下事件),释放事件通过定时查询电平状态来判断。这样既省电又稳定。

好了,这一章的内容就到这里。按键输入看似基础,但它是嵌入式系统人机交互的基石。把消抖和中断吃透,后面写传感器驱动和 OLED 显示驱动时,你会发现很多思路是相通的。嗯,动手试试吧,把代码烧进你的 ESP32,按几下按键,感受一下中断响应的实时性。