ESP32 -GPIO笔记

从 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)接了这两个脚,那它们就被占了。

实用建议:怎么判断哪些引脚可以自由使用?

拿到一块新开发板,按以下顺序排除:

  1. 排除 Flash/PSRAM 引脚(IO26~IO32)—— 完全不可用
  2. 排除 USB 引脚(IO19/IO20)—— 如果用了 USB 功能
  3. 排除 UART0 引脚(TXD0/RXD0,即 IO43/IO44)—— 烧录和调试要用
  4. 查看开发板原理图,排除板载外设已经占用的引脚
  5. 注意 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 更简洁,少了端口参数,电平直接用 01 表示,不需要 GPIO_PIN_SET / GPIO_PIN_RESET 这样的枚举。

返回值

  • gpio_set_level() 返回 esp_err_t,成功返回 ESP_OK。一般不需要检查,除非你传了非法引脚号。
  • gpio_get_level() 返回 int,值为 01

问题 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() 可以 同上

核心原则

  1. ISR 里不能调用任何可能阻塞的函数
  2. FreeRTOS API 必须用 FromISR 后缀的版本xQueueSendFromISR 而不是 xQueueSend
  3. ISR 函数必须加 IRAM_ATTR
  4. 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 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH0 :white_check_mark: T1 :white_check_mark: :warning: Strapping 引脚(BOOT)
1 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH1 :white_check_mark: T2 :white_check_mark:
2 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH2 :white_check_mark: T3 :white_check_mark:
3 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH3 :white_check_mark: T4 :white_check_mark: :warning: Strapping 引脚
4 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH4 :white_check_mark: T5 :white_check_mark:
5 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH5 :white_check_mark: T6 :white_check_mark:
6 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH6 :white_check_mark: T7 :white_check_mark:
7 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH7 :white_check_mark: T8 :white_check_mark:
8 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH8 :white_check_mark: T9 :white_check_mark:
9 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH9 :white_check_mark: T10 :white_check_mark:
10 :white_check_mark: :white_check_mark: :white_check_mark: ADC1_CH10 :white_check_mark: T11 :white_check_mark:
11 :white_check_mark: :white_check_mark: :white_check_mark: ADC2_CH0 :white_check_mark: T12 :white_check_mark:
12 :white_check_mark: :white_check_mark: :white_check_mark: ADC2_CH1 :white_check_mark: T13 :white_check_mark:
13 :white_check_mark: :white_check_mark: :white_check_mark: ADC2_CH2 :white_check_mark: T14 :white_check_mark:
14 :white_check_mark: :white_check_mark: :white_check_mark: ADC2_CH3 - :white_check_mark:
15 :white_check_mark: :white_check_mark: - - :white_check_mark:
16 :white_check_mark: :white_check_mark: - - :white_check_mark:
17 :white_check_mark: :white_check_mark: - - :white_check_mark:
18 :white_check_mark: :white_check_mark: - - :white_check_mark:
19 :white_check_mark: :white_check_mark: - - :white_check_mark: :warning: USB D-
20 :white_check_mark: :white_check_mark: - - :white_check_mark: :warning: USB D+
21 :white_check_mark: :white_check_mark: - - -
26~32 :cross_mark: :cross_mark: - - - :cross_mark: Flash/PSRAM 占用,不可用
33~37 :white_check_mark: :white_check_mark: - - -
38~42 :white_check_mark: :white_check_mark: - - -
43 :white_check_mark: :white_check_mark: - - - :warning: 默认 U0TXD
44 :white_check_mark: :white_check_mark: - - - :warning: 默认 U0RXD
45 :white_check_mark: :white_check_mark: - - - :warning: Strapping 引脚
46 :white_check_mark: :white_check_mark: - - - :warning: Strapping 引脚
47~48 :white_check_mark: :white_check_mark: - - -

:warning: 标记说明:

  • Strapping:上电时有特殊功能,外部电路不能影响上电电平
  • USB:使用 USB 功能时被占用
  • U0TXD/U0RXD:默认串口,烧录和 monitor 要用

附录 B:ESP32 GPIO 常见踩坑总结

坑 1:pin_bit_mask 写成了引脚号

// :cross_mark: 错误:GPIO_NUM_1 的值是 1,掩码 0x01 选中的是 GPIO0
cfg.pin_bit_mask = GPIO_NUM_1;

// :white_check_mark: 正确
cfg.pin_bit_mask = 1ull << GPIO_NUM_1;

症状: 配的引脚和实际工作的引脚对不上。

坑 2:位掩码忘了 ull

// :cross_mark: 错误:1 是 32 位 int,移 35 位溢出
cfg.pin_bit_mask = 1 << GPIO_NUM_35;

// :white_check_mark: 正确: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

// :cross_mark: 没加 IRAM_ATTR
static void my_isr_handler(void *arg) { … }

// :white_check_mark: 加了
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!”); // :cross_mark: 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()
1 个赞