ESP32-S3 SPI总线驱动实战:从硬件接线到DMA传输
2026/8/30 6:06:42 网站建设 项目流程

在ESP32-S3嵌入式AI物联网项目里,SPI总线几乎是绕不开的一环。不管你是驱动ST7796这类TFT彩屏,还是读取外部Flash、高速ADC、传感器数据,甚至在板卡上接一颗带SPI接口的AI协处理器,第一步都是把物理接线和SPI_bus驱动配置做对。本文基于2026版ESP-IDF框架,使用C语言和FreeRTOS,把SPI从硬件接线、驱动初始化、回环测试,到传感器读取、批量DMA传输和常见排错完整走一遍。读完你可以直接照着把SPI外设接到项目里,不需要再去翻零散源码。

先给结论:ESP32-S3的SPI能力足够覆盖绝大多数物联网设备。芯片内部有4个SPI控制器,但应用层能自由使用的是SPI2和SPI3,也就是我们常说的FSPI和HSPI;SPI0和SPI1被系统固件、外部Flash和PSRAM占用,普通用户不要去动。通过GPIO矩阵,SCK、MOSI、MISO、CS这几个信号可以映射到大部分引脚,所以接线比传统MCU灵活得多,但这也会带来一个理解门槛:哪些脚能用,哪些脚能复用,必须看开发板原理图和芯片数据手册。

下面按“规格说明 → 硬件接线 → 驱动配置 → 功能验证 → 性能观察 → 排错清单”的顺序展开。建议先把SPI回环测试跑通,再接真实外设,这样即使后面波形不对,也能快速定位是驱动配置问题还是外设模块问题。

1. SPI总线与ESP32-S3核心能力速览

SPI是同步全双工串行通信协议,标准四线是SCK(时钟)、MOSI(主出从入)、MISO(主入从出)、CS(片选)。在ESP32-S3上,ESP-IDF提供了完整的SPI master驱动,支持DMA、多设备、多模式,也可以配置为SPI slave。这套驱动本身是C语言实现,并且能和FreeRTOS任务调度自然配合,非常适合物联网数据采集和屏幕刷新场景。

项目说明
目标芯片ESP32-S3
可用SPI控制器SPI2_HOST(FSPI)、SPI3_HOST(HSPI)
系统占用SPI0、SPI1用于Flash和PSRAM,应用层不直接用
通信方式同步全双工/半双工,主从模式
开发框架2026版ESP-IDF(v5.x及以上API)
开发语言C语言,可配合FreeRTOS任务
时钟频率常用10MHz到40MHz,具体以信号完整性和从设备规格为准
DMA支持,spi_bus_initialize中可配置SPI_DMA_CH_AUTO
典型外设TFT屏幕、Flash、SD卡、ADC、传感器、协处理器
工程集成支持多设备挂同一总线,按CS分时复用

这个项目的核心特点可以概括为四点:第一,总线吞吐率高,适合屏幕刷帧和大块数据采集;第二,多设备可以共用一组SCK/MOSI/MISO,通过CS片选分时访问;第三,ESP-IDF的SPI驱动自带DMA通道和FreeRTOS锁,任务级调用比较省心;第四,GPIO映射灵活,几乎所有SPI信号都能重新指定引脚。不过灵活带来的问题是,别人移植的Demo引脚不一定匹配你的开发板,所以实际接线前要先确认原理图,这一点后面专门讲。

2. 适用场景与使用边界

SPI在ESP32-S3嵌入式AI物联网项目中,主要承担高速数据通路的作用。常见场景包括:驱动ST7789、ST7796这类TFT屏幕,通过SPI快速刷新画面;读取外部SPI Flash或EEPROM,保存模型参数、配置文件、日志;连接高速ADC或者多轴传感器,周期性采集数据;以及和AI协处理器、语音模块交换数据。在ESP32-S3上做轻量AI推理时,SPI也经常被用来把前端传感器数据快速搬进内存,再交给后续算法处理。

但SPI并不是万能的。它的通信距离很短,通常只适合板级走线或者十几厘米内的飞线,超过这个范围后时序容易变形,所以不要拿SPI去做长距离传输。其次,SPI本身没有设备应答机制,主设备发出数据后,从设备有没有收到、收得对不对,协议层并不知道,需要靠外设自身的寄存器状态或者额外的ACK机制来确认。第三,虽然GPIO矩阵很灵活,但某些引脚在不同封装、不同开发板上存在输入输出限制,乱接脚位会导致通信不稳定。最后,如果你的屏幕或传感器模块来自第三方,要确认芯片供应商的通信时序、工作电压和授权条款,不要直接用来源不明的寄存器配置。

从项目边界来讲,这套方案适合的是“单板高速采集 + 显示 + 数据预处理”这一类场景。如果你的需求是几十米距离、多主设备、强实时反馈,那应该考虑CAN、RS485或者专用总线,而不是在SPI上硬扛。

3. 环境准备与前置条件

在写驱动之前,先把环境准备好。硬件上需要一块ESP32-S3开发板、一根支持数据传输的USB线、需要测试的SPI外设模块(比如TFT屏幕、Flash或传感器),以及一组杜邦线或直接焊接。强烈建议准备一个逻辑分析仪,可以看SCK、MOSI、MISO、CS的实际波形,能避免大量“盲调”。

软件环境以2026版ESP-IDF为基础。安装完成后,先确认工具链可用:

idf.py --version

创建项目并设置目标芯片:

idf.py create-project spi_demo cd spi_demo idf.py set-target esp32s3

编译烧录命令比较固定:

idf.py build idf.py -p /dev/ttyACM0 flash monitor

Windows下串口号要根据设备管理器确认,Linux下常见的可能是/dev/ttyACM0/dev/ttyUSB0

这里有两个环境侧的常见坑,单独说一下:

  • 如果在Windows上直接运行idf.py,提示类似“ESP-IDF not yet activated”,说明你没有先进入ESP-IDF的专用终端或没有执行export.bat环境脚本,这不是代码问题,而是环境变量没有加载。
  • 如果之前用过Arduino IDE,看到类似failed to install platform: 'esp32:3.3.11'的下载失败信息,那是Arduino board manager的下载源或权限问题,和本文的ESP-IDF环境不是一回事。ESP-IDF项目不需要走Arduino的board下载流程,建议按官方安装文档重新装一遍。

还有一点,编译慢是ESP-IDF新手经常遇到的问题。尽量只idf.py build做增量编译,不要反复idf.py fullclean;如果机器资源够,可以开启ccache加速。另外,首次idf.py build会下载依赖组件,网络状况不好时建议提前确认能否访问Espressif的组件仓库。

4. ESP32-S3 SPI接线配置

4.1 常见引脚分配

先说明一个关键概念:ESP32-S3的SPI信号不是像STM32那样固定死在某一组引脚上,而是通过GPIO矩阵把SCK、MOSI、MISO、CS映射到物理引脚。也就是说,理论上很多GPIO都能当SPI用,但你必须避开系统Flash、PSRAM、串口、USB等已经占用的脚位。

很多开发板和官方示例会采用这样一组默认引脚:

功能FSPI(SPI2_HOST)常见引脚HSPI(SPI3_HOST)常见引脚
SCKGPIO12GPIO14
MOSI/COPIGPIO11GPIO15
MISO/CIPOGPIO13GPIO16
CSGPIO10GPIO17

注意,这不是芯片级写死的引脚,而是示例和开发板默认值。实际接线时,一定要打开开发板原理图确认,尤其要确认这组GPIO有没有被板载Flash、RGB灯、按键或者USB外设占用。比如说,如果开发板把GPIO11接到了板载PSRAM或者其它外设,那你再用它做MOSI,必然出问题。

下面以一个典型的SPI屏幕模块为例,做一份六针SPI接法参考:

模块引脚ESP32-S3引脚说明
VCC3.3V模块供电,不要直接接5V到GPIO
GNDGND共地
SCKGPIO12屏幕时钟
MOSIGPIO11主发数据给屏幕
MISO可悬空屏幕通常不需要读回
CSGPIO10片选,低电平有效
DCGPIO4数据/命令选择,由驱动控制
RSTGPIO5复位,由驱动控制

如果你的外设是传感器或者SPI Flash,通常MISO必须接,因为需要读回数据。如果是纯写类型的屏幕,MISO可以留空,这就是我们常说的“三线SPI”变体:SCK、MOSI、CS,省掉MISO。还有一些屏幕模块只有六针,一般就是VCC、GND、SCK、MOSI、MISO、CS,或者VCC、GND、SCK、MOSI、CS、BL,接线前看丝印。

4.2 硬件片选与软件片选

片选分两种方式:硬件片选和软件片选,这也是SPI配置里一个容易混淆的点。

硬件片选,就是在spi_device_interface_config_t里指定spics_io_num为一个具体GPIO,由SPI驱动在每次传输时自动拉低,传输结束自动拉高。优点是时序稳定,不需要你在每个事务里手动控制GPIO。

软件片选,就是把spics_io_num设为-1,然后自己用普通GPIO控制CS。这种场景适用于:从设备要求的CS时序比较特殊,比如每个字节之间CS要变化;或者多个从设备需要非常复杂的片选流程;再或者你希望完全控制CS拉低和拉高的时机。

举个简单配置:

spi_device_interface_config_t devcfg = { .clock_speed_hz = 10 * 1000 * 1000, .mode = 0, .spics_io_num = 10, // 硬件片选:GPIO10 .queue_size = 7, };

如果把这个字段改成-1,后续就需要自己在传输前用gpio_set_level控制CS。

4.3 接线注意事项

接线时最容易翻车的几个点:

  • MOSI和MISO接反。主板的MOSI必须接从设备的MOSI,主板的MISO必须接从设备的MISO,不能交叉。很多模块丝印写的是SDA、SDI、SDO,要查芯片手册确认方向。
  • 供电电压不匹配。ESP32-S3的GPIO是3.3V电平,如果模块是5V逻辑,必须加电平转换,不能直接怼。
  • CS没有上拉。部分从设备要求CS默认高电平,如果板上没有上拉电阻,空闲时CS飘忽不定,会导致误选中。
  • 飞线太长。屏幕刷新率较高时,建议飞线长度控制在10cm以内,SCK频率也要适当降低,否则波形失真。

5. SPI_bus驱动配置:从零跑通

5.1 创建项目并添加驱动头文件

在ESP-IDF中,SPI master驱动的主要头文件是driver/spi_master.h。不同版本的ESP-IDF路径可能略有调整,但driver/spi_master.h在v5.x系列仍然可用。

#include <string.h> #include "freertos/FreeRTOS.h" #include "freertos/task.h" #include "driver/spi_master.h" #include "esp_log.h" #include "esp_heap_caps.h"

5.2 初始化SPI总线

初始化总线用spi_bus_initialize。它需要三个参数:SPI主机编号、总线配置结构体、DMA通道。ESP32-S3上建议使用SPI_DMA_CH_AUTO,让驱动自动分配可用DMA通道;在老版本ESP-IDF中,可能要求显式指定SPI_DMA_CH1SPI_DMA_CH2,如果你用的版本较旧,要按当时文档调整。

#define SPI_HOST SPI2_HOST #define PIN_SCK 12 #define PIN_MOSI 11 #define PIN_MISO 13 #define PIN_CS 10 spi_device_handle_t spi_dev; void spi_bus_example_init(void) { spi_bus_config_t buscfg = { .sclk_io_num = PIN_SCK, .mosi_io_num = PIN_MOSI, .miso_io_num = PIN_MISO, .quadwp_io_num = -1, .quadhd_io_num = -1, .max_transfer_sz = 16 * 1024, }; esp_err_t ret = spi_bus_initialize(SPI_HOST, &buscfg, SPI_DMA_CH_AUTO); ESP_ERROR_CHECK(ret); }

.quadwp_io_num.quadhd_io_num在标准SPI模式下都填-1,只有使用QPI/OCTAL模式访问外部Flash时才需要配置。.max_transfer_sz决定了单次传输的最大字节数,这里设为16KB,按需调整。

5.3 添加SPI设备

总线上可以挂多个设备,每个设备对应一个句柄。添加设备使用spi_bus_add_device,设备配置结构体里的关键字段包括时钟频率、SPI模式、片选引脚、队列大小。

void spi_device_example_add(void) { spi_device_interface_config_t devcfg = { .clock_speed_hz = 10 * 1000 * 1000, .mode = 0, .spics_io_num = PIN_CS, .queue_size = 7, .flags = 0, }; esp_err_t ret = spi_bus_add_device(SPI_HOST, &devcfg, &spi_dev); ESP_ERROR_CHECK(ret); }

mode字段就是SPI的四种模式:模式0到模式3。模式0最常用,CPOL=0,CPHA=0,时钟空闲为低电平,数据在第一个边沿采样。如果外设手册要求模式1、2或3,把这里改成对应数值即可。

queue_size决定驱动内部事务队列的深度。如果任务提交事务的速度大于总线处理速度,队列会满,spi_device_transmit就会阻塞等待。队列设大一点能提升多任务场景的吞吐,但会增加内存占用。

5.4 收发一个字节

SPI是全双工协议,发送和接收同时进行。所以即使你只想发送一个命令字节,也需要接收一个字节,通常丢弃即可。

esp_err_t spi_write_read_byte(spi_device_handle_t handle, uint8_t out, uint8_t *in) { spi_transaction_t t = { .length = 8, .tx_buffer = &out, .rx_buffer = in, }; return spi_device_transmit(handle, &t); }

事务结构体里,length的单位是bit,不是字节。发送16个字节,length要填16 * 8。如果只发送不接收,rx_buffer可以设为NULL;如果只接收,发送缓冲区可以填任意数据。

5.5 使用DMA传输

当传输数据量较大时,比如屏幕刷新、Flash读取,建议启用DMA。DMA的好处是减轻CPU搬运负担,SPI控制器可以直接从内存中取数据发送,或者把收到的数据写入内存。

启用DMA的方式已经在spi_bus_initialize里通过SPI_DMA_CH_AUTO完成。不过要注意,DMA传输对缓冲区有要求,最好使用DMA capable内存,否则可能导致传输失败或性能异常。推荐用heap_caps_malloc申请:

uint8_t *tx_buf = heap_caps_malloc(4096, MALLOC_CAP_DMA); uint8_t *rx_buf = heap_caps_malloc(4096, MALLOC_CAP_DMA); if (tx_buf == NULL || rx_buf == NULL) { ESP_LOGE("MAIN", "dma malloc failed"); return; } memset(tx_buf, 0x5A, 4096); spi_transaction_t t = { .length = 4096 * 8, .tx_buffer = tx_buf, .rx_buffer = rx_buf, }; esp_err_t ret = spi_device_transmit(spi_dev, &t); ESP_ERROR_CHECK(ret);

从代码层面看,普通传输和DMA传输在使用spi_device_transmit时区别不大,驱动会根据传输长度自动选择是否走DMA。但缓冲区如果来自栈上普通数组,不满足DMA对齐和内存属性要求,就可能出现随机失败。所以大块数据缓冲区务必用heap_caps_malloc申请,而不是直接定义一个超大局部数组。

5.6 多设备共用总线

如果总线上挂了屏幕和Flash两个设备,分别调用spi_bus_add_device添加两次,得到两个spi_device_handle_t。每个设备使用不同的CS引脚,但SCK、MOSI、MISO是共享的。

spi_device_handle_t spi_lcd; spi_device_handle_t spi_flash; void spi_multi_device_add(void) { spi_device_interface_config_t lcdcfg = { .clock_speed_hz = 20 * 1000 * 1000, .mode = 0, .spics_io_num = 10, .queue_size = 7, }; spi_device_interface_config_t flashcfg = { .clock_speed_hz = 40 * 1000 * 1000, .mode = 0, .spics_io_num = 9, .queue_size = 7, }; ESP_ERROR_CHECK(spi_bus_add_device(SPI_HOST, &lcdcfg, &spi_lcd)); ESP_ERROR_CHECK(spi_bus_add_device(SPI_HOST, &flashcfg, &spi_flash)); }

在FreeRTOS环境下,如果两个任务分别使用两个不同的设备句柄,ESP-IDF的SPI驱动内部会做事务排队和总线互斥,不会出现两个设备同时占用总线导致数据错乱的情况。同一总线上不同设备之间的切换,驱动会自动处理片选时序。注意,如果其中一个设备使用软件片选,那你需要自己保证对该设备的访问不与其它设备冲突,因为软件片选不受驱动内部锁的完全控制。

6. 功能测试与效果验证

6.1 回环测试:最基础的SPI验证

拿到一块新板子,或者改了接线之后,建议先做一次回环测试。所谓回环,就是把MOSI和MISO用杜邦线短接,这样主设备发送什么数据,MISO上就能原样读回来。

测试步骤:

  1. 把GPIO11和GPIO13用杜邦线直接相连。
  2. 烧录下面的回环测试代码。
  3. 观察日志中发送数据与接收数据是否一致。
void spi_loopback_test(spi_device_handle_t handle) { uint8_t tx_buf[16]; uint8_t rx_buf[16] = {0}; for (int i = 0; i < 16; i++) { tx_buf[i] = i + 1; } spi_transaction_t t = { .tx_buffer = tx_buf, .rx_buffer = rx_buf, .length = 16 * 8, }; esp_err_t ret = spi_device_transmit(handle, &t); if (ret != ESP_OK) { ESP_LOGE("LOOPBACK", "spi_device_transmit failed: %s", esp_err_to_name(ret)); return; } for (int i = 0; i < 16; i++) { ESP_LOGI("LOOPBACK", "tx[%d]=%d rx[%d]=%d", i, tx_buf[i], i, rx_buf[i]); } }

如果rx_buf和tx_buf完全一致,说明SPI控制器、GPIO映射、总线初始化基本没问题。如果rx_buf全是0x00,先检查MOSI和MISO短接是否可靠;如果rx_buf全是0xFF,通常说明MISO采样不到数据,常见原因是GPIO初始化不对或者接收模式配置错误。回环测试最大的价值在于,它把外设问题排除在外,只验证MCU内部SPI通路,后续接传感器或屏幕时,你再遇到通信异常就能判断是接线问题还是模块问题。

6.2 逻辑分析仪看波形

回环测试通过后,可以借助逻辑分析仪确认波形。接线方式:把逻辑分析仪的通道分别接到SCK、MOSI、MISO、CS和GND,配置SPI解码器,选好CPOL/CPHA。正常情况下能看到:

  • CS拉低后,SCK开始输出连续时钟。
  • MOSI上按bit输出数据。
  • 如果从设备有回数据,MISO上能看到相应的电平变化。
  • 传输结束后CS拉高。

如果逻辑分析仪显示SCK一直停在固定电平,很可能是时钟配置错误或者从机没有驱动SCK;如果CS完全没有拉低,检查片选引脚配置是否正确。逻辑分析仪能看到真实时序,比瞎改代码高效得多。

6.3 读取外部Flash的JEDEC ID

回环测试之后,接一个真实外设试试。以常见的W25Q32 SPI Flash为例,发送0x9F命令可以读取芯片的JEDEC ID。在SPI全双工模式下,发送命令字节的同一个时钟周期,MISO上读到的数据是无效的,所以一般会发送“命令 + 多字节dummy”,然后从接收缓冲区的后续字节里取有效ID。

void spi_flash_read_jedec_id(spi_device_handle_t handle) { uint8_t tx_data[4] = {0x9F, 0x00, 0x00, 0x00}; uint8_t rx_data[4] = {0}; spi_transaction_t t = { .tx_buffer = tx_data, .rx_buffer = rx_data, .length = 4 * 8, }; esp_err_t ret = spi_device_transmit(handle, &t); if (ret != ESP_OK) { ESP_LOGE("FLASH", "transmit error: %s", esp_err_to_name(ret)); return; } uint8_t manufacturer_id = rx_data[1]; uint8_t memory_type = rx_data[2]; uint8_t capacity_id = rx_data[3]; ESP_LOGI("FLASH", "JEDEC ID: 0x%02X 0x%02X 0x%02X", manufacturer_id, memory_type, capacity_id); }

如果读出来的是正常的0xEF 0x40 0x16(W25Q32),说明SPI读通道没问题。如果全是0x00或0xFF,重点排查模块供电、MISO接线、片选极性。

6.4 在FreeRTOS任务中执行SPI传输

在FreeRTOS中,SPI传输通常在任务上下文中调用。ESP-IDF的SPI驱动支持多任务并发访问,内部会挂起等待总线的任务。下面是一个简单的循环采集任务示例:

static void spi_sensor_task(void *arg) { spi_device_handle_t handle = (spi_device_handle_t)arg; uint8_t tx_data[4] = {0}; uint8_t rx_data[4] = {0}; while (1) { tx_data[0] = 0x10; tx_data[1] = 0x00; tx_data[2] = 0x00; tx_data[3] = 0x00; memset(rx_data, 0, sizeof(rx_data)); spi_transaction_t t = { .tx_buffer = tx_data, .rx_buffer = rx_data, .length = 4 * 8, }; esp_err_t ret = spi_device_transmit(handle, &t); if (ret != ESP_OK) { ESP_LOGE("SENSOR", "spi transmit failed: %s", esp_err_to_name(ret)); } else { uint16_t sample = (rx_data[1] << 8) | rx_data[2]; ESP_LOGI("SENSOR", "sample = %d", sample); } vTaskDelay(pdMS_TO_TICKS(50)); } } void app_main(void) { spi_bus_example_init(); spi_device_example_add(); xTaskCreate(spi_sensor_task, "spi_sensor", 4096, spi_dev, 5, NULL); }

任务栈大小这里给的是4096字节。如果任务里声明了比较大的局部缓冲,或者引入了屏幕驱动这类比较深的调用栈,建议开大一点,并且打开FreeRTOS的栈溢出检测功能。在menuconfig中开启以下配置:

Component config -> FreeRTOS -> Enable FreeRTOS stack overflow detection

开启后,如果任务栈溢出,系统会打印类似***ERROR*** A stack overflow in task spi_sensor has been detected.的日志,能帮你快速定位问题。SPI驱动本身的调用不算很深,但屏幕驱动、日志输出、复杂协议解析都会吃栈,宁可多给,不要事后崩溃。

6.5 驱动TFT屏幕和批量数据读取

如果项目需要驱动ST7789或ST7796这类TFT屏幕,SPI的工作方式会更接近“写数据通路”。初始化屏幕需要发送一串命令参数,刷屏时则持续发送像素数据。不同的屏幕驱动IC对初始化的命令序列要求不同,比如ST7796通常需要设置像素格式、显示方向、伽马曲线等。建议先从芯片厂商提供的初始化序列抄起,用SPI发送命令时注意区分DC引脚:命令阶段DC拉低,数据阶段DC拉高。

屏幕刷屏的场景非常适合一次性发送大块像素数据,而不是逐像素调用SPI传输。比如RGB565格式下,一个320x240的屏幕一帧大概是150KB,就算按20MHz的SPI时钟,也需要几十毫秒。这时如果每个像素都spi_device_transmit一次,性能会非常差,必须构造一个大的spi_transaction_t,把整行甚至整帧像素交给DMA搬运。

批量读取也是一样。很多SPI ADC或传感器支持连续读取模式,一次事务可以同时输出读命令和读取多个转换结果。下面是一个批量读取的代码模式:

#define SAMPLE_COUNT 256 #define TX_SIZE 256 #define RX_SIZE 256 uint8_t *tx_buf = heap_caps_malloc(TX_SIZE, MALLOC_CAP_DMA); uint8_t *rx_buf = heap_caps_malloc(RX_SIZE, MALLOC_CAP_DMA); void spi_batch_read_example(void) { if (tx_buf == NULL || rx_buf == NULL) { ESP_LOGE("BATCH", "malloc dma buf failed"); return; } memset(tx_buf, 0x00, TX_SIZE); memset(rx_buf, 0x00, RX_SIZE); spi_transaction_t t = { .tx_buffer = tx_buf, .rx_buffer = rx_buf, .length = TX_SIZE * 8, }; esp_err_t ret = spi_device_transmit(spi_dev, &t); ESP_ERROR_CHECK(ret); for (int i = 0; i < SAMPLE_COUNT; i++) { ESP_LOGI("BATCH", "sample[%d] = %d", i, rx_buf[i]); } }

7. 性能观察与优化

SPI项目的性能瓶颈通常不在SPI硬件,而在事务

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询