SPI协议详解:从基础时序到调试实战,一文吃透嵌入式通信
2026/9/6 7:31:55 网站建设 项目流程

1. 先搞清楚SPI到底是什么,以及它凭什么能活到现在

我最早接触通信协议是搞串口,UART那套东西简单得很,一根发送线一根接收线,两边约定好波特率就能聊起来。后来做项目要用到Flash存储、SD卡、屏幕这类外设,串口的速率明显不够用了——你拿115200波特率去刷一块320x240的屏幕试试,一帧画面能传到天荒地老。这时候SPI就派上用场了,这也是它至今没有被I2C或UART淘汰的核心原因。

SPI的英文全称是Serial Peripheral Interface,串行外设接口,由Motorola在20世纪80年代提出。它一开始就是奔着“嵌入式处理器和外围芯片之间高速交换数据”这个目标去的。你没看错,这个协议比我年龄都大,但它今天依然活跃在几乎每一块MCU、每一个FPGA、每一颗Flash芯片内部,属于那种“看起来简单但根本绕不过去”的基础协议。

SPI之所以能存活这么多年且地位稳固,本质上是几个特点叠出来的:

  • 全双工通信:发送和接收可以同时进行,不像UART虽然也支持全双工,但SPI的时钟由主机控制,时序更可控。
  • 速率高:SPI没有固定的波特率限制,理论上只要从设备能承受,主机的时钟能推多快就能跑多快。常见的SPI Flash跑个几十MHz甚至上百MHz都很正常。
  • 协议极简:没有地址帧、没有应答机制(当然也有扩展版本加了应答),就是时钟、收发、片选这几件事,非常容易用逻辑分析仪抓波形排查问题。
  • 实现灵活:你可以用硬件SPI外设,也可以用GPIO直接模拟,也就是俗称的“软SPI”。这在资源受限或引脚冲突时特别有用。

用一个不太恰当的类比来说:I2C像公共汽车,所有设备挂在两条线上,靠地址寻址,有严格的时序仲裁;SPI更像专线出租车,主机拉一根片选线选中谁,谁就独占这条通道,速度自然就上来了。也正因为如此,SPI在“一对一高速传输”和“一对多但能容忍更多引脚”的场景下近乎没有对手。

我见过很多初学者对着一堆协议名发懵,什么I2C、UART、SPI、CAN、USB,感觉每个都长得差不多。实际上你只要记住一句话:SPI把“数据线、时钟线、片选线”分开管理,谁被选中谁说话,时钟由主机说了算,这就是它的全部灵魂。

2. SPI的四根线与两种拓扑结构,接线之前先把这些搞明白

2.1 四根线的分工,比你想的更讲究

SPI通常由四根线组成,分别是SCLK、MOSI、MISO、CS/SS。名字在不同厂家的手册里有细微差别,但角色完全一致:

信号线常见别名方向作用
SCLKSCK、CLK主机到从机串行时钟,所有数据位的传输都对齐到该时钟边沿
MOSISDO、DI、COPI主机到从机Master Output Slave Input,主机输出数据给从机
MISOSDI、DO、CIPO从机到主机Master Input Slave Output,从机输出数据给主机
CS/SSNSS、CE、SYNC主机到从机片选信号,低电平有效,拉到低时该从机被“点名”

这里特别容易踩坑的是MOSI和MISO接反。很多芯片手册上标注的是DO和DI这种视角,不同工程师写标注的习惯不一样,有的按主机视角标,有的按从机视角标。我的建议是:画原理图或接杜邦线的时候,永远从“主机视角”标注MOSI和MISO,到了从机那一侧强制换算成SI/SO或者DI/DO,然后交叉连接。

举个例子:STM32主机的MOSI要接到从机芯片的SI(Slave Input),而不是接到从机的SO上去。你要是对着两个数据手册分别看,很容易搞混,我已经见过不下五个工程师因为这个接反而调了大半天。

2.2 点对点拓扑:一根片选线管一颗芯片

最典型的用法是MCU直连一颗SPI Flash或者一块SPI屏幕。主机给每颗从机分配一根独立的CS引脚,同一时刻只能拉低其中一个。因为每次通信都只有一个从机被选中,所以从机的MISO引脚可以共享到同一条总线上,不会造成冲突。

这种拓扑的优势是简单、速度快、信号完整性好控制。缺点也明显——每增加一颗设备就要增加一个GPIO用于片选。当你的板子上有Flash、屏幕、SD卡、传感器一共四五个SPI设备时,GPIO开销就有点肉疼了。

2.3 总线型拓扑:多设备挂一根总线

SPI也支持把多颗从机挂在同一条SCLK、MOSI、MISO上,靠各自的CS区分。这样做省引脚,但有代价:

  • 所有从机的MISO输出引脚必须是“开漏输出”或“三态输出”,也就是没被选中时必须高阻释放总线,否则多颗芯片同时驱动MISO会打架。
  • 总线上挂的设备越多,寄生电容越大,SCLK速率就不得不降下来,否则波形畸变严重。
  • 调试排错也更麻烦,某颗芯片拉低了总线可能导致其余设备全部通信异常。

我自己做项目的经验是:一颗MCU的SPI外设,点对点挂一个屏、一颗Flash、一个SD卡槽,各用一根片选,这是最舒服的配置。非要节省引脚的话,至少把低速设备和不常访问的设备丢到软件模拟SPI上去,别在硬件SPI上硬挤。

3. 四种工作模式与“时钟极性/相位”,调不出来波形多半是这里错了

SPI有一个让无数人挠头的点:四种工作模式。这四种模式由CPOL(Clock Polarity,时钟极性)和CPHA(Clock Phase,时钟相位)两个参数组合而成。很多LCD驱动芯片、Flash芯片的初始化代码里都有SPI_MODE0SPI_MODE3的宏定义,你要是选错了,屏幕上显示花屏、Flash读出全0xFF,基本都是这个原因。

3.1 CPOL和CPHA到底在描述什么

先说CPOL。它决定的是SCLK在空闲状态(也就是没有数据传输时)的电平是低还是高:

  • CPOL=0:空闲时SCLK为低电平
  • CPOL=1:空闲时SCLK为高电平

再看CPHA。它决定的是数据在SCLK的哪个边沿被采样:

  • CPHA=0:在SCLK的第一个边沿采样数据(前沿采样)
  • CPHA=1:在SCLK的第二个边沿采样数据(后沿采样)

两个参数一组合,就有了大家常说的SPI Mode 0、1、2、3:

模式CPOLCPHA采样边沿(以空闲态为参考)常见场景
Mode 000上升沿采样,下降沿切换数据最常用,绝大多数SPI Flash和LCD默认模式
Mode 101下降沿采样,上升沿切换数据个别传感器
Mode 210下降沿采样,上升沿切换数据某些特定芯片
Mode 311上升沿采样,下降沿切换数据不少SD卡、W25Q系列Flash也支持

说句实话,我遇到的项目里九成都是Mode 0或Mode 3。很多芯片的数据手册会在“SPI Timing Characteristics”章节画一堆时序图,你需要找到“Data Setup Time”和“Data Hold Time”这两项,再对照手册上的波形图判断该用哪个模式。

3.2 一个真实的踩坑案例:Mode 0和Mode 3并非总是通用

之前有人跟我说:“W25Q128不是支持Mode 0和Mode 3吗?那我随便选一个不就行了?”理论上确实如此,W25Q128的数据手册里明确写了支持这两种模式。但问题出在某些从机芯片在不同模式下行为不完全一致

我接过一颗国产的LCD驱动芯片,手册上标注支持Mode 0和Mode 2,结果实际测试发现:初始化命令用Mode 0发送没问题,但连续读显存数据时如果继续用Mode 0,读回来的数据会错位几个字节。换成Mode 2之后一切正常。

排查过程很折腾。先用逻辑分析仪抓波形,确认SCLK频率、电平都正确;再用示波器看MOSI上的数据与SCLK边沿的相对位置,发现数据切换点离采样点太近,时序裕量不足;最后怀疑到模式头上,一条条命令换模式去试,才定位到问题。

这个案例给我们的教训是:不要只看芯片手册上说“支持哪些模式”就以为可以随意选,关键的读操作、写操作可能对时序裕量的敏感度不同。稳妥的做法是:拿到一颗新芯片,第一时间用逻辑分析仪把初始化时序完整抓下来,然后对照手册逐项确认模式、时钟频率、片选时序、命令格式,而不是直接套STM32的HAL库默认配置。

3.3 软件模拟SPI和硬件SPI的模式理解

软件模拟SPI的时候,理解CPOL和CPHA会更直观,因为每一个时钟边沿都是你用delay函数硬“憋”出来的。比如用GPIO模拟Mode 0,大概是这样一个思路:

// 伪代码:软件模拟SPI Mode 0,CPOL=0,CPHA=0 // 空闲时SCLK=0 // 在SCLK上升沿发送数据(实际上是先准备数据,再拉高时钟) // 在SCLK下降沿之后,从机数据已稳定,主机可以采样 void SW_SPI_WriteByte(uint8_t data) { for (int i = 7; i >= 0; i--) { // 先准备好数据线 MOSI_GPIO = (data >> i) & 0x01; // SCLK从低拉高,产生上升沿 SCLK_GPIO = 1; // 空转几个周期,让从机采样 delay_us(1); // 拉低SCLK,产生下降沿 SCLK_GPIO = 0; delay_us(1); } }

但如果是Mode 1,即CPHA=1,那么在上升沿是数据切换、下降沿才算采样,你的代码顺序就要反过来:先拉高SCLK,再改变MOSI,再拉低SCLK,从机在下降沿采样。

理解了这一点,你会发现硬件SPI的寄存器配置和软件模拟本质上描述的是同一件事,只是硬件帮你把边沿对齐的事情做了,一旦模式配错,硬件反而变成了“黑盒”,更难排查。

4. 硬件片选与软件片选,省引脚的高级玩法及其代价

片选这个事看起来很简单:拉低选中、拉高释放。但实际项目里,片选的实现方式会直接影响系统的稳定性、引脚分配和软件架构。这里必须把“硬件片选”和“软件片选”的区别讲透。

4.1 硬件NSS与软件CS的差别

不少MCU的SPI外设自带一个NSS引脚,有的还支持硬件自动控制NSS——主机启动传输时自动拉低,传输结束自动拉高。看起来很省心,但有两个潜在问题:

  • NSS引脚可能被复用:某些MCU上NSS和别的功能共享引脚,你未必能腾得出来。
  • 硬件NSS在“一主多从”场景下并不好用:多颗从机就需要多根片选线,而硬件NSS往往只有一根。你只能额外用GPIO去控制其余从机的片选。

所以我更常用的方案是整个放弃硬件NSS,用普通的GPIO来做软件片选。这样有几个好处:

  • 片选引脚的选择非常自由,哪个GPIO空着就用哪个。
  • 片选的拉高拉低时机完全由软件控制,可以做到传输开始前提前拉低,传输完全结束后再拉高,时序裕量更充足。
  • 多颗从机的片选管理非常自然,就是分别操作不同GPIO。

4.2 软件片选的几个必须注意的细节

软件片选虽然灵活,但有三件事必须注意:

第一,片选信号的建立时间。从CS拉低到SCLK产生第一个时钟沿之间,需要留出一点时间让从机内部完成切换。不同芯片的CS Setup Time不同,快的可能几百纳秒,慢的到微秒级。如果你用HAL库的HAL_SPI_Transmit,它内部可能已经处理了,但如果直接操作寄存器或者用DMA,就得在代码里显式加入延时或检查标志位。

第二,片选不要提前拉高。有些从机要求在最后一个SCLK边沿之后、再保持CS低电平一段时间才能完成内部锁存。虽然大部分芯片对CS Hold Time的要求不苛刻,但设计上宁可多留一点余量。

第三,片选引脚的空闲电平必须确认。SPI片选绝大多数是低电平有效,但有个别芯片是高电平有效(比如某些音频芯片),接反了症状极其诡异:偶尔能通偶尔不通、首字节丢失等。我建议拿到任何一颗SPI从机,第一件事就是去数据手册里搜索CSSS的有效电平说明,不要想当然。

4.3 补充一个实用技巧:DPDK和SPI应用中的片选策略

可能有人会疑惑,SPI片选芯片的时候,代码里怎么管理更优雅?我见过两种风格:

一种是每次传输都“拉低CS -> 发送/接收 -> 等待完成 -> 拉高CS”,简单粗暴,适合低速率、低频次访问的场景。另一种是“一次拉低CS,连续执行多个操作后再拉高”,比如初始化LCD时一连串寄存器配置命令,CS一直拉低,效率高不少。

但注意,后面这种做法要求从机支持“多字节连续写”或者“地址自动递增”模式,否则就得靠命令本身的语义来控制。比如很多SPI Flash支持Write Enable、Page Program、Read Data这种复合指令,CS低电平期间一次发一条完整指令没问题。

我个人写驱动时喜欢把CS操作封装成两个宏或内联函数:

#define SPI_CS_LOW() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET) #define SPI_CS_HIGH() HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET)

然后在每一条完整指令的开头和结尾调用。这样代码可读性高,也方便以后换引脚或换成硬件NSS。

5. 从HAL库到寄存器操作,CubeMX配置里最容易忽略的几个点

STM32CubeMX几乎是现在STM32开发的默认起点,SPI的配置界面虽然看起来选项就那么几项,但新手和高手的配置差距往往体现在细节上。这里挑几个我实际踩过的点展开讲。

5.1 CubeMX中SPI参数的推荐配置

在CubeMX的SPI配置页里,你会看到这些参数:

参数推荐值说明
ModeFull-Duplex Master(全双工主机)大多数场景够用,部分屏用半双工写命令可以省事
Hardware NSS SignalDisable用软件片选,自由度更高
Data Size8 Bits多数外设是8位数据;个别Flash支持4字节地址需要24/32位模式
First BitMSB FirstSPI协议默认高位先行,绝大多数芯片如此
Prescaler根据外设最高频率算见下方说明
Clock Polarity / Phase按芯片手册选前文已详细讲解
CRCDisable极少用,除非你的从机支持并需要

有个地方我要多提醒一句:CubeMX的Prescaler会影响APB2或APB1总线时钟,而SCLK实际频率 = SPI所在总线时钟 / 分频系数。很多人配置完后用示波器量SCLK,发现频率比自己预期高或低,就是因为没算清楚时钟树。比如STM32F407的APB2最高是84MHz,SPI挂在APB2上,如果你选Prescaler=8,那么SCLK就是84/8=10.5MHz,而不是你以为的某个整数。

5.2 HAL库下SPI通信的代码细节

HAL库封装得比较友好,但有几个“坑”非常典型。以HAL_SPI_TransmitReceive为例:

uint8_t txData[16]; uint8_t rxData[16]; HAL_StatusTypeDef status = HAL_SPI_TransmitReceive(&hspi1, txData, rxData, sizeof(txData), 100);

第一坑:发送和接收缓冲区的数据宽度必须和配置的Data Size一致。如果你配置成8位,却传了一个16位数组进去,数据会错位甚至越界。

第二坑:超时参数的单位是毫秒,但对于DMA方式传输,HAL_SPI_TransmitReceive_DMA并没有超时一说,你必须在HAL_SPI_TxRxCpltCallback回调里确认传输完成,而不是在函数返回后立刻操作CS拉高。很多人一开始都用HAL_SPI_Transmit,后来想提高效率改成DMA,结果忘了加传输完成回调,CS在DMA还没搬完数据时就拉高了,导致最后一截数据丢失。

第三坑:HAL_SPI_TransmitHAL_SPI_Receive之间不要交替发得太频繁。HAL库内部有状态机,某些版本对连续快速切换发送/接收模式的处理不够完善,会出现HAL_BUSY的错误码。解决方法是每次传输前调用HAL_SPI_Abort,或者改用HAL_SPI_TransmitReceive这种同时收发的方式。

5.3 从HAL库往下看寄存器,排查问题的关键一步

虽然HAL库用起来省心,但遇到诡异问题你还是得会看寄存器。比如SPI在上电后没有正常工作,我一般先读三个寄存器:

uint8_t sr1 = hspi1.Instance->SR; // 状态寄存器 uint16_t cr1 = hspi1.Instance->CR1; // 控制寄存器1 uint16_t cr2 = hspi1.Instance->CR2; // 控制寄存器2
  • SR里的BSY位表示SPI外设是否忙,如果你发现CS都拉低了但BSY一直为1,说明片选和SPI外设之间没协调好。
  • CR1的SPE位是SPI使能位,HAL初始化失败或者被意外关闭时,这个位会被置0,SPI就完全不工作了。
  • CR2的FRXTH位影响FIFO阈值,如果不匹配会产生RXNE(接收缓冲区非空)标志过早或过晚的问题。

会看寄存器之后,你就不太需要依赖IDE的调试窗口去猜了。这也是从“会用HAL库”进阶到“能独立排查SPI问题”的一道分水岭。

6. SPI DMA:真正把CPU从数据搬运中解放出来的玩法

前面说的HAL库阻塞式收发,在低速场景下没问题,但如果你要连续刷新一个大尺寸屏幕,或者从SD卡连续读几百KB数据,CPU大部分时间都耗在搬运字节上了。这个时候就必须上DMA。

6.1 SPI DMA的核心思路

DMA干的事情很简单:外设和内存之间搬运数据,不经过CPU。以SPI接收为例,数据到达SPI的接收寄存器后,DMA自动把它搬到内存缓冲区,搬满预设数量后产生中断通知CPU。整个过程CPU可以继续跑别的逻辑。

以STM32 HAL库为例,使能SPI RX的DMA是这样配置的:

// 假设已经用CubeMX配置好SPI1的DMA请求 HAL_StatusTypeDef status = HAL_SPI_Receive_DMA(&hspi1, rxBuffer, len); // 之后在回调里确认完成 void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { // 数据已全部收到,可以处理rxBuffer } }

这个回调必须注意:如果你的项目里SPI和别的外设都用了DMA,回调函数名可能冲突。HAL库统一用HAL_SPI_RxCpltCallback作为SPI接收完成回调,多个SPI实例都会进这个回调,你需要通过hspi->Instance判断是哪个SPI。

6.2 SPI DMA与片选时序的矛盾

这个坑我栽过一次,说出来给大家提个醒。

DMA方式下,HAL_SPI_Transmit_DMA返回时数据其实还没发完,DMA还在后台搬运。如果你在这个函数返回后立刻把CS拉高,那么数据传了一半就断了。很多人习惯写:

SPI_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, txBuffer, len); SPI_CS_HIGH(); // 错!!DMA还没传完

正确做法是CS的拉高要放在HAL_SPI_TxCpltCallback回调里,或者用信号量/标志通知主循环:

volatile uint8_t spi_tx_done = 0; void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi->Instance == SPI1) { spi_tx_done = 1; } } // 发送位置 spi_tx_done = 0; SPI_CS_LOW(); HAL_SPI_Transmit_DMA(&hspi1, txBuffer, len); // 等待完成,或去做别的事 while (!spi_tx_done); SPI_CS_HIGH();

如果你用的是RTOS,更合适的方式是直接用二进制信号量在中断里释放。

6.3 DMA vs 中断 vs 阻塞,什么时候选谁

我整理了一个简单的选型思路:

方式优点缺点典型场景
阻塞式(Polling)代码简单,逻辑清晰占用CPU,效率低初始化配置、低频率命令发送
中断方式不阻塞CPU,逻辑和中断绑定每次字节/帧触发一次中断,频繁上下文切换速率中等、长度不长、需要及时响应的场景
DMA方式几乎零CPU占用,搬大数据高效配置复杂,回调逻辑多,和CS时序容易冲突刷屏、连续读Flash、SD卡读写

我现在的习惯是:初始化命令全用阻塞式,批量数据读写用DMA,个别需要即时响应的传感器短数据用中断方式。这个组合在工程上既稳定又高效。

7. 大数据量场景实战:SPI屏幕刷新、Flash读写与SD卡共享SPI

如果一个项目里同时用到SPI屏幕、SPI Flash和SD卡,它们共用同一个SPI外设是常有的事。这种“共享总线”的做法省硬件资源,但要处理好几个问题。

7.1 屏幕和SD卡共享SPI,到底划不划算

先说结论:能分开尽量分开,分不开就上多路片选加仔细的驱动隔离。

屏幕刷新频率很高,尤其是动态界面,动不动就要推几十KB甚至几百KB的像素数据。SD卡的读写也动辄一次几KB到几十KB。两者抢总线的时候,屏幕会掉帧、SD卡读写变慢,严重的时候可能出现SPI总线电平冲突导致数据错误。

如果MCU只有一个SPI外设,我的建议是优先这样分:

  1. 屏幕和SD卡不要同时挂在一个硬件SPI上,至少其中一个用软件模拟SPI。
  2. 屏幕优先吃硬件SPI+DMA,因为它的数据流量最大且实时性要求高。
  3. SD卡的数据操作允许“慢一点”,软件SPI的速率只要在SD卡支持范围内,用起来也不会太难受。

有人会问:“ESP32屏幕与SD卡共享SPI哪个好?”我用ESP32做过一轮对比测试,结论是:如果你的屏幕刷新率要求不高(比如只是静态仪表盘),SD卡和屏幕共享一个SPI,用轮询方式分时访问,完全可以,布线还简洁;但如果你要跑完整的GUI动画,屏幕数据量极大,那SD卡最好单独走一个SPI或者SDMMC接口。

7.2 提高SPI屏幕刷新率的几个手段

SPI屏幕的刷新率瓶颈不在SPI协议本身,而在于你怎么把像素数据送到屏幕。实测中我用以下几招把刷新率提了一大截:

  • 用DMA搬运像素数据:单纯靠CPU逐字节写显存,频率在40MHz SPI下也只有几百万字节每秒,DMA可以把这个数字推到接近SPI硬件的极限。
  • 减少无意义的控制命令:很多LCD驱动IC在连续写显存时,可以使用同一指令不带重复的显存地址写入模式,省掉每次写数据前的命令和地址帧。
  • 提高SPI时钟频率:大部分MCU开板子默认SPI主频才2.25MHz(CubeMX默认分频很大),实际很多屏幕支持几十MHz,调到允许的上限往往刷新率直接翻几倍。
  • 合理使用显存撕裂避免机制:如果屏幕控制器有TE(Tearing Effect)信号,可以利用它来同步刷新,避免画面撕裂,但这本身不会提高帧率,只是体验更好。

SPI屏幕的刷新率上限还受限于SCLK频率和像素格式。一个320x240 RGB565的屏幕,一帧数据是320x240x2=153600字节,如果SPI时钟40MHz,理论传输时间约30毫秒,也就是约33帧上限。想要更高帧率,要么降分辨率、降色深,要么换并口或MIPI DSI接口。这是物理带宽决定的,不是软件能突破的。

7.3 SPI Flash的页编程与擦除特性,驱动代码必须尊重它

SPI Flash和屏幕的逻辑很不一样。屏幕是“我发数据你显示”,Flash是“要先擦除再编程,而且按页写、按扇区擦”。

以W25Q128为例:

  • 页大小256字节,Page Program最多写256字节,超过页边界会自动回卷——这一点特别坑,如果你一次写300字节且跨越了页边界,数据会写到页面开头去。
  • 扇区4KB,写之前必须先擦除,擦除后所有位变成0xFF。
  • 状态寄存器里的BUSY位表示Flash是否正在内部擦写,写完指令后要轮询等待。

实际驱动里,我封装了“任意地址、任意长度”的写函数,内部自动处理跨页拆分:

void SPI_Flash_Write(uint32_t addr, uint8_t *data, uint32_t len) { while (len > 0) { // 计算当前页内剩余空间 uint32_t pageRemain = 256 - (addr % 256); uint32_t chunk = (len < pageRemain) ? len : pageRemain; // 写入使能 SPI_Flash_WriteEnable(); // Page Program指令 + 地址 + 数据 SPI_Flash_PageProgram(addr, data, chunk); // 等待BUSY清零 SPI_Flash_WaitBusy(); addr += chunk; data += chunk; len -= chunk; } }

这种“先算出剩余页大小,按块发送”的思路,是所有Flash驱动的基础,也是面试八股里经常考察的点。

8. 从Verilog到FPGA:用代码实现SPI Slave的思考路径

热搜词里看到不少“SPI slave verilog”“FPGA SPI”之类的关键词。很多做FPGA的同学一开始接触SPI,都是调现成的IP核或参考代码。但我觉得只有亲手写过一遍SPI Slave,才算真正理解SPI时序。这里分享一个用Verilog实现SPI Slave的思路,不贴完整代码,但把关键机制讲清楚。

8.1 SPI Slave的核心状态机

SPI Slave的逻辑本质上是一个“串入并出”的移位寄存器,外加一个片选管理和字节拼接状态机。

核心结构大致是:

reg [7:0] shift_reg; reg [2:0] bit_cnt; always @(posedge sclk or negedge rst_n) begin if (!rst_n) begin shift_reg <= 8'h00; bit_cnt <= 3'd0; end else if (cs_n == 1'b1) begin // 片选释放,复位位计数 bit_cnt <= 3'd0; end else begin shift_reg <= {shift_reg[6:0], mosi}; bit_cnt <= bit_cnt + 1'b1; if (bit_cnt == 3'd7) begin // 一个字节收满,可以置位接收完成标志 rx_done <= 1'b1; end end end

关键点在于:采样边沿的选择必须和主机的模式匹配。如果主机是Mode 0,从机也要在上升沿采样,下降沿切换输出。这个配合在Verilog里就是posedge sclk还是negedge sclk的选择,写错了整个数据全乱。

8.2 跨时钟域与主机速率适配

FPGA内部逻辑经常工作在100MHz甚至更高,而SPI的SCLK可能只有几MHz。如果直接从posedge sclk采样,逻辑综合时会生成跨时钟域路径,时序收敛困难。更稳妥的做法是:

  • 用FPGA高频时钟对SCLK做两级同步,检出上升沿和下降沿脉冲。
  • 基于同步后的脉冲沿进行数据采样和移位,而不是直接用SCLK做触发时钟。

这样做的好处是,SCLK速率变化时只需要调整约束,逻辑本身不变。

另一个常见问题是主机速率过高时,FPGA内部逻辑来不及在一个SCLK周期内完成移位和判断。解决思路是提高FPGA内部时钟频率,或者对SCLK进行分频采样判断。实际上大部分SPI Slave IP都会对SCLK做边沿检测,再根据内部时钟生成合适的采样窗口。

8.3 FPGA实现SPI Flash控制器的常见坑

很多人做FPGA读写SPI Flash时,会遇到“能读ID但读写数据失败”的情况。我的排查顺序一般是:

  1. 先逻辑分析仪抓SCLK、CS、MOSI、MISO四条线的波形,确认命令字和地址是否对。
  2. 确认Flash是否进入QA(Quad Enable)状态,如果之前测试时把状态寄存器改了但没还原,后续普通SPI模式可能不工作。
  3. 检查写操作前的Write Enable命令是否成功,状态寄存器里WEL位是否为1。
  4. 确认页编程时地址和数据是否跨页,以及擦除后是否等待BUSY清零。

FPGA调试和MCU调试最大的不同是:MCU可以打印日志,FPGA只能在波形图里一层层翻。所以从一开始就养成“设计里带上足够的观测信号”的习惯,比什么都重要。至少把状态机当前状态、位计数、接收到的字节数据这些信号引出来,挂在调试接口上,不然出了问题几乎无从下手。

9. 常见故障排查手册:我列一张按症状找原因的对照表

SPI通信问题的症状往往五花八门,但根因翻来覆去就那么几类。我整理了一张表,是这些年排查问题的经验浓缩,适合直接拿来当排查手册:

现象最可能原因次可能原因排查手段
数据完全错乱,接收端读到0x00或0xFFSPI模式不匹配(CPOL/CPHA配错)MOSI/MISO接反逻辑分析仪抓波形,对照时序图确认
首字节丢失,后面数据正常CS建立时间不足或CS操作时序不对主机速率太快,从机未准备好降低SPI频率,检查CS拉低到第一个时钟沿的延时
偶发数据错位或乱码信号线过长导致波形畸变SPI线缆未接下拉/上拉示波器看波形沿,缩短线缆,加端接电阻
发送正常,接收永远为0xFF从机MISO没输出或MISO引脚配置错从机未被正确选中测量CS电平,确认MISO连接
用DMA传输后数据缺尾巴CS在DMA传输完成前被拉高DMA回调未正确处理把CS拉高放到DMA完成回调里
多从机共享总线后数据冲突未选中从机的MISO没置为高阻多个从机CS同时拉低检查每个从机的CS逻辑,确认同一时刻只有一个拉低
Flash擦写后读回全FFFlash需要先擦除再写页编程时跨页被回卷检查Flash内部状态寄存器,确认WEL和BUSY

这些故障里,最容易自我怀疑的就是“偶发错位”这一类。比如板子正常跑了几个月,某一天突然数据偶尔错几个字节,这种大概率是信号完整性问题,和环境温度、线缆长度、接触电阻都有关。这个时候别急着改软件,先拿示波器看SCLK的沿有没有振铃,MISO的数据变化有没有毛刺,比盲调软件有效率得多。

10. 绕不开的调试工具:逻辑分析仪怎么用才高效

工欲善其事必先利其器,SPI调试必须有逻辑分析仪。不是“最好有”,是必须有。没有逻辑分析仪,你就像在黑暗里找一枚黑针,全凭运气。

10.1 最基本的SPI解码用法

现在市面上几十块到几百块不等的逻辑分析仪,配合开源的sigrok PulseView或厂商自带软件,基本都支持SPI解码。用法统一得很:

  1. 把SCLK、MOSI、MISO、CS(假设低有效)分别接到逻辑分析仪的通道上。
  2. 设置采样率,至少要是SPI SCLK频率的4倍以上,稳妥建议10倍以上。如果你SCLK跑10MHz,采样率至少要40MHz,最好能上100MHz。
  3. 设置SPI解码器的参数:CPOL、CPHA、位序(一般是MSB First)、CS有效电平(低有效)。
  4. 触发电平设在CS下降沿,这样每次通信开始就能稳定抓取。
  5. 抓完看解码结果,对照数据手册里的命令格式逐字节核对。

这个过程能把“时序对不对”“数据对没对”直接可视化。很多我看不出问题的场景,一抓波形就发现是时钟极性配反、数据位偏移了一位这种低级问题。

10.2 示波器和逻辑分析仪的分工

逻辑分析仪擅长抓多路数字信号,示波器擅长看模拟波形细节。SPI调试中两者是这样分工的:

  • 逻辑分析仪:确认SCLK频率、CPOL/CPHA配置、MOSI/MISO数据内容、CS时序是否符合逻辑。
  • 示波器:确认电压电平是否达标、上升沿有没有过冲/振铃、线间是否存在串扰、阻抗是否匹配。

如果只是看看数据对不对,逻辑分析仪足够。如果怀疑高速传输时波形畸变导致偶发错误,就必须上示波器。我自己遇到过一个小批量产问题:SPI Flash在常温下正常,进了高温箱就偶发读ID错误,最后用示波器抓波形,发现SCLK的上升沿上有个负向毛刺,高温下芯片阈值漂移后直接误判出多余时钟沿。这种问题只有示波器能看清。

10.3 一个高效的信号抓取技巧

很多工程师抓SPI波形时,习惯用“Free Run”一直刷,然后在海量波形里去翻。效率太低。更合理的做法是:

  1. 把逻辑分析仪的触发条件设为CS下降沿。
  2. 预触发深度设成10%~20%,这样可以看到CS拉低之前的空闲状态。
  3. 抓完后再放大波形窗口,逐段查看每个字节的位对齐情况。

这样每次通信开始都能稳定抓到一条完整记录,排查问题时心里非常有底。如果你做的是SPI Flash读写测试,还可以只在写指令或读指令发生时触发一次,减少无效波形干扰,定位更快。

11. 最后再聊聊我对SPI协议本身的一些理解和扩展

写到这里,SPI的基础到进阶的内容基本都过了一遍。最后想再说说SPI协议的一些“隐藏特性”和容易被低估的地方。

11.1 SPI并非永远是“一主一从”或“一主多从”

标准SPI是单主机架构,主机发起通信,从机被动响应。但在某些高速场景下,有“多主SPI”的需求——比如两颗MCU通过SPI互联,两边都可能发起通信。这种场景没有统一标准,需要自己定义仲裁机制,比如用额外的GPIO做总线请求信号,或者约定某一方始终为主机。如果项目里遇到这种情况,我的建议是:优先考虑改用其它协议,比如UART的点对点互联,或者带地址仲裁的I2C。SPI天生是主从模型,硬要做对等通信架构,后期维护成本很高。

11.2 差分SPI在某些场景下是必要的

普通SPI是单端信号,速率上去之后,抗干扰能力和传输距离都会变差。极端情况下可以采用差分SPI——把SCLK、MOSI、MISO、CS都转成差分对传输,接收端再用差分接收器还原。这种方案在工业控制、车载通信中有见到,但需要专门的PHY芯片,成本和复杂度高,不是常规项目会碰的东西。知道有这么回事就行。

11.3 合理规划SPI驱动的架构

最后想分享一个架构层面的建议。很多小项目用一个裸机循环里塞一堆代码就完事了,但一旦项目变大,SPI驱动很容易变成一团乱麻。我现在写SPI相关代码,基本都按这个分层来组织:

  • 底层:寄存器操作或HAL库调用,只做“收发一帧数据”的事,不关心数据语义。
  • 中间层:针对具体外设,比如SPI Flash、LCD屏幕,把“读ID”“写页”“刷新一帧画面”封装成带语义的函数。
  • 上层:业务逻辑,只调用中间层API,不知道SPI时序和寄存器长什么样。

这样做的好处是换了MCU平台,只需要重写底层;换了一颗Flash芯片,只改中间层的命令定义;而上层业务代码可以跨项目复用。实际做下来,维护成本比“所有逻辑堆在一起”低太多了。

SPI这个协议,说难不难,说简单又处处有坑。它更像一个“你必须亲手踩过几次坑才能真正掌握”的东西。希望这篇整理能帮你少走点弯路。尤其是刚入门的朋友,不用畏惧那些时序图和数据手册,多抓几次波形,多对比几遍手册,SPI很快就会变成你最得心应手的通信工具之一。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询