3. GPIO与外部中断实战:从按键点亮一盏灯说起

GPIO 是嵌入式开发最基础、也最常用的外设。说白了,它就是芯片和外界打交道的「手脚」——要么输出高低电平去控制 LED、继电器,要么读取外部电平来判断按键、传感器状态。而外部中断,则是让芯片「主动响应」外部事件的关键机制。

我个人习惯把 GPIO 和外部中断放在一起讲,因为实际项目中它们几乎总是成对出现。比如你按下一个按键,GPIO 检测到电平变化,中断服务函数立刻响应——这个过程,就是嵌入式系统「感知-响应」的缩影。

3.1 GPIO 基本操作:配置、输出、输入

在 ESP-IDF 里操作 GPIO,核心就三步:配置、写、读。先看配置。

3.1.1 GPIO 配置

ESP-IDF 使用 gpio_config_t 结构体来配置 GPIO。这个结构体包含了引脚号、模式、上下拉、中断类型等信息。我一般这样写:

gpio_config_t io_conf = {
    .pin_bit_mask = (1ULL << GPIO_NUM_2),  // 选中 GPIO2
    .mode = GPIO_MODE_OUTPUT,                // 输出模式
    .pull_up_en = GPIO_PULLUP_DISABLE,       // 不上拉
    .pull_down_en = GPIO_PULLDOWN_DISABLE,   // 不下拉
    .intr_type = GPIO_INTR_DISABLE           // 不使能中断
};
gpio_config(&io_conf);

这里有个细节:pin_bit_mask 是 64 位的位掩码,每个 bit 对应一个 GPIO 引脚。如果你想同时配置多个引脚,用或运算组合就行:

(1ULL << GPIO_NUM_2) | (1ULL << GPIO_NUM_4)
小技巧: 配置输入模式时,我习惯把上拉电阻打开。尤其是按键电路,外部没加上拉电阻时,内部上拉能省一个元件。但要注意,内部上拉约 45kΩ,驱动能力有限,不适合高速信号。

3.1.2 GPIO 输出

输出就两个函数:gpio_set_level() 和 gpio_get_level()。控制 LED 闪烁的经典代码:

while (1) {
    gpio_set_level(GPIO_NUM_2, 1);  // 高电平,LED 亮
    vTaskDelay(1000 / portTICK_PERIOD_MS);
    gpio_set_level(GPIO_NUM_2, 0);  // 低电平,LED 灭
    vTaskDelay(1000 / portTICK_PERIOD_MS);
}

嗯,这里要注意:vTaskDelay 的单位是 tick,用 portTICK_PERIOD_MS 转换一下更直观。我在项目中见过有人直接写 vTaskDelay(1000),结果延时完全不对——因为 tick 周期不一定是 1ms。

3.1.3 GPIO 输入

读取按键状态用 gpio_get_level()。假设按键接在 GPIO0,按下为低电平:

int level = gpio_get_level(GPIO_NUM_0);
if (level == 0) {
    ESP_LOGI("MAIN", "Button pressed!");
}

但这里有个坑:机械按键按下时会有抖动,直接读电平会误判。后面我会专门讲消抖。

3.2 外部中断配置:上升沿与下降沿

轮询读取 GPIO 太浪费 CPU 了。更好的方式是让硬件在电平变化时主动通知 CPU——这就是外部中断。

3.2.1 中断类型

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

中断类型 宏定义 触发条件
上升沿触发 GPIO_INTR_POSEDGE 电平从低变高
下降沿触发 GPIO_INTR_NEGEDGE 电平从高变低
任意沿触发 GPIO_INTR_ANYEDGE 电平变化即触发
低电平触发 GPIO_INTR_LOW_LEVEL 低电平持续触发

实际项目中,按键常用下降沿触发(按下瞬间电平拉低),编码器常用上升沿和下降沿同时检测。

3.2.2 配置步骤

配置外部中断分三步:

  1. 配置 GPIO 为输入模式,并设置中断类型
  2. 安装 GPIO 中断服务:gpio_install_isr_service(0)
  3. 注册中断处理函数:gpio_isr_handler_add()

完整代码示例:

// 1. 配置 GPIO0 为输入,下降沿触发
gpio_config_t io_conf = {
    .pin_bit_mask = (1ULL << GPIO_NUM_0),
    .mode = GPIO_MODE_INPUT,
    .pull_up_en = GPIO_PULLUP_ENABLE,   // 内部上拉,按键接 GND
    .intr_type = GPIO_INTR_NEGEDGE      // 下降沿触发
};
gpio_config(&io_conf);

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

// 3. 注册中断处理函数
gpio_isr_handler_add(GPIO_NUM_0, button_isr_handler, (void*) "Button0");
我曾经踩过的坑: gpio_install_isr_service() 只能调用一次!如果你在多个地方调用,第二次会返回 ESP_ERR_INVALID_STATE。正确的做法是在初始化阶段统一安装一次。

3.3 中断服务函数编写

中断服务函数(ISR)有严格的限制:要快、要短、不能阻塞。为什么?因为中断期间,CPU 不响应其他中断,也不调度任务。你在 ISR 里耗 10ms,系统就「死」10ms。

3.3.1 ISR 的基本写法

static void IRAM_ATTR button_isr_handler(void* arg) {
    // 这里只能做最轻量的事
    uint32_t gpio_num = (uint32_t) arg;
    // 比如:设置一个标志位
    g_button_pressed = true;
}

注意 IRAM_ATTR 属性——它告诉编译器把这个函数放到 IRAM(指令 RAM)中。因为 Flash 访问在中断里可能不可靠,放在 IRAM 里能保证执行速度和确定性。

3.3.2 ISR 与任务通信

ISR 里不能调用 ESP_LOGI、vTaskDelay 等阻塞函数。那怎么把事件传给主任务?我常用的方式有两种:

  • 全局标志位:用 volatile 修饰,主循环轮询
  • 队列/信号量:用 xQueueSendFromISR() 或 xSemaphoreGiveFromISR()

举个例子,用队列传递按键事件:

static void IRAM_ATTR button_isr_handler(void* arg) {
    xQueueSendFromISR(button_evt_queue, &(uint32_t){GPIO_NUM_0}, NULL);
}

// 主任务中
void button_task(void *pvParameter) {
    uint32_t io_num;
    while (1) {
        if (xQueueReceive(button_evt_queue, &io_num, portMAX_DELAY)) {
            ESP_LOGI("TASK", "GPIO %d triggered", io_num);
        }
    }
}

这样 ISR 只负责「通知」,具体处理交给任务,既快又安全。

3.4 消抖处理实战

机械按键按下时,金属触点会弹跳几次,产生多个脉冲。如果不处理,一次按键可能触发多次中断。这就是「抖动」问题。

3.4.1 硬件消抖

最简单的硬件消抖是在按键两端并联一个 0.1μF 电容。电容充放电会「吸收」弹跳脉冲。但电容会增加响应延迟,不适合高速按键。

3.4.2 软件消抖

软件消抖更灵活,我常用两种方法:

方法一:延时采样法

检测到电平变化后,延时 10-20ms 再读一次。如果电平稳定,才认为是有效按键。

if (gpio_get_level(BUTTON_PIN) == 0) {
    vTaskDelay(20 / portTICK_PERIOD_MS);  // 延时 20ms
    if (gpio_get_level(BUTTON_PIN) == 0) {
        // 确认按键按下
    }
}

方法二:定时器消抖法

在 ISR 中启动一个软件定时器,延时 20ms 后检查电平。如果电平稳定,再触发事件。这种方法不阻塞主循环,适合多按键场景。

我的经验: 延时采样法简单可靠,适合单按键。定时器消抖法稍复杂,但适合多按键或需要快速响应的场景。我个人在量产产品中更倾向于定时器消抖——因为它不会阻塞系统,用户体验更好。

3.5 知识体系总览

下面这张图总结了 GPIO 与外部中断的核心知识点和流程关系:

GPIO与外部中断知识体系 GPIO与外部中断知识体系 GPIO 配置 输出模式 输入模式 中断模式 中断类型选择 ISR 编写 ISR→任务通信 消抖处理 硬件消抖(电容) 软件消抖(延时/定时器) gpio_set_level() gpio_get_level() 核心原则 ISR 要短、要快、不阻塞 消抖是机械按键的必修课

3.6 实战要点总结

最后,我把本章的关键点列出来,方便你回顾:

  • GPIO 配置:用 gpio_config_t 结构体,注意位掩码和上下拉设置
  • 输出操作:gpio_set_level() 控制电平,vTaskDelay 注意 tick 转换
  • 输入操作:gpio_get_level() 读取电平,配合消抖使用
  • 外部中断:先配置中断类型,再安装 ISR 服务,最后注册处理函数
  • ISR 编写:用 IRAM_ATTR,只做轻量操作,通过队列/信号量通知任务
  • 消抖:硬件加电容,软件用延时或定时器,二选一或组合使用
最后说一句: GPIO 和中断是嵌入式开发的「基本功」。你想想看,几乎所有外设——I2C、SPI、UART、PWM——底层都离不开 GPIO 操作。把这章的内容吃透,后面学其他外设会轻松很多。