2. GPIO与基础外设控制:GPIO配置、按键输入与消抖、LED呼吸灯(PWM)、中断编程实战
GPIO 是嵌入式开发最基础、也最绕不开的模块。说白了,芯片跟外界打交道,全靠这些引脚。ESP32 的 GPIO 功能很丰富,但坑也不少。这一章我会把 GPIO 配置、按键消抖、PWM 呼吸灯、中断编程这几个点串起来讲,都是实际项目里天天要用的东西。
- GPIO 工作模式与配置方法
- 按键输入与硬件/软件消抖
- PWM 实现 LED 呼吸灯效果
- 中断机制与 ISR 编写规范
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;
}
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 才响应,平时该干嘛干嘛。
配置中断的步骤很简单:
- 配置 GPIO 为输入模式
- 设置中断类型(上升沿、下降沿、双边沿)
- 注册中断服务函数(ISR)
- 使能中断
#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 必须用
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 读一下引脚状态,确认配置生效了再往下走。