1. 为什么SPI这种"老协议"还在统治嵌入式世界
做了这么多年嵌入式开发,我陆续接触过UART、I2C、CAN、USB,但SPI(Serial Peripheral Interface,串行外设接口)始终是我用得最多的通信方式之一。很多刚入门的朋友会问:SPI都发明几十年了,速度上限不如USB,拓扑能力不如CAN,为什么还在大量使用?
答案其实很简单:SPI在"速度、简单性、资源占用"这三个维度上取得了非常好的平衡。它不需要复杂的协议栈,不需要申请地址,不需要仲裁机制,四根线一接,主从设备就能以几十兆赫兹的时钟跑数据。对于ADC采样、Flash读写、显示屏刷新、传感器数据采集这类场景,SPI往往是性价比最高的选择。
这篇文章我不会从零讲一遍课本上的SPI时序图,而是打算从一个实际做项目的角度,把我踩过的坑、验证过的经验、以及那些文档里不会写清楚的细节都梳理一遍。无论你是刚接触单片机的小白,还是已经写过几年驱动的工程师,这篇文章里应该都有你能直接拿去用的东西。
1.1 SPI到底解决的是什么问题
嵌入式系统里,芯片和芯片之间、芯片和传感器之间、芯片和存储之间都需要交换数据。不同的通信协议本质上是在回答三个问题:
- 数据怎么走(物理层的引脚定义和电气特性)
- 数据怎么对齐(时序上的采样点和电平标准)
- 数据怎么理解(协议层的帧格式和命令定义)
UART用两根线做异步传输,双方各自按约定的波特率采样,优点是引脚少、实现简单,缺点是速度上不去、且双方时钟必须足够精确。I2C用两根线做同步传输,挂载设备多,但因为有地址帧、应答位和仲裁机制,效率打了折扣。而SPI走的是另一条路:主设备直接产生时钟信号,从设备跟着时钟走,不需要各自精确的时钟,数据线上怎么采样完全由主设备决定。
这种"时钟由主机说了算"的设计,让SPI天生就具备高速度和低延迟的优势。通信双方不需要精确的波特率匹配,只要从设备能跟上主设备给出的时钟频率,通信就能正常工作。
1.2 什么时候选SPI,什么时候别选SPI
这是我经常被问到的问题。以我的经验,可以从这几个维度来判断:
| 判断维度 | 适合用SPI的场景 | 不适合用SPI的场景 |
|---|---|---|
| 数据速率 | 需要1Mbps以上,甚至几十Mbps | 低速慢速采集,几百Kbps就够 |
| 从设备数量 | 较少(2-5个),有足够GPIO做片选 | 挂载设备很多,GPIO紧张 |
| 通信距离 | 板级通信,几厘米到几十厘米 | 超过1米,需要长线传输 |
| 数据可靠性 | 不需要复杂校验,物理环境干净 | 强干扰环境,需要CRC等机制 |
| 从设备主动性 | 主设备主动发起查询即可 | 从设备需要主动上报事件 |
举个例子:如果你要做一块数据采集板,上面挂了3个高精度ADC、一个SPI Flash存储芯片、一个显示屏,SPI几乎是完美选择。但如果你的项目要控制几十个温湿度传感器分布在房间各个角落,那更适合用I2C甚至RS-485这样的总线型协议。
注意:SPI不是总线协议,本质上是"一对多的点对点连接"。每个从设备都需要独立的片选信号(CS)。这是它和I2C/CAN等真正总线协议的最大区别。
2. SPI四根线的物理层与传输时序:把时序图翻译成人话
2.1 四根信号线各自的角色
SPI用四根线完成全双工通信,这四根线各有明确分工:
- SCLK(Serial Clock):串行时钟,由主设备产生。所有数据传输的节拍都以此为基准。时钟频率直接决定了通信速率。
- MOSI(Master Out Slave In):主设备输出、从设备输入。主设备在这根线上发送数据给从设备。
- MISO(Master In Slave Out):主设备输入、从设备输出。从设备在这根线上回复数据给主设备。
- CS/SS(Chip Select / Slave Select):片选信号,通常低电平有效。主设备拉低某个从设备的CS,表示"我现在要跟你说话",其他从设备看到自己的CS没有拉低,就不参与通信。
这里有一个初学者最容易忽略的点:SPI的收发其实是同时进行的。主设备给从设备发送数据的同时,从设备也在MISO线上回传数据。这一点和UART那种"发送完再等待接收"的模式完全不同。所以在SPI的逻辑里,没有"只读"或"只写"这种说法,每一次时钟跳变,主从双方都各送出一个比特、接收一个比特。
2.2 CPOL和CPHA:SPI世界里最常见的坑
时序相关,绕不开CPOL(Clock Polarity,时钟极性)和CPHA(Clock Phase,时钟相位)这两个参数。它们决定了两件事:
- CPOL:时钟空闲时是高电平还是低电平
- CPHA:数据在时钟的哪个边沿被采样
组合起来就是大家常说的4种SPI模式(Mode 0到Mode 3)。我见过很多项目出问题,最后排查下来就是主设备和从设备的SPI模式没配对。
| SPI模式 | CPOL | CPHA | 数据采样边沿 |
|---|---|---|---|
| Mode 0 | 0 | 0 | 时钟上升沿 |
| Mode 1 | 0 | 1 | 时钟下降沿 |
| Mode 2 | 1 | 0 | 时钟下降沿 |
| Mode 3 | 1 | 1 | 时钟上升沿 |
实际项目里,绝大多数SPI从设备默认支持Mode 0,也就是CPOL=0、CPHA=0。但事无绝对,有些传感器芯片(特别是某些加速度计、陀螺仪)默认工作在Mode 3。我在调试一款气压传感器时就吃过亏:寄存器读到的一直是0xFF,折腾了半天,最后发现是SPI模式没配对。
拿到一个新的SPI设备,第一件事一定是翻数据手册,找到时序章节里关于CPOL和CPHA的说明。有些芯片文档写得很含蓄,只给了时序图,这时候就要自己对照着判断。判断方法很简单:看时序图上时钟信号空闲状态是高还是低,这决定了CPOL;看数据是在时钟上升沿还是下降沿被锁存,这决定了CPHA。
2.3 数据在时钟边沿是怎么流动的
很多教程只画时序图不讲原理,导致初学者看得云里雾里。我习惯用一个比喻来解释:
想象一场接力赛。SCLK就是裁判的哨声,MOSI和MISO是两条跑道。哨声响一次(时钟边沿),主设备和从设备各跑出去一个比特。具体哪个瞬间起跑(发送端在哪个边沿更新数据)、哪个瞬间交接(接收端在哪个边沿采样),就由CPHA决定。
如果CPHA=0,数据在时钟的第一个边沿之前就已经准备好,接收端在第一个边沿采样;如果CPHA=1,数据在第一个边沿之后才更新,接收端在第二个边沿采样。这就解释了为什么主设备和从设备必须用相同的CPOL和CPHA——否则发送端更新数据的时机和接收端采样的时机完全错开,读到的数据全是乱码。
3. 主从架构与多设备拓扑:片选管理里的门道
3.1 一对多连接的两条路线
如果一块主控板上要接多个SPI设备,通常有两种接法:
独立片选(经典接法)
每个从设备单独用一个GPIO做CS,主设备通过控制不同的GPIO来选择当前要通信的从设备。这种方式最简单,也最可靠。缺点是占用的GPIO数量随设备数量线性增长。
菊花链(Daisy Chain)
主设备只用一根CS控制整条链,数据从第一个设备传到第二个、再传到第三个,像串糖葫芦一样。这种方式省GPIO,但要求所有从设备都支持菊花链模式,而且数据延迟会随设备数量增加。大多数普通SPI芯片并不支持菊花链,只有移位寄存器、部分LED驱动芯片等特定类型支持。
3.2 片选信号的时序细节
片选信号看似简单,实际项目里坑也不少。我总结了几条必须注意的事项:
- CS低电平有效是默认,但也有人用高电平:大部分芯片是低电平有效,但总有特例。上电前一定要确认。
- CS切换之间要有间隔:从一个设备切换到另一个设备时,不能瞬间拉低另一个CS。需要留出足够的空闲时间,让前一个设备完成内部状态的复位。特别是Flash芯片,两次操作之间需要一定的延时。
- CS拉低后不能立刻发时钟:有些芯片从CS拉低到准备好接收数据需要几个微秒的建立时间。这个时间通常在数据手册里有标注,叫CS setup time。忽略这个会导致第一个字节读出来是错的。
我遇到过一个很典型的情况:用SPI读取外部ADC的转换结果,数据偶尔会是上一次的旧值,而且没有任何规律。后来用示波器抓CS和SCLK的时序,发现CS拉低之后大约2微秒才开始有SCLK,而该ADC芯片要求的CS setup time正好是2.5微秒。差了0.5微秒,导致芯片有时候来不及准备好。在初始化代码里加上一个3微秒的延时之后,问题彻底消失。
3.3 多设备共用一个CS的隐患
有些项目为了让代码简单,把多个设备直接接在同一个CS上,靠不同的命令字来区分设备。这种做法在硬件上省了引脚,但隐患不小:
如果两个设备在同一时刻都被CS选中,它们的MISO都会尝试输出数据,MOSI上的信号也会同时到达多个设备。如果其中一个设备驱动能力较强,另一个较弱,MISO线上就相当于两个输出在打架,轻则数据错乱,重则损坏引脚。我的建议是,除非你对每个设备的电气特性非常清楚,否则不要省这几个GPIO。GPIO不够的话,可以用译码器(如74HC138)来扩展片选,这样既能扩展设备数量,每个设备又能保持独立的CS控制。
4. 从零配置SPI:以STM32为例的完整实操
4.1 初始化配置的完整代码
说是以STM32为例,其实大部分单片机平台的SPI配置思路都是相通的。配置项无非就是:工作模式、时钟极性/相位、时钟频率、数据位宽、MSB/LSB先行。
下面是一段我经常用的标准初始化代码(基于STM32 HAL库,主模式、Mode 0、8位数据、MSB先行):
SPI_HandleTypeDef hspi1; void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; // 主模式 hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 全双工,使用MOSI和MISO两根线 hspi1.Init.DataSize = SPI_DATASIZE_8BIT; // 8位数据帧 hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; // CPOL = 0 hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; // CPHA = 0 hspi1.Init.NSS = SPI_NSS_SOFT; // 软件管理片选,CS用普通GPIO控制 hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_16; // 分频系数,最终时钟频率 = PCLK / 16 hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; // 高位先行 hspi1.Init.TIMode = SPI_TIMODE_DISABLE; // 禁用TI模式 hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; // 关闭CRC校验 hspi1.Init.CRCPolynomial = 10; // CRC多项式,关闭状态不用管 if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }4.2 中断方式发送与接收的差异
SPI数据传输有轮询、中断、DMA三种方式。很多初学者一开始都用轮询,因为代码最简单。但轮询有一个问题:发送和接收是同步阻塞的,在等待硬件完成期间CPU什么都干不了。如果SPI时钟是10MHz,一次传输8位数据只需要0.8微秒,看起来很快,但如果每次要传输几十个字节甚至几百个字节,累积起来就会占用大量CPU时间。
中断方式适合传输中等长度数据的场景,CPU可以去做别的事情,等SPI传输完成后进入中断回调处理数据。DMA方式则适合大批量传输,比如刷屏、读Flash大块数据。实际项目里我的经验是:
- 传输小于16字节:轮询最方便,性能也可接受
- 传输16到256字节:用中断,兼顾响应速度和CPU占用
- 传输大于256字节:用DMA,否则会长时间阻塞CPU
4.3 片选GPIO的配置细节
前面提到用软件管理CS,这里有一个细节值得单独说:CS引脚的GPIO配置建议用推挽输出,而不是开漏输出。
开漏输出本来是I2C的标准用法,因为I2C需要线与机制。但SPI是主从一对一通信,CS需要快速翻转,推挽输出的驱动能力更强、翻转速度更快。用开漏的话,如果忘记接上拉电阻,CS信号会变得非常"软",可能出现电平无法及时拉低的情况,导致从设备状态判断异常。
另外,CS的GPIO初始状态必须为高电平,也就是"未选中"状态。很多芯片对CS的电平要求非常敏感,如果上电时CS被默认拉低,从设备可能进入一个不确定的状态。我习惯在GPIO初始化的那一刻就把CS拉到高电平,再去初始化SPI外设,这样能保证外设上电时序的安全。
4.4 HAL库收发函数的返回值检查
使用HAL库时,我最常提醒别人的是:不要忽略HAL_SPI_TransmitReceive函数的返回值。很多人写完代码发现偶尔通信失败,反复查硬件查不出来,最后发现是HAL层返回了HAL_BUSY或HAL_TIMEOUT,但代码没有处理。
一个比较稳妥的写法是:
uint8_t tx_buf[8]; uint8_t rx_buf[8]; HAL_StatusTypeDef status; status = HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, 8, 100); if (status != HAL_OK) { // 处理错误:重新初始化SPI,或者复位从设备 // 不要把错误吞掉,否则排查问题时会非常痛苦 }超时时间根据SPI时钟频率和数据长度来计算。假设SPI时钟是10MHz,传8个字节需要6.4微秒,加上指令开销,100毫秒的超时设置是非常充裕的。如果频繁出现HAL_TIMEOUT,大概率不是超时时间的问题,而是硬件连接或片选控制有问题。
5. 常见问题排查链路:从乱码到定位根因
这一节我打算用真实的排查思路来写,而不是直接给出答案。遇到SPI通信异常,很多人第一反应是"换一根杜邦线"或者"重新焊接一下",这当然也是一种排查方式,但不系统。我更推荐按下面的链路一步步来。
5.1 排查链路一:先看硬件连接和电平
SPI出现通信异常,我第一个动手的地方永远是示波器或逻辑分析仪。没有仪器的话,也可以用万用表测电平:
- SCLK在空闲状态的电平是否符合CPOL的配置(Mode 0和Mode 1空闲为低,Mode 2和Mode 3空闲为高)
- 片选CS在通信发起后是否被拉低
- MOSI上是否有数据信号活动
- MISO上是否有数据信号活动
如果CS没有拉低,问题大概率出在GPIO配置或软件逻辑上。如果MOSI有活动但MISO没有反应,要么是MISO没有正确连接,要么是从设备根本没被唤醒(CS时序不对或供电异常)。如果MISO始终为高或始终为低,可能是从设备在复位状态,或者从设备没有被正确初始化。
这里分享一个经验:优先准备一个逻辑分析仪。示波器看SPI这种低速数字信号当然可以,但逻辑分析仪可以同时抓到多根线的时序关系,而且软件自带SPI协议解码,直接就能看到每个字节的内容。一个几十块钱的逻辑分析仪就能覆盖日常开发中90%的调试需求。
5.2 排查链路二:用回环测试排除软件问题
硬件连接看起来没问题,但数据还是不对,这时候可以做回环测试(Loopback Test):把MOSI和MISO直接短接,然后主设备发送一串已知数据,看接收到的数据是否完全一致。
如果回环测试通过,说明SPI外设本身、时钟配置、DMA/中断配置都是正常的,问题大概率出在从设备端的配置或连接上。如果回环测试不通过,那就要检查SPI外设的初始化代码、GPIO的复用功能配置、以及时钟是否真正开启。
回环测试是排查SPI问题的"黄金手段",它能把问题域切开:硬件外设问题还是外围设备问题。我在调试一块新板子的时候,上电后的第一件事永远是跑回环测试,确保SPI这条数据通路是通的,再做后面的工作。
5.3 排查链路三:寄存器的回读验证
从设备端的验证,最常用的方法是读取芯片的ID寄存器(Device ID)。几乎所有SPI芯片都有这样一个寄存器,里面存着固定的芯片身份信息。如果能够正确读到预期的ID值,说明物理连接、SPI模式、帧格式全部正确,可以放心进行后续功能调试。
如果ID读出来不对,注意观察两个细节:读出来的值是全0xFF还是全0x00还是乱跳。
- 全0xFF:MISO线上没有信号回来,多半是从设备没回应。检查CS是否被正确拉低、供电是否正常。
- 全0x00:MOSI和MISO可能被短接了,或者MISO被外部下拉到了地。
- 乱跳:时序可能有问题,检查CPOL/CPHA配置是否匹配。
5.4 排查链路四:时钟频率是不是太高了
这是很隐蔽的问题。很多SPI从设备标称支持20MHz甚至更高的时钟频率,但这是在理想的PCB布局、短走线条件下。如果用杜邦线连接,线长超过10厘米,在10MHz以上的频率下,信号完整性问题就会开始浮现。
我遇到过一次非常典型的案例:一块LCD屏幕,官方标称SPI最高支持40MHz,我按照标称用32MHz时钟跑,结果屏幕偶尔花屏。把时钟降到16MHz之后,连续跑了几个小时都没再出问题。后来检查发现,是我的飞线布局太长、而且电源去耦做得不好导致的信号质量问题,不是芯片本身的限制。
所以拿到一个新设备,不要一上来就把SPI时钟拉到最高频率。建议先按保守值(比如芯片标称最高频率的四分之一)跑通功能,确认无误后再逐步提高频率,直到出现异常,再降回上一个稳定频率。这个"阶梯式升频"的方法能帮你快速摸清系统在实际工程环境下的真实工作上限。
6. SPI进阶玩法与工程建议
6.1 DMA环形缓冲:刷屏和采集的利器
做屏幕显示或者连续数据采集时,通常会遇到一个痛点:需要持续性、大批量地传输数据,但CPU还要承担其他任务。这时候SPI+DMA的组合几乎是标准答案。
以刷屏为例:显示一张320x240的图片,16位色深,数据量是320×240×2=153600字节。如果用10MHz的SPI,理论上需要122.88毫秒,加上CPU写寄存器的时间,实际会更久。如果所有数据都让CPU逐字节搬运,CPU基本就被占满了。但用DMA的话,CPU只需要启动一次DMA传输,之后可以去做刷新逻辑、触摸检测等任务,DMA传输完成后通过中断通知CPU。
DMA配置有几个容易踩的坑:
- DMA的通道要和SPI的请求映射对应(不同型号的MCU映射关系不同)
- DMA传输完成后要及时清除标志位
- 如果使用了双缓冲/环形缓冲,要注意缓冲区的边界对齐,避免数据撕裂
6.2 SPI Flash的Page Program特性
SPI Flash是SPI设备里最典型也最常用的一种。它有个特殊机制叫Page Program(页编程),也就是写入数据时必须以页(通常是256字节)为单位。跨页写入会导致失败,或者数据错乱。
正确的做法是:写入数据之前,先计算当前地址所在页还剩多少空间,如果数据长度超过了页剩余空间,就必须拆分成多次Page Program操作,每次不超过页边界。这个逻辑在写通用Flash驱动时尤其重要,因为不同的Flash芯片页大小可能不同,要设计成可配置的。
还有一个细节:Flash擦除操作(Sector Erase或Block Erase)比较耗时,通常需要几十到几百毫秒。擦除过程中Flash芯片会忽略所有的数据写入指令。读取Flash的状态寄存器(读状态命令,轮询BUSY位)是标准的等待擦除完成的方式,而不是简单延时。用延时的话,如果延时太短,擦除没完成就继续写,数据会丢;延时太长,又影响整体效率。
6.3 双机SPI通信:主从角色的动态切换
有些场景需要两个MCU之间通过SPI通信,这时候通常一个是主、一个是从,但有些场景需要动态切换主从角色,比如一个设备在A模式下作为主设备采集传感器,在B模式下要作为从设备把数据汇报给另一个主控。
主从切换的麻烦在于:SPI的时钟总是由主设备产生,从设备只是被动响应。动态切换时,如果主设备这边在切换过程中产生了一个多余的时钟脉冲,或者从设备模式下的CS信号不稳定,通信状态就很容易错乱。
我的建议是:
- 使用额外的GPIO信号来同步角色切换,保证切换是握手完成的,而不是"想切就切"
- 切换前把SPI外设完全禁用(Deinit),切换后重新初始化
- 主从双方的硬件上预留一个"角色确认"引脚,避免双方同时认为自己是主设备,导致时钟冲突
6.4 加锁机制:多任务环境下SPI资源管理
如果你的代码跑在RTOS上,多个任务可能需要访问同一个SPI总线(比如一个任务读传感器,一个任务刷屏幕)。这时候必须给SPI总线加互斥锁,否则两个任务同时发起SPI传输,会导致片选信号交叉、数据错乱。
正确做法是:把每个SPI外设封装成一个"总线资源",配合一个Mutex或Semaphore。任务在发起传输前获取锁,传输完成后释放锁。这样既能保证数据安全,又能避免片选的时序被其他任务打断。
一个容易被忽略的细节:SPI传输时的中断回调里,尽量不要调用RTOS的阻塞API(比如等待信号量),否则可能引起优先级反转或死锁。更稳妥的做法是在中断回调里只置标志位,由任务上下文来处理后续逻辑。
6.5 低功耗设计中的SPI处理
低功耗产品里,SPI外设也是耗电大户之一。进入低功耗模式之前,建议:
- 关闭SPI外设时钟(有些MCU的SPI在空闲时时钟仍然在跑)
- 把MOSI、SCLK、CS都设置为确定电平,避免悬浮导致漏电
- 如果从设备支持,可以通过CS或专用引脚让从设备进入休眠状态
- 唤醒后重新初始化SPI并检查从设备状态,因为有些从设备在休眠过程中会丢失配置
实测下来,一个工作在5MHz的SPI外设,如果全程不关闭时钟,空闲时的电流消耗可能比深度睡眠模式下整个系统还大。低功耗项目的功耗一直在线上不来,往往就是这些外设的时钟没关干净。
7. 写在最后:SPI调试的几条心得
最后分享几条我用这么多年SPI总结出来的经验,不算什么高深理论,但都是实打实换来的教训。
第一,数据手册永远比网上的代码可信。网上的SPI驱动代码千千万,但每个芯片对时序的要求都有细微差异。拿到一个新芯片,先花半小时把数据手册里SPI相关的章节从头到尾读一遍,尤其是时序参数和寄存器描述部分。这一步做好,后面能省下大量排查时间。
第二,示波器和逻辑分析仪是SPI调试的必需品。不要指望靠"眼睛看代码"就能找到SPI的时序问题。时序问题用眼睛看不出来,必须用仪器去看真实的波形。几十块钱的逻辑分析仪就能解SPI协议,真的很值得投资。
第三,任何时候遇到SPI数据不对,第一反应应该是"回环测试+读ID寄存器"。这两个操作能快速把问题域缩小:是自己这边的硬件/代码问题,还是从设备那边的问题。很多时候问题并没有想象中复杂,只是排查的顺序不对。
第四,SPI的坑多数藏在细节里。时钟频率太高、片选建立时间不够、CPOL/CPHA不匹配、CS电平极性反了、GPIO复用配置错误……每个问题单独看都很小,但堆在一起就会把人整得头大。所以做SPI驱动,耐心比聪明更重要。
SPI这个协议已经存在了几十年,在可预见的未来也不会消失。它的简洁、高效、灵活,让它在嵌入式领域保持着不可替代的地位。希望这篇文章能帮你在SPI的开发道路上少走一些弯路。如果你在实际项目中遇到过什么有意思的SPI问题,也欢迎交流,大家一起少踩坑。