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

按键,是嵌入式系统里最基础、也最容易出问题的输入设备。你想想看,一个简单的开关动作,到了单片机眼里却是一连串的抖动信号。我刚开始做项目时,就被这个“抖动”坑过——按一下键,灯闪了三次。嗯,今天我们就彻底把按键这件事讲透。

3.1 数字输入原理

ESP32 的 GPIO 作为数字输入时,其实就是在检测引脚上的电平:高电平(3.3V)读作 1,低电平(0V)读作 0。但实际电路里,按键按下和释放的瞬间,电平并不是干净跳变的。

核心概念:数字输入的本质是电压比较。ESP32 的 Schmitt Trigger 输入缓冲器带有迟滞特性,能滤除一部分噪声,但对机械抖动无能为力。

我个人习惯在按键电路上做两件事:

  • 上拉/下拉电阻:按键未按下时,引脚电平必须确定。常用 10kΩ 电阻上拉到 VCC 或下拉到 GND。
  • 外部 RC 滤波:在按键两端并联 0.1μF 电容,能硬件上减少抖动幅度。
按键接法 未按下电平 按下电平 适用场景
上拉输入 高 (1) 低 (0) 常用,默认高电平省电
下拉输入 低 (0) 高 (1) 特殊逻辑需求
内部上拉 高 (1) 低 (0) 节省外部元件

我的经验:ESP32 内部上拉电阻约 45kΩ,比外部 10kΩ 弱很多。在强电磁干扰环境中,我建议用外部上拉,更可靠。

3.2 按键消抖

机械按键从按下到稳定,通常有 5-20ms 的抖动期。这期间电平会快速跳变多次。如果不处理,一次按键会被误判成多次。

消抖方法分两种:

3.2.1 硬件消抖

在按键两端并联电容(0.1μF~1μF),利用电容的充放电特性平滑抖动。优点是零 CPU 开销,缺点是增加 BOM 成本。

3.2.2 软件消抖

这是最常用的方式。核心思路:检测到电平变化后,延时 10-20ms 再读一次,确认电平稳定。

// 简单的软件消抖函数
int debounce_read(int pin) {
    if (gpio_get_level(pin) == 0) {      // 第一次检测到按下
        vTaskDelay(pdMS_TO_TICKS(20));    // 延时 20ms 避开抖动
        if (gpio_get_level(pin) == 0) {   // 再次确认
            return 1;                     // 确认按下
        }
    }
    return 0;                             // 抖动或未按下
}

我曾经踩过的坑:在 FreeRTOS 任务里用 vTaskDelay 做消抖,如果任务优先级太低,20ms 延时可能被切走,导致消抖失效。建议把按键扫描放在中等优先级任务中,或者用硬件定时器触发。

更高效的消抖方式是状态机法,不阻塞 CPU:

typedef enum {
    KEY_IDLE,
    KEY_DEBOUNCE,
    KEY_PRESSED
} key_state_t;

key_state_t key_state = KEY_IDLE;
uint32_t last_tick = 0;

void key_scan(int pin) {
    uint8_t level = gpio_get_level(pin);
    switch (key_state) {
        case KEY_IDLE:
            if (level == 0) {
                key_state = KEY_DEBOUNCE;
                last_tick = xTaskGetTickCount();
            }
            break;
        case KEY_DEBOUNCE:
            if ((xTaskGetTickCount() - last_tick) > pdMS_TO_TICKS(20)) {
                if (gpio_get_level(pin) == 0) {
                    key_state = KEY_PRESSED;
                    // 触发按键事件
                    key_event_callback();
                } else {
                    key_state = KEY_IDLE;  // 抖动恢复
                }
            }
            break;
        case KEY_PRESSED:
            if (level == 1) {
                key_state = KEY_IDLE;      // 等待释放
            }
            break;
    }
}

3.3 外部中断配置

轮询方式浪费 CPU,尤其当系统需要同时处理多个传感器时。外部中断才是正经做法——按键按下时,CPU 暂停当前任务,跳转到中断服务函数处理。

ESP32 的 GPIO 中断支持四种触发方式:

触发模式 宏定义 典型用途
上升沿触发 GPIO_INTR_POSEDGE 按键释放检测
下降沿触发 GPIO_INTR_NEGEDGE 按键按下检测
任意沿触发 GPIO_INTR_ANYEDGE 电平变化检测
低电平触发 GPIO_INTR_LOW_LEVEL 唤醒睡眠模式

配置步骤其实很固定,我一般写成这样:

// 按键中断初始化
void key_interrupt_init(int pin) {
    // 1. 配置 GPIO 为输入模式
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << pin),
        .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);

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

    // 3. 注册中断回调
    gpio_isr_handler_add(pin, key_isr_handler, (void*) &pin);
}

注意:gpio_install_isr_service 只需调用一次,多个按键共享同一个中断服务。我习惯在系统初始化时统一安装。

3.4 中断服务函数编写

中断服务函数(ISR)有严格限制:快进快出。不能在 ISR 里做延时、打印、动态内存分配等阻塞操作。

正确的做法是:ISR 里只做标记,具体处理交给任务。

// 中断服务函数——只做标记
static void IRAM_ATTR key_isr_handler(void* arg) {
    int pin = *(int*)arg;
    // 记录按键事件到队列(非阻塞)
    key_event_t evt = {
        .pin = pin,
        .timestamp = xTaskGetTickCountFromISR()
    };
    xQueueSendFromISR(key_evt_queue, &evt, NULL);
}

// 任务函数——处理按键事件
void key_process_task(void* pvParameters) {
    key_event_t evt;
    while (1) {
        if (xQueueReceive(key_evt_queue, &evt, portMAX_DELAY)) {
            // 在这里做消抖和业务逻辑
            if (debounce_read(evt.pin)) {
                printf("Key %d pressed at %lu ms\n", evt.pin, evt.timestamp);
                // 执行按键动作...
            }
        }
    }
}

IRAM_ATTR 是什么?这个属性告诉编译器把函数放在 IRAM(指令 RAM)中,而不是 Flash。因为 Flash 访问有延迟,中断响应必须快。我见过有人忘了加这个,导致中断响应时间不稳定。

为什么用队列而不是全局变量?因为多个按键可能同时触发中断,队列能保证事件不丢失。全局变量会被覆盖。

3.5 知识体系总览

下面这张图总结了按键输入与中断的完整流程:

按键输入与中断知识体系 按键输入与中断知识体系 机械按键 物理接触 / 抖动 5-20ms 上拉/下拉电路 10kΩ 电阻 / 内部 45kΩ RC 滤波 0.1μF 电容硬件消抖 ESP32 GPIO 数字输入 Schmitt Trigger / 电平检测 / 内部上拉可选 轮询方式 软件消抖 / 状态机 占用 CPU / 适合简单系统 中断方式 外部中断配置 / ISR 高效响应 / 队列传递事件 应用处理:按键事件分发 / 长按短按识别 / 组合键 核心原则:硬件保证电平稳定 → 软件消抖确认状态 → 中断实现高效响应 → 队列保证事件不丢

3.6 完整示例:长按与短按识别

最后,我分享一个实际项目中用到的长按短按识别代码。这个逻辑在智能家居面板上跑了一年多,没出过问题。

#define KEY_PIN          GPIO_NUM_0
#define LONG_PRESS_MS    1000

typedef enum {
    KEY_ACTION_NONE,
    KEY_ACTION_SHORT,
    KEY_ACTION_LONG
} key_action_t;

static QueueHandle_t key_evt_queue;
static uint32_t press_start_tick = 0;

static void IRAM_ATTR key_isr_handler(void* arg) {
    int pin = (int)arg;
    uint32_t tick = xTaskGetTickCountFromISR();
    xQueueSendFromISR(key_evt_queue, &tick, NULL);
}

void key_task(void* pvParameters) {
    uint32_t tick;
    key_action_t action = KEY_ACTION_NONE;
    int state = 0;  // 0:等待按下, 1:等待释放

    while (1) {
        if (xQueueReceive(key_evt_queue, &tick, portMAX_DELAY)) {
            if (state == 0) {
                // 按下事件
                press_start_tick = tick;
                state = 1;
            } else {
                // 释放事件
                uint32_t elapsed = (tick - press_start_tick) * portTICK_PERIOD_MS;
                if (elapsed >= LONG_PRESS_MS) {
                    action = KEY_ACTION_LONG;
                } else {
                    action = KEY_ACTION_SHORT;
                }
                state = 0;

                // 分发按键动作
                switch (action) {
                    case KEY_ACTION_SHORT:
                        printf("短按\n");
                        // 执行短按功能
                        break;
                    case KEY_ACTION_LONG:
                        printf("长按 %lu ms\n", elapsed);
                        // 执行长按功能
                        break;
                    default:
                        break;
                }
            }
        }
    }
}

void app_main(void) {
    key_evt_queue = xQueueCreate(10, sizeof(uint32_t));
    key_interrupt_init(KEY_PIN);
    xTaskCreate(key_task, "key_task", 2048, NULL, 10, NULL);
}

关于长按阈值:1000ms 是我常用的值。但不同用户手感不同,我建议把这个值做成可配置参数,甚至允许用户通过上位机调整。

嗯,按键看似简单,但要做好真不容易。从硬件电路到软件消抖,再到中断响应和事件分发,每一步都有坑。记住一个原则:硬件能解决的不要丢给软件,中断里能少做的事绝不多做。这样写出来的代码,才经得起产品级的考验。