3. GPIO与外部中断实战:从按键点亮一盏灯说起
GPIO 是嵌入式开发最基础、也最常用的外设。说白了,它就是芯片和外界打交道的「手脚」——要么输出高低电平去控制 LED、继电器,要么读取外部电平来判断按键、传感器状态。而外部中断,则是让芯片「主动响应」外部事件的关键机制。
我个人习惯把 GPIO 和外部中断放在一起讲,因为实际项目中它们几乎总是成对出现。比如你按下一个按键,GPIO 检测到电平变化,中断服务函数立刻响应——这个过程,就是嵌入式系统「感知-响应」的缩影。
3.1 GPIO 基本操作:配置、输出、输入
在 ESP-IDF 里操作 GPIO,核心就三步:配置、写、读。先看配置。
3.1.1 GPIO 配置
ESP-IDF 使用 gpio_config_t 结构体来配置 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);
这里有个细节:pin_bit_mask 是 64 位的位掩码,每个 bit 对应一个 GPIO 引脚。如果你想同时配置多个引脚,用或运算组合就行:
(1ULL << GPIO_NUM_2) | (1ULL << GPIO_NUM_4)
3.1.2 GPIO 输出
输出就两个函数:gpio_set_level() 和 gpio_get_level()。控制 LED 闪烁的经典代码:
while (1) {
gpio_set_level(GPIO_NUM_2, 1); // 高电平,LED 亮
vTaskDelay(1000 / portTICK_PERIOD_MS);
gpio_set_level(GPIO_NUM_2, 0); // 低电平,LED 灭
vTaskDelay(1000 / portTICK_PERIOD_MS);
}
嗯,这里要注意:vTaskDelay 的单位是 tick,用 portTICK_PERIOD_MS 转换一下更直观。我在项目中见过有人直接写 vTaskDelay(1000),结果延时完全不对——因为 tick 周期不一定是 1ms。
3.1.3 GPIO 输入
读取按键状态用 gpio_get_level()。假设按键接在 GPIO0,按下为低电平:
int level = gpio_get_level(GPIO_NUM_0);
if (level == 0) {
ESP_LOGI("MAIN", "Button pressed!");
}
但这里有个坑:机械按键按下时会有抖动,直接读电平会误判。后面我会专门讲消抖。
3.2 外部中断配置:上升沿与下降沿
轮询读取 GPIO 太浪费 CPU 了。更好的方式是让硬件在电平变化时主动通知 CPU——这就是外部中断。
3.2.1 中断类型
ESP32 的 GPIO 支持四种中断触发方式:
| 中断类型 | 宏定义 | 触发条件 |
|---|---|---|
| 上升沿触发 | GPIO_INTR_POSEDGE |
电平从低变高 |
| 下降沿触发 | GPIO_INTR_NEGEDGE |
电平从高变低 |
| 任意沿触发 | GPIO_INTR_ANYEDGE |
电平变化即触发 |
| 低电平触发 | GPIO_INTR_LOW_LEVEL |
低电平持续触发 |
实际项目中,按键常用下降沿触发(按下瞬间电平拉低),编码器常用上升沿和下降沿同时检测。
3.2.2 配置步骤
配置外部中断分三步:
- 配置 GPIO 为输入模式,并设置中断类型
- 安装 GPIO 中断服务:
gpio_install_isr_service(0) - 注册中断处理函数:
gpio_isr_handler_add()
完整代码示例:
// 1. 配置 GPIO0 为输入,下降沿触发
gpio_config_t io_conf = {
.pin_bit_mask = (1ULL << GPIO_NUM_0),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, // 内部上拉,按键接 GND
.intr_type = GPIO_INTR_NEGEDGE // 下降沿触发
};
gpio_config(&io_conf);
// 2. 安装中断服务
gpio_install_isr_service(0);
// 3. 注册中断处理函数
gpio_isr_handler_add(GPIO_NUM_0, button_isr_handler, (void*) "Button0");
gpio_install_isr_service() 只能调用一次!如果你在多个地方调用,第二次会返回 ESP_ERR_INVALID_STATE。正确的做法是在初始化阶段统一安装一次。
3.3 中断服务函数编写
中断服务函数(ISR)有严格的限制:要快、要短、不能阻塞。为什么?因为中断期间,CPU 不响应其他中断,也不调度任务。你在 ISR 里耗 10ms,系统就「死」10ms。
3.3.1 ISR 的基本写法
static void IRAM_ATTR button_isr_handler(void* arg) {
// 这里只能做最轻量的事
uint32_t gpio_num = (uint32_t) arg;
// 比如:设置一个标志位
g_button_pressed = true;
}
注意 IRAM_ATTR 属性——它告诉编译器把这个函数放到 IRAM(指令 RAM)中。因为 Flash 访问在中断里可能不可靠,放在 IRAM 里能保证执行速度和确定性。
3.3.2 ISR 与任务通信
ISR 里不能调用 ESP_LOGI、vTaskDelay 等阻塞函数。那怎么把事件传给主任务?我常用的方式有两种:
- 全局标志位:用
volatile修饰,主循环轮询 - 队列/信号量:用
xQueueSendFromISR()或xSemaphoreGiveFromISR()
举个例子,用队列传递按键事件:
static void IRAM_ATTR button_isr_handler(void* arg) {
xQueueSendFromISR(button_evt_queue, &(uint32_t){GPIO_NUM_0}, NULL);
}
// 主任务中
void button_task(void *pvParameter) {
uint32_t io_num;
while (1) {
if (xQueueReceive(button_evt_queue, &io_num, portMAX_DELAY)) {
ESP_LOGI("TASK", "GPIO %d triggered", io_num);
}
}
}
这样 ISR 只负责「通知」,具体处理交给任务,既快又安全。
3.4 消抖处理实战
机械按键按下时,金属触点会弹跳几次,产生多个脉冲。如果不处理,一次按键可能触发多次中断。这就是「抖动」问题。
3.4.1 硬件消抖
最简单的硬件消抖是在按键两端并联一个 0.1μF 电容。电容充放电会「吸收」弹跳脉冲。但电容会增加响应延迟,不适合高速按键。
3.4.2 软件消抖
软件消抖更灵活,我常用两种方法:
方法一:延时采样法
检测到电平变化后,延时 10-20ms 再读一次。如果电平稳定,才认为是有效按键。
if (gpio_get_level(BUTTON_PIN) == 0) {
vTaskDelay(20 / portTICK_PERIOD_MS); // 延时 20ms
if (gpio_get_level(BUTTON_PIN) == 0) {
// 确认按键按下
}
}
方法二:定时器消抖法
在 ISR 中启动一个软件定时器,延时 20ms 后检查电平。如果电平稳定,再触发事件。这种方法不阻塞主循环,适合多按键场景。
3.5 知识体系总览
下面这张图总结了 GPIO 与外部中断的核心知识点和流程关系:
3.6 实战要点总结
最后,我把本章的关键点列出来,方便你回顾:
- GPIO 配置:用
gpio_config_t结构体,注意位掩码和上下拉设置 - 输出操作:
gpio_set_level()控制电平,vTaskDelay注意 tick 转换 - 输入操作:
gpio_get_level()读取电平,配合消抖使用 - 外部中断:先配置中断类型,再安装 ISR 服务,最后注册处理函数
- ISR 编写:用
IRAM_ATTR,只做轻量操作,通过队列/信号量通知任务 - 消抖:硬件加电容,软件用延时或定时器,二选一或组合使用