3. 按键输入与中断:数字输入读取,软件消抖,外部中断配置,中断服务函数编写注意事项

按键,是嵌入式系统里最基础、也最容易出问题的输入方式。很多初学者觉得按键不就是读个电平吗?其实不然。我在项目里见过太多因为按键处理不当导致的诡异bug——按一下触发两次、长按变短按、甚至系统死机。今天我们就来彻底搞定它。

3.1 数字输入读取基础

ESP32的GPIO作为数字输入时,核心就是读电平高低。但这里有个坑——引脚悬空时电平是不确定的。你想想看,一个没接任何东西的引脚,读到的值可能是0也可能是1,完全随机。

解决办法很简单:上拉或下拉电阻。ESP32内部有可编程的上拉/下拉电阻,我们可以在初始化时配置。

核心原则:按键按下时,电平要明确变化。通常有两种接法:

  • 按下为低电平:按键一端接GND,另一端接GPIO,GPIO内部上拉
  • 按下为高电平:按键一端接VCC(3.3V),另一端接GPIO,GPIO内部下拉

我个人习惯用「按下为低」的接法。为什么?因为ESP32内部上拉电阻默认就是使能的,省事。而且大多数开发板按键就是这么设计的。

3.1.1 GPIO输入配置代码

#include "driver/gpio.h"

#define BUTTON_GPIO  0  // 开发板上的BOOT按键,默认上拉

void button_init(void) {
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << BUTTON_GPIO),
        .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);
}

// 读取按键状态
int button_read(void) {
    return gpio_get_level(BUTTON_GPIO);  // 返回0表示按下,1表示松开
}

小技巧:GPIO0在ESP32上默认是BOOT按键,按下时拉低。很多开发板都把它引出来了,调试时特别方便。我经常用它做测试按键。

3.2 软件消抖——为什么必须做?

机械按键有个物理特性:按下和松开的瞬间,触点会弹跳。这个弹跳时间通常持续5-20ms。如果不做处理,一次按键会被误读成多次。

我曾经在一个产品里遇到过:用户按一次,设备切换了三个模式。查了半天,就是没做消抖。用户骂我们产品有鬼,其实只是机械弹跳在捣乱。

3.2.1 消抖原理

说白了就是「等一等,再确认」。检测到电平变化后,不立即响应,而是等一段时间再读一次。如果两次结果一致,才认为是有效按键。

消抖方法 原理 适用场景 缺点
延时消抖 检测到变化后delay(20ms)再读 简单项目,非实时 阻塞CPU,浪费资源
定时轮询 定时器每10ms读一次,连续N次一致才确认 大多数嵌入式项目 需要定时器资源
状态机消抖 用状态机记录按键状态变迁 复杂按键逻辑(长按、双击) 代码量稍大

3.2.2 实战:定时轮询消抖

我个人最推荐定时轮询法。它不阻塞CPU,而且容易扩展成长按、双击检测。下面是一个经典实现:

#define DEBOUNCE_TIME_MS  20  // 消抖时间
#define SAMPLE_INTERVAL_MS 5  // 采样间隔

static uint8_t last_stable_level = 1;  // 上次稳定电平
static uint8_t current_level = 1;      // 当前电平
static uint32_t last_change_time = 0;

// 在定时器中断或主循环中每5ms调用一次
uint8_t button_debounce(void) {
    uint8_t level = gpio_get_level(BUTTON_GPIO);
    uint32_t now = esp_timer_get_time() / 1000;  // 转成ms
    
    if (level != current_level) {
        current_level = level;
        last_change_time = now;
    }
    
    // 电平稳定超过消抖时间
    if ((now - last_change_time) >= DEBOUNCE_TIME_MS) {
        if (last_stable_level != current_level) {
            last_stable_level = current_level;
            return current_level;  // 返回有效电平变化
        }
    }
    return 0xFF;  // 无有效变化
}

注意:不要在中断服务函数里做延时消抖!中断服务要快进快出。延时消抖应该在任务或主循环里做。

3.3 外部中断配置

轮询虽然简单,但CPU要一直跑着去查按键状态,浪费电。对于电池供电的设备,我们更希望按键按下时CPU才被唤醒——这就是外部中断的用武之地。

ESP32的每个GPIO都可以配置为外部中断源。支持以下几种触发方式:

中断触发方式 宏定义 说明
下降沿触发 GPIO_INTR_NEGEDGE 电平从高变低时触发(按键按下)
上升沿触发 GPIO_INTR_POSEDGE 电平从低变高时触发(按键松开)
任意沿触发 GPIO_INTR_ANYEDGE 电平变化就触发
低电平触发 GPIO_INTR_LOW_LEVEL 低电平时持续触发
高电平触发 GPIO_INTR_HIGH_LEVEL 高电平时持续触发

3.3.1 配置外部中断

#include "driver/gpio.h"

#define BUTTON_GPIO  0

// 中断服务函数声明
static void IRAM_ATTR button_isr_handler(void* arg);

void button_interrupt_init(void) {
    // 1. 配置GPIO
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << BUTTON_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);
    
    // 2. 安装GPIO中断服务
    gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
    
    // 3. 注册中断处理函数
    gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, (void*) BUTTON_GPIO);
}

// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg) {
    // 这里只做标记,不做耗时操作
    uint32_t gpio_num = (uint32_t) arg;
    // 设置一个标志位,通知主循环处理
    g_button_pressed = true;
}

关键点:

  • IRAM_ATTR 属性必须加!它把函数放在IRAM中,保证中断能快速响应
  • 中断服务函数里不要调用 printf、delay、malloc 等阻塞或耗时函数
  • 中断里只做标记,实际处理放到主循环或任务里

3.4 中断服务函数编写注意事项

这部分是重点,也是很多工程师容易翻车的地方。我见过最惨的一次:同事在中断里调了 vTaskDelay(),结果系统直接卡死,连看门狗都救不回来。

3.4.1 中断服务函数的「三要三不要」

原则 说明 为什么
要快进快出 执行时间控制在微秒级 中断会阻塞其他中断和任务
要使用IRAM_ATTR 函数放在内部RAM Flash访问慢,且中断中可能被其他操作阻塞
要使用volatile标志 共享变量加volatile 防止编译器优化掉变量读取
不要调用FreeRTOS API 如vTaskDelay、xQueueSend等 大部分FreeRTOS API不能在中断中调用
不要做I2C/SPI通信 不要在中断里读写传感器 通信耗时且可能触发其他中断
不要使用printf 打印输出太慢 printf内部可能使用互斥锁,导致死锁

3.4.2 中断与任务的同步

中断里标记,任务里处理。这是嵌入式开发的黄金法则。具体怎么做?

// 全局标志,volatile必须加!
static volatile bool g_button_pressed = false;

// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg) {
    g_button_pressed = true;  // 只做标记
}

// 主循环或任务
void app_main(void) {
    button_interrupt_init();
    
    while (1) {
        if (g_button_pressed) {
            g_button_pressed = false;  // 清除标志
            
            // 这里做消抖和实际处理
            // 注意:消抖不能在中断里做!
            vTaskDelay(pdMS_TO_TICKS(20));
            if (gpio_get_level(BUTTON_GPIO) == 0) {
                // 确认按键按下,执行操作
                handle_button_press();
            }
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

我曾经踩过的坑:在中断里直接调用了 gpio_get_level() 去读另一个引脚,结果那个引脚正好配置了中断,导致递归中断,栈溢出复位。记住:中断里只做最必要的事。

3.5 本章知识体系

下面这张图总结了按键输入与中断的完整处理流程,从硬件连接到软件处理,每一步都环环相扣:

按键输入与中断处理流程 按键输入与中断处理流程 硬件连接 GPIO配置 中断配置 中断触发(下降沿/上升沿) ISR:只设置标志位,不做耗时操作 主循环/任务:检测标志 → 软件消抖 → 确认按键 执行按键动作(LED/蜂鸣器/通信) 注意:消抖必须在主循环或任务中做,不能在ISR里做延时

3.6 总结与避坑清单

按键看似简单,但要做好其实不容易。我把这些年踩过的坑整理成清单,你写代码时对照着检查:

避坑清单:

  • ✅ GPIO配置时一定要使能上拉或下拉,防止悬空
  • ✅ 软件消抖时间建议15-30ms,太短消不干净,太长影响手感
  • ✅ 中断服务函数必须加 IRAM_ATTR 属性
  • ✅ 中断和主循环共享的变量必须加 volatile
  • ❌ 不要在ISR里调用 printf、delay、malloc
  • ❌ 不要在ISR里做I2C/SPI通信
  • ❌ 不要在ISR里调用FreeRTOS的阻塞API

嗯,这一章的内容就到这里。按键处理是嵌入式开发的入门必修课,也是后续学习中断驱动编程的基础。把这些基础打牢了,后面写传感器驱动、通信协议时才能游刃有余。