入行第三年,我接到了一个"不可能完成的任务":在一块 320×240 的 TFT 彩屏上实现 30fps 的视频播放。当时手里只有 I2C 接口的 OLED,一试,刷新一帧画面要足足 2 秒钟------别说视频了,翻页都卡得像幻灯片。
就在我一筹莫展的时候,主管丢过来一块 SPI 接口的 LCD 屏幕和一句话:"用这个,别再用 I2C 刷屏了。"
换上 SPI 之后,帧率直接从 0.5fps 飙到了 35fps。那一瞬间我才真正理解:通信协议没有好坏,只有合不合适。I2C 用两根线挂了 127 个设备,但 SPI 用四根线给你兆级带宽------各有各的战场,各有各的绝活。
一、SPI 协议基础
1.1 什么是 SPI
SPI(Serial Peripheral Interface,串行外设接口)是由摩托罗拉公司在 1980 年代推出的一种同步、全双工、主从式串行通信总线。
它需要 4 根线(标准模式):
| 信号 | 全称 | 方向 | 说明 |
|---|---|---|---|
| SCLK | Serial Clock | 主→从 | 时钟信号,由主设备产生 |
| MOSI | Master Out Slave In | 主→从 | 主设备发送数据到从设备 |
| MISO | Master In Slave Out | 从→主 | 从设备发送数据到主设备 |
| CS/SS | Chip Select / Slave Select | 主→从 | 片选信号,低电平有效,选中对应从设备 |
SPI 的核心设计哲学就四个字------简单粗暴:没有地址、没有应答、没有协议开销,主设备想和哪个从设备通信就把那个设备的 CS 拉低,然后时钟一跳,数据就"流"起来了。
1.2 SPI 与 I2C 的对比
我经常用一个比喻来向新人解释两者的区别:
I2C 像电话会议 ------用一条线"喊"大家的名字(地址),点到谁谁说话,效率不高但接线简单。 SPI 像对讲机------每人一条独立的呼叫线(CS),谁有专用线就只管和谁聊,速度快得飞起,但线也多。
| 特性 | I2C | SPI |
|---|---|---|
| 线数 | 2 根(SDA + SCL) | 4 根(可增至 3+N 根) |
| 通信模式 | 半双工 | 全双工 |
| 寻址方式 | 软件地址(7 位) | 硬件片选(CS) |
| 速率 | 100 kHz / 400 kHz | 最高 80 MHz(ESP32-S3) |
| 多从设备 | 地址复用,无需额外引脚 | 每设备需独立 CS 引脚 |
| 协议复杂度 | 较复杂(地址、应答、仲裁) | 极简 |
| 远距离能力 | 受限于总线电容 | 可以跑更远(差分 SPI 可达数米) |
1.3 SPI 的四种模式(CPOL & CPHA)
SPI 有四种子模式,由**时钟极性(CPOL)和时钟相位(CPHA)**组合决定:
| 模式 | CPOL | CPHA | 采样沿 | 数据变化沿 | 典型设备 |
|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿采样 | 下降沿变化 | 绝大多数设备 |
| Mode 1 | 0 | 1 | 下降沿采样 | 上升沿变化 | 较少 |
| Mode 2 | 1 | 0 | 下降沿采样 | 上升沿变化 | 部分 LCD |
| Mode 3 | 1 | 1 | 上升沿采样 | 下降沿变化 | SD 卡(默认) |
我踩过最大的坑之一就是 SPI 模式不匹配。有一次调试 SD 卡驱动,读回来的数据全是
0xFF,查了整整两天硬件,最后发现是 SPI 模式设置成了 Mode 0,而 SD 卡协议要求的是 Mode 3。从此我的 SPI 调试清单第一项就是:"确认 CPOL 和 CPHA 是否匹配"。绝大多数设备用 Mode 0,拿到新设备先试 Mode 0,不行再试 Mode 3,这能省去你大量翻阅数据手册的时间。
二、ESP32-S3 的 SPI 控制器
2.1 硬件资源
ESP32-S3 集成了 4 个 SPI 控制器:
| 控制器 | 角色 | 主要用途 |
|---|---|---|
| SPI0 | 主/从 | 内部使用(连接 Flash/PSRAM),不可由用户编程 |
| SPI1 | 主 | 内部使用(Flash/PSRAM 缓存操作),不建议用户使用 |
| SPI2 | 主/从 | 🎯 用户可用,通用 SPI 控制器 |
| SPI3 | 主/从 | 🎯 用户可用,通用 SPI 控制器 |
也就是说,在应用层面上,ESP32-S3 提供了 2 个独立的、完全可编程的 SPI 控制器(SPI2 和 SPI3)。
| 特性 | 参数 |
|---|---|
| 可用控制器 | SPI2、SPI3(共 2 个) |
| 最高速率 | 80 MHz(主模式) |
| 引脚映射 | 任意 GPIO 可配置 |
| 全双工 | ✅ 支持 |
| 支持模式 | Mode 0 / 1 / 2 / 3 |
| DMA | ✅ 支持(大数据传输) |
| 队列深度 | 64 笔事务 |
2.2 典型引脚分配
c
#define SPI_HOST SPI2_HOST
#define SPI_MOSI GPIO_NUM_11
#define SPI_MISO GPIO_NUM_13
#define SPI_SCLK GPIO_NUM_12
#define SPI_CS GPIO_NUM_10
SPI 的引脚分配也比 UART 和 I2C 更讲究,因为高速信号对走线质量敏感。MOSI 和 MISO 不要搞反------这是 SPI 接线里最常见的低级错误,接线前多看两眼原理图。
三、SPI 编程实战
3.1 主模式基本配置
ESP-IDF 的 SPI 驱动也是基于事务队列模式,流程与 I2C 类似:
c
#include "driver/spi_master.h"
#include "driver/gpio.h"
#define SPI_HOST SPI2_HOST
#define SPI_MOSI GPIO_NUM_11
#define SPI_MISO GPIO_NUM_13
#define SPI_SCLK GPIO_NUM_12
#define SPI_CS GPIO_NUM_10
#define SPI_CLOCK_HZ 40 * 1000 * 1000 // 40 MHz
spi_device_handle_t spi_dev;
void spi_init(void) {
// 1. 初始化 SPI 总线
spi_bus_config_t bus_cfg = {
.mosi_io_num = SPI_MOSI,
.miso_io_num = SPI_MISO,
.sclk_io_num = SPI_SCLK,
.quadwp_io_num = -1, // 未使用 QSPI
.quadhd_io_num = -1, // 未使用 QSPI
.max_transfer_sz = 4096, // 最大传输字节数
};
ESP_ERROR_CHECK(spi_bus_initialize(SPI_HOST,
&bus_cfg, SPI_DMA_CH_AUTO));
// 2. 添加设备
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = SPI_CLOCK_HZ,
.mode = 0, // SPI Mode 0
.spics_io_num = SPI_CS,
.queue_size = 7, // 事务队列深度
.flags = 0,
};
ESP_ERROR_CHECK(spi_bus_add_device(SPI_HOST,
&dev_cfg, &spi_dev));
}
SPI_DMA_CH_AUTO让驱动自动选择 DMA 通道。当传输数据量较大(超过 32 字节)时,DMA 能大大减轻 CPU 负担,如果你的屏幕分辨率很高,一定要开 DMA。
3.2 发送与接收
SPI 标准收发函数:
c
// 发送数据
esp_err_t spi_write(uint8_t *data, size_t len) {
spi_transaction_t t = {
.length = len * 8, // 位长度
.tx_buffer = data,
.rx_buffer = NULL, // 不接收
};
return spi_device_transmit(spi_dev, &t);
}
// 接收数据(同时发送 dummy 字节)
esp_err_t spi_read(uint8_t *rx_buf, size_t len) {
spi_transaction_t t = {
.length = len * 8,
.tx_buffer = NULL, // 自动发送 0xFF
.rx_buffer = rx_buf,
};
return spi_device_transmit(spi_dev, &t);
}
// 全双工:发送并接收
esp_err_t spi_write_read(uint8_t *tx_data,
uint8_t *rx_data, size_t len) {
spi_transaction_t t = {
.length = len * 8,
.tx_buffer = tx_data,
.rx_buffer = rx_data,
};
return spi_device_transmit(spi_dev, &t);
}
关键点:SPI 是全双工的!即使你只发送不接收,从设备也在往 MISO 线上输出数据------只不过我们忽略了而已。反过来,如果你只想接收,你仍然必须在 MOSI 上输出 dummy 字节来驱动时钟。这个"发数据才能收数据"的机制是初学者最容易困惑的地方。
四、实战一:驱动 TFT 彩屏(ST7789)
ST7789 是一款 240×240 或 240×320 像素的 TFT LCD 驱动芯片,SPI 接口,支持 16 位色(65K 色),刷新率约 30fps。这是我当年用 SPI 驱动的第一块彩屏,也是我"从黑白到彩色"的里程碑。
4.1 硬件连接
scss
ESP32-S3 ST7789 LCD
GPIO11 ──────── MOSI (SDA)
GPIO12 ──────── SCLK (SCL)
GPIO10 ──────── CS
GPIO9 ──────── DC (数据/命令选择)
GPIO8 ──────── RST (复位)
3.3V ──────── VCC
GND ──────── GND
和 I2C 不同,SPI 不需要上拉电阻(推挽输出),但高速时钟线建议串联 22Ω 电阻来抑制振铃,走线尽量短。
4.2 代码实现
c
#define LCD_HOST SPI2_HOST
#define LCD_MOSI GPIO_NUM_11
#define LCD_SCLK GPIO_NUM_12
#define LCD_CS GPIO_NUM_10
#define LCD_DC GPIO_NUM_9
#define LCD_RST GPIO_NUM_8
#define LCD_WIDTH 240
#define LCD_HEIGHT 240
spi_device_handle_t lcd_spi;
// 写命令
static void lcd_write_cmd(uint8_t cmd) {
gpio_set_level(LCD_DC, 0); // DC = 0 表示命令
spi_transaction_t t = {
.length = 8,
.tx_buffer = &cmd,
};
spi_device_transmit(lcd_spi, &t);
}
// 写数据
static void lcd_write_data(uint8_t *data, size_t len) {
gpio_set_level(LCD_DC, 1); // DC = 1 表示数据
spi_transaction_t t = {
.length = len * 8,
.tx_buffer = data,
};
spi_device_transmit(lcd_spi, &t);
}
// 写一个字节的数据(简便方法)
static void lcd_write_data_byte(uint8_t data) {
lcd_write_data(&data, 1);
}
// 初始化 ST7789
void lcd_init(void) {
// 初始化 SPI 总线
spi_bus_config_t bus_cfg = {
.mosi_io_num = LCD_MOSI,
.miso_io_num = -1, // LCD 不需要 MISO
.sclk_io_num = LCD_SCLK,
.max_transfer_sz = LCD_WIDTH * LCD_HEIGHT * 2,
};
spi_bus_initialize(LCD_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
spi_device_interface_config_t dev_cfg = {
.clock_speed_hz = 40 * 1000 * 1000, // 40 MHz
.mode = 0,
.spics_io_num = LCD_CS,
.queue_size = 7,
};
spi_bus_add_device(LCD_HOST, &dev_cfg, &lcd_spi);
// 初始化 GPIO(DC 和 RST)
gpio_set_direction(LCD_DC, GPIO_MODE_OUTPUT);
gpio_set_direction(LCD_RST, GPIO_MODE_OUTPUT);
// 硬件复位
gpio_set_level(LCD_RST, 0);
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(LCD_RST, 1);
vTaskDelay(pdMS_TO_TICKS(120));
// 初始化序列(ST7789 数据手册关键命令)
lcd_write_cmd(0x01); // 软件复位
vTaskDelay(pdMS_TO_TICKS(150));
lcd_write_cmd(0x11); // 退出睡眠模式
vTaskDelay(pdMS_TO_TICKS(200));
lcd_write_cmd(0x36); // 内存访问控制
lcd_write_data_byte(0x00);
lcd_write_cmd(0x3A); // 像素格式
lcd_write_data_byte(0x05); // 16 位色(65K 色)
lcd_write_cmd(0x21); // 反色关闭
lcd_write_cmd(0x13); // 正常显示模式
lcd_write_cmd(0x29); // 开启显示
}
// 设置绘屏窗口
void lcd_set_window(uint16_t x0, uint16_t y0,
uint16_t x1, uint16_t y1) {
lcd_write_cmd(0x2A); // 列地址
lcd_write_data_byte(x0 >> 8);
lcd_write_data_byte(x0 & 0xFF);
lcd_write_data_byte(x1 >> 8);
lcd_write_data_byte(x1 & 0xFF);
lcd_write_cmd(0x2B); // 行地址
lcd_write_data_byte(y0 >> 8);
lcd_write_data_byte(y0 & 0xFF);
lcd_write_data_byte(y1 >> 8);
lcd_write_data_byte(y1 & 0xFF);
lcd_write_cmd(0x2C); // 写入显存
}
// 全屏填充一种颜色
void lcd_fill(uint16_t color) {
size_t pixel_count = LCD_WIDTH * LCD_HEIGHT;
uint16_t *buf = malloc(pixel_count * 2);
if (!buf) return;
for (size_t i = 0; i < pixel_count; i++) {
buf[i] = color;
}
lcd_set_window(0, 0, LCD_WIDTH - 1, LCD_HEIGHT - 1);
gpio_set_level(LCD_DC, 1);
spi_transaction_t t = {
.length = pixel_count * 16, // 每个像素 16 位
.tx_buffer = buf,
};
spi_device_transmit(lcd_spi, &t);
free(buf);
}
// 点亮屏幕------展现绚丽的色彩
void app_main(void) {
lcd_init();
while (1) {
lcd_fill(0xF800); // 红色
vTaskDelay(pdMS_TO_TICKS(1000));
lcd_fill(0x07E0); // 绿色
vTaskDelay(pdMS_TO_TICKS(1000));
lcd_fill(0x001F); // 蓝色
vTaskDelay(pdMS_TO_TICKS(1000));
lcd_fill(0xFFFF); // 白色
vTaskDelay(pdMS_TO_TICKS(1000));
}
}
当你看到屏幕上红绿蓝白交替闪烁的那一刻,你会觉得之前所有调试的辛苦都值得了。从黑白 OLED 到彩色 TFT,不仅仅是颜色的变化,更是 SPI 相对于 I2C 在带宽上降维打击的直观体现。
五、实战二:SD 卡读写(SPI 模式)
SD 卡支持两种接口模式:SDIO(原生模式)和 SPI 模式。SPI 模式虽然速度不及 SDIO,但接线简单(仅需 4 根线),非常适用于数据记录类应用。
5.1 SPI 模式下的 SD 卡硬件连接
scss
ESP32-S3 MicroSD 卡座
GPIO11 ──────── MOSI (DI)
GPIO13 ──────── MISO (DO)
GPIO12 ──────── SCLK
GPIO10 ──────── CS (CD/DAT3)
3.3V ──────── VCC
GND ──────── GND
⚠️ 注意:SD 卡 SPI 模式要求 Mode 3(CPOL=1, CPHA=1),而不是 LCD 常用的 Mode 0。如果你在同一个 SPI 总线上挂载 LCD 和 SD 卡,需要切换到相应设备的模式后再进行通信,或者使用两个独立的 SPI 控制器分别驱动。
5.2 使用 ESP-IDF 的 SDSPI 驱动
ESP-IDF 提供了 sdspi 组件,无需手动实现完整的 SD 卡协议:
c
#include "sdspi_crt.h"
#include "esp_vfs_fat.h"
#include "driver/sdspi_host.h"
#include "driver/spi_common.h"
#define SD_SPI_HOST SPI2_HOST
#define SD_MOSI GPIO_NUM_11
#define SD_MISO GPIO_NUM_13
#define SD_SCLK GPIO_NUM_12
#define SD_CS GPIO_NUM_10
void sdcard_init(void) {
esp_err_t ret;
// 配置 SPI 总线
spi_bus_config_t bus_cfg = {
.mosi_io_num = SD_MOSI,
.miso_io_num = SD_MISO,
.sclk_io_num = SD_SCLK,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 4000,
};
ret = spi_bus_initialize(SD_SPI_HOST, &bus_cfg, SPI_DMA_CH_AUTO);
if (ret != ESP_OK) {
printf("SPI bus init failed: %s\n", esp_err_to_name(ret));
return;
}
// 配置 SD 卡 slot
sdspi_device_config_t slot_cfg = SDSPI_DEVICE_CONFIG_DEFAULT();
slot_cfg.gpio_cs = SD_CS;
slot_cfg.host_id = SD_SPI_HOST;
// 挂载 FAT 文件系统
esp_vfs_fat_sdmmc_mount_config_t mount_cfg = {
.format_if_mount_failed = false, // 格式化失败时不自动格式化
.max_files = 5, // 最多同时打开 5 个文件
.allocation_unit_size = 16 * 1024, // 分配单元 16KB
};
sdmmc_card_t *card;
ret = esp_vfs_fat_sdspi_mount("/sdcard", &bus_cfg,
&slot_cfg, &mount_cfg, &card);
if (ret != ESP_OK) {
printf("SD card mount failed: %s\n", esp_err_to_name(ret));
return;
}
// 打印 SD 卡信息
sdmmc_card_print_info(stdout, card);
// 创建测试文件
FILE *f = fopen("/sdcard/hello.txt", "w");
if (f) {
fprintf(f, "Hello from ESP32-S3!\n");
fprintf(f, "Card size: %llu MB\n",
card->csd.capacity / (1024 * 1024));
fclose(f);
printf("File written successfully!\n");
}
// 读取验证
f = fopen("/sdcard/hello.txt", "r");
if (f) {
char buf[128];
while (fgets(buf, sizeof(buf), f)) {
printf("%s", buf);
}
fclose(f);
}
}
看到 "Hello from ESP32-S3!" 成功写入 SD 卡并读取回来时,你手中的 ESP32-S3 就真正具备了数据持久化的能力------无论是记录传感器日志、存储配置文件、还是缓存图片资源,SD 卡都是最经济实用的方案之一。
🧠 深度思考
SPI 的哲学就是"不去想太多"。
回顾我学过的各种通信协议,SPI 永远是那个"最没心机"的协议------它不做地址解析,不做应答检测,不做冲突仲裁。时钟来了,数据就走了;时钟停了,一切归零。这份纯粹赋予了它极高的效率,却也带来了最现实的代价:每多一个设备,就多一根 CS 线。
在工作中我逐渐形成了一个三板斧的原则:需要高速数据流(显示、音频、存储)→ 用 SPI;需要多设备接入(传感器网络)→ 用 I2C;需要远距离或调试 → 用 UART。
但 SPI 也并非没有陷阱。我在一个量产项目中就吃过亏:设计时 SPI 时钟跑到了 60 MHz,但 PCB 走线没有做阻抗匹配,MISO 上的回波干扰导致高概率误码。后来不得不在固件中降速到 26 MHz 才解决问题。SPI 的速度上限不是由芯片决定的,而是由你的布线、连接器、线缆长度共同决定的------这一点在设计阶段就应当考虑进去。
另一个常被忽略的点是:SPI 从设备没有"忙信号"。一旦主设备拉低 CS 开始传输,从设备必须时刻准备就绪。所以对于那些需要时间处理数据的从设备,要么用额外的"Busy"引脚做握手,要么在两次传输之间插入足够延时。
⚠️ 避坑指南 / 注意事项
- SPI 模式不匹配是头号杀手
- 拿到设备先确认 SPI Mode(CPOL / CPHA),多数 LCD 用 Mode 0,SD 卡用 Mode 3
- 如果读回来的数据全
0xFF或0x00,八成是模式问题,先别怀疑硬件坏了 - 同一个 SPI 总线上的多个设备如果 mode 不同,传输前必须切换模式
- MOSI 和 MISO 不能接反
- 这是 SPI 接线最常见错误------主设备的 MOSI 接从设备的 MOSI(同名相连!)
- 注意:主设备的 MOSI 接从设备的 MOSI(有些数据手册叫 SDI/SDA,不同的命名可能让你混淆)
- CS 片选管理
- 多设备共享 SPI 总线时,未选中的设备 CS 必须拉高(高电平),否则会干扰总线
- 切换设备时,务必确保前一个设备的传输完全结束后再拉高 CS
- 高速 SPI 的硬件设计要点
- SCLK 走线尽量短(< 10cm),远离电源和电感
- 串联 22Ω~33Ω 电阻抑制振铃
- SPI 线束不要平行于时钟源或 PWM 走线
- 建议 SPI 信号用地线包围
- SD 卡 SPI 的特殊要求
- SD 卡需要一定的初始化时序(至少 74 个时钟周期的 dummy 传输)
- 使用 ESP-IDF 的
sdspi组件时,确保初始化spi_bus_initialize时配置了足够的max_transfer_sz - SD 卡供电必须充足(峰值可达 100mA+),不要直接从 GPIO 取电
- ESP-IDF 版本差异
- v5.x 中
spi_device_transmit默认是阻塞的,多任务环境下要考虑超时 - 大批量数据传输(如刷全屏)建议使用
spi_device_queue_trans+ 回调 + DMA 分批处理,避免阻塞任务 max_transfer_sz的设置直接影响大块数据传输的稳定性
总结
串行通信三部曲走到这一步,我们已经掌握了嵌入式世界的三种"通用语言":
- SPI 协议原理:全双工、主从式、四线同步通信
- ESP32-S3 的 SPI 资源:2 个可用控制器、最高 80 MHz
- 驱动配置:基于 ESP-IDF spi_master 的标准流程
- 实战:ST7789 TFT 彩屏------SPI 写操作,彩色显示的高带宽应用
- 实战:SD 卡读写------SPI 双向通信与文件系统操作
- SPI 与 I2C 对比:各有优劣,选型看场景
回想当年那个用 I2C OLED 刷屏卡成 PPT 的项目,如果早一天换上 SPI,也许就不用加班到凌晨三点了。但话说回来,正是那次翻车让我理解了不同协议的适用场景------没有最好的协议,只有最合适的协议。
如今再看到有人纠结"用 I2C 还是 SPI",我的答案始终是同一个:屏幕、音频、存储这种数据密集型应用,SPI 是第一选择;传感器网络、配置芯片这类低速多设备场景,I2C 更优雅;调试和日志,UART 永远可靠。
本文基于 ESP-IDF v5.x 编写,GPIO 引脚号请根据实际硬件连接调整。ST7789 初始化命令可能因具体型号(240×240 与 240×320)有细微差异,请参考对应数据手册。SD 卡实验建议使用 32GB 以下的 FAT32 格式卡片。