2. ESP32 GPIO 基础:数字输入输出、按键消抖、LED 呼吸灯(PWM)

好,咱们正式开始动手了。这一章我带你搞定 ESP32 最基础、也是最常用的几个功能:数字信号的输入输出、按键按下时那个烦人的抖动怎么处理,以及用 PWM 让 LED 像呼吸一样亮暗变化。

别小看这些基础操作。我在项目里见过太多“高级”功能调通了,结果卡在 GPIO 配置不对、按键误触发、LED 闪烁不均匀这种小问题上。说白了,地基没打牢,楼盖得再高也悬。

2.1 数字输入输出:让 ESP32 感知世界

ESP32 的 GPIO(通用输入输出引脚)可以配置成输入模式或输出模式。输出模式就是让引脚输出高电平(3.3V)或低电平(0V),用来控制 LED、继电器这类外设。输入模式则是读取外部信号是高还是低,比如按键按下时引脚被拉低。

我个人习惯在初始化时就把每个 GPIO 的方向、上下拉电阻、初始电平都明确写好。别偷懒,否则调试时你会怀疑人生。

2.1.1 输出模式:点亮一盏 LED

先来个最简单的——点亮板载 LED。大多数 ESP32 开发板的 LED 接在 GPIO2 上,低电平点亮。

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"

#define LED_GPIO   GPIO_NUM_2

void app_main(void)
{
    // 配置 GPIO2 为推挽输出模式
    gpio_reset_pin(LED_GPIO);
    gpio_set_direction(LED_GPIO, GPIO_MODE_OUTPUT);

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

这里有个细节:gpio_reset_pin() 会把引脚恢复到默认状态,包括禁用上下拉电阻、设置为输入模式。然后再调用 gpio_set_direction() 明确设为输出。两步走,更稳妥。

2.1.2 输入模式:读取按键状态

按键通常一端接 GPIO,另一端接 GND。按下时引脚被拉到低电平,松开时引脚悬空。悬空状态很容易受干扰,所以需要启用内部上拉电阻。

#define BUTTON_GPIO GPIO_NUM_0   // 很多开发板上的 BOOT 按键

void app_main(void)
{
    gpio_reset_pin(BUTTON_GPIO);
    gpio_set_direction(BUTTON_GPIO, GPIO_MODE_INPUT);
    gpio_set_pull_mode(BUTTON_GPIO, GPIO_PULLUP_ONLY);  // 启用内部上拉

    while (1) {
        int level = gpio_get_level(BUTTON_GPIO);
        printf("Button level: %d\n", level);
        vTaskDelay(100 / portTICK_PERIOD_MS);
    }
}

嗯,这里要注意:gpio_set_pull_mode() 只对输入模式有效。如果你设成输出模式再调这个函数,它不会报错,但也不起作用。我刚开始学的时候就踩过这个坑。

2.2 按键消抖:别让一次按下变成十次触发

你想想看,机械按键按下和松开的瞬间,金属触点会弹跳几次,持续几毫秒到十几毫秒。如果不处理,一次按键会被误读成多次高低变化。这就是“抖动”。

我曾经在一个智能家居项目里,因为没做消抖,用户按一下开关,灯闪了三次。客户差点退货。从那以后,我所有按键处理都老老实实加消抖。

2.2.1 软件消抖:延时 + 状态确认

最常用的方法是:检测到电平变化后,延时 10~20ms,再读一次。如果状态和之前一致,才认为是有效动作。

#define DEBOUNCE_MS 20

int read_button_debounced(gpio_num_t gpio)
{
    static int last_stable = 1;
    int current = gpio_get_level(gpio);

    if (current != last_stable) {
        vTaskDelay(DEBOUNCE_MS / portTICK_PERIOD_MS);
        int confirm = gpio_get_level(gpio);
        if (confirm == current) {
            last_stable = current;
            return current;
        }
    }
    return last_stable;
}

这个函数每次返回稳定的电平值。你只需要在主循环里轮询它,检测到电平从高变低(下降沿),就认为按键被按下了一次。

2.2.2 硬件消抖:RC 滤波

如果对实时性要求高,或者不想占用 CPU 做延时,可以在按键引脚上加一个 RC 低通滤波器。一个 10kΩ 电阻串联,一个 0.1μF 电容对地,时间常数约 1ms,能有效滤除高频抖动。

消抖方式 优点 缺点 适用场景
软件延时 无需额外元件,修改灵活 占用 CPU 时间,阻塞式 按键数量少,实时性要求不高
RC 硬件滤波 不占 CPU,响应快 需要电阻电容,参数固定 工业控制、高频按键
状态机消抖 非阻塞,适合 RTOS 代码稍复杂 多按键、复杂交互

我个人在量产产品里偏好“RC 滤波 + 软件状态机”的组合。硬件滤掉大部分毛刺,软件做最终确认,双重保险。

2.3 LED 呼吸灯:用 PWM 模拟模拟量

数字输出只有 0 和 1,但人眼有视觉暂留效应。如果让 LED 以极快的频率(比如 1kHz)在亮和灭之间切换,并且改变亮的时间占比(占空比),人眼就会感觉 LED 的亮度在变化。这就是 PWM(脉冲宽度调制)。

ESP32 的 LEDC 外设专门干这个事。它支持 8 路独立通道,16 位分辨率,频率可调。说白了,就是硬件自动产生 PWM 波形,CPU 只需要设置占空比就行。

2.3.1 配置 LEDC 通道

#include "driver/ledc.h"

#define LEDC_TIMER      LEDC_TIMER_0
#define LEDC_MODE       LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL    LEDC_CHANNEL_0
#define LEDC_GPIO       GPIO_NUM_2
#define LEDC_FREQ_HZ    5000        // 5kHz,人眼无闪烁
#define LEDC_RESOLUTION LEDC_TIMER_10_BIT  // 10位分辨率,占空比 0~1023

void ledc_init(void)
{
    ledc_timer_config_t timer_conf = {
        .speed_mode       = LEDC_MODE,
        .timer_num        = LEDC_TIMER,
        .duty_resolution  = LEDC_RESOLUTION,
        .freq_hz          = LEDC_FREQ_HZ,
        .clk_cfg          = LEDC_AUTO_CLK
    };
    ledc_timer_config(&timer_conf);

    ledc_channel_config_t channel_conf = {
        .gpio_num       = LEDC_GPIO,
        .speed_mode     = LEDC_MODE,
        .channel        = LEDC_CHANNEL,
        .timer_sel      = LEDC_TIMER,
        .duty           = 0,
        .hpoint         = 0
    };
    ledc_channel_config(&channel_conf);
}

配置分两步:先配置定时器(频率、分辨率),再配置通道(绑定哪个 GPIO、哪个定时器)。分辨率越高,亮度调节越细腻。10 位分辨率有 1024 级,足够了。

2.3.2 实现呼吸效果

呼吸灯就是让占空比从 0 逐渐增加到最大值,再逐渐减小到 0,循环往复。用 ledc_set_duty() 和 ledc_update_duty() 来更新占空比。

void breathe_task(void *arg)
{
    int duty = 0;
    int step = 5;
    int max_duty = (1 << 10) - 1;  // 1023

    while (1) {
        // 渐亮
        for (duty = 0; duty < max_duty; duty += step) {
            ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
            vTaskDelay(10 / portTICK_PERIOD_MS);
        }
        // 渐暗
        for (duty = max_duty; duty > 0; duty -= step) {
            ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
            ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
            vTaskDelay(10 / portTICK_PERIOD_MS);
        }
    }
}

这里有个小技巧:ledc_set_duty() 只是把值写到寄存器缓存,必须调用 ledc_update_duty() 才会真正生效。我刚开始漏了后面这个函数,调了半天灯都不亮,还以为硬件坏了。

提示: 如果呼吸灯效果不平滑,可以试试减小 step 值(比如 2 或 3),或者增大延时时间。step 越小,亮度变化越细腻,但循环周期会变长。根据实际观感调整。

2.4 本章知识结构图

下面这张图把 GPIO 数字输入输出、按键消抖、PWM 呼吸灯的核心流程串起来了。你可以把它当作本章的“地图”。

ESP32 GPIO 基础:知识结构图 ESP32 GPIO 数字输出 数字输入 PWM 输出 gpio_set_direction() gpio_set_level() gpio_get_level() 内部上拉/下拉配置 软件消抖:延时 + 确认 硬件消抖:RC 滤波 ledc_timer_config() ledc_channel_config() ledc_set_duty() + update 核心思路:数字输出控制电平,数字输入读取电平,PWM 用硬件产生可变占空比波形 按键消抖是输入场景的必备处理,RC 滤波 + 软件确认是工程中最稳的方案
核心要点:
  • GPIO 输出用 gpio_set_level(),输入用 gpio_get_level(),方向必须提前配置。
  • 按键消抖不能省。软件延时 10~20ms 是最低保障,硬件 RC 滤波是加分项。
  • PWM 用 LEDC 外设,配置定时器 → 配置通道 → 更新占空比,三步走。
  • 呼吸灯效果的关键是占空比平滑变化,step 和延时时间决定观感。
常见坑点:
  • 忘记调用 ledc_update_duty(),占空比不生效。
  • 输入模式没启用上拉电阻,引脚悬空导致电平随机跳变。
  • 消抖延时太短(<5ms),抖动没滤干净;太长(>50ms),按键响应迟钝。
  • PWM 频率太低(<100Hz),LED 会肉眼可见地闪烁。