GPIO 基础与数字外设:从按键到呼吸灯
GPIO,说白了就是芯片的「手脚」。ESP32 有 34 个 GPIO 引脚,但并不是每个都能随便用。我刚开始用 ESP32 时,就踩过「用错引脚导致程序死活跑不起来」的坑。这一章,咱们把 GPIO 的配置、按键消抖、PWM 呼吸灯和中断编程一次性讲透。
2.1 GPIO 模式配置:别让引脚「裸奔」
ESP32 的 GPIO 可以配置成输入、输出、开漏、上拉、下拉等模式。我个人习惯在初始化时,把所有用到的引脚都显式配置一遍,哪怕默认值是对的。为什么?因为芯片复位后,引脚状态是「浮空」的,直接读可能读到不确定的值。
| 模式 | 说明 | 典型场景 |
|---|---|---|
| GPIO_MODE_INPUT | 数字输入 | 按键、传感器信号读取 |
| GPIO_MODE_OUTPUT | 推挽输出 | LED、继电器控制 |
| GPIO_MODE_OUTPUT_OD | 开漏输出 | I2C 总线、电平转换 |
| GPIO_MODE_INPUT_OUTPUT | 输入输出双向 | 单总线协议 |
// 标准 GPIO 初始化示例
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_2), // 选择 GPIO2
.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);
2.2 按键输入与消抖:别让一次按下变成十次
按键按下时,金属触点会「弹跳」几毫秒到几十毫秒。如果不做消抖,一次按下可能被识别成多次。你想想看,如果这是「一键开机」功能,那后果就严重了。
消抖有两种主流方式:
- 硬件消抖:在按键两端并联一个 0.1μF 电容,利用电容充放电滤除毛刺。
- 软件消抖:检测到电平变化后,延时 10-20ms 再读一次,确认状态稳定。
我在项目中更倾向软件消抖,因为省元件、改参数方便。但要注意,延时消抖期间不能阻塞主循环——可以用状态机或者定时器轮询。
// 软件消抖示例(非阻塞)
#define DEBOUNCE_MS 20
uint8_t read_key_debounced(uint8_t gpio_num) {
static uint8_t last_state = 1;
static uint32_t last_time = 0;
uint8_t cur_state = gpio_get_level(gpio_num);
if (cur_state != last_state) {
last_time = esp_timer_get_time() / 1000; // 记录变化时间
last_state = cur_state;
}
// 如果状态稳定超过消抖时间,才认为有效
if ((esp_timer_get_time() / 1000 - last_time) > DEBOUNCE_MS) {
return cur_state;
}
return last_state;
}
2.3 LED 呼吸灯(PWM):用占空比模拟亮度
PWM,就是通过快速开关引脚,让 LED 看起来「变暗」了。人眼有视觉暂留效应,只要频率高于 50Hz,就感觉不到闪烁。ESP32 的 LEDC 控制器可以输出高达 40MHz 的 PWM,做呼吸灯绰绰有余。
呼吸灯的核心逻辑:让占空比从 0% 逐渐增加到 100%,再逐渐减小到 0%,循环往复。我习惯用 5000Hz 的 PWM 频率,这样既听不到线圈啸叫,也不会产生可见闪烁。
// LEDC 呼吸灯配置
#define LEDC_TIMER LEDC_TIMER_0
#define LEDC_MODE LEDC_LOW_SPEED_MODE
#define LEDC_CHANNEL LEDC_CHANNEL_0
#define LEDC_DUTY_RES LEDC_TIMER_13_BIT // 13位分辨率,占空比范围 0-8191
#define LEDC_FREQ 5000 // 5kHz
void ledc_init(void) {
ledc_timer_config_t timer_conf = {
.speed_mode = LEDC_MODE,
.timer_num = LEDC_TIMER,
.duty_resolution = LEDC_DUTY_RES,
.freq_hz = LEDC_FREQ,
.clk_cfg = LEDC_AUTO_CLK
};
ledc_timer_config(&timer_conf);
ledc_channel_config_t ch_conf = {
.gpio_num = GPIO_NUM_2,
.speed_mode = LEDC_MODE,
.channel = LEDC_CHANNEL,
.timer_sel = LEDC_TIMER,
.duty = 0,
.hpoint = 0
};
ledc_channel_config(&ch_conf);
}
// 呼吸效果:占空比从 0 渐变到 8191,再回到 0
void breath_led(void) {
for (int duty = 0; duty < 8191; duty += 10) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(2 / portTICK_PERIOD_MS);
}
for (int duty = 8191; duty > 0; duty -= 10) {
ledc_set_duty(LEDC_MODE, LEDC_CHANNEL, duty);
ledc_update_duty(LEDC_MODE, LEDC_CHANNEL);
vTaskDelay(2 / portTICK_PERIOD_MS);
}
}
2.4 中断编程实战:别让 CPU 空等
轮询方式读取按键,CPU 一直在「问」按键状态,浪费算力。中断方式则是在事件发生时,CPU 暂停当前任务,跳转到中断服务函数(ISR)处理。处理完再回来继续干活。
ESP32 的中断系统支持上升沿、下降沿、高电平、低电平四种触发方式。我个人建议:按键用下降沿触发(按下瞬间),这样响应最快。
// GPIO 中断配置
#define KEY_GPIO GPIO_NUM_0 // BOOT 按键
static void IRAM_ATTR key_isr_handler(void* arg) {
// 中断服务函数——要短!要快!
uint32_t gpio_num = (uint32_t) arg;
// 这里只做标记,具体处理放到任务中
xQueueSendFromISR(key_evt_queue, &gpio_num, NULL);
}
void key_interrupt_init(void) {
// 配置 GPIO
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << KEY_GPIO),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
// 创建队列,用于 ISR 和任务通信
key_evt_queue = xQueueCreate(10, sizeof(uint32_t));
// 安装 GPIO ISR 服务
gpio_install_isr_service(ESP_INTR_FLAG_DEFAULT);
gpio_isr_handler_add(KEY_GPIO, key_isr_handler, (void*) KEY_GPIO);
}
// 任务中处理按键事件
void key_task(void* arg) {
uint32_t io_num;
while (1) {
if (xQueueReceive(key_evt_queue, &io_num, portMAX_DELAY)) {
// 收到中断通知,做消抖处理
vTaskDelay(20 / portTICK_PERIOD_MS);
if (gpio_get_level(io_num) == 0) {
printf("按键按下!GPIO: %d\n", io_num);
// 执行按键对应的功能
}
}
}
}
- ISR 里不能调用 printf、malloc、delay 等阻塞函数
- ISR 要尽可能短,只做标记或发消息
- ISR 函数必须加 IRAM_ATTR 属性,放在 IRAM 中运行
2.5 综合实战:按键控制呼吸灯
把前面几个知识点串起来:按一下按键,呼吸灯开始「呼吸」;再按一下,呼吸灯停止。这个项目虽然简单,但涵盖了 GPIO 配置、中断、PWM 和任务通信四个核心技能。
| 模块 | 功能 | 所用技术 |
|---|---|---|
| 按键 | 检测用户按下 | GPIO 输入 + 中断 + 消抖 |
| LED | 实现呼吸效果 | PWM 占空比渐变 |
| 任务 | 协调按键与 LED | FreeRTOS 队列 + 任务 |
实现思路:按键中断触发后,通过队列通知控制任务。控制任务收到消息后,切换呼吸灯的状态(启动/停止)。呼吸灯本身由另一个任务独立运行,互不干扰。
// 主函数框架
void app_main(void) {
// 初始化 LEDC(PWM)
ledc_init();
// 初始化按键中断
key_interrupt_init();
// 创建呼吸灯控制任务
xTaskCreate(breath_control_task, "breath_ctrl", 2048, NULL, 5, NULL);
// 创建按键处理任务
xTaskCreate(key_task, "key_task", 2048, NULL, 5, NULL);
}
- GPIO 配置前先查数据手册,避开保留引脚
- 按键消抖延时不要用阻塞式 delay,用状态机或定时器
- PWM 频率选 1kHz-10kHz,兼顾 LED 响应和人眼感知
- 中断 ISR 里只做标记,具体处理放到任务中
嗯,这一章的内容就到这里。GPIO 看似简单,但它是所有外设交互的基础。把 GPIO 玩透了,后面 I2C、SPI、UART 这些协议学起来会顺手很多。记住:每个引脚都有它的脾气,配置前多看两眼数据手册,能省下不少调试时间。