从 STM32 到 ESP32 —— GPIO 完全指南
第一章:基础认知
问题 1:ESP32 的引脚编号为什么没有端口(GPIOA/B/C)?
STM32 的做法
STM32 的 GPIO 按 端口(Port) 分组,每组 16 个引脚:
GPIOA: PA0 PA1 PA2 … PA15 (16 个)
GPIOB: PB0 PB1 PB2 … PB15 (16 个)
GPIOC: PC0 PC1 PC2 … PC15 (16 个)
…
操作引脚时必须同时指定端口 + 引脚号:
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
// ^^^^^ 端口 ^^^^^ 引脚
这是因为 STM32 硬件上每个端口对应一组独立的寄存器(ODR、IDR、BSRR 等),端口就是寄存器的寻址单位。
ESP32 的做法
ESP32 没有端口概念,所有引脚用 一个连续编号 表示:
GPIO_NUM_0, GPIO_NUM_1, GPIO_NUM_2, … GPIO_NUM_48
操作引脚只需要一个数字:
gpio_set_level(GPIO_NUM_1, 1);
// ^^^^^^^^^^ 一个编号搞定
为什么不同?
这是芯片设计思路的差异:
- STM32 脱胎于传统 MCU 架构,每组端口有独立的硬件寄存器组,分端口管理是硬件结构的直接映射。
- ESP32 是乐鑫从零设计的 SoC,GPIO 控制器是统一的一个模块,用连续编号寻址,没有分组的必要。
对你来说,记忆负担少了——不用再想"这个引脚在哪个端口"。
问题 2:为什么 ESP32 的外设可以映射到任意引脚?
这是从 STM32 转过来最让人困惑的一点。STM32 的 UART1_TX 只能放在 PA9 或 PB6(查数据手册),但 ESP32 的 UART1_TX 可以放在几乎任何一个 GPIO 上。
STM32:复用选择器(AF)
STM32 内部,外设信号到引脚是硬件布线的。每个引脚最多有 16 个复用功能(AF0~AF15),但这些都是出厂时焊死的,你只能从手册规定的选项里选:
PA9 → AF7 = USART1_TX (可以)
PA10 → AF7 = USART1_RX (可以)
PC3 → USART1_TX (不行,手册没有这个映射)
所以每次做 STM32 项目,你都要翻数据手册的引脚复用表来排引脚。
ESP32:GPIO 交换矩阵(GPIO Matrix)
ESP32 内部有一个可编程的硬件交换矩阵,外设信号不直连引脚,而是通过这个矩阵做路由:
┌─────────────────────┐
UART1_TX ─────→│ │──→ IO35 ←── 你指定的
UART1_RX ─────→│ GPIO Matrix │──→ IO36 ←── 你指定的
SPI2_CLK ─────→│ (可编程路由矩阵) │──→ IO12 ←── 你指定的
SPI2_MOSI ────→│ │──→ IO11 ←── 你指定的
└─────────────────────┘
你在代码里告诉矩阵"UART1_TX 走 IO35",矩阵就帮你接上去。明天你想改成 IO37,改一行代码就行。
IOMUX:高速直连通道
GPIO Matrix 虽然灵活,但信号经过矩阵路由会有几纳秒的延迟。对于低速外设(UART、I2C)完全无所谓,但高速外设(SPI 跑 80MHz)可能受不了。
所以 ESP32 还保留了一条直连通道叫 IOMUX,绕过矩阵直接连接,此时引脚就是固定的了——和 STM32 一样:
┌─────────────────────┐
SPI2_CLK ─────→│ GPIO Matrix │──→ 任意引脚(≤40MHz)
└─────────────────────┘
SPI2_CLK ═══════════════════════════════→ IO12(IOMUX 直连,≤80MHz)
| 通道 | 速度 | 引脚 | 适用场景 |
|---|---|---|---|
| GPIO Matrix | 较低(≤40MHz) | 任意 | UART、I2C、LED PWM 等 |
| IOMUX | 最高(≤80MHz) | 固定 | 高速 SPI、LCD 并行接口等 |
你目前学习阶段用到的外设(GPIO、UART、I2C)全部走 GPIO Matrix 就行,不用关心 IOMUX。
代码上的体现
STM32 配串口引脚——在 CubeMX 里选,或者手动写复用:
// STM32:引脚复用是硬件决定的
GPIO_InitStruct.Alternate = GPIO_AF7_USART1; // AF7 = USART1,查手册得知
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
ESP32 配串口引脚——初始化时直接指定:
// ESP32:引脚是你自己选的
uart_config_t cfg = { … };
uart_param_config(UART_NUM_1, &cfg);
uart_set_pin(UART_NUM_1,
GPIO_NUM_35, // TX → 你随便选
GPIO_NUM_36, // RX → 你随便选
-1, -1); // RTS/CTS 不用
不需要查复用表,不需要配 AF,直接告诉驱动"TX 用哪个脚,RX 用哪个脚"。
问题 3:哪些引脚有特殊限制不能随便用?
虽然 GPIO Matrix 很灵活,但不是所有引脚都能随便用。ESP32-S3 有以下几类限制引脚:
一、Strapping 引脚(启动配置引脚)
和 STM32 的 BOOT0/BOOT1 类似,ESP32-S3 在上电瞬间会读取特定引脚的电平来决定启动模式:
| 引脚 | 上电时的作用 | 启动后 |
|---|---|---|
| IO0 | 低电平 = 下载模式,高电平 = 正常运行 | 可正常使用 |
| IO45 | 内部 LDO 电压选择 | 可正常使用 |
| IO46 | ROM 日志输出控制 | 可正常使用 |
| IO3 | JTAG 信号源选择 | 可正常使用 |
启动后这些引脚可以当普通 GPIO 用,但你外接的电路不能影响上电时的电平,否则芯片可能进不了正常模式。
类比 STM32:就像你不能在 BOOT0 上随便接东西一样。
二、仅输入引脚(GPIO34~39 仅限原始 ESP32)
ESP32-S3 没有这个限制,所有 GPIO 都支持输入输出。但如果你以后用原始 ESP32(不带 S3),要注意 GPIO34~39 只能输入,不能输出。
三、Flash/PSRAM 占用引脚
ESP32-S3 的内部 Flash 和 PSRAM(如果有)会占用一部分引脚:
| 引脚 | 占用者 |
|---|---|
| IO26~IO32 | 内置 Flash(SPI0/1) |
这些引脚被 Flash 占用,完全不可用,在引脚列表里你根本看不到它们。
四、USB 固定引脚
| 引脚 | 功能 | 能否复用 |
|---|---|---|
| IO19 | USB_D- | 用了 USB 就不能做别的 |
| IO20 | USB_D+ | 用了 USB 就不能做别的 |
如果开发板上 USB 转串口芯片(如 CH340、CP2102)接了这两个脚,那它们就被占了。
实用建议:怎么判断哪些引脚可以自由使用?
拿到一块新开发板,按以下顺序排除:
- 排除 Flash/PSRAM 引脚(IO26~IO32)—— 完全不可用
- 排除 USB 引脚(IO19/IO20)—— 如果用了 USB 功能
- 排除 UART0 引脚(TXD0/RXD0,即 IO43/IO44)—— 烧录和调试要用
- 查看开发板原理图,排除板载外设已经占用的引脚
- 注意 Strapping 引脚(IO0 等)—— 可以用,但外部电路不能影响上电电平
剩下的就是可以自由使用的引脚。具体到每块开发板,建议对照原理图整理一份自己的引脚分配表。
第一章小结
| STM32 概念 | ESP32 对应 | 区别 |
|---|---|---|
| 端口 + 引脚(PA5) | 直接编号(GPIO_NUM_5) | 无端口,更简单 |
| AF 复用表(查手册) | GPIO Matrix(代码里指定) | 不用查表,任意映射 |
| 高速 GPIO Speed | IOMUX 直连通道 | 高速场景才需要关心 |
| BOOT0/BOOT1 | Strapping 引脚(IO0 等) | 概念相同 |
| 需手动开时钟 RCC | 不需要 | ESP32 省掉了这一步 |
第二章:GPIO 配置
问题 4:ESP32 怎么配置一个输出引脚?
STM32 的做法(HAL 库)
// 1. 开时钟(忘了就不工作,经典踩坑)
__HAL_RCC_GPIOA_CLK_ENABLE();
// 2. 填结构体
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; // 推挽输出
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
// 3. 初始化
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
三步:开时钟 → 填结构体 → 调用 Init。少了开时钟这一步,后面全白干。
ESP32 的做法
// 1. 填结构体
gpio_config_t cfg = {0};
cfg.pin_bit_mask = 1ull << GPIO_NUM_1; // 选择引脚
cfg.mode = GPIO_MODE_OUTPUT; // 输出模式
cfg.pull_up_en = GPIO_PULLUP_DISABLE; // 不上拉
cfg.pull_down_en = GPIO_PULLDOWN_DISABLE; // 不下拉
cfg.intr_type = GPIO_INTR_DISABLE; // 不用中断
// 2. 应用配置
gpio_config(&cfg);
两步:填结构体 → 调用 gpio_config()。不需要开时钟。
逐字段对比
| 功能 | STM32 | ESP32 |
|---|---|---|
| 选引脚 | .Pin = GPIO_PIN_5 |
.pin_bit_mask = 1ull << GPIO_NUM_1 |
| 方向 | .Mode = GPIO_MODE_OUTPUT_PP |
.mode = GPIO_MODE_OUTPUT |
| 上下拉 | .Pull = GPIO_NOPULL |
.pull_up_en + .pull_down_en 分开设置 |
| 速度 | .Speed = GPIO_SPEED_FREQ_LOW |
无此选项(通过 gpio_set_drive_capability() 单独设置,一般不需要) |
| 中断 | 不在 GPIO Init 里配 | .intr_type 直接在结构体里配 |
| 开时钟 | __HAL_RCC_GPIOx_CLK_ENABLE() |
不需要 |
| 应用 | HAL_GPIO_Init(GPIOA, &cfg) |
gpio_config(&cfg) |
注意 ESP32 把上拉和下拉拆成了两个独立开关,不像 STM32 是一个枚举三选一(NOPULL / PULLUP / PULLDOWN)。ESP32 这样做是因为硬件上允许同时开启上拉和下拉(虽然一般不会这么用)。
问题 5:ESP32 怎么配置一个输入引脚?
和配输出几乎一样,只是把 mode 改成 GPIO_MODE_INPUT:
gpio_config_t cfg = {0};
cfg.pin_bit_mask = 1ull << GPIO_NUM_0; // 选择引脚
cfg.mode = GPIO_MODE_INPUT; // 输入模式
cfg.pull_up_en = GPIO_PULLUP_ENABLE; // 开上拉
cfg.pull_down_en = GPIO_PULLDOWN_DISABLE;
cfg.intr_type = GPIO_INTR_DISABLE;
gpio_config(&cfg);
上拉 / 下拉 / 浮空 对比
| 配置 | STM32 | ESP32 |
|---|---|---|
| 浮空输入 | .Pull = GPIO_NOPULL |
pull_up_en = DISABLE, pull_down_en = DISABLE |
| 上拉输入 | .Pull = GPIO_PULLUP |
pull_up_en = ENABLE, pull_down_en = DISABLE |
| 下拉输入 | .Pull = GPIO_PULLDOWN |
pull_up_en = DISABLE, pull_down_en = ENABLE |
典型场景:按键接地,松开时需要保持高电平 → 开内部上拉。这一点和 STM32 完全一样。
什么时候用 GPIO_MODE_INPUT_OUTPUT?
你可能在前面的 LED 代码里看到过 GPIO_MODE_INPUT_OUTPUT,这不是"又输入又输出"的意思,而是:输出的同时可以回读引脚当前电平。
| 模式 | 能写? | 能读? | 用途 |
|---|---|---|---|
GPIO_MODE_INPUT |
否 | 是 | 纯输入(按键、传感器信号) |
GPIO_MODE_OUTPUT |
是 | 否 | 纯输出(不需要回读) |
GPIO_MODE_INPUT_OUTPUT |
是 | 是 | 输出 + 回读(确认引脚实际状态) |
类比 STM32:STM32 的推挽输出模式下也可以读 IDR 寄存器来回读电平,只是 ESP32 把这个能力显式地分成了不同的模式。
问题 6:为什么 pin_bit_mask 要用位掩码而不是直接写引脚号?
这是新手最常见的困惑。STM32 的 GPIO_PIN_5 其实也是位掩码(值是 0x0020,即 1 << 5),只是被宏藏起来了。ESP32 没藏,直接让你写:
cfg.pin_bit_mask = 1ull << GPIO_NUM_1;
为什么用位掩码?
因为可以一次配多个引脚。如果你有 3 个 LED 分别在 IO1、IO2、IO3,不需要写三遍配置:
// 一次性配置 3 个引脚
cfg.pin_bit_mask = (1ull << GPIO_NUM_1) |
(1ull << GPIO_NUM_2) |
(1ull << GPIO_NUM_3);
cfg.mode = GPIO_MODE_OUTPUT;
gpio_config(&cfg); // 三个引脚同时配好
STM32 也能这样做:GPIO_InitStruct.Pin = GPIO_PIN_5 | GPIO_PIN_6 | GPIO_PIN_7;,原理完全一样。
为什么要用 1ull 而不是 1?
1 << GPIO_NUM_35 // 错误!1 是 32 位 int,左移 35 位溢出
1ull << GPIO_NUM_35 // 正确!1ull 是 64 位,可以移到 48 位
ESP32-S3 有 48 个 GPIO,所以掩码需要 64 位。ull = unsigned long long,保证是 64 位。这是初学者最容易踩的坑——用 1 代替 1ull,配 GPIO0~GPIO31 没问题,配 GPIO32 以上就出 bug。
常见错误
// 错误 1:直接写引脚号(不是位掩码)
cfg.pin_bit_mask = GPIO_NUM_1; // GPIO_NUM_1 = 1,实际选中的是 GPIO0!
// 错误 2:忘了 ull
cfg.pin_bit_mask = 1 << GPIO_NUM_35; // 32 位溢出,未定义行为
// 正确写法
cfg.pin_bit_mask = 1ull << GPIO_NUM_1; // 选中 GPIO1
问题 7:ESP32 有没有推挽 / 开漏模式?
有。和 STM32 一样,ESP32 支持推挽和开漏两种输出模式:
| ESP32 模式 | STM32 对应 | 说明 |
|---|---|---|
GPIO_MODE_OUTPUT |
GPIO_MODE_OUTPUT_PP |
推挽输出,能主动拉高拉低 |
GPIO_MODE_OUTPUT_OD |
GPIO_MODE_OUTPUT_OD |
开漏输出,只能拉低,拉高靠外部上拉 |
GPIO_MODE_INPUT_OUTPUT |
推挽 + 可回读 | 推挽输出 + 可读回引脚电平 |
GPIO_MODE_INPUT_OUTPUT_OD |
开漏 + 可回读 | 开漏输出 + 可读回引脚电平 |
什么时候用开漏?
和 STM32 一样的场景:
- I2C 总线 —— I2C 协议要求开漏 + 外部上拉(不过用 ESP-IDF 的 I2C 驱动会自动配好,不需要你手动设置)
- 多设备共享信号线 —— 线与逻辑,任何一个设备拉低就是低电平
- 电平不匹配 —— ESP32 是 3.3V,外部设备是 5V,开漏 + 外部上拉到 5V 可以实现电平转换
日常学习阶段,LED、按键这些用默认的 GPIO_MODE_OUTPUT(推挽)就够了,不需要关心开漏。
第二章小结
| 操作 | STM32 | ESP32 |
|---|---|---|
| 配置流程 | 开时钟 → 填结构体 → Init | 填结构体 → gpio_config() |
| 选引脚 | GPIO_PIN_x(宏藏了位掩码) |
1ull << GPIO_NUM_x(显式位掩码) |
| 上下拉 | 一个字段三选一 | 两个独立开关 |
| 输出模式 | 推挽 / 开漏 | 推挽 / 开漏(一样) |
| 回读能力 | 输出模式下读 IDR | 需要用 INPUT_OUTPUT 模式 |
| 中断 | 单独配 EXTI | 结构体里直接配 intr_type |
| 速度/驱动能力 | .Speed 字段 |
单独函数 gpio_set_drive_capability() |
第三章:GPIO 操作
问题 8:怎么读写引脚电平?
STM32 的做法
// 写
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); // 拉高
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); // 拉低
// 读
GPIO_PinState state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0);
每次都要写端口 + 引脚,参数多。
ESP32 的做法
// 写
gpio_set_level(GPIO_NUM_1, 1); // 拉高
gpio_set_level(GPIO_NUM_1, 0); // 拉低
// 读
int level = gpio_get_level(GPIO_NUM_0);
对比一目了然:
| 操作 | STM32 | ESP32 |
|---|---|---|
| 写高 | HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) |
gpio_set_level(GPIO_NUM_1, 1) |
| 写低 | HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET) |
gpio_set_level(GPIO_NUM_1, 0) |
| 读 | HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_0) |
gpio_get_level(GPIO_NUM_0) |
ESP32 的 API 更简洁,少了端口参数,电平直接用 0 和 1 表示,不需要 GPIO_PIN_SET / GPIO_PIN_RESET 这样的枚举。
返回值
gpio_set_level()返回esp_err_t,成功返回ESP_OK。一般不需要检查,除非你传了非法引脚号。gpio_get_level()返回int,值为0或1。
问题 9:ESP32 有没有像 STM32 那样的 GPIO 翻转(Toggle)函数?
没有。
STM32 HAL 提供了翻转函数:
HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); // 高变低,低变高
ESP-IDF 没有 gpio_toggle_level() 这样的 API。你需要自己实现,有两种方式:
方式一:用变量记录状态
static uint8_t led_state = 0;
led_state ^= 1; // 异或翻转
gpio_set_level(GPIO_NUM_1, led_state);
这是最常用的做法,简单可靠。
方式二:回读引脚电平再取反
// 前提:引脚必须配成 GPIO_MODE_INPUT_OUTPUT,否则读不到
int level = gpio_get_level(GPIO_NUM_1);
gpio_set_level(GPIO_NUM_1, !level);
这种方式不需要额外变量,但引脚必须配成 INPUT_OUTPUT 模式才能回读。这也是为什么有些 LED 驱动代码用 GPIO_MODE_INPUT_OUTPUT 而不是 GPIO_MODE_OUTPUT 的原因之一。
问题 10:怎么一次操作多个引脚?
STM32 的做法
STM32 可以直接写端口寄存器,一次操作同一端口下的多个引脚:
// 同时拉高 PA5 和 PA6
GPIOA->BSRR = GPIO_PIN_5 | GPIO_PIN_6;
因为同一端口共享寄存器,所以一条写操作就能同时生效。
ESP32 的做法
ESP-IDF 没有提供批量操作 GPIO 的高层 API。gpio_set_level() 每次只能操作一个引脚:
// 只能逐个设置
gpio_set_level(GPIO_NUM_1, 1);
gpio_set_level(GPIO_NUM_2, 1);
gpio_set_level(GPIO_NUM_3, 1);
这三行代码不是严格同时执行的,中间有几十纳秒的间隔。对于 LED、继电器这些场景完全没影响,但如果你需要多个引脚严格同时翻转(比如并行总线时序),可以直接操作寄存器:
#include “soc/gpio_reg.h”
// GPIO0~31:写 GPIO_OUT_W1TS_REG 批量置 1,写 GPIO_OUT_W1TC_REG 批量置 0
REG_WRITE(GPIO_OUT_W1TS_REG, (1 << 1) | (1 << 2) | (1 << 3)); // IO1/2/3 同时拉高
// GPIO32~48:用 GPIO_OUT1_W1TS_REG / GPIO_OUT1_W1TC_REG
| 寄存器 | 功能 | 作用范围 |
|---|---|---|
GPIO_OUT_W1TS_REG |
置 1(拉高) | GPIO0~31 |
GPIO_OUT_W1TC_REG |
置 0(拉低) | GPIO0~31 |
GPIO_OUT1_W1TS_REG |
置 1(拉高) | GPIO32~48 |
GPIO_OUT1_W1TC_REG |
置 0(拉低) | GPIO32~48 |
和 STM32 的 BSRR 寄存器原理完全一样——写 1 的位生效,写 0 的位不变。
不过日常开发中极少需要这样做,逐个 gpio_set_level() 就够了。
第三章小结
| 操作 | STM32 | ESP32 |
|---|---|---|
| 写电平 | HAL_GPIO_WritePin() 三个参数 |
gpio_set_level() 两个参数 |
| 读电平 | HAL_GPIO_ReadPin() |
gpio_get_level() |
| 翻转 | HAL_GPIO_TogglePin() |
无内置函数,手动异或 |
| 批量操作 | 写 BSRR 寄存器 | 逐个调用,或写 GPIO_OUT_W1TS/W1TC_REG |
第四章:中断
问题 11:ESP32 的 GPIO 中断怎么配?
STM32 的做法
STM32 配 GPIO 中断很繁琐,涉及多个模块:
// 1. 开 GPIO 时钟
__HAL_RCC_GPIOA_CLK_ENABLE();
// 2. 配置引脚为输入
GPIO_InitTypeDef GPIO_InitStruct = {0};
GPIO_InitStruct.Pin = GPIO_PIN_0;
GPIO_InitStruct.Mode = GPIO_MODE_IT_FALLING; // 下降沿中断
GPIO_InitStruct.Pull = GPIO_PULLUP;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
// 3. 配置 NVIC 优先级
HAL_NVIC_SetPriority(EXTI0_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(EXTI0_IRQn);
// 4. 写中断服务函数(文件名、函数名都是固定的)
void EXTI0_IRQHandler(void) {
HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}
// 5. 写回调函数
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin) {
if (GPIO_Pin == GPIO_PIN_0) {
// 你的逻辑
}
}
涉及:GPIO 配置 → EXTI 线映射 → NVIC 优先级 → IRQHandler → Callback,分散在多个文件里。
ESP32 的做法
ESP32 把中断配置集中在一起,步骤更少:
// 1. 配置引脚(中断类型直接写在结构体里)
gpio_config_t cfg = {0};
cfg.pin_bit_mask = 1ull << GPIO_NUM_0;
cfg.mode = GPIO_MODE_INPUT;
cfg.pull_up_en = GPIO_PULLUP_ENABLE;
cfg.intr_type = GPIO_INTR_NEGEDGE; // 下降沿触发
gpio_config(&cfg);
// 2. 安装 GPIO 中断服务(整个程序只需调用一次)
gpio_install_isr_service(0);
// 3. 给具体引脚挂上中断处理函数
gpio_isr_handler_add(GPIO_NUM_0, my_isr_handler, NULL);
// ^^^^^^^^^^^^^^ ^^^^
// 你的回调函数 传给回调的参数
中断处理函数:
static void IRAM_ATTR my_isr_handler(void *arg)
{
// 你的逻辑(注意 ISR 里的限制,见问题 12)
}
流程对比
| 步骤 | STM32 | ESP32 |
|---|---|---|
| 引脚配置 + 中断类型 | GPIO Init(Mode 里选 IT_xxx) | gpio_config()(intr_type 字段) |
| 中断控制器 | 配 NVIC 优先级 + 使能 | gpio_install_isr_service(0) 一行搞定 |
| 挂回调函数 | 固定函数名 EXTI0_IRQHandler + Callback |
gpio_isr_handler_add() 自由指定函数 |
| 中断线映射 | EXTI 线和引脚有对应关系,部分引脚共享 | 不存在,每个引脚独立 |
中断触发类型对比
| 触发方式 | STM32 | ESP32 |
|---|---|---|
| 上升沿 | GPIO_MODE_IT_RISING |
GPIO_INTR_POSEDGE |
| 下降沿 | GPIO_MODE_IT_FALLING |
GPIO_INTR_NEGEDGE |
| 双边沿 | GPIO_MODE_IT_RISING_FALLING |
GPIO_INTR_ANYEDGE |
| 高电平 | 无(STM32 GPIO 不支持电平触发) | GPIO_INTR_HIGH_LEVEL |
| 低电平 | 无 | GPIO_INTR_LOW_LEVEL |
ESP32 比 STM32 多了电平触发模式。电平触发的意思是:只要引脚保持在指定电平,中断就会持续触发,而不是只触发一次。一般用得少,知道有这个东西就行。
IRAM_ATTR 是什么?
你会注意到中断函数前面有个 IRAM_ATTR:
static void IRAM_ATTR my_isr_handler(void *arg)
要理解这个东西,需要先搞清楚芯片内部的内存结构。如果你对 Flash、RAM 这些概念还不太熟悉,下面从头讲起。
前置知识:芯片里的两种存储器
不管 STM32 还是 ESP32,芯片需要存两样东西:
| 存什么 | 举例 | 要求 |
|---|---|---|
| 程序代码 | 你写的 app_main()、gpio_set_level() 编译后的机器指令 |
断电不能丢 |
| 运行数据 | 变量、数组、函数调用栈 | 断电丢了没关系 |
对应两种存储器:
| 名字 | 干什么的 | 断电后 | 生活类比 |
|---|---|---|---|
| Flash(闪存) | 存程序代码 | 不丢 | 手机的 128GB 存储空间(存 APP、照片) |
| RAM(运行内存) | 存运行时的变量 | 丢了 | 手机的 8GB 运行内存(APP 运行时占用) |
注:STM32 资料里常看到 SRAM,它就是 RAM 的一种,你直接当 RAM 理解就行。
STM32 的内存结构:全在芯片里面
STM32 把 Flash 和 RAM 都做在芯片内部,CPU 想读代码就直接读,想存变量就直接存:
┌──────────── STM32 芯片(一颗) ─────────────┐
│ │
│ CPU ──直接读──→ 片内 Flash(存代码,最大 2MB)│
│ ──直接读写→ 片内 RAM(存变量,最大 512KB)│
│ │
└──────────────────────────────────────────────┘
一颗芯片,自给自足,简单直接。
ESP32 的内存结构:Flash 在芯片外面
ESP32 不一样。它要跑 WiFi + 蓝牙协议栈,代码量很大(随便一个 WiFi 项目就几百 KB),如果把大容量 Flash 做进芯片内部,成本太高。所以 ESP32 把 Flash 放在芯片外面,是开发板上单独的一颗小芯片:
┌──── ESP32 芯片(主芯片) ────┐ ┌──── Flash 芯片(旁边那颗小的) ────┐
│ │ SPI │ │
│ CPU │←──总线──→│ 存放你的程序代码 │
│ │ 连接 │ 4MB ~ 16MB │
│ 片内 RAM(520KB) │ │ │
│ ├── IRAM(放少量代码) │ └───────────────────────────────────┘
│ └── DRAM(放变量) │
│ │
└──────────────────────────────┘
CPU 要执行代码,得通过 SPI 总线去外部 Flash 芯片里读。就像你的电脑从硬盘里加载程序到内存一样,只不过这里是通过 SPI 连接。
对比总结
| STM32 | ESP32 | |
|---|---|---|
| Flash(存代码) | 在芯片里面,CPU 直接读 | 在芯片外面,通过 SPI 总线读 |
| RAM(存变量) | 在芯片里面(叫 SRAM) | 在芯片里面(分 IRAM 和 DRAM) |
| 烧录程序烧到哪 | 片内 Flash | 片外 Flash 芯片 |
现在可以理解 IRAM_ATTR 了
因为 ESP32 的代码在外部 Flash 里,CPU 每次执行代码都要通过 SPI 总线去读。
问题来了:如果你的程序正在写 Flash(比如保存 WiFi 密码),SPI 总线就被占着。这时候中断来了,CPU 要跳去执行中断函数,但中断函数也在外部 Flash 里,也需要 SPI 总线——总线被占了,读不了,崩溃。
IRAM_ATTR 的作用:告诉编译器把这个函数放在芯片内部的 IRAM(不是外部 Flash)里。这样中断来了,CPU 直接从内部 RAM 读取执行,不依赖 SPI 总线,不管 Flash 忙不忙都没问题。
普通函数 → 放在外部 Flash → 需要 SPI 总线读取 → Flash 忙时可能出问题
中断函数 → 加 IRAM_ATTR → 放在片内 IRAM → 直接读取,永远不会出问题
STM32 不需要这个标记,因为代码本来就在片内 Flash,CPU 直接读,不存在总线冲突的问题。
结论:ESP32 的中断函数一律加 IRAM_ATTR,养成习惯就行。
完整示例:按键中断控制 LED
#include “driver/gpio.h”
#include “freertos/FreeRTOS.h”
#include “freertos/queue.h”
static QueueHandle_t gpio_evt_queue = NULL;
/* 中断处理函数:只发消息,不做实际逻辑 */
static void IRAM_ATTR key_isr_handler(void *arg)
{
uint32_t gpio_num = (uint32_t)arg;
xQueueSendFromISR(gpio_evt_queue, &gpio_num, NULL);
}
/* 任务:从队列取消息,执行实际逻辑 */
static void key_task(void *arg)
{
uint32_t gpio_num;
while (1) {
if (xQueueReceive(gpio_evt_queue, &gpio_num, portMAX_DELAY)) {
// 收到中断消息,翻转 LED
static uint8_t led_state = 0;
led_state ^= 1;
gpio_set_level(GPIO_NUM_1, led_state);
}
}
}
void app_main(void)
{
/* 配置 LED 输出 */
gpio_config_t led_cfg = {
.pin_bit_mask = 1ull << GPIO_NUM_1,
.mode = GPIO_MODE_OUTPUT,
};
gpio_config(&led_cfg);
/* 配置按键输入 + 下降沿中断 */
gpio_config_t key_cfg = {
.pin_bit_mask = 1ull << GPIO_NUM_0,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE,
};
gpio_config(&key_cfg);
/* 创建队列 + 安装中断 + 挂回调 */
gpio_evt_queue = xQueueCreate(10, sizeof(uint32_t));
gpio_install_isr_service(0);
gpio_isr_handler_add(GPIO_NUM_0, key_isr_handler, (void *)GPIO_NUM_0);
xTaskCreate(key_task, “key_task”, 2048, NULL, 10, NULL);
}
这个例子展示了 ESP32 中断的推荐用法:ISR 里只发队列消息,实际逻辑放在任务里处理。原因见下一个问题。
问题 12:中断里能做什么不能做什么?
这一点和 STM32 类似但更严格。
STM32 的 ISR 规则
STM32 裸机开发中,ISR 里通常的建议是"尽量短",但实际上你在 ISR 里干什么都可以(操作外设、串口发数据、改变量),只是可能影响实时性。
ESP32 的 ISR 规则
ESP32 运行的是 FreeRTOS,ISR 的限制更严格:
| 操作 | 能不能做 | 原因 |
|---|---|---|
| 改一个全局变量 | 可以 | 简单内存操作 |
gpio_set_level() |
可以 | 该函数是 ISR 安全的 |
printf() / ESP_LOGI() |
不可以 | 涉及 Flash 读取和动态内存分配 |
gpio_config() |
不可以 | 非 ISR 安全函数 |
vTaskDelay() |
不可以 | ISR 里不能阻塞 |
xQueueSendFromISR() |
可以 | 带 FromISR 后缀的是 ISR 安全版本 |
xSemaphoreGiveFromISR() |
可以 | 同上 |
核心原则
- ISR 里不能调用任何可能阻塞的函数
- FreeRTOS API 必须用
FromISR后缀的版本(xQueueSendFromISR而不是xQueueSend) - ISR 函数必须加
IRAM_ATTR - ISR 里调用的所有函数也必须在 IRAM 中(包括你自己写的辅助函数)
推荐模式:ISR + 队列 + 任务
按键按下
↓
ISR 触发 → xQueueSendFromISR() 发一条消息 (快,几微秒)
↓
任务收到消息 → 执行实际逻辑(串口打印、控制外设…)(慢操作放这里)
这就是上面完整示例采用的模式,也是 ESP-IDF 官方推荐的做法。STM32 用 FreeRTOS 时也是同样的套路,如果你之前在 STM32 上用过 FreeRTOS,这部分应该很熟悉。
第四章小结
| 对比项 | STM32 | ESP32 |
|---|---|---|
| 中断配置位置 | GPIO Init + NVIC + EXTI,分散多处 | gpio_config() + gpio_install_isr_service() + gpio_isr_handler_add(),集中配置 |
| 中断函数名 | 固定(EXTI0_IRQHandler) |
自由指定(通过函数指针) |
| 触发类型 | 上升沿 / 下降沿 / 双边沿 | 上升沿 / 下降沿 / 双边沿 / 高电平 / 低电平 |
| ISR 特殊要求 | 无特别要求 | 必须加 IRAM_ATTR |
| ISR 内限制 | 建议尽量短 | 严格限制:不能阻塞、不能 printf、必须用 FromISR 版本 API |
第五章:进阶
问题 13:ESP32 需要像 STM32 那样手动开时钟吗?
不需要。
这是从 STM32 转过来最容易多想的一点。
STM32 的做法
STM32 的每个外设都有独立的时钟开关,用之前必须手动打开,否则外设完全不工作:
// 忘了这一行,后面全白干
__HAL_RCC_GPIOA_CLK_ENABLE();
__HAL_RCC_USART1_CLK_ENABLE();
__HAL_RCC_I2C1_CLK_ENABLE();
这是 STM32 初学者最经典的坑——配置写得完美,就是忘了开时钟,外设死活不动。
ESP32 的做法
ESP32 没有手动开时钟这个步骤。你调用驱动的初始化函数(比如 gpio_config()、uart_param_config()),驱动内部会自动处理时钟使能。
// ESP32:直接配置,不需要提前开时钟
gpio_config_t cfg = { … };
gpio_config(&cfg); // 内部自动处理好了
所以你在 ESP-IDF 的代码里永远看不到类似 RCC_CLK_ENABLE 的东西。少了这一步,也就少了一个出 bug 的地方。
问题 14:GPIO 的驱动能力怎么设置?
STM32 的做法
STM32 在 GPIO 初始化结构体里有个 Speed 字段:
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; // 2MHz
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_MEDIUM; // 12.5MHz
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; // 50MHz
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH; // 100MHz
Speed 本质上控制的是引脚的驱动能力(输出电流大小)和翻转速率。Speed 越高,翻转越快,但功耗和电磁干扰也越大。
ESP32 的做法
ESP32 的 gpio_config() 结构体里没有 Speed 字段。驱动能力通过单独的函数设置:
gpio_set_drive_capability(GPIO_NUM_1, GPIO_DRIVE_CAP_0); // 最弱,约 5mA
gpio_set_drive_capability(GPIO_NUM_1, GPIO_DRIVE_CAP_1); // 较弱,约 10mA
gpio_set_drive_capability(GPIO_NUM_1, GPIO_DRIVE_CAP_2); // 默认,约 20mA
gpio_set_drive_capability(GPIO_NUM_1, GPIO_DRIVE_CAP_3); // 最强,约 40mA
也可以读取当前的驱动能力:
gpio_drive_cap_t cap;
gpio_get_drive_capability(GPIO_NUM_1, &cap);
对比
| STM32 | ESP32 | |
|---|---|---|
| 设置方式 | 初始化结构体里的 .Speed 字段 |
单独调用 gpio_set_drive_capability() |
| 默认值 | 低速 | GPIO_DRIVE_CAP_2(约 20mA) |
| 档位 | 4 档 | 4 档 |
实际开发中很少需要改这个设置。 默认的驱动能力足够点亮 LED、驱动大部分外设。只有在以下场景才需要调整:
- 需要驱动较大负载(比如直接驱动蜂鸣器)→ 调高
- 想降低功耗或减少电磁干扰 → 调低
问题 15:低功耗模式下 GPIO 怎么保持状态?
这个问题在 STM32 上很少遇到,但在 ESP32 上很重要,因为 ESP32 经常用在电池供电的 WiFi / 蓝牙设备上,需要进入深度睡眠(Deep Sleep)省电。
问题是什么?
ESP32 进入 Deep Sleep 后,主 CPU 完全关闭,GPIO 的配置会全部丢失,引脚状态变为不确定。
这会导致一个现实问题:比如你的 GPIO 控制着一个电源开关,进入睡眠前你把它拉高(打开电源),结果一进 Deep Sleep,引脚状态丢了,电源就被意外关掉了。
STM32 的情况
STM32 的低功耗模式(Stop / Standby)下,GPIO 行为取决于模式:
- Stop 模式:GPIO 状态保持,不丢失
- Standby 模式:GPIO 状态丢失(和 ESP32 Deep Sleep 类似)
ESP32 的解决方案:RTC GPIO
ESP32 内部有一个 RTC(实时时钟)模块,在 Deep Sleep 期间它还活着(靠极低功耗运行)。部分 GPIO 引脚连接到了这个 RTC 模块,叫做 RTC GPIO。
通过 RTC GPIO 功能,你可以在 Deep Sleep 期间保持引脚电平或者用引脚唤醒芯片。
保持引脚电平
#include “driver/gpio.h”
// 正常配置引脚并设置电平
gpio_config_t cfg = {
.pin_bit_mask = 1ull << GPIO_NUM_1,
.mode = GPIO_MODE_OUTPUT,
};
gpio_config(&cfg);
gpio_set_level(GPIO_NUM_1, 1); // 拉高
// 进入 Deep Sleep 前:告诉 RTC 模块保持这个引脚的电平
gpio_hold_en(GPIO_NUM_1); // 锁定当前电平
// 还需要开启 Deep Sleep 期间的引脚保持功能
gpio_deep_sleep_hold_en();
// 现在进入 Deep Sleep,GPIO1 会保持高电平
esp_deep_sleep_start();
唤醒后如果要重新控制这个引脚,需要先解锁:
gpio_hold_dis(GPIO_NUM_1); // 解锁,恢复正常控制
用 GPIO 唤醒芯片
#include “esp_sleep.h”
// 配置 GPIO0 为唤醒源:低电平唤醒(比如按键按下)
esp_deep_sleep_enable_gpio_wakeup(1ull << GPIO_NUM_0, ESP_GPIO_WAKEUP_GPIO_LOW);
esp_deep_sleep_start(); // 进入睡眠,按下按键就会醒来
哪些引脚支持 RTC GPIO?
不是所有引脚都支持,ESP32-S3 的 RTC GPIO 引脚:
| RTC GPIO 编号 | 对应的普通 GPIO |
|---|---|
| RTC_GPIO0 | GPIO0 |
| RTC_GPIO1 | GPIO1 |
| RTC_GPIO2 | GPIO2 |
| RTC_GPIO3 | GPIO3 |
| RTC_GPIO4 | GPIO4 |
| RTC_GPIO5 | GPIO5 |
| RTC_GPIO6 | GPIO6 |
| RTC_GPIO7 | GPIO7 |
| RTC_GPIO8 | GPIO15 |
| RTC_GPIO9 | GPIO16 |
| RTC_GPIO10 | GPIO17 |
| RTC_GPIO11 | GPIO18 |
| RTC_GPIO12 | GPIO8 |
| RTC_GPIO13 | GPIO19 |
| RTC_GPIO14 | GPIO20 |
| RTC_GPIO15 | GPIO9 |
| RTC_GPIO16 | GPIO10 |
| RTC_GPIO17 | GPIO11 |
| RTC_GPIO18 | GPIO12 |
| RTC_GPIO19 | GPIO13 |
| RTC_GPIO20 | GPIO14 |
只有这些引脚才能在 Deep Sleep 期间保持电平或作为唤醒源。如果你需要低功耗功能,选引脚时要注意选 RTC GPIO 支持的引脚。
对比总结
| STM32 | ESP32 | |
|---|---|---|
| 低功耗模式 | Stop / Standby | Light Sleep / Deep Sleep |
| GPIO 保持 | Stop 模式自动保持 | 需要手动调用 gpio_hold_en() + gpio_deep_sleep_hold_en() |
| GPIO 唤醒 | 通过 EXTI + WKUP 引脚 | 通过 RTC GPIO,调用 esp_deep_sleep_enable_gpio_wakeup() |
| 支持的引脚 | 特定 WKUP 引脚 | RTC GPIO 引脚(约 21 个) |
学习阶段不需要关心低功耗。 等你做电池供电的项目时再回来看这部分就行。
第五章小结
| 对比项 | STM32 | ESP32 |
|---|---|---|
| 手动开时钟 | 必须(RCC_CLK_ENABLE),忘了外设不工作 |
不需要,驱动内部自动处理 |
| 驱动能力 | .Speed 字段,在初始化结构体里 |
gpio_set_drive_capability(),单独设置 |
| 低功耗 GPIO 保持 | Stop 模式自动保持 | 需要手动锁定(gpio_hold_en) |
| GPIO 唤醒 | WKUP 引脚 | RTC GPIO 引脚 |
附录
附录 A:ESP32-S3 GPIO 功能速查表
每个引脚能干什么,一张表看完:
| GPIO | 输入 | 输出 | ADC | 触摸 | RTC GPIO | 备注 |
|---|---|---|---|---|---|---|
| 0 | ||||||
| 1 | ||||||
| 2 | ||||||
| 3 | ||||||
| 4 | ||||||
| 5 | ||||||
| 6 | ||||||
| 7 | ||||||
| 8 | ||||||
| 9 | ||||||
| 10 | ||||||
| 11 | ||||||
| 12 | ||||||
| 13 | ||||||
| 14 | - | |||||
| 15 | - | - | ||||
| 16 | - | - | ||||
| 17 | - | - | ||||
| 18 | - | - | ||||
| 19 | - | - | ||||
| 20 | - | - | ||||
| 21 | - | - | - | |||
| 26~32 | - | - | - | |||
| 33~37 | - | - | - | |||
| 38~42 | - | - | - | |||
| 43 | - | - | - | |||
| 44 | - | - | - | |||
| 45 | - | - | - | |||
| 46 | - | - | - | |||
| 47~48 | - | - | - |
标记说明:
- Strapping:上电时有特殊功能,外部电路不能影响上电电平
- USB:使用 USB 功能时被占用
- U0TXD/U0RXD:默认串口,烧录和 monitor 要用
附录 B:ESP32 GPIO 常见踩坑总结
坑 1:pin_bit_mask 写成了引脚号
//
错误:GPIO_NUM_1 的值是 1,掩码 0x01 选中的是 GPIO0
cfg.pin_bit_mask = GPIO_NUM_1;
//
正确
cfg.pin_bit_mask = 1ull << GPIO_NUM_1;
症状: 配的引脚和实际工作的引脚对不上。
坑 2:位掩码忘了 ull
//
错误:1 是 32 位 int,移 35 位溢出
cfg.pin_bit_mask = 1 << GPIO_NUM_35;
//
正确:1ull 是 64 位
cfg.pin_bit_mask = 1ull << GPIO_NUM_35;
症状: GPIO0~31 正常,GPIO32 以上的引脚配不上。编译不报错,运行时也不报错,很难排查。
坑 3:输出模式下读不到引脚电平
cfg.mode = GPIO_MODE_OUTPUT;
gpio_config(&cfg);
gpio_set_level(GPIO_NUM_1, 1);
int level = gpio_get_level(GPIO_NUM_1); // 可能读到 0!
原因: GPIO_MODE_OUTPUT 模式下不保证能回读电平。
解决: 如果需要回读,用 GPIO_MODE_INPUT_OUTPUT。
坑 4:用了被 Flash 占用的引脚(GPIO26~32)
cfg.pin_bit_mask = 1ull << GPIO_NUM_27; // 这个引脚被内部 Flash 占着
gpio_config(&cfg); // 可能导致 Flash 异常,芯片崩溃
症状: 程序崩溃、反复重启、Flash 读写异常。
解决: 永远不要使用 GPIO26~32。
坑 5:Strapping 引脚外接了低电平设备
// GPIO0 外接了一个传感器,传感器上电时输出低电平
// → ESP32 上电时读到 GPIO0 = 低电平 → 进入下载模式 → 程序不运行
症状: 开发板上电后不运行程序,串口一直输出 waiting for download。
解决: Strapping 引脚外接设备时,确保上电瞬间不会被拉到错误电平。最简单的方法是换一个非 Strapping 引脚。
坑 6:中断函数没加 IRAM_ATTR
//
没加 IRAM_ATTR
static void my_isr_handler(void *arg) { … }
//
加了
static void IRAM_ATTR my_isr_handler(void *arg) { … }
症状: 大部分时候正常,偶尔崩溃(在 Flash 操作期间触发中断时)。这种 bug 很难复现,可能测试一百次才遇到一次。
解决: 中断函数一律加 IRAM_ATTR,养成习惯。
坑 7:中断里调用了 printf / ESP_LOGI
static void IRAM_ATTR my_isr_handler(void *arg)
{
ESP_LOGI(TAG, “key pressed!”); //
ISR 里不能这样做
}
症状: 程序崩溃,串口输出一堆寄存器信息(panic backtrace)。
解决: ISR 里只做简单操作(改变量、发队列),打印放到任务里。
坑 8:Deep Sleep 后引脚电平丢失
gpio_set_level(GPIO_NUM_1, 1); // 拉高
esp_deep_sleep_start(); // 进入睡眠 → GPIO1 电平不确定了
症状: 进入 Deep Sleep 后,外部设备被意外断电或状态改变。
解决: 睡眠前调用 gpio_hold_en() + gpio_deep_sleep_hold_en() 锁定电平。
附录 C:ESP-IDF GPIO API 速查
| 函数 | 作用 | STM32 对应 |
|---|---|---|
gpio_config(&cfg) |
配置引脚(方向、上下拉、中断) | HAL_GPIO_Init() |
gpio_set_level(pin, level) |
设置输出电平 | HAL_GPIO_WritePin() |
gpio_get_level(pin) |
读取引脚电平 | HAL_GPIO_ReadPin() |
gpio_set_direction(pin, mode) |
单独改变引脚方向 | 重新调 HAL_GPIO_Init() |
gpio_set_pull_mode(pin, mode) |
单独改变上下拉 | 重新调 HAL_GPIO_Init() |
gpio_set_drive_capability(pin, cap) |
设置驱动能力 | .Speed 字段 |
gpio_get_drive_capability(pin, &cap) |
读取驱动能力 | 无 |
gpio_install_isr_service(flags) |
安装 GPIO 中断服务 | HAL_NVIC_EnableIRQ() |
gpio_isr_handler_add(pin, fn, arg) |
给引脚挂中断回调 | 写 EXTIx_IRQHandler() |
gpio_isr_handler_remove(pin) |
移除引脚中断回调 | HAL_NVIC_DisableIRQ() |
gpio_intr_enable(pin) |
使能引脚中断 | HAL_NVIC_EnableIRQ() |
gpio_intr_disable(pin) |
禁用引脚中断 | HAL_NVIC_DisableIRQ() |
gpio_hold_en(pin) |
锁定引脚电平(用于 Deep Sleep) | 无直接对应 |
gpio_hold_dis(pin) |
解锁引脚电平 | 无直接对应 |
gpio_deep_sleep_hold_en() |
使能 Deep Sleep 期间引脚保持 | 无直接对应 |
gpio_reset_pin(pin) |
重置引脚为默认状态 | ``HAL_GPIO_DeInit() |