1. SPI 是什么?先搞懂它解决什么问题
做嵌入式开发的人,迟早都要跟 SPI 打交道。不管是驱动一块 TFT 屏幕、读写一颗 Flash 芯片,还是接一个 ADC 采样模块,SPI 几乎是无处不在的总线协议。我最早接触 SPI 是在一个 STM32 的项目上,当时要驱动一颗 SPI 接口的 Flash 存储芯片,第一反应是“不就是接四根线嘛”,结果调了整整两天才把数据读出来——问题恰恰出在对 SPI 最基础的机制理解不够上。后来做过的项目多了,反而觉得 SPI 是个“入门容易精通难”的协议,很多隐藏的坑,要在实际调试中踩过才知道。
SPI 的全称是 Serial Peripheral Interface,串行外设接口,由 Motorola 在 1979 年提出。它属于典型的“主从式”同步串行通信协议,说直白点就是有一方当“老板”负责发号施令,其他各方当“员工”听令干活。老板叫 Master(主机),员工叫 Slave(从机)。SPI 最吸引人的地方在于它的传输速率可以做得非常高,标准模式下轻松跑到几十 Mbps,如果硬件支持的话上百 Mbps 也不是问题,这在很多场景下远比 I2C 那种 400kHz 级别的速度要实用得多。再加上它收发数据是独立的,可以同时读和写,也就是全双工,所以在对吞吐量有要求的场合,SPI 往往是首选。
那这篇内容适合谁看?我的判断是:如果你是刚接触 SPI 的新手,需要从原理到实操完整走一遍;如果你已经会用了,但经常遇到“数据读出来全是 0xFF”“屏幕上花屏”这类诡异问题,那本文的时序分析和故障排查部分大概率能帮到你;如果你正准备在 FPGA 上用 Verilog 自己写一个 SPI 从机或者主机,那本文的时序拆解部分可以直接作为设计的参考。后面我会从物理层讲起,一路讲到协议层、配置层、实践经验层,尽量把一个问题讲透而不是只给一个结论。
2. SPI 的物理层与通信机制:四根线如何撑起高速传输
2.1 四根线各自承担什么角色
SPI 一共用到四根信号线,名字很好记。SCLK(Serial Clock)是时钟线,由主机产生,它是整个通信的节拍器,所有数据的移位和采样都对齐到这个时钟的边沿上;MOSI(Master Out Slave In)是主机输出、从机输入的数据线,方向固定;MISO(Master In Slave Out)是从机输出、主机输入的数据线,方向固定;最后是 CS/SS(Chip Select / Slave Select),片选线,一般是低电平有效,主机把某个从机的片选拉低,就意味着“我要跟你说话,别人别插嘴”。
很多初学者会忽略片选线的意义,觉得它就是一根普通的 GPIO。但其实片选是 SPI 通信里非常重要的控制信号,它决定了当前总线上谁在“听讲”。一个 SPI 主机可以同时挂多个从机,所有从机的 SCLK、MOSI、MISO 都并联在一起,唯独片选线是各自独立的。主机要和哪个从机通信,就把对应的片选线拉低。这个机制的好处是硬件布线极其简单,扩展性极强,代价是片选线会随着从机数量线性增加。
MISO 这根线还有一个细节,就是必须支持“高阻态”或者叫三态输出。因为所有从机的 MISO 是接在一起的,如果某个没有被选中的从机还在往 MISO 上输出数据,就会和正在通信的从机抢总线,轻则数据错误,重则损坏引脚。所以 SPI 从机在片选无效时,MISO 必须释放掉,也就是输出高阻态 Z。这一点在硬件设计或者 FPGA 实现从机的时候要特别注意。
2.2 核心工作机制:移位寄存器的“环形接力”
SPI 没有复杂的帧格式,也没有地址寻址的概念,它的数据交换本质上是主从双方各有一个移位寄存器,通过时钟边沿一次性把数据“挤”给对方,同时把对方“挤”过来的数据收进来。你可以联想两个人在传递一根水管,水管里同时有两股水,一股从 A 流向 B,另一股从 B 流向 A,只要两边节奏一致,数据就完成了互换。这就是为什么 SPI 的读操作往往要配合写操作一起进行。
具体到硬件行为上,当主机要发起一次通信时,先拉低从机的 CS 片选线,告诉从机“准备接收”。然后主机开始在 SCLK 上产生时钟脉冲,每来一个时钟边沿,主机把 MOSI 上的一位数据送给从机,同时从机把 MISO 上的一位数据送给主机,双方各自把接收到的位移位进自己的寄存器。8 个时钟脉冲后,一个字节就交换完了。如果主机想读从机的数据,那它不能什么都不干地“等”数据回来,而是必须继续发送时钟,哪怕发送的数据是 0x00 或者 0xFF 都行,因为从机的数据是靠时钟“挤”出来的。
这个“读必须伴写”的机制,很多刚从串口转过来的人不适应。串口是典型的异步收发,读和写是独立的;SPI 是同步全双工,读和写在一次通信中同时发生。以读一颗 SPI Flash 为例,主机要先发送读指令和地址,这个过程里 Flash 返回的数据是无效的;等指令和地址发完,主机需要继续发“空字节”来提供时钟,Flash 才会把存储单元的数据放到 MISO 上。理解了这一点,就不会在调试的时候疑惑“为什么我发了读指令却读不到数据”。
2.3 单主多从的两种接法:独立片选与菊花链
SPI 的单主多从接法有两种,一种是最常用的独立片选方式,也就是每个从机独占一根 CS 线,主机想和谁通信就拉低谁的片选。另一种是菊花链(Daisy Chain)方式,把所有从机的数据线串联起来,主机只有一根 CS 线统一选中所有从机。菊花链的典型应用场景是移位寄存器级联,比如多个 74HC595 驱动 LED 点阵,数据从第一个芯片的 Q7' 引脚流入第二个芯片的 DS 引脚,像传接力棒一样把数据传递下去。这种方式节省了 IO 资源,但每次通信需要把所有从机的数据都攒够了再一次性推出去,增加了时延,普通外设很少这么用。
我在实际项目中基本都用独立片选,因为它的时序直观、调试方便,而且每个从机可以独立复位和配置。需要注意的是,虽然 SPI 从机在片选无效时已经不参与总线活动了,但 SCLK 和 MOSI 的干扰仍然可能通过寄生参数影响从机内部逻辑,尤其是高速信号。所以 PCB 布局时,SPI 信号线要走得尽量短、尽量等长,SCLK 不要和高速开关信号平行走线,否则你可能会看到莫名其妙的误触发。
3. SPI 协议最核心的部分:CPOL 和 CPHA 的四种组合
3.1 时钟极性 CPOL 与时钟相位 CPHA 的定义
如果说 SPI 有一个地方最让人头疼,那一定是 CPOL(Clock Polarity)和 CPHA(Clock Phase)这两个参数。我见过太多人上来就照着别人的代码抄,SPI_MODE_0 还是 SPI_MODE_3 随便填一个,结果屏幕显示的颜色不对、Flash 读出来的 ID 不对,折腾半天最后发现是模式选错了。所以这里务必要把原理讲清楚。
CPOL 决定的是时钟在空闲状态(也就是没有数据传输时)的电平。CPOL=0 时,SCLK 空闲为低电平,传输开始后第一个边沿是上升沿;CPOL=1 时,SCLK 空闲为高电平,传输开始后第一个边沿是下降沿。CPHA 决定的是数据在哪个边沿被采样。CPHA=0 时,数据在时钟的第一个边沿被采样,也就是在“前沿”采样;CPHA=1 时,数据在时钟的第二个边沿被采样,也就是在“后沿”采样。这两个参数组合起来,就形成了四种 SPI 模式:
| SPI 模式 | CPOL | CPHA | 空闲电平 | 数据采样边沿 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低 | 上升沿(第一个边沿) |
| Mode 1 | 0 | 1 | 低 | 下降沿(第二个边沿) |
| Mode 2 | 1 | 0 | 高 | 下降沿(第一个边沿) |
| Mode 3 | 1 | 1 | 高 | 上升沿(第二个边沿) |
记住这张表只是第一步,更重要的是要理解为什么会有这些区分。SPI 是同步协议,主机产生时钟,主从双方都根据时钟边沿来“对齐”数据。但不同芯片内部的数据锁存器可能不同,有的芯片习惯在上升沿锁存数据,有的习惯在下降沿锁存数据;有的要求第一个边沿就锁存,有的要等第二个边沿。为了让主机能适配各种从机,SPI 才定义了这些模式。
3.2 如何确定一个从机到底该用哪个模式
这里给出一个实操中非常有效的方法。翻出你想用的那个从机芯片的数据手册,找到 SPI 时序图那一页,看两个信息:一是 SCLK 在空闲时是高还是低,这直接决定了 CPOL;二是数据通常是在哪个边沿被采样,比如手册上经常画一根虚线标注 “Data sampled at rising edge” 或者 “Data latched on falling edge”,那根虚线的位置就是采样点。如果采样点位于第一个边沿,CPHA=0;如果位于第二个边沿,CPHA=1。
以最常见的 W25Q64 Flash 芯片为例,它的数据手册上写得很清楚,支持 Mode 0 和 Mode 3,也就是 CPOL 和 CPHA 都是 0 或者都是 1。为什么支持两个模式?因为 Mode 0 和 Mode 3 实际上共享同一个采样沿的特性,都是“在第一个有效边沿采样数据”,区别只是空闲电平和第一个边沿的方向。很多 SPI Flash 和屏幕驱动芯片在设计时就同时兼容这两种模式,所以你在 STM32 上配成 Mode 0 或者 Mode 3 都能正常工作。但有些传感器芯片只支持一种模式,比如某些气压传感器只支持 Mode 0,配成 Mode 3 读出来的数据就是乱的。
我在给别人的代码做 review 时,经常看到有人把 SPI 模式当成一个可以随便试的参数——Mode 0 不行就试 Mode 1,再不行就试 Mode 3。不是说不能试,但你至少应该先看一眼手册确认选型,乱试大方向一旦错了,后面的问题排查会很浪费时间。我的建议是,新项目里每接一颗用 SPI 的芯片,先花两分钟看时序图,把模式确定下来再写代码,这能帮你省掉后面至少一个小时的调试时间。
3.3 通过波形图理解四种模式的区别
我在这里用文字描述一下波形,大家脑海里可以跟着画。假设主机要发送一个字节 0b10110010,从最高位开始发。
Mode 0:SCLK 空闲为低,时钟开始后第一个边沿是上升沿,数据在 MOSI 上先准备好,上升沿来临时主机采样这个数据,所以数据在整个时钟周期的前半段有效。你会看到数据位的变化发生在下降沿附近,而在上升沿附近是稳定状态。
Mode 1:SCLK 空闲为低,同样第一个边沿是上升沿,但数据是在下降沿(第二个边沿)才被采样。也就是说数据变化发生在上升沿附近,在随后的下降沿被锁存。这类模式在实际芯片里相对少见,但确实存在。
Mode 2:SCLK 空闲为高,第一个边沿是下降沿,数据在下降沿被采样,数据变化发生在上升沿附近。Mode 3:SCLK 空闲为高,第一个边沿是下降沿,但数据在第二个边沿(上升沿)被采样,数据变化发生在下降沿附近。
如果你手头有逻辑分析仪,把这四种模式各抓一次波形放在一起对比,会很快形成直觉。我强烈建议大家在实验室里用开发板把四种模式都跑一遍,观察 MISO 上读回来的数据,很多对 SPI 的理解就是这么“看得见摸得着”地建立起来的。
4. 硬件片选还是软件片选?这决定了你调试痛苦的程度
4.1 硬件片选(NSS)的工作方式
很多 MCU 的 SPI 外设都有一个专用的硬件片选引脚,比如 STM32 上的 NSS(Not Slave Select)。在主机模式下,你可以把 NSS 配置为硬件输出,这样当 SPI 外设发起传输时,硬件会自动把 NSS 拉低,传输结束后自动拉高,全程不需要 CPU 干预。这个特性在某些场景下非常有用,比如配合 DMA 做高速持续传输时,CPU 根本没有时间去翻转 GPIO,硬件自动管理片选能保证时序紧凑。
但硬件片选也有一个非常大的坑:如果 NSS 被配置成硬件模式,而总线上又挂了多个从机,你就不方便用这个引脚来分别控制不同从机了,因为硬件只会操作一根固定的 NSS 线。所以多从机场景下,通常的做法是让 NSS 工作在软件模式(也就是禁用硬件片选功能,把这个引脚释放出来做普通 GPIO),然后用多个 GPIO 分别控制每颗从机的片选。这种“软件片选”的方案更灵活,也是我日常工作里最常用的方式。
还有一个隐蔽的坑是,有些 MCU 的 SPI 外设在硬件 NSS 模式下,如果 NSS 引脚没有正确接上拉电阻或者被外部干扰拉低,SPI 外设可能会错误地认为自己被拉低进人了从机模式,整个总线状态就乱了。我在一次项目中就碰到过类似问题,排查了很久才发现是 NSS 引脚悬空导致的。所以哪怕你用的是软件片选,最好也把 NSS 引脚配置成普通 GPIO 并上拉,不要让它悬空。
4.2 软件片选为什么会成为主流做法
软件片选就是用普通 GPIO 模拟片选信号,传输前把 GPIO 拉低,传输结束后拉高。它的优势非常明显:灵活、直观、支持任意数量的从机。在软件片选模式下,你的代码大概长这样:
#define CS_LOW() HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET) #define CS_HIGH() HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET) CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, len, 100); CS_HIGH();很多芯片的数据手册会要求片选信号在所有字节传输完成后才能拉高,比如 SPI Flash 的页编程操作,主机需要连续发送指令、地址和数据,中途片选不能变化。用软件片选你可以精确控制整个过程,非常自然。
但软件片选有一个需要留神的细节:片选信号存在建立时间(CS setup time)和保持时间(CS hold time)。也就是说,CS 拉低之后不能立刻开始产生时钟,需要等一小段时间让从机内部准备好;CS 拉高之前也要保证最后一个时钟边沿已经过去了足够的时间。不同芯片的要求不同,有的只要几十纳秒,有的需要几个微秒。如果你在 CS 刚拉低就马上去操作 SPI 外设寄存器,或者最后一个字节传输完立刻拉高 CS,一些时序要求严苛的从机就可能出现数据出错。解决办法很简单:在 CS 拉低之后加一个微小的延时(通常几个微秒足够),或者利用 DMA 传输完成中断确认所有数据都发送完毕后再拉高 CS。
4.3 多设备共用 SPI 总线的片选仲裁策略
当多个设备共用同一条 SPI 总线时,片选的管理就变成了一种简单的“仲裁”。核心原则是:同一时刻只能有一个从机被选中,绝不能出现两个从机的片选同时有效。否则两个从机的 MISO 都会尝试驱动总线,轻则数据错乱,重则器件损伤。
我在一个 ESP32 项目里同时挂了显示屏和 SD 卡,两者共用 SPI 总线。刚开始我是分别控制各自的片选,先对屏幕操作再对 SD 卡操作,看起来没问题,但实际跑起来偶尔会在 SD 卡写入时看到屏幕出现噪点。后来用逻辑分析仪抓了总线波形才发现,问题出在片选释放的时序上:我从 SD 卡操作切回屏幕操作时,SD 卡的片选线已经拉高了,但内部的 SPI 状态机可能还没完全退出,这时候屏幕的数据就开始传输,SD 卡误抓了几个时钟,内部的读写状态被干扰了。解决办法是切换设备时强制让总线空闲一小段时间,或者在片选拉高之后加一个安全延时,给从机留足退出时间。
这个问题的根源在于很多 SPI 从机对片选信号的要求比我们想象中严格,它们不仅关注片选在通信期间的电平状态,也会在片选拉高后的边缘处做内部状态锁存。如果片选拉高发生在传输中途,从机可能认为传输被异常中断。因此软件片选下的切换动作一定要干净利落,不要在时钟还在跑的时候断开片选。
5. 从屏幕到 Flash 再到传感器:不同外设的 SPI 实践要点
5.1 显示屏类外设:ST7789 和刷新率问题
SPI 接口的屏幕在低成本和低引脚数的场景下极其常见,尤其是 Arduino、ESP32 这类生态里,ST7789、ILI9341、ST7735 这些驱动芯片几乎人手一个。这类屏幕的数据传输量很大,一张 240x320 的 RGB565 图片大约是 153,600 字节,如果用 SPI 跑 40MHz,不计算命令开销的话也需要约 30ms。如果要求流畅刷动画,最好把帧率控制在 30fps 以上,这就意味着 SPI 时钟不能低于约 100MHz,但大多数 MCU 的 SPI 达不到这个速度。
所以实际项目中,这类小屏通常只用于显示静态内容或者简单的动画,而不是高频刷新。如果你确实想要高刷新率,有几个常用手段:一是用 RGB 并口屏幕而不是 SPI 屏幕,STM32F429 这类带 LTDC 控制器的 MCU 就是为了这种场景生的;二是用 SPI DMA 传输,让 CPU 不用逐字节地搬运数据,省下来时间去处理其他逻辑;三是直接用自带显存的 SPI 屏幕,比如 ST7789 内部有 240x320x16bit 的显存,你只需要把改动的那一小块区域推送到屏幕即可,不需要整屏重发。
ST 官方库或者 Arduino 库里的 pushColor 函数就是按这个思路实现的,它会把旧的图像数据保存在局部变量里,通过比较新旧像素来决定是开窗刷新还是整屏刷新,从而最大化刷新效率。如果你发现自己的 SPI 屏幕刷新很卡,先不要怀疑 SPI 模式,先检查是不是每次都在全屏重绘。
5.2 存储类外设:SPI Flash 的指令集与编程流程
SPI NOR Flash(比如 W25Q64、W25Q128)是学习 SPI 通信最好的“教具”,因为它指令丰富、时序严谨,几乎涵盖了 SPI 的常见知识点。这类芯片的工作流程一般分为这样几步:上电后,主机发送 0x9F 读 ID 指令,Flash 会返回制造商 ID 和设备 ID,这是验证通信链路是否正常最常用的手段;然后发送 0x05 读状态寄存器指令,等待 BUSY 位清零;写入数据前要先发送 0x06 写使能指令,再发 0x20 扇区擦除指令;擦除完成后,发送 0x02 页编程指令,把数据写进去。
这里有一个非常容易踩的坑是针对“写使能”的。SPI Flash 出于保护机制,上电后默认是禁止写入的,必须先发送 0x06 指令把 WEL 位(写使能锁存位)置 1,才可以执行写操作。如果跳过写使能直接发页编程指令,Flash 会把这条指令忽略掉,状态寄存器的 WEL 位也不会变化。很多初学者调 Flash 写操作不生效,十有八九就是忘了这一步。另一个坑是页编程的写入地址不是连续的:W25Q 系列每页大小通常是 256 字节,页编程指令最多只能写入一页的数据,如果跨越页边界,数据会回绕到本页的开头写入,而不是顺延到下一页。所以写 Flash 之前必须做地址对齐的处理,否则就会出现“写入的数据不对”这种诡异问题。
读操作相对简单,主机发送 0x03 指令加 24 位地址,然后持续给时钟,Flash 就会把数据连续地推出。读可以跨页连续读,不需要考虑页边界问题。这部分如果你在用 STM32 的 HAL 库,可以参考 stm32f4xx_hal_ospi.c 这类文件里的封装,但底层原理就是这个。
5.3 传感器类外设:采样率与 SPI 总线速率的匹配
传感器芯片是 SPI 协议的另一大类应用,常见的有加速度计(如 ADXL345)、陀螺仪(如 MPU6500 的 SPI 模式)、气压计(如 BMP388)、ADC(如 ADS1256)等。传感器类的特点通常是“数据量不大,但单个寄存器的读写频繁”,而且很多传感器要求使用 SPI Mode 0 或者 Mode 3,具体得看手册。
和屏幕、Flash 这类“主机主动推数据”的场景不同,传感器更常见的是“主机问,从机答”。主机先写寄存器地址,然后读取数据。很多传感器芯片的读操作要求地址字节的最高位为 1,用来指示“这是一个读请求”,如果最高位为 0 则是写请求。比如 MPU6500 的寄存器读取,发送的地址是 reg | 0x80。如果你忘记了这个最高位标志,读出来的数据永远是默认值或者 0。
传感器对 SPI 时序的另一个要求是 SCLK 频率不能超过芯片的最大支持频率,否则芯片无法可靠采样 MOSI 上的数据。传感器的最大 SPI 频率一般比 Flash 低,Flash 动辄可以跑 104MHz,很多传感器最高就 10MHz 左右。所以如果你用同一根 SPI 总线的最高速率去驱动所有从机,高速外设跑得欢,低速外设可能一塌糊涂。解决办法是给 SPI 外设配置一个折中的时钟频率,或者在切换不同从机时动态调整 SPI 预分频。动态调整虽然有点啰嗦,但在汇流排上挂了性能差异明显的多个从机时,这是最稳妥的方案。
5.4 从机模式:SPI Slave 的实现注意事项
很多人只用过 SPI 主机模式,但从机模式也一样重要,尤其是在多个 MCU 之间通信的场景。SPI 从机模式下的核心挑战是:时钟完全由主机决定,你没法控制节奏。如果主机的时钟极性和相位和你的从机配置不一致,数据会在边沿上采样错位,整个通信就废了。所以作为从机端,你必须在初始化时就把模式设定好,并且和主机端约定成一致。
另一个从机模式的坑是数据发送时机。主机产生时钟后,从机需要及时地把数据放到 MISO 上;如果从机在时钟边沿到达的那一刻还没有准备好数据,主机就会采到一个空值。所以很多 SPI 从机在硬件上会在片选拉低时就开始准备第一个字节的数据,比如把移位寄存器预装载。如果你要用通用 IO 口模拟 SPI 从机(这在低成本的 51 单片机项目里很常见),一定要用硬件中断来响应片选下降沿,并且尽快装载数据,否则大概率会出现第一个字节丢失或者错位的问题。
我曾在 FPGA 上用 Verilog 实现过一个 SPI Slave,严格按照“检测 CS 下降沿 -> 在第一个时钟边沿之前完成数据装载 -> 根据 CPOL/CPHA 确定采样沿”的状态机来设计。建议写 HDL 的人参考一下芯片内部的机制,而不是只对着波形图猜状态,否则很容易忽略建立时间和保持时间的要求。
6. 从 DMA 到多设备共享总线:SPI 性能优化的几个方向
6.1 为什么要有 SPI DMA:一次传输为什么需要它
MCU 做 SPI 通信时,最简单的模式是“轮询”:CPU 往 SPI 数据寄存器写一个字节,等 SPI 状态寄存器的发送完成标志置位,再写下一个字节。这个过程 CPU 几乎全程占着,而且等待标志位的空转时间也在消耗 CPU 资源。如果要发送 1000 个字节,CPU 就要空转几千次。如果用 SPI DMA,数据的搬运由 DMA 控制器完成,CPU 只需要在初始化时配置好 DMA 通道,然后做自己的事情,传输完成后再触发一个中断通知 CPU 就可以了。
这个优化在刷屏幕或者存储大量数据时效果尤其明显。以 STM32 为例,使用HAL_SPI_Transmit_DMA()发起传输后,HAL 库会注册 DMA 完成回调函数,在HAL_SPI_TxCpltCallback里你就能知道这次传输结束了。如果配合上硬件片选,理论上你可以在屏幕数据全部发送完后才拉高片选,中间完全不用 CPU 参与。
不过 DMA 也有它自己的坑。一个常见问题是 DMA 传输和 CPU 访问同一块内存时可能出现缓存一致性问题,尤其是在 DMA 读取的内存区域最近刚被 CPU 写过的情况下。有些 MCU 的 D-Cache 需要在 DMA 操作前做 flush 或 invalidate,否则 DMA 搬运的可能是一段“旧”的数据。我在 STM32H7 系列上就踩过这个坑——H7 的数据总线架构和 F1/F4 不一样,默认开了 D-Cache,DMA 读到的数据一度是乱码,后来查了勘误手册才发现需要在 DMA 传输前调用SCB_CleanDCache()。
6.2 ESP32 屏幕与 SD 卡共享 SPI:分时复用还是双总线
ESP32 几乎是当代嵌入式 DIY 圈里绕不开的芯片,尤其是它的 SPI 接口非常灵活,支持 VSPI、HSPI 等不同控制器。一个非常常见的问题是:屏幕和 SD 卡能不能共用同一条 SPI 总线?答案是能,但要处理好几个细节。
首先是总线速率的问题。SD 卡通常可以跑到 25MHz 以上,而 ST7789 屏幕的 SPI 接口一般也能接受类似速率,所以理论上可以共用。但 SD 卡的 SPI 模式在初始化时需要先发送至少 74 个时钟脉冲的空闲序列,然后发送 CMD0 进入 SPI 模式,整个过程对时序的要求比较严格。如果在 SD 卡初始化期间屏幕还在不断地产生 SPI 数据,两者就会互相干扰。我的建议是:初始化时分步进行,先把 SPI 总线独占给 SD 卡完成初始化,确认 SD 卡进入 SPI 模式后再把总线切给屏幕用。
另外还有一个很现实的问题:屏幕的控制信号里,DC(Data/Command)线和 RST(复位)线通常是普通 GPIO,而 CS 片选可以接到 SPI 外设的硬件片选上。如果你只有一个硬件片选,就得用软件片选来解决多从机问题。在 ESP32 上用 Arduino 框架时,常见的做法是 SPI.begin() 初始化总线,然后屏幕用ST7789库、SD 卡用SD库,各自实例化时传入不同的 CS 引脚号。只要底层的 SPI 总线互斥处理好(比如用锁或者仅在传输期间占用总线),大多数时候可以稳定工作。
如果你的项目对屏幕刷新率和 SD 卡写速度都有比较高的要求,那最好还是给它们分配独立的 SPI 总线。ESP32 有好几个 SPI 控制器,屏幕接一个控制器、SD 卡接另一个控制器,两者互不干扰,代码也更干净。毕竟屏幕每帧十几 KB 的数据量,如果和 SD 卡写日志抢总线,总有一方会被拖慢。
6.3 逻辑分析仪是 SPI 调试的第一利器
用 SPI 通信最痛苦的阶段是什么?不是不懂原理,而是在代码里看不出来哪里出错了。你是写了HAL_SPI_Transmit,但总线上的实际信号是什么样,谁也不知道。所以我强烈建议每一个做嵌入式开发的人,手头都备一个逻辑分析仪,哪怕是二十来块钱的 USB 逻辑分析仪,在 SPI 调试上都能发挥巨大的价值。
抓 SPI 波形时,至少把 CLK 和 MOSI 接上,调试从机返回数据时再把 MISO 接上。逻辑分析仪的采样率建议不低于 SPI 时钟的 4 倍,比如 SPI 跑 10MHz,逻辑分析仪至少要有 40M 采样率,否则边缘会被采模糊。抓完波形后,逐位去数 MOSI 上的数据,对照数据手册上的指令周期,几乎能一眼看出问题出在哪个边沿、哪个字节、哪个 bit。我调试 SPI Flash 读 ID 失败时,就是靠逻辑分析仪发现主机发出的地址字节少了一位——原来是代码里地址变量定义成了 8 位,而 Flash 需要 24 位地址,导致高位全被截断了。
相比示波器,逻辑分析仪在这种多通道数字信号的场景下更实用,它不需要你手动触发,而能完整记录一段时间内的全部信号,并且自动解析出 SPI 数据帧。调试效率真的不是一个量级的。
7. 常见问题与排查技巧实录
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 读回的寄存器全是 0xFF | 从机没有被选中,或 MISO 未被驱动 | 检查 CS 片选电平、从机供电、MISO 是否虚焊 |
| 读回的数据是全 0 | MISO 被拉死低电平 | 检查从机的 MOSI/MISO 是否接反、从机初始化是否完成 |
| 数据错乱,但第 1 个字节对 | SPI 模式不匹配 | 对比数据手册时序图,重新确认 CPOL/CPHA |
| 数据整体向右或向左移了 1 位 | 采样沿选择导致错半个时钟周期 | 尝试把 CPHA 从 0 改成 1 或相反 |
| 只有首字节对,后续全错 | 片选提前拉高或 DMA 传输长度不对 | 检查 DMA 配置,传输完成后确认片选保持时间足够 |
| SPI 正常但有随机干扰 | 信号线过长、接线未共地、电源纹波大 | 缩短线序,SPI 信号加下拉、电源就近滤波 |
| 屏幕花屏但有输出 | DC 引脚时序不对或 SPI 速率超过屏幕上限 | 检查 DC 切换时机,降低 SPI 时钟频率 |
| Flash 写不进去 | 缺少写使能(0x06)指令 | 每次写/擦前先发写使能,检查 WEL 状态寄存器 |
| Flash 写数据跨页回绕 | 页编程超过 256 字节边界 | 强制做页内偏移对齐,按页分段写入 |
| 主机 MISO 引脚无输入 | 从机未释放 MISO 高阻态 | 检查从机的片选无效状态,是否仍占用总线 |
| DMA 传输的数据是旧数据 | 缓存一致性问题 | 配置 D-Cache 的 clean/invalidate 操作 |
7.1 我踩过最狠的一个 SPI 坑:模式配对了,时钟速率还是太高
有一次调一颗 ADC 芯片 ADS1256,手册上清清楚楚写着支持 SPI Mode 1,我配置确实是对的,初始化代码也检查了很多遍,可读回来的数据就是不正常。后来用逻辑分析仪抓波形,发现 SCLK 的频率是 40MHz,而 ADS1256 的数据手册上写着最大 SCLK 是 10MHz。当主机时钟太快时,从机内部的采样逻辑根本跟不上,即使模式对了也白搭。解决办法是把 SPI 预分频调到 SCLK = 10MHz 以下,数据立刻恢复正常。这个案例告诉我们,SPI 调试不能只看“模式”和“接线”,时钟速率是一个独立的必须检查的维度。很多从机的 datasheet 会在确定时序参数的表里明确列出最小 SCLK 周期,把它换算成频率和实际配置对比一下,往往能直接排查掉一半问题。
7.2 一个小技巧:用假数据读真数据
SPI 是“读写同时进行”的协议,所以调试读操作时,你可以故意发一串有规律的假数据(比如 0xA5、0x5A、0xFF、0x00),然后观察 MISO 上返回的数据。如果返回的数据是 0xA5 的反序或者其他有规律的变化,说明通信链路基本是通的,问题大概率出现在指令格式或者寄存器地址上。如果返回的数据完全是乱的、不重复的,再考虑模式配置问题。这个方法比我见过的很多人“盲调”要高效得多。
还有一个长期实用的习惯:初始化 SPI 外设后,先读一次从机芯片的 ID 寄存器,无论是 Flash、屏幕还是传感器,几乎都有唯一的 ID。这个 ID 相当于通信链路的“握手信号”,如果 ID 读出来和手册一致,那整个 SPI 协议栈就算打通了,后续的功能开发就有了坚实的基础。
7.3 多从机总线上的电平冲突怎么排查
多从机共享总线时,最怕的是两个从机同时驱动 MISO,造成电平冲突。排查手段很简单:把其他从机的片选都释放掉(拉高),只保留一个从机工作,看通信是否正常;如果能正常工作,问题几乎可以锁定在片选时序或者从机的 MISO 高阻态控制上。
有时候问题不是出在片选信号本身,而是某个从机的 MISO 在掉电状态下仍然会驱动总线。比如系统里有一路 3.3V 的传感器,它的 MISO 直接连到了 MCU 的引脚上,其他从机是 5V 系统,两者之间没有做电平转换,结果 3.3V 的传感器感受到了高电平反灌,内部产生了闩锁效应,MISO 一直输出异常电平。这种问题的排查很容易跑偏,因为看起来像是 SPI 时序问题,实际是电源域隔离没做好。在混接 3.3V 和 5V 器件的电路里,SPI 信号线必须做电平转换或至少串联限流电阻,这也算是硬件层面的一个重要避坑手段。
8. 写在最后的经验总结
如果你现在正准备开始第一个 SPI 项目,我的建议很简单:先不要急着写代码,先把从机的手册翻开,找到时序图和电气参数表,搞清楚三个问题——它支持哪些 SPI 模式、最大的 SCLK 频率是多少、CS 信号的建立时间和保持时间分别要求多少。这三个问题的答案,决定了你初始化代码里 80% 的配置。
在实际调试中,我也越来越体会到“分层验证”的价值。先用逻辑分析仪或者示波器确认物理层有信号、模式正确;再通过读 ID 确认协议层打通;最后才去写具体的数据收发逻辑。很多人一上来就把 SPI Flash 驱动、文件系统、日志系统全部堆在一起调,一旦出问题根本不知道从哪开始排查。分层验证的思路虽然慢一点,但每一步都走得踏实,反而能更快地定位问题。
至于我个人的偏好,通信协议学习的顺序,我建议先把 SPI 吃透,再去学 I2C 和 UART。SPI 涉及了同步通信、时钟极性/相位、从机选择、多设备仲裁、DMA 优化等几乎全部串行通信的关键概念,学好 SPI 相当于一次建立起了对串行通信的整体认知,后面的其他协议学起来会轻松很多。祝你在 SPI 的世界里少踩几个坑,一次打通。