4、SPI接口驱动适配:从协议到波形

SPI 接口,说白了就是嵌入式 GUI 里最常用的「四线制」通信方式。我这些年经手的 LCD 驱动,十有八九都是 SPI 接口。为什么?因为它简单、可靠,而且大多数 MCU 都自带硬件 SPI 外设。

但简单归简单,真要把 SPI LCD 驱动写好、跑顺、还不占 CPU,里面还是有不少门道的。这一章,我就带你从协议回顾开始,一步步把驱动写出来,再上 DMA 优化,最后用示波器看看真实波形——嗯,这才是工程师该有的调试方式。

4.1 SPI 协议快速回顾

SPI 全称 Serial Peripheral Interface,摩托罗拉发明的。四根线:

  • SCLK — 时钟,由主机产生
  • MOSI — 主机输出,从机输入
  • MISO — 主机输入,从机输出
  • CS — 片选,低电平有效

对于 LCD 驱动,我们通常只需要 SCLK、MOSI、CS 三根线就够了。为什么?因为 LCD 基本不回传数据,MISO 可以省掉。我早期做的一个项目,为了省一个引脚,还把 CS 直接接地了——结果发现多个 SPI 设备共用总线时,片选必须单独控制,否则数据会串。嗯,这个坑我踩过。

SPI 有四种模式,由时钟极性(CPOL)和时钟相位(CPHA)决定:

模式 CPOL CPHA 数据采样边沿
模式 0 0 0 上升沿采样
模式 1 0 1 下降沿采样
模式 2 1 0 上升沿采样
模式 3 1 1 下降沿采样

LCD 驱动芯片,比如 ILI9341、ST7789,绝大多数默认使用 模式 0。你想想看,如果模式配错了,屏幕要么不亮,要么花屏。我建议你拿到 LCD 数据手册后,第一件事就是确认 SPI 模式,别急着写代码。

4.2 SPI LCD 驱动编写

驱动 SPI LCD,核心就两个操作:写命令 和 写数据。区别在于,写命令时 DC 引脚拉低,写数据时 DC 引脚拉高。

先看一个最基础的实现:

// 硬件抽象层 — 发送一个字节
static void spi_write_byte(uint8_t data)
{
    // 等待发送缓冲区空
    while (!(SPI->SR & SPI_SR_TXE));
    // 写入数据寄存器
    SPI->DR = data;
    // 等待接收完成(确保发送完毕)
    while (!(SPI->SR & SPI_SR_RXNE));
    // 读走接收寄存器(清标志)
    (void)SPI->DR;
}

// 写命令
void lcd_write_cmd(uint8_t cmd)
{
    LCD_DC_LOW();       // DC 拉低,表示命令
    LCD_CS_LOW();       // 片选使能
    spi_write_byte(cmd);
    LCD_CS_HIGH();      // 片选释放
}

// 写数据
void lcd_write_data(uint8_t data)
{
    LCD_DC_HIGH();      // DC 拉高,表示数据
    LCD_CS_LOW();
    spi_write_byte(data);
    LCD_CS_HIGH();
}

这个代码能跑,但效率很低。为什么?因为每个字节都要等待硬件标志位。如果你要刷一帧 320×240 的图片,每个像素 2 字节,那就是 153600 次 SPI 传输——光等标志位就能把 CPU 拖死。

我的习惯: 初始化 LCD 时,先发一串配置命令。比如 ILI9341 的初始化序列有几十条命令,我一般把它们放在一个 const 数组里,用状态机逐条发送。这样代码干净,也方便后期调整。

4.3 DMA 传输优化

要解决 CPU 被 SPI 拖死的问题,就得请出 DMA(直接存储器访问)。DMA 可以在不经过 CPU 的情况下,把数据从内存搬到 SPI 发送寄存器。

配置 DMA 的步骤大致如下:

  1. 使能 DMA 时钟
  2. 配置 DMA 通道:源地址(内存)、目的地址(SPI->DR)、传输方向(内存到外设)
  3. 设置数据宽度(8 位或 16 位,看 LCD 要求)
  4. 使能传输完成中断(可选)
  5. 启动 DMA 传输

来看一个实际配置片段(以 STM32F4 为例):

void lcd_dma_send(uint8_t *buf, uint32_t len)
{
    // 等待上一次 DMA 传输完成
    while (DMA2->LISR & DMA_LISR_TCIF0);

    // 关闭 DMA 流,清配置
    DMA2_Stream0->CR &= ~DMA_SxCR_EN;

    // 配置源地址、目的地址、传输长度
    DMA2_Stream0->PAR = (uint32_t)&SPI1->DR;
    DMA2_Stream0->M0AR = (uint32_t)buf;
    DMA2_Stream0->NDTR = len;

    // 使能 DMA 流
    DMA2_Stream0->CR |= DMA_SxCR_EN;

    // 使能 SPI 的 DMA 发送请求
    SPI1->CR2 |= SPI_CR2_TXDMAEN;
}

这里有个关键点:DMA 传输完成后,一定要等 SPI 把最后一个字节发完。我曾经遇到过一个问题:DMA 说传完了,但 SPI 移位寄存器里还有数据没送出去,结果屏幕最后一行数据丢了。解决方案是在 DMA 传输完成中断里,等待 SPI 忙标志位清除:

void DMA2_Stream0_IRQHandler(void)
{
    if (DMA2->LISR & DMA_LISR_TCIF0) {
        // 清除标志
        DMA2->LIFCR = DMA_LIFCR_CTCIF0;
        // 等待 SPI 发送完毕
        while (SPI1->SR & SPI_SR_BSY);
        // 关闭 DMA 请求
        SPI1->CR2 &= ~SPI_CR2_TXDMAEN;
    }
}
注意: 有些 LCD 在刷大块数据时,需要保持 CS 持续拉低,不能每发一个字节就拉高一次。正确的做法是:在 DMA 传输开始前拉低 CS,传输完成后拉高 CS。否则屏幕会出现「断帧」现象。

4.4 实测波形调试

代码写完了,跑起来屏幕亮了,但总觉得刷新速度不够快?或者偶尔花屏?这时候别瞎猜,上示波器看波形。

我调试 SPI 时,一般会看这几个关键点:

  • SCLK 频率:是不是和配置的一致?如果示波器显示频率只有配置的一半,检查时钟分频系数。
  • CS 时序:CS 拉低后,SCLK 第一个边沿之前有没有足够的建立时间?我见过一个案例,CS 和 SCLK 几乎同时变化,导致 LCD 误判第一个时钟边沿。
  • MOSI 数据:数据是否在 SCLK 的采样边沿稳定?如果数据在边沿附近跳变,说明时序裕量不足。
  • DMA 传输间隔:两次 DMA 传输之间有没有不必要的延迟?

下面这张图是我总结的 SPI LCD 驱动调试流程:

SPI LCD 驱动调试流程 1. 确认 SPI 模式与参数 2. 编写基础 SPI 驱动 3. 验证基本显示功能 4. 加入 DMA 传输优化 5. 示波器实测波形 ✅ 调试通过 关键检查点 • SCLK 频率 • CS 建立时间 • MOSI 数据稳定 • DMA 完成时序

实际调试时,我习惯把 SCLK 和 CS 放在示波器的两个通道上,触发条件设为 CS 下降沿。这样每次刷屏时,都能看到完整的片选周期和时钟波形。如果发现 CS 拉低后 SCLK 迟迟不来,说明软件里可能有额外的延迟——检查一下是不是在 CS 操作前后插了不必要的延时函数。

一个真实案例: 我之前调试一块 320×480 的 SPI LCD,发现刷新率只有 15fps,远低于预期的 30fps。用示波器一看,SCLK 频率只有 8MHz,而配置的是 20MHz。查了半天,发现是 SPI 时钟分频系数配错了——代码里写的是 SPI_BAUDRATEPRESCALER_8,但实际硬件分频器是 16 分频。改过来之后,刷新率直接翻倍。

最后说一句:SPI 驱动调试,不要只盯着代码看。示波器才是你的眼睛。波形对了,代码基本没问题;波形不对,代码再漂亮也没用。