2. GPIO与基础外设控制:GPIO配置、按键输入与消抖、LED呼吸灯(PWM)、中断编程实战

GPIO 是嵌入式开发最基础、也最绕不开的模块。说白了,芯片跟外界打交道,全靠这些引脚。ESP32 的 GPIO 功能很丰富,但坑也不少。这一章我会把 GPIO 配置、按键消抖、PWM 呼吸灯、中断编程这几个点串起来讲,都是实际项目里天天要用的东西。

本章核心知识点:
  • GPIO 工作模式与配置方法
  • 按键输入与硬件/软件消抖
  • PWM 实现 LED 呼吸灯效果
  • 中断机制与 ISR 编写规范
GPIO 与基础外设控制知识体系 ESP32 GPIO GPIO 配置 按键输入与消抖 PWM 呼吸灯 中断编程 输出模式 输入模式 硬件消抖 软件消抖 占空比控制 频率设置 ISR 编写 临界区保护 实际项目中,GPIO 配置、按键消抖、PWM、中断往往组合使用 例如:按键中断触发 → 软件消抖 → 控制 PWM 占空比 → LED 呼吸效果

2.1 GPIO 配置——从硬件到代码

ESP32 的 GPIO 跟传统单片机不太一样。它内部有一个复杂的 IO MUX 矩阵,每个物理引脚可以映射到多个外设功能。我个人习惯在初始化时明确指定每个引脚的工作模式,而不是依赖默认值。

配置 GPIO 主要用 ESP-IDF 的 gpio_config_t 结构体。来看一个典型例子:

// 配置 GPIO 输出——驱动 LED
gpio_config_t io_conf = {};
io_conf.intr_type = GPIO_INTR_DISABLE;    // 输出模式不需要中断
io_conf.mode = GPIO_MODE_OUTPUT;          // 设置为输出模式
io_conf.pin_bit_mask = (1ULL << GPIO_LED_PIN); // 选中引脚
io_conf.pull_down_en = 0;                 // 不上拉
io_conf.pull_up_en = 0;                   // 不下拉
gpio_config(&io_conf);
小技巧: 用 (1ULL << pin) 而不是 (1 << pin),避免 pin 超过 31 时溢出。ESP32 有 40 个 GPIO,这个细节我踩过坑。

输入配置也类似,但多了上拉/下拉的选择。按键场景下,我通常启用内部上拉,这样外部只需要一个按键到地,省一个电阻。

// 配置 GPIO 输入——读取按键
gpio_config_t io_conf = {};
io_conf.intr_type = GPIO_INTR_DISABLE;    // 先不配中断
io_conf.mode = GPIO_MODE_INPUT;           // 输入模式
io_conf.pin_bit_mask = (1ULL << GPIO_BUTTON_PIN);
io_conf.pull_up_en = 1;                   // 内部上拉
io_conf.pull_down_en = 0;
gpio_config(&io_conf);

2.2 按键输入与消抖——别让毛刺坑了你

按键按下和释放的瞬间,金属触点会弹跳,产生一连串毛刺。如果不处理,一次按下可能被识别成十几次。我在一个量产项目里遇到过这个问题,产品返修率一度很高,后来发现就是消抖没做好。

消抖有两种方式:

方式 原理 优点 缺点
硬件消抖 RC 低通滤波 + 施密特触发器 不占 CPU,响应快 增加 BOM 成本
软件消抖 延时采样或状态机 零成本,灵活 占用 CPU 时间

实际项目中,我推荐软件消抖。ESP32 跑 240MHz,几十毫秒的延时根本感觉不到。下面是我常用的延时消抖代码:

#define BUTTON_PIN      GPIO_NUM_0
#define DEBOUNCE_MS     50   // 消抖时间,单位毫秒

int read_button_debounced(void) {
    if (gpio_get_level(BUTTON_PIN) == 0) {  // 检测到按下(低电平)
        vTaskDelay(pdMS_TO_TICKS(DEBOUNCE_MS)); // 等待 50ms
        if (gpio_get_level(BUTTON_PIN) == 0) {  // 再次确认
            return 1;  // 确实按下了
        }
    }
    return 0;
}
注意: 不要在中断服务函数(ISR)里调用 vTaskDelay!FreeRTOS 的延时函数只能在任务上下文中使用。ISR 里要用 esp_timer 或硬件定时器来做延时。

还有一种更优雅的方式——状态机消抖。它不阻塞 CPU,适合多按键场景:

typedef enum {
    STATE_IDLE,
    STATE_DEBOUNCE,
    STATE_PRESSED
} button_state_t;

button_state_t btn_state = STATE_IDLE;
uint32_t last_tick = 0;

void button_task(void *pvParameter) {
    while (1) {
        int level = gpio_get_level(BUTTON_PIN);
        switch (btn_state) {
            case STATE_IDLE:
                if (level == 0) {
                    btn_state = STATE_DEBOUNCE;
                    last_tick = xTaskGetTickCount();
                }
                break;
            case STATE_DEBOUNCE:
                if ((xTaskGetTickCount() - last_tick) > pdMS_TO_TICKS(50)) {
                    if (level == 0) {
                        btn_state = STATE_PRESSED;
                        // 触发按键事件
                        printf("Button pressed!\n");
                    } else {
                        btn_state = STATE_IDLE; // 毛刺,复位
                    }
                }
                break;
            case STATE_PRESSED:
                if (level == 1) {
                    btn_state = STATE_IDLE; // 等待释放
                }
                break;
        }
        vTaskDelay(pdMS_TO_TICKS(10)); // 每 10ms 扫描一次
    }
}

2.3 LED 呼吸灯——PWM 的精髓

呼吸灯效果,说白了就是让 LED 的亮度从暗到亮、再从亮到暗循环变化。这需要 PWM(脉冲宽度调制)来控制平均电压。ESP32 的 LEDC 控制器专门干这个,最多支持 16 路独立 PWM。

配置 PWM 需要确定三个参数:频率、占空比分辨率、占空比值。我一般这样配:

#define LED_PIN         GPIO_NUM_2
#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_FREQ       5000    // 5kHz,人耳听不到线圈声
#define LEDC_RESOLUTION LEDC_TIMER_13_BIT  // 13位分辨率,0~8191

void ledc_init(void) {
    // 配置定时器
    ledc_timer_config_t timer_conf = {
        .speed_mode = LEDC_HIGH_SPEED_MODE,
        .timer_num = LEDC_TIMER,
        .duty_resolution = LEDC_RESOLUTION,
        .freq_hz = LEDC_FREQ,
        .clk_cfg = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    // 配置通道
    ledc_channel_config_t channel_conf = {
        .gpio_num = LED_PIN,
        .speed_mode = LEDC_HIGH_SPEED_MODE,
        .channel = LEDC_CHANNEL,
        .timer_sel = LEDC_TIMER,
        .duty = 0,
        .hpoint = 0
    };
    ledc_channel_config(&channel_conf);
}

呼吸效果的核心是不断改变占空比。我习惯用 ledc_set_duty 和 ledc_update_duty 组合:

void breath_led(void) {
    int duty = 0;
    int step = 8;  // 步进值,控制呼吸速度
    while (1) {
        // 渐亮
        for (duty = 0; duty < 8191; duty += step) {
            ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
            vTaskDelay(pdMS_TO_TICKS(2));
        }
        // 渐暗
        for (duty = 8191; duty > 0; duty -= step) {
            ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
            vTaskDelay(pdMS_TO_TICKS(2));
        }
    }
}
经验之谈: 步进值 step 和延时时间共同决定呼吸速度。我一般先固定延时 2ms,然后调 step。step 越大,呼吸越快。13 位分辨率下,step=8 是比较自然的节奏。

2.4 中断编程实战——别让 CPU 空转

轮询按键太浪费 CPU 了。ESP32 跑着 Wi-Fi 和蓝牙,CPU 时间很宝贵。用中断,按键按下时 CPU 才响应,平时该干嘛干嘛。

配置中断的步骤很简单:

  1. 配置 GPIO 为输入模式
  2. 设置中断类型(上升沿、下降沿、双边沿)
  3. 注册中断服务函数(ISR)
  4. 使能中断
#define GPIO_BUTTON_PIN GPIO_NUM_0

// 中断服务函数——注意:ISR 里不能做耗时操作
static void IRAM_ATTR button_isr_handler(void *arg) {
    // 用信号量通知任务,而不是直接在 ISR 里处理
    xSemaphoreGiveFromISR(button_semaphore, NULL);
}

void button_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 << GPIO_BUTTON_PIN);
    io_conf.pull_up_en = 1;
    gpio_config(&io_conf);

    // 2. 安装 GPIO 中断驱动
    gpio_install_isr_service(0);

    // 3. 注册 ISR
    gpio_isr_handler_add(GPIO_BUTTON_PIN, button_isr_handler, NULL);
}
ISR 编写铁律:
  • ISR 必须用 IRAM_ATTR 修饰,放在 IRAM 中执行
  • ISR 里不能调用 printf、vTaskDelay、malloc 等阻塞函数
  • ISR 里尽量只做标记,实际处理交给任务
  • ISR 里访问的全局变量要用 volatile 修饰

对应的任务代码:

SemaphoreHandle_t button_semaphore;

void button_event_task(void *pvParameter) {
    button_semaphore = xSemaphoreCreateBinary();
    while (1) {
        // 等待信号量——没有按键时任务挂起,不占 CPU
        if (xSemaphoreTake(button_semaphore, portMAX_DELAY) == pdTRUE) {
            // 这里做消抖和按键处理
            vTaskDelay(pdMS_TO_TICKS(50));
            if (gpio_get_level(GPIO_BUTTON_PIN) == 0) {
                printf("Button pressed via interrupt!\n");
                // 可以在这里控制 LED 或 PWM
            }
        }
    }
}

2.5 综合实战——按键控制呼吸灯

把上面所有知识点串起来:按键中断触发 → 软件消抖 → 切换呼吸灯模式。这个例子在智能家居面板里很常见。

// 全局变量
volatile int breath_mode = 0;  // 0: 关闭, 1: 呼吸

static void IRAM_ATTR button_isr_handler(void *arg) {
    xSemaphoreGiveFromISR(button_semaphore, NULL);
}

void button_task(void *pvParameter) {
    while (1) {
        if (xSemaphoreTake(button_semaphore, portMAX_DELAY) == pdTRUE) {
            vTaskDelay(pdMS_TO_TICKS(50));
            if (gpio_get_level(GPIO_BUTTON_PIN) == 0) {
                breath_mode = !breath_mode;  // 切换模式
                printf("Mode: %s\n", breath_mode ? "Breathing" : "Off");
            }
        }
    }
}

void breath_task(void *pvParameter) {
    int duty = 0;
    int step = 8;
    while (1) {
        if (breath_mode) {
            // 呼吸效果
            for (duty = 0; duty < 8191; duty += step) {
                ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
                ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
                vTaskDelay(pdMS_TO_TICKS(2));
                if (!breath_mode) break;  // 模式切换时立即退出
            }
            for (duty = 8191; duty > 0; duty -= step) {
                ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, duty);
                ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
                vTaskDelay(pdMS_TO_TICKS(2));
                if (!breath_mode) break;
            }
        } else {
            ledc_set_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL, 0);
            ledc_update_duty(LEDC_HIGH_SPEED_MODE, LEDC_CHANNEL);
            vTaskDelay(pdMS_TO_TICKS(100));
        }
    }
}
避坑总结:
  • GPIO 配置时注意引脚号不要超过 39,部分引脚(如 GPIO6~11)被 Flash 占用
  • 按键消抖时间 20~50ms 足够,太长影响手感
  • PWM 频率选 5kHz 以上,避免 LED 闪烁被人眼察觉
  • ISR 里绝对不要做延时或打印,用信号量/队列通知任务
  • 多任务访问的全局变量加 volatile,必要时用临界区保护

嗯,这一章的内容就到这里。GPIO 看似简单,但配置不当会引发各种奇怪问题。我见过有人把输出引脚误配成输入,导致外设驱动不工作,查了两天才找到原因。所以每次配置完,建议用 gpio_get_level 读一下引脚状态,确认配置生效了再往下走。