上一篇已经把 GPIO1、低电平点亮和 BSP 分层讲清楚。这一篇继续跟学《DNESP32S3 使用指南(IDF 版)》第 11 章:把 BOOT 按键作为 GPIO 输入,每完成一次有效按下,就翻转一次红色用户 LED。
我们不会把“读到低电平”直接当成一次按键动作,而是依次解决三个问题:
- BOOT 按下和松开时,GPIO0 分别是什么电平?
- 机械按键为什么会抖动,怎样确认一次真正的按下?
- 怎样让长按只产生一个事件,并由
main.c用这个事件控制 LED?
配套工程位于 examples/03-boot-key-control-led/。仓库只保留最终工程,正文通过小片段展示从原始电平到按键事件的过程。
配套工程已在 macOS、ESP-IDF v5.5.5 下完成 idf.py set-target esp32s3、idf.py build,并通过 /dev/cu.usbmodem31101 成功烧录到 ESP32-S3。Monitor 已看到应用启动和 Ready: press BOOT to toggle LED;按键事件与红色 LED 翻转仍待人工按键确认。
1. 先读原理图:BOOT 为什么按下为 0
下面是 ATK_DNESP32S3_V1.2.pdf 的局部图。左侧可以看到模组的 IO0 连接 BOOT 网络,右侧可以看到 BOOT 按键按下后会把这条网络接到 GND。

图 1:BOOT 按键连接 GPIO0,按下时接地。图源:正点原子 DNESP32S3 V1.2 原理图。
原理图中没有给 BOOT 画外部上拉电阻。如果把 GPIO0 只配置成普通输入,按键松开后引脚可能处于悬空状态,读数不稳定。因此程序还要开启 ESP32-S3 的内部上拉电阻:
1const gpio_config_t config = {
2 .pin_bit_mask = 1ULL << GPIO_NUM_0,
3 .mode = GPIO_MODE_INPUT,
4 .pull_up_en = GPIO_PULLUP_ENABLE,
5 .pull_down_en = GPIO_PULLDOWN_DISABLE,
6 .intr_type = GPIO_INTR_DISABLE,
7};
有了内部上拉,GPIO0 的电平关系才是确定的:
| BOOT 状态 | 电路状态 | GPIO0 电平 | 程序含义 |
|---|---|---|---|
| 松开 | 内部上拉把 GPIO0 拉向 3.3 V | 1 | 没有按下 |
| 按下 | 按键把 GPIO0 接到 GND | 0 | 按键按下 |
因此 BOOT 是低电平有效的输入。这里的“上拉”只负责让松开状态稳定,它不会把按下后的低电平变成高电平。
GPIO0 不只是一根普通输入引脚。ESP32-S3 会在启动阶段采样它;如果上电或复位时一直按住 BOOT,芯片可能进入下载模式。程序正常运行后再按 BOOT,才能把它当作本实验的用户按键。
2. 读到电平,不等于得到一次按键事件
2.1 先观察原始电平
配置完 GPIO0 后,可以暂时用下面的循环观察它。松开时日志应打印 1,按下时应打印 0:
1while (1) {
2 // 这里只观察原始电平,还没有消抖,也没有“只触发一次”的逻辑。
3 ESP_LOGI("key_raw", "BOOT level = %d", gpio_get_level(GPIO_NUM_0));
4 vTaskDelay(pdMS_TO_TICKS(100));
5}
这段代码只能回答“此刻是高电平还是低电平”。如果直接写成下面这样,按住按键时循环会不断执行,LED 可能连续翻转:
1// 错误示意:低电平会在按住期间持续存在,不代表每次循环都是新按了一次。
2if (gpio_get_level(GPIO_NUM_0) == 0) {
3 led_on = !led_on;
4 led_set(led_on);
5}
我们真正需要的不是“当前为低电平”,而是“一次新的按下动作已经发生”。这就是从电平到事件的转换。
2.2 机械触点为什么需要消抖
按键内部是机械触点。按下或松开的一瞬间,触点可能在几毫秒内反复接通、断开,使 GPIO0 在 0 和 1 之间快速跳变。如果每次跳变都算一次按下,一个动作就可能触发多次。
图 2:第一次读到低电平后等待 10 ms,再次读取仍为低电平,才确认一次按下。
本篇采用最容易理解的延时消抖:
- 第一次读到低电平。
- 让当前任务阻塞约 10 ms,跳过主要抖动阶段。
- 再读一次;仍为低电平才产生按下事件。
- 事件产生后锁住,持续按住不再返回事件。
- 松开后也等待 10 ms 再确认,确认稳定为高电平后解除锁定。
vTaskDelay(pdMS_TO_TICKS(10)) 中的 pdMS_TO_TICKS() 会按工程的 FreeRTOS tick 频率把 10 ms 换算成 tick。这样比直接写 vTaskDelay(10) 更明确,也不会把“10 tick”误认为在任何配置下都是 10 ms。
GPIO 中断还会引入 ISR 限制、队列和中断消抖。这里先用轮询建立输入、电平、消抖和事件的完整认识;中断方式留到后续 EXIT 实验。
3. 从上一篇 BSP 工程继续扩展
3.1 创建第三个工程
如果已经完成上一篇的 02-02-bsp-led,可以创建一个新工程骨架,再复制 LED BSP:
1# 每个新终端先激活 ESP-IDF。
2source "$HOME/.espressif/tools/activate_idf_v5.5.5.sh"
3
4cd "$HOME/esp"
5
6# 创建独立工程,不直接修改上一篇的示例。
7idf.py create-project 03-boot-key-control-led
8cd 03-boot-key-control-led
9mv main/03-boot-key-control-led.c main/main.c
10
11# 新建 BSP 目录,并复用上一篇已经验证过的 LED 模块。
12mkdir -p components/BSP/LED components/BSP/KEY
13cp ../02-02-bsp-led/components/BSP/LED/led.c components/BSP/LED/
14cp ../02-02-bsp-led/components/BSP/LED/led.h components/BSP/LED/
15cp ../02-02-bsp-led/sdkconfig.defaults .
最终目录结构如下:
103-boot-key-control-led/
2├── CMakeLists.txt
3├── sdkconfig.defaults
4├── main/
5│ ├── CMakeLists.txt
6│ └── main.c # 应用层:按键事件驱动 LED 状态
7└── components/
8 └── BSP/
9 ├── CMakeLists.txt
10 ├── LED/ # GPIO1、低电平点亮
11 │ ├── led.c
12 │ └── led.h
13 └── KEY/ # GPIO0、内部上拉、消抖和事件
14 ├── key.c
15 └── key.h
3.2 KEY 对应用层提供什么
在 components/BSP/KEY/key.h 定义事件和接口:
1#pragma once
2
3#include "esp_err.h"
4
5/**
6 * @brief 按键扫描产生的事件。
7 */
8typedef enum {
9 KEY_EVENT_NONE = 0, // 当前没有新的按下事件。
10 KEY_EVENT_BOOT_PRESS, // BOOT 完成了一次有效按下。
11} key_event_t;
12
13/**
14 * @brief 初始化 BOOT 按键对应的 GPIO0 输入和内部上拉。
15 */
16esp_err_t key_init(void);
17
18/**
19 * @brief 轮询 BOOT 按键,并对按下和松开过程进行软件消抖。
20 *
21 * 一次按住只产生一个 KEY_EVENT_BOOT_PRESS;松开并再次按下后,
22 * 才会产生下一个事件。
23 */
24key_event_t key_scan(void);
接口没有把 GPIO0、低电平有效和 10 ms 消抖暴露给 main.c。应用层只需要区分“没有新事件”和“BOOT 完成了一次有效按下”。
这里也没有照抄官方 key_scan(mode) 的连续触发模式。本实验只需要一次按住返回一次事件,删除暂时用不到的模式参数,可以减少调用者必须理解的状态。
3.3 在 KEY BSP 中完成输入与消抖
在 components/BSP/KEY/key.c 写入:
1#include <stdbool.h>
2#include "key.h"
3#include "driver/gpio.h"
4#include "freertos/FreeRTOS.h"
5#include "freertos/task.h"
6
7// DNESP32S3 V1.2 的 BOOT 按键连接 GPIO0,按下时接地。
8#define BOOT_KEY_GPIO GPIO_NUM_0
9#define BOOT_KEY_ACTIVE_LEVEL 0
10#define BOOT_KEY_RELEASED_LEVEL 1
11#define KEY_DEBOUNCE_MS 10
12
13// true 表示已经确认按键处于松开状态,可以接收下一次按下。
14static bool s_key_ready = true;
15
16esp_err_t key_init(void)
17{
18 const gpio_config_t config = {
19 .pin_bit_mask = 1ULL << BOOT_KEY_GPIO,
20 .mode = GPIO_MODE_INPUT,
21 // 原理图未画外部上拉;内部上拉让松开时的 GPIO0 稳定为高电平。
22 .pull_up_en = GPIO_PULLUP_ENABLE,
23 .pull_down_en = GPIO_PULLDOWN_DISABLE,
24 // 本篇使用轮询,中断留到后续 EXIT 实验。
25 .intr_type = GPIO_INTR_DISABLE,
26 };
27
28 s_key_ready = true;
29 return gpio_config(&config);
30}
31
32key_event_t key_scan(void)
33{
34 const int level = gpio_get_level(BOOT_KEY_GPIO);
35
36 if (s_key_ready && level == BOOT_KEY_ACTIVE_LEVEL) {
37 // 第一次读到低电平后等待触点稳定,再进行第二次确认。
38 vTaskDelay(pdMS_TO_TICKS(KEY_DEBOUNCE_MS));
39
40 if (gpio_get_level(BOOT_KEY_GPIO) == BOOT_KEY_ACTIVE_LEVEL) {
41 // 锁住本次按下;持续按住时不再重复产生事件。
42 s_key_ready = false;
43 return KEY_EVENT_BOOT_PRESS;
44 }
45 } else if (!s_key_ready && level == BOOT_KEY_RELEASED_LEVEL) {
46 // 松开同样可能抖动,稳定后再允许下一次按下。
47 vTaskDelay(pdMS_TO_TICKS(KEY_DEBOUNCE_MS));
48
49 if (gpio_get_level(BOOT_KEY_GPIO) == BOOT_KEY_RELEASED_LEVEL) {
50 s_key_ready = true;
51 }
52 }
53
54 return KEY_EVENT_NONE;
55}
s_key_ready 是一个门闩:
- 初始为
true,允许接收一次新的按下。 - 确认按下后改成
false,长按期间不再产生事件。 - 确认松开后恢复
true,下一次按下才能再次触发。
按下和松开都做二次采样,是为了避免释放抖动刚出现一个高电平,就过早解除门闩。
3.4 注册 KEY 与 LED
components/BSP/CMakeLists.txt 同时注册两个模块:
1# BSP 同时包含 LED 输出和 KEY 输入两个模块。
2# 两个模块都使用 GPIO 驱动,KEY 的消抖还会调用 FreeRTOS 延时。
3idf_component_register(SRCS "LED/led.c" "KEY/key.c"
4 INCLUDE_DIRS "LED" "KEY"
5 PRIV_REQUIRES esp_driver_gpio freertos)
INCLUDE_DIRS "LED" "KEY" 把两个目录作为 BSP 的公开头文件搜索目录,所以依赖 BSP 的 main 可以直接包含 led.h 和 key.h。
main/CMakeLists.txt 仍然只依赖 BSP:
1# main 只负责组合 KEY 和 LED 两个 BSP 接口。
2idf_component_register(SRCS "main.c"
3 INCLUDE_DIRS "."
4 PRIV_REQUIRES BSP)
4. 用按下事件翻转 LED
把 main/main.c 替换为:
1#include <stdbool.h>
2#include "esp_check.h"
3#include "esp_log.h"
4#include "freertos/FreeRTOS.h"
5#include "freertos/task.h"
6#include "key.h"
7#include "led.h"
8
9static const char *TAG = "key_demo";
10
11void app_main(void)
12{
13 // LED 初始化后默认熄灭;应用层保存的是“亮/灭”这个逻辑状态。
14 bool led_on = false;
15
16 ESP_ERROR_CHECK(led_init());
17 ESP_ERROR_CHECK(key_init());
18 ESP_LOGI(TAG, "Ready: press BOOT to toggle LED");
19
20 while (1) {
21 // 只有一次经过消抖确认的新按下动作发生时,才会进入 if。
22 if (key_scan() == KEY_EVENT_BOOT_PRESS) {
23 led_on = !led_on;
24 ESP_ERROR_CHECK(led_set(led_on));
25 ESP_LOGI(TAG, "BOOT pressed, LED %s", led_on ? "on" : "off");
26 }
27
28 // 限制轮询频率,让其他 FreeRTOS 任务有机会运行。
29 vTaskDelay(pdMS_TO_TICKS(10));
30 }
31}
这一层的职责现在很清楚:
| 层次 | 知道什么 |
|---|---|
main.c | BOOT 按下后翻转 LED 状态 |
BSP/KEY | GPIO0、输入上拉、低电平有效、消抖、按下事件 |
BSP/LED | GPIO1、输出模式、低电平点亮 |
LED 当前状态由 main.c 保存,因为“按键控制 LED”属于本实验的应用规则。KEY BSP 不直接调用 LED BSP,两个硬件模块因此可以独立复用。
5. 编译、烧录与验收
在工程根目录执行:
1# 每个新终端都要先激活对应版本的 ESP-IDF。
2source "$HOME/.espressif/tools/activate_idf_v5.5.5.sh"
3
4# 新工程首次明确选择 ESP32-S3,然后编译。
5idf.py set-target esp32s3
6idf.py build
macOS 可以插拔开发板并比较设备列表。本机识别到的 ESP32-S3 端口是 /dev/cu.usbmodem31101:
1# 列出 macOS 串口;其他机器应使用自己实际出现的端口。
2find /dev -maxdepth 1 -name 'cu.*' -print | sort
3
4# flash 会自动检查构建,monitor 会在烧录后显示串口日志。
5idf.py -p /dev/cu.usbmodem31101 flash monitor
按 Ctrl+] 退出 Monitor。验收时逐条检查:
- 启动后红色用户 LED 熄灭,日志打印
Ready: press BOOT to toggle LED。 - 短按并松开一次 BOOT,只打印一条
BOOT pressed,LED 只翻转一次。 - 一直按住 BOOT,LED 不会连续翻转,日志不会连续出现。
- 松开后再次按下,才能产生下一条日志和下一次翻转。
- 蓝色
PWR灯始终亮属于正常供电,与 GPIO1 控制的红色用户 LED 无关。
固件写入、Flash 哈希校验、复位启动和应用就绪日志已经通过。尚未在 Monitor 期间完成实体 BOOT 按键操作,因此本文没有把“按键只触发一次”和“红色 LED 翻转”标成已验证。
芯片可能进入下载模式,应用程序不会正常启动。先松开 BOOT,再按 RESET,即可重新运行已经烧录的程序。
6. 和教材官方工程对答案
教材第 11 章和官方 02_key 工程同样使用 BOOT/GPIO0、输入上拉、低电平有效和轮询消抖。本篇有意保留实验现象,同时调整接口与错误处理:
| 项目 | 官方 02_key | 本篇工程 | 原因 |
|---|---|---|---|
| 扫描接口 | key_scan(mode) 返回整数键值 | key_scan() 返回 key_event_t | 当前实验只需要一次按下触发一次 |
| 连续触发 | mode=1 支持长按连续返回 | 不支持 | 避免一个长按多次翻转 LED |
| 消抖延时 | vTaskDelay(10) | vTaskDelay(pdMS_TO_TICKS(10)) | 明确表达 10 ms,不依赖 tick 频率恰好为 1000 Hz |
| 松开过程 | 读到高电平立即解除门闩 | 延时后再次确认高电平 | 避免释放抖动过早解锁 |
| LED 翻转 | BSP 宏读回 GPIO 后翻转 | main.c 保存逻辑状态并调用 led_set() | 应用规则留在应用层 |
| 初始化错误 | GPIO 返回值未传给调用者 | 返回 esp_err_t 并检查 | 初始化失败时给出明确错误 |
教材下载验证写的是“烧录后 LED 初始亮”,但官方 led_init() 实际将低电平有效的 GPIO1 设为高电平,按原理图应当初始熄灭。本篇以原理图和代码为依据,将初始状态设为熄灭,并把实板观察结果作为最终判断。
7. 小结
这一篇新增的核心不是 gpio_get_level(),而是把不稳定、持续存在的物理电平整理成应用层可以安全使用的一次按下事件:
1GPIO0 原始电平
2 ↓ 内部上拉与低电平判断
3按下、松开二次采样
4 ↓ 消抖与门闩
5KEY_EVENT_BOOT_PRESS
6 ↓ main.c 的应用规则
7翻转 LED 逻辑状态
有了这个边界,后续把 BOOT 换成其他按键,或者把 LED 换成蜂鸣器时,应用层都不需要重新理解底层电平细节。
参考资料
- 《DNESP32S3 使用指南(IDF 版)》第 11 章 KEY 实验
- 正点原子
ATK_DNESP32S3_V1.2.pdf原理图与官方02_key工程(本地课程资料) - 乐鑫:ESP-IDF GPIO API
- FreeRTOS:任务延时与时间换算