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() 才会真正生效。我刚开始漏了后面这个函数,调了半天灯都不亮,还以为硬件坏了。
2.4 本章知识结构图
下面这张图把 GPIO 数字输入输出、按键消抖、PWM 呼吸灯的核心流程串起来了。你可以把它当作本章的“地图”。
- GPIO 输出用
gpio_set_level(),输入用gpio_get_level(),方向必须提前配置。 - 按键消抖不能省。软件延时 10~20ms 是最低保障,硬件 RC 滤波是加分项。
- PWM 用 LEDC 外设,配置定时器 → 配置通道 → 更新占空比,三步走。
- 呼吸灯效果的关键是占空比平滑变化,step 和延时时间决定观感。
- 忘记调用
ledc_update_duty(),占空比不生效。 - 输入模式没启用上拉电阻,引脚悬空导致电平随机跳变。
- 消抖延时太短(<5ms),抖动没滤干净;太长(>50ms),按键响应迟钝。
- PWM 频率太低(<100Hz),LED 会肉眼可见地闪烁。