SPI 协议基础

SPI – 串行外设接口,说白了一根时钟线、两根数据线、再加一根片选线,就能让两个芯片之间全双工地聊起来。我这些年做嵌入式,从传感器读到 Flash 写,从 ADC 采集到 OLED 刷屏,最常用的就是 SPI。别看它简单,一旦时序没弄对,数据错位、通信失败那是家常便饭。

SPI 总线架构

SPI 是典型的主从架构,只有一个主设备(Master)负责产生时钟,一个或多个从设备(Slave)被动响应。主设备通过片选信号来选择要跟哪个从设备通信。你想想看,多从机场景里,所有从机的 SCK、MOSI、MISO 其实是并联在一起的,全靠各自的 SS 引脚来区分。

角色 时钟控制 数据驱动方向 片选控制
主设备 产生 SCK MOSI 输出,MISO 输入 控制所有 SS 引脚
从设备 接收 SCK MOSI 输入,MISO 输出 等待主设备拉低自己的 SS

这里有个容易踩的坑 – 我刚开始做设计时以为从机的 MISO 可以一直输出,结果两个从机同时把数据推到同一条线上,烧了好几个引脚。其实从机只有在自己的 SS 被选中时才能驱动 MISO,其他时候必须输出高阻态。

个人习惯:多从机布局时,我一般把 SS 走线单独拉出来,不要跟其他信号混在一起,否则片选毛刺容易导致从机误触发。
SPI 总线架构(一主多从) 主设备 从机 1 从机 2 从机 3 SCK MOSI MISO SS1 SS2 SS3
注意:上面图中的虚线代表片选信号,实际布线时 SS 线尽量不要跨过其他信号线,否则串扰可能会让从机在非选通状态下错误地捕获数据。

信号线定义

SPI 一共四根信号线,名字你可能早就背熟了,但每条线的特性还是有些细节值得聊一聊。

  • SCK(Serial Clock) – 时钟线,由主设备产生。所有数据都在 SCK 的边沿采样或改变。极性和相位可以通过 CPOL 和 CPHA 来配置,一共四种模式。
  • MOSI(Master Out Slave In) – 主设备输出,从设备输入。数据从主设备发往从设备。
  • MISO(Master In Slave Out) – 主设备输入,从设备输出。数据从从设备发往主设备。
  • SS(Slave Select) – 片选信号,低电平有效。主设备拉低某个从机的 SS,表示选中它进行通信。

我曾经在某次产品测试时发现,SPI 通信偶尔会读出全零数据,折腾了一整天才找到原因 – 从机那边的 SS 引脚上拉电阻没焊,悬空状态下噪声抖动,导致从机频繁进出选通状态。从那以后我设计板子时,SS 引脚必定加一个 10kΩ 上拉到 VCC。

避坑指南:SPI 从机在不被选中的时候,MISO 引脚必须配置为高阻态,否则多个从机会冲突。有些从机芯片内部已经做了三态处理,但有些便宜的传感器需要外部通过 GPIO 控制输出使能,设计前一定要看 datasheet。

全双工通信原理

全双工?说白了就是主机发一个 bit 的同时,从机也发一个 bit 回来。这个机制依赖一个“移位寄存器”结构。主从双方各有一个 8 位(或 16 位、32 位)的移位寄存器,SCK 每来一个边沿,双方的寄存器同时左移一位,主机把最高位推到 MOSI 上,同时从机把最高位推到 MISO 上。8 个时钟之后,两边的寄存器内容就交换完成了。

我刚开始学的时候,以为全双工意味着一帧数据可以同时发和收,其实你每次发出去的字节和收到的字节在时间上是对齐的,而且收和发的数据是同步移位完成的。下面这个伪代码可以帮你理解:

// SPI 发送一个字节并接收一个字节
uint8_t spi_transfer(uint8_t tx_data) {
    uint8_t rx_data = 0;
    for (int i = 7; i >= 0; i--) {
        // 主机将 tx_data 的第 i 位输出到 MOSI
        MOSI = (tx_data >> i) & 0x01;
        // 从机将数据输出到 MISO(由从机硬件自动完成)
        // 等待一个 SCK 周期(上升沿或下降沿)
        SCK = 1;
        // 读取 MISO 上的 bit,存入 rx_data 对应位
        rx_data |= (MISO << i);
        SCK = 0;
    }
    return rx_data;
}
个人经验:调试 SPI 时,我习惯先强制把时钟频率降到 1MHz 以下,然后用逻辑分析仪抓时序。看 SCK 的边沿和 MOSI/MISO 的数据是否对齐,一目了然。等协议调通了再往上提频率,这样能省去很多排查时间。

举个例子,你要从 SPI Flash 里读一个 ID 字节。通常流程是先发一个读命令(0x9F),Flash 会在接下来的 8 个时钟里把 ID 数据从 MISO 返回给你。注意,这里你发命令的时候,从机也在往 MISO 上推数据,只不过那些数据是前一次传输的残留或无效数据,别被它们误导就好。

警告:全双工模式下,如果你的主设备没有读取 MISO,或者从机在未选通时 MISO 没有置高阻,数据总线上的竞争会导致不可预期的结果。我见过有人在裸机程序里只调用发送函数却不读接收缓冲,结果下次发数据时读回来的是上次的残余数据。