SPI 通信与高速外设驱动详解

入行第三年,我接到了一个"不可能完成的任务":在一块 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"引脚做握手,要么在两次传输之间插入足够延时。


⚠️ 避坑指南 / 注意事项

  1. SPI 模式不匹配是头号杀手
  • 拿到设备先确认 SPI Mode(CPOL / CPHA),多数 LCD 用 Mode 0,SD 卡用 Mode 3
  • 如果读回来的数据全 0xFF0x00,八成是模式问题,先别怀疑硬件坏了
  • 同一个 SPI 总线上的多个设备如果 mode 不同,传输前必须切换模式
  1. MOSI 和 MISO 不能接反
  • 这是 SPI 接线最常见错误------主设备的 MOSI 接从设备的 MOSI(同名相连!)
  • 注意:主设备的 MOSI 接从设备的 MOSI(有些数据手册叫 SDI/SDA,不同的命名可能让你混淆)
  1. CS 片选管理
  • 多设备共享 SPI 总线时,未选中的设备 CS 必须拉高(高电平),否则会干扰总线
  • 切换设备时,务必确保前一个设备的传输完全结束后再拉高 CS
  1. 高速 SPI 的硬件设计要点
  • SCLK 走线尽量短(< 10cm),远离电源和电感
  • 串联 22Ω~33Ω 电阻抑制振铃
  • SPI 线束不要平行于时钟源或 PWM 走线
  • 建议 SPI 信号用地线包围
  1. SD 卡 SPI 的特殊要求
  • SD 卡需要一定的初始化时序(至少 74 个时钟周期的 dummy 传输)
  • 使用 ESP-IDF 的 sdspi 组件时,确保初始化 spi_bus_initialize 时配置了足够的 max_transfer_sz
  • SD 卡供电必须充足(峰值可达 100mA+),不要直接从 GPIO 取电
  1. ESP-IDF 版本差异
  • v5.x 中 spi_device_transmit 默认是阻塞的,多任务环境下要考虑超时
  • 大批量数据传输(如刷全屏)建议使用 spi_device_queue_trans + 回调 + DMA 分批处理,避免阻塞任务
  • max_transfer_sz 的设置直接影响大块数据传输的稳定性

总结

串行通信三部曲走到这一步,我们已经掌握了嵌入式世界的三种"通用语言":

  1. SPI 协议原理:全双工、主从式、四线同步通信
  2. ESP32-S3 的 SPI 资源:2 个可用控制器、最高 80 MHz
  3. 驱动配置:基于 ESP-IDF spi_master 的标准流程
  4. 实战:ST7789 TFT 彩屏------SPI 写操作,彩色显示的高带宽应用
  5. 实战:SD 卡读写------SPI 双向通信与文件系统操作
  6. SPI 与 I2C 对比:各有优劣,选型看场景

回想当年那个用 I2C OLED 刷屏卡成 PPT 的项目,如果早一天换上 SPI,也许就不用加班到凌晨三点了。但话说回来,正是那次翻车让我理解了不同协议的适用场景------没有最好的协议,只有最合适的协议

如今再看到有人纠结"用 I2C 还是 SPI",我的答案始终是同一个:屏幕、音频、存储这种数据密集型应用,SPI 是第一选择;传感器网络、配置芯片这类低速多设备场景,I2C 更优雅;调试和日志,UART 永远可靠。


本文基于 ESP-IDF v5.x 编写,GPIO 引脚号请根据实际硬件连接调整。ST7789 初始化命令可能因具体型号(240×240 与 240×320)有细微差异,请参考对应数据手册。SD 卡实验建议使用 32GB 以下的 FAT32 格式卡片。

相关推荐
魏祖潇1 小时前
SDD 完整指南——Spec 端打底、Story 端交付、留白区
人工智能·后端
feelmylife592 小时前
消息队列可靠投递与幂等消费 -- 从"消息丢了"到"消息别重复"的完整工程实践
后端
雪隐2 小时前
个人电脑玩AI-10让5060 Ti给你打工——部署 Odysseus:终于有个能打的"AI管家"了
人工智能·后端
copyer_xyf2 小时前
FastAPI 如何连接 MySQL
后端·python
IT_陈寒2 小时前
Vite打包时踩的坑:静态资源为啥突然404了?
前端·人工智能·后端
葫芦和十三3 小时前
图解 MongoDB 25|分片架构三件套:mongos、config server 和 shard
后端·mongodb·agent
葫芦和十三10 小时前
图解 MongoDB 26|片键设计:决定集群命运的一个决定
后端·mongodb·agent
Avan_菜菜11 小时前
使用 Docker + rclone 自建 WebDAV
后端·agent·claude
阳光是sunny12 小时前
别再被 worktree 绕晕了!AI 编程时代你必须掌握的 Git 隔离神器
前端·人工智能·后端