3. ESP32 GPIO基础与中断编程

各位同学好,欢迎来到第三章。这一章我们聊点实在的——GPIO和中断。说白了,这是ESP32跟外界打交道的「手脚」和「耳朵」。

我记得刚做嵌入式那会儿,总觉得GPIO不就是拉高拉低嘛,有啥好学的?结果第一次做产品原型,按键消抖没处理好,设备一上电就疯狂误触发。嗯,从那以后我再也不敢小看GPIO了。

3.1 GPIO配置与操作

ESP32的GPIO引脚,功能很丰富。它不光是普通的输入输出,还能复用成I2C、SPI、UART、PWM、ADC等外设接口。但咱们先从最基础的讲起。

3.1.1 GPIO工作模式

每个GPIO引脚,可以配置成以下几种模式:

模式 说明 典型应用
GPIO_MODE_INPUT 数字输入 读取按键、传感器电平
GPIO_MODE_OUTPUT 数字输出 控制LED、继电器
GPIO_MODE_INPUT_OUTPUT 双向模式 开漏总线(如I2C)
GPIO_MODE_DISABLE 禁用 省电、引脚隔离

我个人习惯,在初始化时明确指定模式,不要依赖默认值。ESP32的默认状态不一定是高阻,踩过坑你就懂了。

3.1.2 基本操作函数

ESP-IDF提供了简洁的API。来看一个LED闪烁的例子:

#include "driver/gpio.h"

#define LED_GPIO   GPIO_NUM_2

void app_main(void)
{
    // 配置GPIO为推挽输出
    gpio_config_t io_conf = {
        .pin_bit_mask = (1ULL << LED_GPIO),
        .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);

    while (1) {
        gpio_set_level(LED_GPIO, 1);
        vTaskDelay(500 / portTICK_PERIOD_MS);
        gpio_set_level(LED_GPIO, 0);
        vTaskDelay(500 / portTICK_PERIOD_MS);
    }
}

这里有个细节:gpio_config_t结构体里的pin_bit_mask用的是位掩码。你想想看,如果同时配置多个引脚,用|组合就行,很方便。

小技巧: 调试时可以用gpio_get_level()实时读取引脚电平,配合串口打印,比示波器来得快。

3.2 外部中断原理

为什么要用中断?说白了,就是不让CPU傻等。轮询方式下,CPU得一直问「按键按了没?」,浪费电。中断方式下,CPU该睡睡,事件来了再被叫醒。

ESP32的GPIO中断支持以下几种触发方式:

  • 上升沿触发:电平从低变高时触发
  • 下降沿触发:电平从高变低时触发
  • 任意沿触发:电平变化就触发
  • 低电平触发:低电平时持续触发
  • 高电平触发:高电平时持续触发

我在项目中遇到过一个问题:用低电平触发做按键,结果按键按下时一直触发中断,导致程序卡死在中断里。后来改成下降沿触发,配合软件消抖,问题就解决了。

注意: 电平触发模式下,如果中断服务函数执行时间过长,或者中断没有被及时清除,会导致中断反复触发,造成系统假死。建议优先使用边沿触发。

3.3 按键中断实战

好,咱们直接上手一个按键中断的例子。这个案例我用了很多次,基本框架可以直接复用。

3.3.1 硬件连接

按键一端接GPIO,另一端接GND。内部上拉电阻打开,这样按键没按下时引脚是高电平,按下时被拉低。

3.3.2 代码实现

#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define BUTTON_GPIO  GPIO_NUM_0

// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg)
{
    // 注意:ISR中不能调用printf、vTaskDelay等阻塞函数
    // 通常的做法是发一个信号量给任务
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    if (xHigherPriorityTaskWoken) {
        portYIELD_FROM_ISR();
    }
}

void app_main(void)
{
    // 配置按键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);

    // 创建二值信号量
    xSemaphore = xSemaphoreCreateBinary();

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

    // 注册中断回调
    gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, NULL);

    // 任务循环处理按键事件
    while (1) {
        if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 软件消抖:延时20ms再检测
            vTaskDelay(20 / portTICK_PERIOD_MS);
            if (gpio_get_level(BUTTON_GPIO) == 0) {
                // 按键确实被按下了
                printf("Button pressed!\n");
            }
        }
    }
}
核心思路: ISR只做最轻量的事——发信号量。真正的业务逻辑放在任务循环里处理。这样既保证了中断响应速度,又避免了ISR里做复杂操作带来的风险。

3.4 中断服务函数编写规范

中断服务函数(ISR)的编写,有很多讲究。我见过不少新手把整个业务逻辑塞进ISR,结果系统跑起来各种诡异。这里我总结几条铁律:

  1. 快进快出:ISR执行时间越短越好,一般控制在几十微秒以内。
  2. 禁止阻塞:ISR里不能调用vTaskDelay()、printf()、malloc()等可能阻塞或引起上下文切换的函数。
  3. 使用IRAM_ATTR:ISR函数必须加IRAM_ATTR属性,确保代码放在IRAM中,避免flash访问延迟。
  4. 共享数据加volatile:ISR和主循环共享的变量,一定要用volatile修饰,防止编译器优化。
  5. 信号量传递:ISR通过xSemaphoreGiveFromISR()或xQueueSendFromISR()将事件传递给任务。

我曾经在一个项目中,ISR里直接调用了printf打印调试信息。结果串口驱动在ISR里被重入,直接死机。从那以后,我所有ISR都严格遵守「只发信号,不做业务」的原则。

避坑指南: 如果你在ISR里需要访问某个全局变量,记得用portENTER_CRITICAL_ISR()和portEXIT_CRITICAL_ISR()保护临界区。不过能不用就不用,保持ISR简洁才是王道。

3.5 本章知识体系

下面这张图,帮你把本章的核心逻辑串起来:

ESP32 GPIO与中断知识体系 GPIO配置 外部中断原理 按键中断实战 输入/输出/双向 gpio_config/set/get 边沿/电平触发 快进快出/信号量 软件消抖20ms 核心原则:ISR只发信号,业务交给任务 配置 -> 中断 -> 消抖 -> 处理,环环相扣