ESP32-S3 SPI总线配置实战:从接线到FreeRTOS并发管理 很多玩 ESP32-S3 的小伙伴第一次被 SPI 难住往往不是死在协议栈上而是死在“接线”和“总线配置”上。屏幕花屏了读 Flash 全是 0xFF系统一开 WiFi 外设就抽风这些问题查到最后十有八九是 SPI 的引脚选择、时钟极性或者设备注册参数不对。尤其当你的项目还在用 FreeRTOS 做多任务调度时SPI 的并发访问问题会进一步放大。我的一个明确判断是在 ESP-IDF 框架里SPI 的 API 本身并不复杂真正的难点只有三件事——选对引脚、配好总线、管好并发。只要把这三件事想清楚SPI 读写 Flash、驱动屏幕、采集传感器数据都能很快跑通而且不容易留下隐性问题。这篇文章就以 ESP32-S3 为例从 SPI 物理接线开始完整讲解 ESP-IDF 中 SPI_bus 的配置流程。文中会以 ST7789 3.5 寸屏幕和 W25Q64 Flash 两个常见外设作为实战对象给出可以直接复制的 C 语言代码也会把三线 SPI、硬件片选与软件片选、DMA 这几个高频概念讲清楚。读完之后你可以对照自己的开发板把 SPI 部分完整串起来。1. 这篇文章真正要解决的问题先说说为什么要专门写 SPI。在嵌入式 AI 物联网项目里屏幕显示、模型参数存储、传感器数据采集、外部 Flash 日志记录几乎都会用到 SPI。ESP32-S3 本身已经有很强的处理能力但数据进出总得有条通道SPI 就是那条在“速度”和“资源占用”之间最平衡的通道。很多新手把 SPI 当成 I2C 来学觉得无非就是两根线发数据接上就能通。实际开发中真正花时间的环节是看从机数据手册确认时序模式确认主控的 SPI 控制器资源避开已经被 Flash、PSRAM、USB-JTAG 占用的引脚再处理多个从机共享总线时的片选逻辑。任何一个环节出错表现都是“代码能编译、运行不报错、数据就是不对”。所以这篇文章要解决的问题很具体看懂外设接线图确认 SCLK、MOSI、MISO、CS 分别接哪个引脚。用 ESP-IDF 的spi_bus_initialize和spi_bus_add_device正确初始化 SPI 总线并理解每个参数的含义。在 FreeRTOS 多任务环境中安全地共享 SPI 总线避免数据交叉和超时。只要你把这三点吃透以后再接任何 SPI 设备基本就是换个数据手册和寄存器指令的事。2. SPI 协议核心概念与 ESP32-S3 的 SPI 外设2.1 四线 SPI 的工作机制SPI 是一种同步全双工通信协议英文全称是 Serial Peripheral Interface。它最少需要四根线SCLK串行时钟由主机产生。MOSI主机输出、从机输入。MISO主机输入、从机输出。CS片选信号通常是低电平有效由主机控制。通信时主机控制时钟在每个时钟边沿把 MOSI 上的数据移入从机同时从 MISO 上读回从机输出的数据。所以 SPI 的读写是同时进行的这是它比 I2C 快的一个重要原因。CS 的作用是“点名”。总线上可以挂多个从机但同一时刻只能有一个从机的 CS 被拉低。其他从机的 CS 保持高电平不参与通信。这个逻辑和 I2C 的地址寻址不同理解这一点对后面配置硬件片选和软件片选很有帮助。2.2 SPI 的四种工作模式SPI 设备必须约定两个时序参数时钟极性 CPOL 和时钟相位 CPHA。CPOL 决定空闲时 SCLK 是高还是低CPHA 决定数据在哪个边沿被采样。组合起来就是 Mode 0 到 Mode 3。模式CPOLCPHA空闲时钟电平数据采样边沿Mode 000低电平第一个边沿上升沿采样Mode 101低电平第二个边沿下降沿采样Mode 210高电平第一个边沿下降沿采样Mode 311高电平第二个边沿上升沿采样大多数 Flash 芯片和 LCD 控制器默认支持 Mode 0 或 Mode 3。实际项目里如果你不确定从机支持哪种模式先从 Mode 0 开始试再根据读回来的数据判断。判断方法很简单读一个固定寄存器比如 Flash 的 JEDEC ID如果读出来和芯片手册一致说明模式和速率都没问题。如果读出来是 0xFF 或者乱码先把时钟频率降到 1 MHz 再试排除了速率问题后基本就是模式不对。2.3 ESP32-S3 的 SPI 外设资源ESP32-S3 内部有多个 SPI 控制器但并不是所有都能拿来做通用 SPI。SPI0 和 SPI1专门用来访问片内 Flash 和 PSRAM不能作为通用 SPI 使用。SPI2 和 SPI3可以作为通用 SPI 控制器使用在代码中通常引用为SPI2_HOST和SPI3_HOST。这里的SPI2_HOST是 ESP-IDF 定义的主机编号不是芯片物理引脚编号。一个 SPI 主机可以挂多个设备只要每个设备有独立的 CS 引脚即可。另外一个很关键的机制是 GPIO 矩阵。ESP32-S3 几乎可以把任意 GPIO 映射为 SCLK、MOSI、MISO 等 SPI 信号。这在硬件设计上非常灵活但也带来了一个麻烦引脚选择太多反而容易选到被其他功能占用的脚。后面接线部分我会详细说。2.4 硬件片选与软件片选片选 CS 有两种实现方式。硬件片选在spi_device_interface_config_t里指定spics_io_num由 ESP32-S3 的 SPI 外设控制器自动控制 CS 电平。发送数据前自动拉低发送完成后自动拉高。这种方式时序可靠推荐默认使用。软件片选把spics_io_num设为 -1然后自己用普通 GPIO 控制 CS。这种方式适合 CS 信号需要和普通 GPIO 联动、或者多个设备共用 CS 逻辑的场合。但代价是你需要保证 GPIO 的置位/清零与 SPI 传输之间没有交叉干扰。具体来说多个任务同时操作同一个软件 CS 时会存在竞态必须自己加锁。从项目稳定性角度只要硬件设计允许优先用硬件片选。软件片选看起来省引脚实际会让驱动代码复杂不少。3. 硬件接线从数据手册到真实连线3.1 接线前必须搞清的三件事拿到一块新的 SPI 从机模块不要急着接线。先确认三件事电平是否匹配。ESP32-S3 的 GPIO 是 3.3V 电平。如果从机模块是 5V 供电但 SCLK、MOSI 等信号线没有电平转换电路就不能直接连否则可能损坏芯片。从机支持的最高时钟频率。这个在数据手册里会写比如 W25Q64 支持最高 80 MHz但实际项目里要考虑信号完整性和 PCB 走线。时序模式。前面说过先看手册里的时序图确认是 Mode 0、1、2 还是 3。很多模块的原理图会标注 SPI 引脚直接照着接即可。但如果你用的是市面上常见的开发板还要注意板载器件已经占用了哪些引脚。比如 ESP32-S3 的 Octal PSRAM 如果启用部分 GPIO 就不能再作为通用 SPI 数据线使用。3.2 以 ST7789 3.5 寸屏幕为例3.5 寸 SPI 屏幕是嵌入式项目里最常见的显示设备。以 ST7789 或 ST7796 控制器为例模块上一般会引出这些引脚VCC、GND、SCLK、MOSI、MISO、CS、DC、RESET、BL。接线时可以参考下表屏幕引脚ESP32-S3 GPIO说明VCC3.3V注意背光供电可能需要 5VGNDGND共地必须接SCLKGPIO12SPI 时钟MOSIGPIO11主机输出MISOGPIO13多数 LCD 不读数据可不接CSGPIO10硬件片选DCGPIO9命令/数据选择普通 GPIORESETGPIO14复位引脚普通 GPIOBL3.3V 或 GPIO背光控制可直连高电平注意一个细节SPI LCD 通常只需要主机写数据不需要从机回数据所以 MISO 可以不接。这种“SCLK MOSI CS/DC”的接法在很多资料里被称为三线 SPI 或 SPI 写屏模式。这里的“三线”并不是说只需要三根线而是指 SPI 数据方向上去掉了读回通路。DC 引脚是 LCD 驱动里特有的它不属于标准 SPI 协议而是用来告诉屏幕控制器“当前字节是命令还是数据”。DC 为低时发命令DC 为高时发数据。如果你用的是 9 位 SPI 模式也可以把 DC 作为第 9 位数据嵌入到 SPI 字节流里但驱动会复杂一些。更常见的做法仍然是 DC 单独拉一个 GPIO。3.3 以 W25Q64 Flash 为例外挂 SPI Flash 在物联网项目里很常见用来存模型参数、OTA 固件、日志记录等。以 W25Q64 为例接线如下Flash 引脚ESP32-S3 GPIO说明CSGPIO10片选CLKGPIO12时钟MOSIGPIO11主机输出、从机输入MISOGPIO13主机输入、从机输出VCC3.3V供电GNDGND共地和 LCD 不同Flash 必须接 MISO因为读数据是 Flash 的核心功能。你不仅要写更要读比如上电后读取上次保存的 WiFi 配置、模型参数。这里引出一个实际规划问题如果屏幕和 Flash 都挂在同一路 SPI 总线上SCLK、MOSI、MISO 可以共用CS 必须分开。也就是说两个设备可以共用 GPIO12、GPIO11、GPIO13但屏幕的 CS 用 GPIO10Flash 的 CS 用 GPIO15 或其它空闲引脚。3.4 接线可靠性与信号完整性SPI 是板级通信协议设计上就不适合长距离传输。你可能在教程里看到有人用几十厘米杜邦线跑几十 MHz 还能工作但那属于运气好不应该是工程设计的依据。实际项目里SPI 时钟超过 10 MHz 时建议缩短连接线尽量使用 PCB 走线。如果实在要用杜邦线做原型验证把时钟频率降到 1 MHz 到 4 MHz 之间先确认功能正确再逐步提速。花屏、偶发读错、CS 波形畸变很多时候不是代码问题而是飞线太长、接触不良、信号反射造成的。4. ESP-IDF 环境准备与工程结构4.1 安装 ESP-IDF 与版本选择SPI 相关代码基于 ESP-IDF 的spi_master驱动。ESP-IDF 的版本迭代比较快v4.4 和 v5.x 之间的 API 有少量差异但spi_bus_initialize、spi_bus_add_device这些核心函数保持了相似的用法。标题里提到的“2026 ESP-IDF”可以理解为项目长期迭代的版本规划。实际开发时以你安装的稳定版本为准就行。本文示例以 ESP-IDF v5.x 为主如果你用的是 v4.4注意 DMA 通道参数写法略有不同后面会说明。安装方式推荐两种方式一VS Code Espressif IDF 插件这种方式对新手最友好。在 VS Code 里搜索安装 Espressif IDF 插件打开命令面板执行ESP-IDF: Configure ESP-IDF Extension选择下载或者指定本地 ESP-IDF 路径。插件会自动下载工具链、OpenOCD、QEMU 等组件。常见的一个提示是“not yet activated”。这通常是插件没有找到 IDF 路径或者 IDE 终端没有加载 IDF 环境变量。解决办法是重启 VS Code或者手动指定正确的 ESP-IDF 目录再执行ESP-IDF: Set ESP-IDF Path。方式二命令行安装在 Linux 或 macOS 上可以使用以下命令mkdir -p ~/esp cd ~/esp git clone --recursive https://github.com/espressif/esp-idf.git cd esp-idf ./install.sh esp32s3 source export.shWindows 命令行用户可以使用官方提供的 ESP-IDF Tools Installer安装完成后在开始菜单里打开 “ESP-IDF CMD”里面已经配置好了环境变量。4.2 创建工程用idf.py创建工程非常简单idf.py create-project spi_demo cd spi_demo idf.py set-target esp32s3创建完成后工程结构大致如下spi_demo/ ├── CMakeLists.txt ├── main/ │ ├── CMakeLists.txt │ └── spi_demo.c └── sdkconfig其中main/spi_demo.c是主程序文件我们之后的核心代码都写在这里。sdkconfig是工程配置会在第一次编译时自动生成。4.3 C 语言与 FreeRTOS 在 ESP-IDF 中的角色ESP-IDF 的 API 全部是 C 语言接口。SPI 驱动相关的结构体、宏定义、函数声明都在driver/spi_master.h中。你不需要额外添加第三方组件因为spi_master属于 ESP-IDF 内置驱动。FreeRTOS 也是 ESP-IDF 内置的实时操作系统。app_main函数本身运行在一个 FreeRTOS 任务中默认栈大小通常足够跑 SPI 初始化。如果你要开更多任务可以在app_main里用xTaskCreate或xTaskCreatePinnedToCore创建。ESP-IDF 的 FreeRTOS 支持双核任务调度因此可以指定任务跑在哪个核心上。5. SPI_bus 配置完整代码实现5.1 第一步定义 spi_bus_config_tESP-IDF 的 SPI 配置分两层总线层和设备层。总线层描述的是 SCLK、MOSI、MISO 这些共享信号设备层描述的是某个从机的 CS、速率、工作模式。先看总线配置结构体#include driver/spi_master.h #include driver/gpio.h #include esp_log.h #include freertos/FreeRTOS.h #include freertos/task.h #define PIN_SCLK GPIO_NUM_12 #define PIN_MOSI GPIO_NUM_11 #define PIN_MISO GPIO_NUM_13 #define PIN_CS_LCD GPIO_NUM_10 #define PIN_DC GPIO_NUM_9 #define PIN_RST GPIO_NUM_14 spi_bus_config_t buscfg { .sclk_io_num PIN_SCLK, .mosi_io_num PIN_MOSI, .miso_io_num PIN_MISO, .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 4096, };这里每个字段的含义需要理解清楚sclk_io_numSCLK 时钟信号对应的 GPIO 编号。mosi_io_num主机输出从机输入信号线的 GPIO。miso_io_num主机输入从机输出信号线的 GPIO。如果设备不支持读可以设为 -1。quadwp_io_num和quadhd_io_numQSPI 模式使用的写保护引脚和保持引脚。标准 SPI 模式下不需要必须设为 -1。max_transfer_sz最大传输字节数。如果后续要用 DMA 刷屏或读写大块数据这个值要大于等于单次最大传输长度。5.2 第二步初始化总线 spi_bus_initialize总线配置好之后调用spi_bus_initialize把配置注册到系统中esp_err_t ret spi_bus_initialize(SPI2_HOST, buscfg, SPI_DMA_CH_AUTO); ESP_ERROR_CHECK(ret);第一个参数是主机编号这里用SPI2_HOST。第二个参数是总线配置结构体指针。第三个参数是 DMA 通道。在 ESP-IDF v5.x 中推荐使用SPI_DMA_CH_AUTO让驱动自动分配一个可用的 DMA 通道。在 v4.4 中你可能需要写SPI_DMA_CH1或SPI_DMA_CH2。如果你完全不用 DMA可以传 0但一般不建议因为 SPI 刷屏、读写 Flash 都很需要 DMA 来减轻 CPU 负担。5.3 第三步注册设备 spi_bus_add_device总线初始化完成后还需要把具体的从机设备注册到总线上spi_device_interface_config_t devcfg { .clock_speed_hz 10 * 1000 * 1000, .mode 0, .spics_io_num PIN_CS_LCD, .queue_size 4, }; spi_device_handle_t spi_dev; ret spi_bus_add_device(SPI2_HOST, devcfg, spi_dev); ESP_ERROR_CHECK(ret);设备配置结构体的关键字段clock_speed_hz通信时钟频率单位 Hz。这里设置为 10 MHz开发时建议先用低频验证。modeSPI 工作模式范围 0 到 3。LCD 通常支持 Mode 0 或 Mode 3需要看具体模块手册。spics_io_num硬件片选引脚。如果不想用硬件片选改为 -1后续手动控制 CS。queue_size事务队列大小。驱动内部会用队列保存待发送的 SPI 事务队列满时发送函数会返回超时。队列越大越能容忍突发的大量 SPI 请求但也越耗内存。spi_bus_add_device成功后会返回一个spi_device_handle_t句柄。后面所有发送操作都通过这个句柄完成。5.4 完整示例一读取 W25Q64 Flash 的状态寄存器先看一个最小可运行的完整示例。假设单片 W25Q64 Flash 挂在 SPI2 总线上CS 接 GPIO10读取它的状态寄存器// 文件路径main/spi_flash_demo.c #include stdio.h #include esp_log.h #include driver/spi_master.h static const char *TAG spi_flash; void app_main(void) { spi_bus_config_t buscfg { .sclk_io_num GPIO_NUM_12, .mosi_io_num GPIO_NUM_11, .miso_io_num GPIO_NUM_13, .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 4096, }; spi_device_interface_config_t devcfg { .clock_speed_hz 10 * 1000 * 1000, .mode 0, .spics_io_num GPIO_NUM_10, .queue_size 4, }; spi_device_handle_t flash_dev; esp_err_t ret spi_bus_initialize(SPI2_HOST, buscfg, SPI_DMA_CH_AUTO); ESP_ERROR_CHECK(ret); ret spi_bus_add_device(SPI2_HOST, devcfg, flash_dev); ESP_ERROR_CHECK(ret); uint8_t cmd 0x05; // W25Q 的 Read Status Register 命令 uint8_t rx 0x00; spi_transaction_t t { .length 8, // 传输位数为 8 位 .tx_buffer cmd, // 发送缓冲区 .rx_buffer rx, // 接收缓冲区 }; ret spi_device_transmit(flash_dev, t); ESP_ERROR_CHECK(ret); ESP_LOGI(TAG, Flash Status Register 0x%02X, rx); }这段代码的逻辑是SPI 全双工发送 0x05 命令字节的同时从 MISO 上接收到从机返回的一个字节。对 W25Q 系列 Flash 来说状态寄存器内容会被放到这个字节里。编译烧录后正常情况下日志输出类似I (352) spi_flash: Flash Status Register 0x00状态寄存器最低位是 BUSY 位。刚上电时如果 Flash 正在执行内部操作读出来可能是 0x01 或 0x03。如果读出来一直是 0xFF大概率是接线或模式问题。5.5 完整示例二向 ST7789 屏幕写命令屏幕驱动的完整代码量比较大但核心机制是命令与数据切换。这里演示最基础的两个动作初始化 GPIO 控制 DC 脚以及发送一条命令。// 文件路径main/lcd_cmd_demo.c #include driver/spi_master.h #include driver/gpio.h #include esp_log.h #include freertos/FreeRTOS.h #include freertos/task.h #define PIN_DC GPIO_NUM_9 #define PIN_RST GPIO_NUM_14 #define PIN_SCLK GPIO_NUM_12 #define PIN_MOSI GPIO_NUM_11 #define PIN_CS_LCD GPIO_NUM_10 static spi_device_handle_t lcd_dev; static void lcd_write_cmd(uint8_t cmd) { gpio_set_level(PIN_DC, 0); // DC0 表示命令 spi_transaction_t t { .length 8, .tx_buffer cmd, }; spi_device_transmit(lcd_dev, t); } static void lcd_write_data(uint8_t data) { gpio_set_level(PIN_DC, 1); // DC1 表示数据 spi_transaction_t t { .length 8, .tx_buffer data, }; spi_device_transmit(lcd_dev, t); } void app_main(void) { spi_bus_config_t buscfg { .sclk_io_num PIN_SCLK, .mosi_io_num PIN_MOSI, .miso_io_num -1, // LCD 不回数据MISO 不需要 .quadwp_io_num -1, .quadhd_io_num -1, .max_transfer_sz 4096, }; spi_device_interface_config_t devcfg { .clock_speed_hz 20 * 1000 * 1000, .mode 0, .spics_io_num PIN_CS_LCD, .queue_size 4, }; gpio_config_t io_conf { .pin_bit_mask (1ULL PIN_DC) | (1ULL PIN_RST), .mode GPIO_MODE_OUTPUT, }; gpio_config(io_conf); spi_bus_initialize(SPI2_HOST, buscfg, SPI_DMA_CH_AUTO); spi_bus_add_device(SPI2_HOST, devcfg, lcd_dev); gpio_set_level(PIN_RST, 1); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(PIN_RST, 0); vTaskDelay(pdMS_TO_TICKS(20)); gpio_set_level(PIN_RST, 1); lcd_write_cmd(0x11); // Sleep Out vTaskDelay(pdMS_TO_TICKS(120)); lcd_write_cmd(0x36); // MADCTL设置显示方向 lcd_write_data(0x00); }这段代码引入了 DC 引脚的控制逻辑。LCD 控制器内部依靠 DC 电平区分命令和数据所以每次发数据前都要用 GPIO 先设置 DC 状态。这也是 SPI LCD 驱动的一个常见坑有些教程把 DC 引脚接到其他 GPIO没有统一管理结果某个函数忘了切 DC屏幕就会显示异常。5.6 DMA 参数到底该不该开很多初学者对 DMA 有误解以为开了 DMA 就能自动解决时序问题。其实 DMA 只负责数据搬运不改变 SPI 协议本身的时序。它解决的是 CPU 负担问题。如果只是偶尔读一个寄存器、发一条命令不开 DMA 完全没问题。但如果要刷屏比如一张 320x480 的 RGB565 图片原始数据量就有 307200 字节纯靠 CPU 一个字节一个字节搬会长期占用处理器。这种场景必须开 DMA。使用SPI_DMA_CH_AUTO后spi_device_transmit在传输大块数据时会自动使用 DMA 搬运。max_transfer_sz需要设置成不小于单次最大传输字节数否则会传输失败。6. 在 FreeRTOS 任务中使用 SPI6.1 为什么需要关心任务ESP32-S3 的典型物联网项目里屏幕刷新、传感器采集、WiFi 上报往往运行在不同任务中。如果这些任务都要访问同一个 SPI 设备或者访问不同设备但共享同一路 SPI 总线就必须考虑并发问题。spi_device_transmit本身是线程安全的。驱动内部维护了一个队列多个任务调用时事务会排队依次执行。所以简单的“多个任务偶尔发一条 SPI 命令”不会有问题。真正需要小心的是“复合操作”。比如读 Flash 的 JEDEC ID一般建议先发送 0x9F 命令然后连续读取 3 个字节的数据。如果中间被其他设备的事务插进来整个操作就被打乱了。这种场景需要锁定总线。6.2 两个任务共享 SPI 总线的并发问题举个例子一个任务在刷新屏幕另一个任务在定时读取 Flash 状态。屏幕刷屏时每次发送的数据量很大可能由多个 SPI 事务