SPI协议详解:从时序原理到嵌入式工程实践与调试技巧

发布时间:2026/9/5 2:05:22
SPI协议详解:从时序原理到嵌入式工程实践与调试技巧 1. 为什么SPI这种老协议还在统治嵌入式世界做了这么多年嵌入式开发我陆续接触过UART、I2C、CAN、USB但SPISerial 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用四根线完成全双工通信这四根线各有明确分工SCLKSerial Clock串行时钟由主设备产生。所有数据传输的节拍都以此为基准。时钟频率直接决定了通信速率。MOSIMaster Out Slave In主设备输出、从设备输入。主设备在这根线上发送数据给从设备。MISOMaster In Slave Out主设备输入、从设备输出。从设备在这根线上回复数据给主设备。CS/SSChip Select / Slave Select片选信号通常低电平有效。主设备拉低某个从设备的CS表示我现在要跟你说话其他从设备看到自己的CS没有拉低就不参与通信。这里有一个初学者最容易忽略的点SPI的收发其实是同时进行的。主设备给从设备发送数据的同时从设备也在MISO线上回传数据。这一点和UART那种发送完再等待接收的模式完全不同。所以在SPI的逻辑里没有只读或只写这种说法每一次时钟跳变主从双方都各送出一个比特、接收一个比特。2.2 CPOL和CPHASPI世界里最常见的坑时序相关绕不开CPOLClock Polarity时钟极性和CPHAClock Phase时钟相位这两个参数。它们决定了两件事CPOL时钟空闲时是高电平还是低电平CPHA数据在时钟的哪个边沿被采样组合起来就是大家常说的4种SPI模式Mode 0到Mode 3。我见过很多项目出问题最后排查下来就是主设备和从设备的SPI模式没配对。SPI模式CPOLCPHA数据采样边沿Mode 000时钟上升沿Mode 101时钟下降沿Mode 210时钟下降沿Mode 311时钟上升沿实际项目里绝大多数SPI从设备默认支持Mode 0也就是CPOL0、CPHA0。但事无绝对有些传感器芯片特别是某些加速度计、陀螺仪默认工作在Mode 3。我在调试一款气压传感器时就吃过亏寄存器读到的一直是0xFF折腾了半天最后发现是SPI模式没配对。拿到一个新的SPI设备第一件事一定是翻数据手册找到时序章节里关于CPOL和CPHA的说明。有些芯片文档写得很含蓄只给了时序图这时候就要自己对照着判断。判断方法很简单看时序图上时钟信号空闲状态是高还是低这决定了CPOL看数据是在时钟上升沿还是下降沿被锁存这决定了CPHA。2.3 数据在时钟边沿是怎么流动的很多教程只画时序图不讲原理导致初学者看得云里雾里。我习惯用一个比喻来解释想象一场接力赛。SCLK就是裁判的哨声MOSI和MISO是两条跑道。哨声响一次时钟边沿主设备和从设备各跑出去一个比特。具体哪个瞬间起跑发送端在哪个边沿更新数据、哪个瞬间交接接收端在哪个边沿采样就由CPHA决定。如果CPHA0数据在时钟的第一个边沿之前就已经准备好接收端在第一个边沿采样如果CPHA1数据在第一个边沿之后才更新接收端在第二个边沿采样。这就解释了为什么主设备和从设备必须用相同的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否则会长时间阻塞CPU4.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还是乱跳。全0xFFMISO线上没有信号回来多半是从设备没回应。检查CS是否被正确拉低、供电是否正常。全0x00MOSI和MISO可能被短接了或者MISO被外部下拉到了地。乱跳时序可能有问题检查CPOL/CPHA配置是否匹配。5.4 排查链路四时钟频率是不是太高了这是很隐蔽的问题。很多SPI从设备标称支持20MHz甚至更高的时钟频率但这是在理想的PCB布局、短走线条件下。如果用杜邦线连接线长超过10厘米在10MHz以上的频率下信号完整性问题就会开始浮现。我遇到过一次非常典型的案例一块LCD屏幕官方标称SPI最高支持40MHz我按照标称用32MHz时钟跑结果屏幕偶尔花屏。把时钟降到16MHz之后连续跑了几个小时都没再出问题。后来检查发现是我的飞线布局太长、而且电源去耦做得不好导致的信号质量问题不是芯片本身的限制。所以拿到一个新设备不要一上来就把SPI时钟拉到最高频率。建议先按保守值比如芯片标称最高频率的四分之一跑通功能确认无误后再逐步提高频率直到出现异常再降回上一个稳定频率。这个阶梯式升频的方法能帮你快速摸清系统在实际工程环境下的真实工作上限。6. SPI进阶玩法与工程建议6.1 DMA环形缓冲刷屏和采集的利器做屏幕显示或者连续数据采集时通常会遇到一个痛点需要持续性、大批量地传输数据但CPU还要承担其他任务。这时候SPIDMA的组合几乎是标准答案。以刷屏为例显示一张320x240的图片16位色深数据量是320×240×2153600字节。如果用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问题也欢迎交流大家一起少踩坑。