3. ESP32 GPIO基础与中断编程
各位同学好,欢迎来到第三章。这一章我们聊点实在的——GPIO和中断。说白了,这是ESP32跟外界打交道的「手脚」和「耳朵」。
我记得刚做嵌入式那会儿,总觉得GPIO不就是拉高拉低嘛,有啥好学的?结果第一次做产品原型,按键消抖没处理好,设备一上电就疯狂误触发。嗯,从那以后我再也不敢小看GPIO了。
3.1 GPIO配置与操作
ESP32的GPIO引脚,功能很丰富。它不光是普通的输入输出,还能复用成I2C、SPI、UART、PWM、ADC等外设接口。但咱们先从最基础的讲起。
3.1.1 GPIO工作模式
每个GPIO引脚,可以配置成以下几种模式:
| 模式 | 说明 | 典型应用 |
|---|---|---|
| GPIO_MODE_INPUT | 数字输入 | 读取按键、传感器电平 |
| GPIO_MODE_OUTPUT | 数字输出 | 控制LED、继电器 |
| GPIO_MODE_INPUT_OUTPUT | 双向模式 | 开漏总线(如I2C) |
| GPIO_MODE_DISABLE | 禁用 | 省电、引脚隔离 |
我个人习惯,在初始化时明确指定模式,不要依赖默认值。ESP32的默认状态不一定是高阻,踩过坑你就懂了。
3.1.2 基本操作函数
ESP-IDF提供了简洁的API。来看一个LED闪烁的例子:
#include "driver/gpio.h"
#define LED_GPIO GPIO_NUM_2
void app_main(void)
{
// 配置GPIO为推挽输出
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << LED_GPIO),
.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);
while (1) {
gpio_set_level(LED_GPIO, 1);
vTaskDelay(500 / portTICK_PERIOD_MS);
gpio_set_level(LED_GPIO, 0);
vTaskDelay(500 / portTICK_PERIOD_MS);
}
}
这里有个细节:gpio_config_t结构体里的pin_bit_mask用的是位掩码。你想想看,如果同时配置多个引脚,用|组合就行,很方便。
gpio_get_level()实时读取引脚电平,配合串口打印,比示波器来得快。
3.2 外部中断原理
为什么要用中断?说白了,就是不让CPU傻等。轮询方式下,CPU得一直问「按键按了没?」,浪费电。中断方式下,CPU该睡睡,事件来了再被叫醒。
ESP32的GPIO中断支持以下几种触发方式:
- 上升沿触发:电平从低变高时触发
- 下降沿触发:电平从高变低时触发
- 任意沿触发:电平变化就触发
- 低电平触发:低电平时持续触发
- 高电平触发:高电平时持续触发
我在项目中遇到过一个问题:用低电平触发做按键,结果按键按下时一直触发中断,导致程序卡死在中断里。后来改成下降沿触发,配合软件消抖,问题就解决了。
3.3 按键中断实战
好,咱们直接上手一个按键中断的例子。这个案例我用了很多次,基本框架可以直接复用。
3.3.1 硬件连接
按键一端接GPIO,另一端接GND。内部上拉电阻打开,这样按键没按下时引脚是高电平,按下时被拉低。
3.3.2 代码实现
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define BUTTON_GPIO GPIO_NUM_0
// 中断服务函数
static void IRAM_ATTR button_isr_handler(void* arg)
{
// 注意:ISR中不能调用printf、vTaskDelay等阻塞函数
// 通常的做法是发一个信号量给任务
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
if (xHigherPriorityTaskWoken) {
portYIELD_FROM_ISR();
}
}
void app_main(void)
{
// 配置按键GPIO
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << BUTTON_GPIO),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
// 创建二值信号量
xSemaphore = xSemaphoreCreateBinary();
// 安装GPIO中断服务
gpio_install_isr_service(0);
// 注册中断回调
gpio_isr_handler_add(BUTTON_GPIO, button_isr_handler, NULL);
// 任务循环处理按键事件
while (1) {
if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
// 软件消抖:延时20ms再检测
vTaskDelay(20 / portTICK_PERIOD_MS);
if (gpio_get_level(BUTTON_GPIO) == 0) {
// 按键确实被按下了
printf("Button pressed!\n");
}
}
}
}
3.4 中断服务函数编写规范
中断服务函数(ISR)的编写,有很多讲究。我见过不少新手把整个业务逻辑塞进ISR,结果系统跑起来各种诡异。这里我总结几条铁律:
- 快进快出:ISR执行时间越短越好,一般控制在几十微秒以内。
- 禁止阻塞:ISR里不能调用
vTaskDelay()、printf()、malloc()等可能阻塞或引起上下文切换的函数。 - 使用IRAM_ATTR:ISR函数必须加
IRAM_ATTR属性,确保代码放在IRAM中,避免flash访问延迟。 - 共享数据加volatile:ISR和主循环共享的变量,一定要用
volatile修饰,防止编译器优化。 - 信号量传递:ISR通过
xSemaphoreGiveFromISR()或xQueueSendFromISR()将事件传递给任务。
我曾经在一个项目中,ISR里直接调用了printf打印调试信息。结果串口驱动在ISR里被重入,直接死机。从那以后,我所有ISR都严格遵守「只发信号,不做业务」的原则。
portENTER_CRITICAL_ISR()和portEXIT_CRITICAL_ISR()保护临界区。不过能不用就不用,保持ISR简洁才是王道。
3.5 本章知识体系
下面这张图,帮你把本章的核心逻辑串起来: