从轮询到中断:用 GPIO0 外部中断控制 DNESP32S3 LED

2026-09-10 00:00    #ESP32-S3   #ESP-IDF   #GPIO   #外部中断   #FreeRTOS   #BSP   #LED  

第 03 课每 10 ms 轮询一次 BOOT 按键。这种写法容易理解,但任务即使按键一年不按,也会一直醒来读取 GPIO。本课跟学《DNESP32S3 使用指南(IDF 版)》第 12 章,改用 GPIO 外部中断:GPIO0 出现下降沿时由 ISR 产生事件,普通 FreeRTOS 任务再完成消抖和 LED 控制。

配套工程位于 examples/04-gpio-interrupt-led/。工程沿用前几课的 GPIO1 红色 LED 和 GPIO0 BOOT 按键,但把中断与业务处理分成两层。

验证范围

工程已按 ESP-IDF v5.5.5 的 API 编写,包含 idf.py set-target esp32s3、构建和烧录所需文件。本文先以源码和构建验收为准;GPIO 按键的消抖效果仍需要在接上目标开发板后人工连续短按、长按验证。

1. 先核对两个引脚

本课没有增加外部接线。正点原子 DNESP32S3 V1.2 的原理图给出:

资源GPIO有效状态本课用途
红色用户 LEDGPIO1低电平点亮观察中断事件是否被接受
BOOT 按键GPIO0按下接地,低电平下降沿触发 GPIO 中断

GPIO0 还是 ESP32-S3 的启动配置脚。开发板复位或上电时一直按住 BOOT,芯片可能进入下载模式;运行程序时先松开按键,再进行实验。

GPIO1 的 LED 电路仍然是 3.3 V → 限流电阻 → LED → GPIO1,所以应用层的 led_set(true) 最终写入低电平。LED 的极性不应从“亮或灭的现象”猜,而应以板卡原理图和 BSP 中的转换为准。

2. 轮询和中断的职责差异

第 03 课的任务大致是:

1每 10 ms 读取 GPIO0
2    ├─ 读到低电平:延时后再次读取,确认一次按下
3    └─ 读到高电平:确认松开,允许下一次按下

中断版本把“什么时候发生了边沿”交给 GPIO 外设:

1GPIO0 下降沿
2    -> GPIO ISR(只投递引脚号)
3    -> FreeRTOS 队列
4    -> 普通任务延时消抖、确认电平
5    -> 翻转 GPIO1 LED,等待按键松开

中断并不自动解决机械抖动。一次真实按下可能产生多个快速边沿,因此 ISR 只负责低成本地记录事件,不能直接把每一个边沿都当作一次业务按键。

3. 工程结构

 104-gpio-interrupt-led/
 2├── CMakeLists.txt
 3├── sdkconfig.defaults
 4├── main/
 5│   ├── CMakeLists.txt
 6│   └── main.c                 # 队列、消抖任务和应用日志
 7└── components/
 8    └── BSP/
 9        ├── CMakeLists.txt
10        ├── LED/
11        │   ├── led.c          # GPIO1、低电平点亮
12        │   └── led.h
13        └── EXIT/
14            ├── exit.c         # GPIO0 配置和 ISR
15            └── exit.h

EXIT 是教材对外部中断驱动的命名。这里的 BSP 接口不暴露 GPIO 中断服务的内部细节,只把事件队列作为初始化参数传入。

main 与 BSP 的依赖在 main/CMakeLists.txt 中声明:

1idf_component_register(SRCS "main.c"
2                    INCLUDE_DIRS "."
3                    PRIV_REQUIRES BSP freertos esp_driver_gpio)

esp_driver_gpio 是 ESP-IDF v5.5 中 GPIO 驱动组件的依赖名;不要把旧版本示例中的组件名原样照抄到新工程。

4. ISR 只投递事件

EXIT 模块保存一个由应用创建的队列句柄。GPIO 服务调用 ISR 时,把 GPIO0 的编号复制到队列中:

 1static QueueHandle_t s_event_queue;
 2
 3static void IRAM_ATTR boot_gpio_isr(void *arg)
 4{
 5    const uint32_t gpio_num = (uint32_t)(uintptr_t)arg;
 6    BaseType_t higher_priority_task_woken = pdFALSE;
 7
 8    xQueueSendFromISR(s_event_queue,
 9                      &gpio_num,
10                      &higher_priority_task_woken);
11    if (higher_priority_task_woken == pdTRUE) {
12        portYIELD_FROM_ISR();
13    }
14}

这里有四个需要同时满足的约束:

  1. ISR 带有 IRAM_ATTR,并通过 ESP_INTR_FLAG_IRAM 安装 GPIO ISR 服务;
  2. 使用 xQueueSendFromISR(),不能在中断上下文调用普通的 xQueueSend();
  3. 队列中的数据是一个简单的整数,避免在 ISR 中分配内存或访问复杂对象;
  4. higher_priority_task_woken 为真时请求一次任务切换,让等待队列的任务尽快运行。

ISR 没有 ESP_LOGI()、vTaskDelay()、gpio_set_level() 或消抖循环。这些操作要么可能阻塞,要么工作量不可控,都不适合放在中断上下文。

5. 配置下降沿和注册服务

exit_init() 先配置 GPIO0,再安装共享 ISR 服务和本引脚的处理函数:

 1const gpio_config_t config = {
 2    .pin_bit_mask = 1ULL << BOOT_INT_GPIO_PIN,
 3    .mode = GPIO_MODE_INPUT,
 4    .pull_up_en = GPIO_PULLUP_ENABLE,
 5    .pull_down_en = GPIO_PULLDOWN_DISABLE,
 6    .intr_type = GPIO_INTR_NEGEDGE,
 7};
 8
 9ESP_ERROR_CHECK(gpio_config(&config));
10ESP_ERROR_CHECK(gpio_install_isr_service(ESP_INTR_FLAG_IRAM));
11ESP_ERROR_CHECK(gpio_isr_handler_add(BOOT_INT_GPIO_PIN,
12                                     boot_gpio_isr,
13                                     (void *)(uintptr_t)BOOT_INT_GPIO_PIN));
14ESP_ERROR_CHECK(gpio_intr_enable(BOOT_INT_GPIO_PIN));

BOOT 松开时内部上拉把 GPIO0 保持为高电平,按下时接地形成高到低的变化,所以使用 GPIO_INTR_NEGEDGE 表示下降沿。

gpio_install_isr_service() 负责安装一个共享的 GPIO 中断服务;gpio_isr_handler_add() 再把 GPIO0 映射到自己的回调。只在同一个应用里安装一次服务,不能每次按键时重复安装。

6. 普通任务完成消抖和业务处理

任务从队列收到事件后,先延时 20 ms,再读取 GPIO0 确认按键仍然为低:

 1if (xQueueReceive(exit_event_queue(), &gpio_num, portMAX_DELAY) != pdTRUE) {
 2    continue;
 3}
 4
 5vTaskDelay(pdMS_TO_TICKS(20));
 6if (gpio_get_level(BOOT_INT_GPIO_PIN) != 0) {
 7    continue;
 8}
 9
10led_on = !led_on;
11ESP_ERROR_CHECK(led_set(led_on));
12ESP_LOGI(TAG, "BOOT press accepted, LED %s",
13         led_on ? "on" : "off");

确认一次有效按下后,任务继续等待 GPIO0 回到高电平:

1while (gpio_get_level(BOOT_INT_GPIO_PIN) == 0) {
2    vTaskDelay(pdMS_TO_TICKS(10));
3}
4vTaskDelay(pdMS_TO_TICKS(20));

这一步使“一直按住 BOOT”只对应一次翻转。等待松开期间如果按键触点抖动再次产生下降沿,事件最多暂存在队列中;松开后的 20 ms 稳定期会把已经失效的边沿过滤掉。

这是一种适合入门实验的消抖策略,不是所有产品都应照搬。高频按键、旋转编码器或需要记录每个边沿的场景,应使用专门的状态机、硬件滤波或定时采样方案,并为队列溢出制定明确策略。

7. 按依赖顺序启动

app_main() 的顺序是“先准备消费者,再打开事件源”:

 1ESP_ERROR_CHECK(led_init());
 2
 3QueueHandle_t event_queue = xQueueCreate(8, sizeof(uint32_t));
 4ESP_ERROR_CHECK(event_queue != NULL ? ESP_OK : ESP_ERR_NO_MEM);
 5
 6ESP_ERROR_CHECK(exit_init(event_queue));
 7ESP_ERROR_CHECK(xTaskCreate(exit_task,
 8                            "exit_task",
 9                            3072,
10                            NULL,
11                            5,
12                            NULL) == pdPASS
13                    ? ESP_OK
14                    : ESP_ERR_NO_MEM);

先创建队列,再把队列交给 exit_init(),最后创建等待队列的任务。GPIO 中断在初始化后就可能发生,所以队列句柄必须在安装 ISR 之前有效。开发板启动时若 BOOT 仍被按住,初始化完成后可能立即产生一个下降沿;实验时应在复位完成前松开按键。

8. 编译、烧录与验收

在工程根目录执行:

1source "$HOME/.espressif/tools/activate_idf_v5.5.5.sh"
2idf.py --version
3idf.py set-target esp32s3
4idf.py build

确认 build 成功后,插拔开发板比较串口列表,再替换实际端口:

1idf.py -p '/dev/cu.开发板实际端口' flash monitor

串口启动后应看到:

1Ready: press BOOT to toggle LED

每次短按 BOOT 应看到一条 BOOT press accepted,并观察红色 LED 改变一次状态。按住 BOOT 两秒,LED 不应连续快速翻转;松开后再次短按,才允许下一次翻转。

如果按键完全没有反应,先检查:

9. 与官方 03_exit 对答案

教材配套的 03_exit 工程同样使用 GPIO0 下降沿、内部上拉、gpio_install_isr_service() 和 gpio_isr_handler_add()。本工程保留这些硬件和 API 关系,但有一处刻意的架构差异:

位置官方示例本工程
ISR直接调用 LED_TOGGLE()用 xQueueSendFromISR() 投递 GPIO 编号
消抖没有软件消抖普通任务延时确认电平并等待松开
LED 控制中断里读写 GPIO任务里调用 led_set()
错误处理驱动函数多为 void初始化返回 esp_err_t,应用用 ESP_ERROR_CHECK()
组件组织官方 BSP 宏和课程注释LED、EXIT 分目录,应用只组合接口

官方实现适合展示“GPIO 中断回调会被调用”。把事件送到任务则更容易扩展:后续可以把同一个队列交给按键、串口或传感器事件消费者,也能在任务中加入日志、消抖和状态机,而不用让 ISR 变复杂。

10. 小结

下一步可以把本课的事件队列与第 05 课的任务、队列和任务通知结合,比较“GPIO ISR → 队列 → LED 任务”和“任务通知”的适用边界。

参考资料