外观
2.2 GPIO:LED 与按键
这节要搞懂:点亮一颗灯、读一个按键。这是嵌入式的"Hello World"。 需要基础:2.1
一、什么是 GPIO
GPIO(General Purpose Input/Output,通用输入输出)是芯片上可由程序自由配置为输入或输出的引脚。作为输出时,它可以被设定为高电平或低电平;作为输入时,可以读取外部电平的高低。
简单说:芯片边上那一排金属脚,每一个都可以:
- 当输出用:由你决定它输出 3.3V(高)还是 0V(低)→ 点灯、控制模块
- 当输入用:读它现在是高还是低 → 读按键、读开关
二、本课程板子用了哪些引脚 ⭐
这块板子上的器件都已经焊好了,不用自己接线。要知道的只有一件事:哪个器件接在哪个引脚上。
后面每一节的代码都按这张表写。
三颗 LED
| 引脚 | 电路 | |
|---|---|---|
| LED1 | GPIO18 | 正极 → 1kΩ 电阻 → GPIO;负极 → GND |
| LED2 | GPIO39 | 同上 |
| LED3 | GPIO21 | 同上 |

负极接地、正极接 GPIO,所以:
GPIO 输出 1(高电平)→ 有电流流过 → 灯亮
GPIO 输出 0(低电平)→ 两端都是 0V → 灯灭电阻不能省
LED 直接接引脚会烧掉。必须串一个 220Ω ~ 1kΩ 的电阻来限制电流。
三个按键
| 引脚 | 电路 | 内部要开 | 按下读到 | |
|---|---|---|---|---|
| KEY1 | GPIO45 | 3.3V ──[10kΩ]── 按键 ── GPIO45 | 下拉 | 1 |
| KEY2 | GPIO47 | GND ──[10kΩ]── 按键 ── GPIO47 | 上拉 | 0 |
| KEY3 | GPIO48 | GND ──[10kΩ]── 按键 ── GPIO48 | 上拉 | 0 |

这块板子的接法有点特别 ⭐
网上大多数教程里,按键是这么画的:
3.3V
│
[10kΩ] ← 电阻并联在 GPIO 上,起"上拉"作用
│
GPIO ────────────┤
│
按键
│
GND电阻一头接电源、一头接 GPIO,松开时 GPIO 被拉到 3.3V,按下时被按键短接到 GND。这叫并联式,电阻本身就是那个"上拉电阻"。
但这块板子画的是串联式:
GND ──[10kΩ]── 按键 ──── GPIO47电阻串在按键的回路里,和 GPIO 不是并联关系。这带来一个后果:
- 按下时:GPIO 经 10kΩ 接到 GND → 读到 0
- 松开时:回路断开,GPIO 什么都没接上 —— 悬空!读到的是随机值
所以这块板子上,内部上拉/下拉不是"可选项",是必须开。 它负责的是"松开时把引脚拉到一个确定电平"这件事。
对应到代码:
| 电阻另一头接 | 内部要开 | 松开读到 | 按下读到 | |
|---|---|---|---|---|
| KEY1 | 3.3V | GPIO_PULLDOWN_ENABLE | 0 | 1 |
| KEY2 / KEY3 | GND | GPIO_PULLUP_ENABLE | 1 | 0 |
KEY1 和另外两个是反的,写代码时别搞混。
本节只用一颗灯 + 一个按键
一颗灯加一个按键,就足够把原理、代码和用法讲完整了。
GPIO 要讲的就那么几件事 —— 怎么输出高低电平、怎么读引脚电平、上拉下拉怎么配、怎么消抖。这些用一路输出加一路输入全都能演示到,再多接几路也讲不出新东西。
所以这一节只用:
| 引脚 | 说明 | |
|---|---|---|
| LED | GPIO39 | 输出 1 亮 |
| 按键 | GPIO47 | 开内部上拉,按下读 0 |
剩下两颗灯、两个按键电路完全一样,想用的时候改个引脚号就行 —— 只有 KEY1(GPIO45)要记得把内部上拉换成下拉、判断条件从 0 改成 1。
三、点亮一颗 LED
用上面表里的 GPIO39,输出高电平就亮。
c
#include <stdio.h>
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
void led_task(void *arg)
{
(void)arg;
while (1)
{
gpio_set_level(GPIO_NUM_39, 1);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void app_main(void)
{
gpio_config_t led = {
.pin_bit_mask = (1ULL << GPIO_NUM_39),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&led);
xTaskCreate(led_task,"LED",2048,NULL,5,NULL);
}灯不亮怎么办
- LED 有方向,反了不亮 —— 掉个头试试
- 电阻接了吗
- 换个引脚试试(有些脚有特定功能,不能随便用)
四、读一个按键
用上面表里的 KEY2(GPIO47),接法是 GND ──[10kΩ]── 按键 ── GPIO47,内部上拉必须打开。
五、什么是上拉和下拉
上拉电阻(Pull-up Resistor)是连接在信号线与电源之间的电阻,没有其他信号驱动时把引脚拉到高电平。
下拉电阻(Pull-down Resistor)则接在信号线与地之间,把引脚拉到低电平。
为什么必须要它:引脚既没接电源也没接地的时候叫"悬空",读出来是随机值 —— 周围一有干扰就乱跳。
ESP32-S3 内部就自带上拉和下拉电阻,写一行配置就能打开,不用外接。
KEY2(GPIO47)打开内部上拉之后:
松开时:回路断开,内部上拉把引脚拉到 3.3V → 读到 1
按下时:引脚经 10kΩ 接到 GND → 读到 0所以:按下读到的是 0,不是 1。
KEY1 要开的是下拉,不是上拉
KEY1 的电阻另一头接的是 3.3V,方向反过来了:
c
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_ENABLE, // ⭐ KEY1 开下拉松开时:回路断开,内部下拉把引脚拉到 0V → 读到 0
按下时:引脚经 10kΩ 接到 3.3V → 读到 1同一块板子上,KEY1 按下读 1,KEY2/KEY3 按下读 0。 判断条件写反了,现象是"不按的时候一直触发"。
c
#define KEY_GPIO GPIO_NUM_47
static void key_init(void)
{
gpio_config_t config = {
.pin_bit_mask = 1ULL << KEY_GPIO,
.mode = GPIO_MODE_INPUT, // 设为输入
.pull_up_en = GPIO_PULLUP_ENABLE, // ⭐ 打开内部上拉,这行很关键
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&config);
}
// 读取
if (gpio_get_level(KEY_GPIO) == 0) {
printf("key pressed\n"); // 注意:按下是 0
}六、什么是按键抖动
抖动(Bounce)指的是机械按键在按下或松开的瞬间,触点会快速通断多次,持续约 5 ~ 20 毫秒,导致一次按压可能会被识别成多次。
理想: ─────┐
└──────────
实际: ─────┐ ┌┐ ┌┐
└─┘└─┘└──── ← 这段抖动持续 5~20 毫秒消抖的最简单办法:按下后等 20 毫秒再确认一次。
c
void key_task(void *arg)
{
(void)arg;
while (1)
{
if(gpio_get_level(KEY_GPIO) == 0)
{
vTaskDelay(pdMS_TO_TICKS(20));
if(gpio_get_level(KEY_GPIO) == 0)
{
gpio_set_level(LED_GPIO, 1);
}
}
else
{
gpio_set_level(LED_GPIO, 0);
}
vTaskDelay(pdMS_TO_TICKS(10));//没有按下按键就要让出CPU运行空闲任务
}
}看,多任务的好处
闪灯和读按键是两个互不干扰的任务。
灯该闪闪它的,按键该等等它的 —— 这就是第一章 1.4 说的"拆成独立的小程序"。
七、ESP32-S3 引脚使用禁忌 ⭐
这张表能帮你省下几个小时的调试时间。
注意:S3 和老款 ESP32 完全不同
网上大量教程写的是老款 ESP32("别用 6-11"、"34-39 只能输入")——那些规则在 S3 上是错的,照抄会踩坑。
以下是 ESP32-S3 的实际情况。
| 引脚 | 能用吗 | 说明 |
|---|---|---|
| GPIO 26 – 32 | ❌ 绝对不能用 | 接内部 SPI Flash,动了就启动不了 |
| GPIO 22 – 25 | ❌ 根本不存在 | S3 上没有这几个编号 |
| GPIO 33 – 37 | ⚠️ 看模组 | 带 8 线 PSRAM 的模组(如 N16R8)会占用它们 |
| GPIO 19 / 20 | ⚠️ 慎用 | 板载 USB 接口用的 |
| GPIO 43 / 44 | ⚠️ 慎用 | UART0,占了会看不到日志 |
| GPIO 0 / 3 / 45 / 46 | ⚠️ 慎用 | Strapping 脚,影响开机模式 |
| GPIO 39 – 42 | ⚠️ 一般可用 | JTAG 调试用,不接调试器就能用 |
| 其他引脚 | ✅ 放心用 | 推荐:GPIO 1 – 18、21 |
记一句就够
S3 上挑 GPIO 1 到 18,再加一个 21,闭着眼用都不会错。
S3 的一个重要改进:没有"只能输入"的引脚
老款 ESP32 的 GPIO34-39 只能读不能写,还没有内部上拉,接按键很麻烦。
ESP32-S3 取消了这个限制 —— 所有可用的 GPIO 既能输入也能输出,都带内部上下拉。画板子时自由得多。
八、别直接驱动大功率负载
一个引脚最多输出 20 毫安左右,够点一颗 LED。
想控制电机、继电器、大功率灯?必须加三极管或 MOS 管,直接接会烧芯片。
本节要点
- GPIO = 可配置为输入或输出的通用引脚
- LED 必须串电阻
- ⭐ 这块板子的按键是串联式接法,松手时引脚悬空,内部上拉/下拉必须打开
- KEY1(GPIO45)开下拉、按下读 1;KEY2/KEY3(GPIO47/48)开上拉、按下读 0
- 抖动会让一次按压变多次,必须消抖(等 20ms 再确认)
- ⭐ S3 上用 GPIO 1-18 和 21 最安全;GPIO 26-32 接 Flash 绝对不能碰
- ⭐ S3 所有 GPIO 都能输入输出,不像老款 ESP32 有"只能输入"的脚
操作过程
目标:LED 每秒闪一次,按一下按键串口打印一行。
第 1 步:建工程 ⭐ 通用流程
这一步后面每节都一样
第二章每个实验都是独立的小工程,建法完全相同 —— 只有工程名和目标芯片要填对。
后面 2.3 ~ 2.8 会直接引用这里,不再重复。
我们用 VS Code 的 ESP-IDF 插件,点四下就建好,不用敲命令。
① 打开新项目向导
左侧点 ESP-IDF 图标 → 在命令列表里找到最下面的 Advanced → 点 新项目向导。
顶部会弹出"选择要使用的框架",选你装的那个 ESP-IDF 版本。

装了多个版本怎么办
列表里可能有好几个路径。选你平时用的那个就行,本课程用 v5.4.3。
版本差一点问题不大,但至少要 v5.x —— v4 的 API 和课程里写的不一样。
② 选模板
左侧展开 ESP-IDF Templates → 点 sample_project(最简单的空工程)。
右上角点 Create project using template sample_project。

上面那个 ESP-IDF Examples 是什么
是官方例子库,里面有几百个现成的例子。
想直接改现成的就从那里挑 —— 比如 2.8 蓝牙那节用的 bleprph,就是从 Examples 里找的。
本章前几节从空工程开始,代码自己写,学得更扎实。
③ 填工程信息 ⭐ 最关键的一步

| 字段 | 填什么 |
|---|---|
| Project Name | 工程名,本节填 LED(后面每节换成对应的名字) |
| Enter Project directory | 工程存哪,路径里别有中文和空格 |
| Choose ESP-IDF Target | ⭐ esp32s3 —— 千万别选错 |
| Choose ESP-IDF Board | ESP32-S3 chip (via builtin USB-JTAG) |
| Choose serial port | detect(自动检测) |
| Add your ESP-IDF Component directory | 留空 |
填完点右边的 Create Project。
Target 一定要选 esp32s3
默认可能是 esp32(老款)。选错的话编译出来的固件烧进 S3 根本跑不起来,而且现象很迷惑 —— 串口只会不停重启,不会告诉你"芯片选错了"。
这是新手最常踩的第一个坑。
路径里别有中文和空格
G:\项目\我的 工程\ 这种路径,编译时大概率报一堆莫名其妙的错。
用纯英文路径,比如 G:\PROJECT\ESP32\。
④ 打开工程
看到 Project has been created! 就成功了,点 Open Project。

打开之后长这样
LED/
├── CMakeLists.txt
├── main/
│ ├── CMakeLists.txt
│ └── LED.c ← ⭐ 代码写在这里
└── sdkconfig ← 配置文件,向导已经按 esp32s3 生成好了代码就写在 main/ 下那个和工程同名的 .c 文件里。
第 2 步:写代码
把 main/gpio_demo.c 的内容整个替换成:
c
#include <stdio.h>
#include "driver/gpio.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#define LED_GPIO GPIO_NUM_39
#define KEY_GPIO GPIO_NUM_47
void key_task(void *arg)
{
(void)arg;
while (1)
{
if(gpio_get_level(KEY_GPIO) == 0)
{
vTaskDelay(pdMS_TO_TICKS(20));
if(gpio_get_level(KEY_GPIO) == 0)
{
gpio_set_level(LED_GPIO, 1);
}
}
else
{
gpio_set_level(LED_GPIO, 0);
}
vTaskDelay(pdMS_TO_TICKS(10));//没有按下按键就要让出CPU运行空闲任务
}
}
void app_main(void)
{
gpio_config_t led = {
.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(&led);
gpio_set_level(LED_GPIO, 0);
gpio_config_t key = {
.pin_bit_mask = (1ULL << KEY_GPIO),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
gpio_config(&key);
xTaskCreate(key_task,"key",2048,NULL,6,NULL);
}第 3 步:编译烧录 ⭐ 通用流程
这一步后面每节也都一样
和建工程一样,编译烧录的操作每节完全相同,后面 2.3 ~ 2.8 直接引用这里。
所有操作都在 VS Code 最底部那一条状态栏上,从左到右排着一排小图标。
找不到按钮?
鼠标悬停在图标上会显示名称。 下面每一步都会说明对应哪个。
如果整条状态栏都没有,说明工程没被识别 —— 确认你打开的是工程根目录(里面有 CMakeLists.txt 那一层),不是它的上级目录。
前三步是设置,一个工程只需要做一次;之后每次改完代码,只要重复第 ④ ⑤ 两步。
① 选烧录方式
点状态栏上显示 UART 的那个按钮,在弹出的列表里选 UART。

三种方式的区别
| 方式 | 说明 |
|---|---|
| UART | ⭐ 最常用,就是平时插的那根 USB 线 |
| JTAG | 需要调试器,能单步调试 |
| DFU | 特定场景用,新手用不到 |
选 UART 就对了。
② 选串口
点状态栏上显示 COMxx 的按钮,列表里选认得出是你板子的那个。

怎么认出哪个是板子
图里 COM13 后面跟着 ESP32-S3 (QFN56) —— 插件直接告诉你这是块 S3,选它准没错。
其他几个标着 Microsoft 或"标准端口类型"的是电脑自带的,不是板子。
也可以选 detect 让它自动找。
串口被占用
注意看图里终端的报错:
Error: GetOverlappedResult failed (PermissionError(13, ...))
--- Waiting for the device to reconnect...说明串口被别的程序占着了 —— 最常见的是上一次的串口监视器还开着,或者你另外开了个串口助手。
关掉那个再烧。
③ 选单片机型号
点状态栏上显示芯片型号的按钮,列表里选 esp32s3。

列表里 esp32 和 esp32s3 挨得很近
上面第二行是 esp32s2、第三行是 esp32c3…… 看清楚再点,选错了编译出来的固件烧进去跑不起来。
改型号会重建配置
看图里输出窗口那几行:
Set Target to: esp32s3, new sdkconfig will be created.
Existing sdkconfig renamed to sdkconfig.old改目标芯片会重新生成 sdkconfig,你之前在 menuconfig 里改过的东西会被清掉(旧的备份成 sdkconfig.old)。
所以型号要一开始就选对,别等配置改了一堆再回来改型号。
④ 编译
点状态栏上的编译按钮(悬停显示"编译")。

第一次编译要几分钟 —— 它要把整个 ESP-IDF 框架编一遍。之后再编只编改动的部分,几秒就好。
编译成功的标志是输出窗口最后出现:
Build files have been written to: .../LED/build⑤ 烧录
点状态栏上的烧录按钮(悬停显示"烧录/编译后烧录")。

其实可以一步到位
状态栏上还有个**"编译、烧录并监视"的按钮(火焰图标),点它会依次做完编译 → 烧录 → 打开串口监视**。
平时调试用它最省事,这里拆成三步是为了讲清楚每步在干什么。
烧录连不上
如果卡在 Connecting...... 一直连不上:
- 按住板子上的 BOOT 键,再按一下 RST,然后松开 BOOT —— 手动让它进下载模式
- 换一根 能传数据的 USB 线(很多充电线只有电源线芯)
- 确认串口选对了,而且没被别的程序占用
第 4 步:应该看到什么
板子上:LED 亮 0.5 秒、灭 0.5 秒,一直循环。
串口(每按一次键出现一行):
key pressed, count = 1
key pressed, count = 2
key pressed, count = 3看不到就别往下走
LED 不闪,或者按键没打印,先回头检查代码和引脚号,不要接着往下做。
动手试试
- 让 LED 每秒闪一次。
- 加个按键任务:按一下 LED 亮,再按一下灭(用第一章学的队列传消息)。
- 故意把消抖代码去掉,打印按键次数,看看按一下会被识别成几次。
- 查一下你手上开发板的原理图,确认板载 LED 接在哪个引脚。