SPI通信实战:从时序到调试,嵌入式工程师的必备指南

SPI通信实战:从时序到调试,嵌入式工程师的必备指南 1. 修屏幕不亮、SD卡不识别、Flash读不到数据问题多半出在四根线上我最早接触SPI通信是被一个工程折腾到凌晨才想明白的。那会儿客户那边反馈产品偶尔出现显示屏花屏刚开始怀疑是电源纹波把电容加了一圈也没用又怀疑是主控坏了换了个新片照样花。最后拿示波器量CLK引脚发现时钟线上有一小段毛刺顺着信号往前查才发现是SPI通信的从设备地址选通引脚没拉稳主控在初始化时先动了数据线、后动片选线导致从设备在一瞬间接收到了半截指令。从那以后我就养成一个习惯凡是遇到“看起来像硬件问题”的故障先把SPI通信相关的几根线按顺序过一遍。SPI的全称是Serial Peripheral Interface串行外设接口是摩托罗拉在八十年代提出的一种同步全双工通信协议。你只需要四根线SCLK串行时钟由主机产生决定通信节奏MOSI主出从入主机发送数据到从设备MISO主入从出从设备回传数据到主机CS/SS片选信号低电平有效标记当前与主机通信的是哪个从设备SPI通信属于典型的“一主多从”架构。一个主机可以挂多个从设备每个从设备独占一条片选线但SCLK、MOSI、MISO三条线可以共用——这也就是后面要重点讲的共享总线问题。它不像UART那样要约定波特率也不像I2C那样需要地址寻址和应答机制它的设计哲学很简单时钟由我定速率随我调数据全双工同时收发从设备只需要跟着时钟节奏走就行。这套设计在今天依然大量出现在Flash存储芯片、SD卡、显示屏驱动、传感器采样、AD/DA转换器等场景中。SSD里的主控和Flash颗粒之间用的是SPI演变出的协议路由器固件恢复要靠SPI Flash编程器很多MCU的固件引导也依赖SPI NOR Flash。说得直白一点你只要在嵌入式这一行干活SPI通信就是你绕不过去的基本功。适合什么人读这篇文章刚接触MCU开发、被时序图折磨过的初学者可以通读一遍建立整体认知做嵌入式产品维护、被间歇性通信故障折磨的工程师可以直接跳到片选和共享总线这两章找答案想做高频数据采集或者大吞吐量传输的朋友重点看DMA那一章。下面的内容全部来自实际项目中的排查记录和验证结果不是文档翻译。2. 四种模式不是文档上随便写的CPOL/CPHA定错等于整条总线白干很多初学者看SPI时序图容易看晕根源在于没有理解SPI通信的“采样”本质。主机和从设备之间没有独立的握手线双方能在正确的时间点读到正确的数据位靠的是对时钟极性和相位的一致约定。SPI协议把这种约定拆成两个参数CPOL时钟极性和CPHA时钟相位。CPOL决定空闲时SCLK是高电平还是低电平。CPOL0表示空闲时时钟线是低电平开始传输时第一个跳变是上升沿CPOL1表示空闲时时钟线是高电平开始传输时第一个跳变是下降沿。CPHA决定数据是在时钟的第一个边沿采样还是第二个边沿采样。CPHA0表示在第一个边沿采样数据CPHA1表示在第二个边沿采样数据。把这两个参数两两组合就产生了四种模式模式CPOLCPHA采样边沿以CPOL0为例典型应用Mode 000上升沿采样绝大多数Flash、显示屏、SD卡Mode 101下降沿采样部分传感器、音频芯片Mode 210下降沿采样特定DSP外设、个别AD芯片Mode 311上升沿采样部分射频芯片、工业接口硬件上SPI通信本身没有自动协商机制。通信双方必须对模式和速率做出一致约定否则从设备解析到的全部是错位数据。最常见的现象就是初始化好像成功了寄存器配置也写了但读回来的数据要么全0x00要么全0xFF要么是毫无规律的一串乱码。我见过一个用STM32驱动ADS1256的项目工程师把模式从Mode 1改成Mode 3之后数据就正常了原因就是ADS1256的数据手册里要求SPI通信在SCLK空闲高电平、第二个边沿采样的条件下工作。判断一个从设备到底工作在什么模式唯一可靠的办法是去看它的数据手册时序章节。大多数器件手册会把要求的时序图画出来图上会标出SCLK的默认电平、数据建立时间、数据采样点你根据图反推CPOL和CPHA。比如有的手册写“Data is clocked in on the rising edge of SCLK”那基本就是CPOL0、CPHA0也就是Mode 0如果写“Data is clocked out on the falling edge”要结合空闲电平综合判断。还有一个容易忽略的点你的主控SPI外设支持的模式范围不代表你的项目代码里能用任意模式。如果通信双方配置不一致比如主机是Mode 0、从设备要求Mode 3那么从设备虽然不会烧坏但通信永远无法正常建立。排查的时候先看代码里SPI_InitTypeDef或者等价结构体里的CPOL和CPHA两个字段再对照数据手册确认比来回翻示波器高效得多。3. 硬件片选和软件片选选错了就是设备打架片选线CS是SPI通信里最容易出问题、也最容易被忽略的一根线。它决定了当前总线上哪一个从设备在工作。硬件片选指的是把CS引脚直接连接到主控的SPI外设专用片选输出上由硬件模块在每次传输开始前自动拉低CS、传输结束后自动拉高CS。好处是CPU不用管片选逻辑代码简单时序精准坏处是灵活性差不能随意控制片选变化的时机而且在多从设备共享总线的情况下容易出现“切设备”的中间状态。软件片选则是指将CS接到一个普通GPIO上由程序手动控制拉低和拉高。初始化时先把所有从设备的CS都置为高电平释放总线需要跟哪个设备通信就先把它的CS拉低通信完成后拉高。从实际项目角度讲我更推荐用软件片选尤其是在总线上挂了多个设备的时候。原因有三个硬件片选的时序是由外设模块自动产生的它在连续传输多个字节时并不会在字节之间释放CS这通常没问题但如果你需要在两个数据帧之间插入一些间隔比如让从设备处理完上一帧再接收下一帧硬件片选就不够灵活。当你用DMA搬运数据时DMA传输结束和SPI外设实际发送完最后一个字节之间存在延迟硬件片选可能会提前释放导致从设备截断数据。软件片选可以精确控制在确认发送完成后才拉高CS。某些从设备需要在CS拉低后额外等待一段稳定时间才开始接收时钟或者需要CS保持低电平超过某个最小宽度以便处理数据软件片选可以用delay精确满足这些要求。但软件片选也有它的坑。最大的坑是CS的拉低动作和SPI时钟的启动之间必须有足够的时序余量。很多从设备在CS下降沿之后需要几十纳秒到几微秒的稳定时间才能准备接收数据。如果你用软件GPIO直接拉低CS紧接着立刻调用SPI发送函数时钟信号可能来得太早从设备根本没准备好。解决办法很简单在CS拉低之后、正式启动SPI传输之前插入一个几微秒的延时或者至少执行几条空指令。反过来在SPI发送结束后也不能立刻拉高CS要确认最后一个字节的移位寄存器已经完全送出否则数据位会被截断。硬件片选自动处理了这些时序但软件片选完全没有保护全靠代码控制。用ADC芯片ADS1256时我有一个习惯每次CS拉低后延时5微秒再读写每次读完最后一个字节后先延时再拉高CS连续读数据时的采样率几乎不受影响但稳定性提升非常明显。早期不这么做的时候CS刚拉低就立刻发命令芯片有大概千分之一的概率会收到两个bit的错误数据转换成电压值之后就出现偶尔的跳变毛刺很难排查。4. 一屏一卡共享SPI总线谁先占谁后用数据错乱全都怪状态切换共享SPI通信总线是个很高频的应用场景。拿ESP32驱动的项目来说最常见的是屏幕和SD卡共用同一条SPI总线的方案。省IO口布线也简单但实际跑起来之后总会出现各种稀奇古怪的问题屏幕偶尔花一下、SD卡写入掉数据、录音文件开头有几个字节的杂音等等。这一切的根源在于共享总线时的“状态切换”没有处理好。先明确一个硬件前提SPI通信的MOSI、MISO、SCLK三条线是多个从设备共用的同一时刻只允许一个从设备被片选其他从设备的片选线必须保持在非选中的高电平状态。问题往往出在切换过程中。比如你先操作了SD卡然后把CS拉高释放总线紧接着去操作屏幕。这个切换看起来简单但实际上有一个细节被忽略了——被释放的从设备SD卡在失去片选的那一刻会立刻停止响应但它内部可能还处在一个“等待数据”的半工作状态。如果SD卡的CS被拉高的瞬间SPI时钟线上恰好残留了一两个脉冲或者MOSI线上还有未完成的电平状态转换SD卡可能会错误地接收到命令尾巴上的几个时钟周期内部状态机紊乱。等下次再片选它时它可能仍然停留在错误的状态返回的数据自然不对。两块同型号的ST7789屏幕和一个TF卡模块共线时我用逻辑分析仪抓过波形。现象非常清晰从“SD卡读操作”切到“屏幕刷新”时若SCLK线在CS切换期间还有残余翻转屏幕大概率会在本次刷新帧中出现一条横向的颜色异常。如果切换前把SCLK拉到空闲电平并保持一段时间再释放总线屏幕就没有问题。所以共享SPI通信总线的正确操作顺序应该是确保当前从设备的传输已经完全结束。对于DMA传输要等待DMA传输完成中断或标志位同时确认SPI外设的发送移位寄存器已经清空。拉高当前从设备的CS线。如果有需要在释放总线之后加一小段延时让总线电平彻底稳定下来。拉低目标从设备的CS线。插入必要的建立时间延时再启动下一次SPI传输。另外SPI总线上的空闲电平也需要注意。如果总线长时间没有传输SCLK和MOSI的电平状态取决于你上一次传输结束时的最后一拍。有些从设备对电平状态比较敏感在CS拉高、总线空闲的那段时间里如果SCLK上出现毛刺或者MOSI上出现半高电平可能会造成从设备内部逻辑误触发。解决方法是明确把SCLK在空闲时固定到CPOL对应的电平Mode 0和Mode 1空闲时SCLK应该是低电平Mode 2和Mode 3空闲时SCLK应该是高电平。共享总线的代码在架构上建议封装成独立的SPI通信管理模块统一管理总线的占用和释放。比如写一个spi_acquire()函数负责把CS切到指定设备再写一个spi_release()函数负责结束当前传输并释放总线。所有上层业务都只能通过这两个函数访问总线禁止在业务代码里直接操作GPIO片选。这样虽然多写几十行代码但排查问题时收益极大——你只需要在这两个函数里打断点就能看到每一次总线切换的完整时序。5. 到了DMA就没人讲明白的事情SPI DMA为什么快又为什么容易让数据“飞”走先回答一个很多初学者问过我的问题SPI通信本身已经够用了为什么还要引入DMA普通方式发送一个字节是这样的CPU把数据写入SPI数据寄存器然后等待SPI模块把数据移出再写下一个。这个过程CPU全程在“等”一个9MHz的SPI外设传输一屏240x320的RGB565图像数据大概有几十毫秒CPU都在空转。如果系统里还有Wi-Fi协议栈、传感器采集、UI渲染这些任务CPU就会被SPI传输卡死一大截。DMADirect Memory Access直接内存访问解决的就是这个问题DMA控制器直接把内存里的数据搬运到SPI发送寄存器每个字节搬运完成后自动触发下一次搬运CPU只需要在整块数据传输结束后收到一个完成中断。换句话说CPU负责“安排任务”DMA负责“跑腿”。在实际项目中SPI DMA的收益非常明显。同样刷新一块ST7789屏幕不开DMA时在240MHz主频的MCU上CPU占用率大约在35%左右开启DMA之后CPU占用率直接降到5%以下。这个差距在需要同时运行其他实时任务的场景中影响非常大。但DMA也有它的特殊脾气使用不当会带来几个特别隐蔽的问题。第一个坑是缓存一致性问题。如果你的MCU带D-Cache比如Cortex-M7内核的STM32H7系列DMA对外设进行传输时数据来自SRAM而CPU可能已经把数据先写入了Cache还没写回内存。DMA从内存搬运时读到的是旧数据发出去的就是乱码。解决办法是使用cache clean/invalidate操作或者干脆把DMA缓冲区定义在非cacheable的内存区域。第二个坑是DMA传输完成中断和SPI实际发送完成不是一个概念。DMA完成只代表“数据已经从内存搬运到了SPI发送寄存器”不代表最后一个字节已经从SPI的移位寄存器中完整发出。如果CPU在DMA完成中断里立刻拉高CS或者立刻切换总线状态最后一两个字节很可能被截断。在数据量大的传输中这个现象不明显但在短数据帧传输中非常致命。标准做法是等待SPI外设的“发送空闲”标志比如STM32里的TXE和BSY标志ESP32的SPI外设也有类似状态位再执行后续操作。第三个坑是DMA缓冲区生命周期。如果你把DMA要发送的数据放在一个局部变量数组里函数返回后栈空间被回收DMA还在搬运已经失效的数据结果就是偶发性乱码。大量项目都栽在这个坑上我自己也踩过。后来一律用static修饰DMA缓冲区或者用全局数组、专用的DMA内存池来存放待发送数据。第四个坑是DMA和中断优先级的配合。SPI DMA传输结束后会产生DMA通道中断如果你在中断处理函数里要做大量工作比如解析数据、事件通知这个中断不能长时间占用CPU否则会拖累其他高优先级实时任务的响应。建议在DMA中断里只做标志位置位和事件上报真正的数据处理放到主循环或低优先级任务中执行。以STM32的CubeMX配置为例最基本的SPI DMA配置步骤是只勾选SPI外设的TX DMA请求然后把DMA模式设为Normal数据宽度设为Byte并开启传输完成中断。再用HAL_SPI_Transmit_DMA()函数启动一次基于DMA的发送。这里有个需要特别重视的点CubeMX生成的代码默认把SPI DMA使能但实际调用DMA传输之前必须确认目标缓冲区地址是合法的、数据内容是完整的并且在回调函数HAL_SPI_TxCpltCallback里做收尾清理工作比如清标志、释放信号量、关DMA等。SPI方向要记录一个经典教训有一个跑FreeRTOS的项目用DMA从W25Q64 Flash读数据数据量大时偶尔会出两个错字节。用示波器抓物理层波形完全正常看寄存器值也正常后来才发现是任务栈溢出导致DMA缓冲区被破坏。问题不在于SPI通信配置错误而是任务栈分配太小、高优先级任务嵌套太多。排查SPI DMA相关问题始终要记得往内存分配方向多看一眼。6. 拿着示波器量MOSI和CLK比我翻十天文档管用在所有嵌入式调试工具里示波器在SPI通信调试中的地位无法被替代。SPI是同步通信协议时序关系比UART复杂很多问题只用代码逻辑推断很难定位但波形图上一眼就能看出异常。推荐每一位做嵌入式开发的朋友准备一台至少100MHz带宽的示波器。调SPI通信时把探头接到SCLK和MOSI两个引脚触发模式设为SCLK边沿触发观察数据波形是否符合预期。对于没有示波器的朋友逻辑分析仪是更经济的选择采样率在24MHz以上就足够分析常见的9MHz以下SPI通信了还能直接解码出十六进制数据比单纯看波形要直观得多。具体排查流程我一般按照下面这个顺序来量。第一步确认时钟频率在合理范围内。把示波器时间轴调到合适档位数一下一个完整时钟周期的时长倒推出频率。对比代码里配置的SPI时钟频率如果两者对不上优先查时钟树配置。第二步看CS时序。CS下降沿到第一个SCLK上升沿之间应该有足够的建立时间最后一个SCLK下降沿到CS上升沿之间应该有足够的保持时间。这两段时间太短从设备就会采样出错或者丢掉最后一两位数据。很多从设备数据手册里会给出tCSSUCS建立时间和tCSHCS保持时间肉眼观察波形时如果觉得很难精确比较可以用示波器的光标测量。第三步看数据线电平稳定性。MOSI线上的数据位应该在SCLK边沿前后保持稳定。如果数据位在时钟边沿附近出现抖动或者毛刺可能是信号完整性出了问题走线太长、线间电容耦合、或者驱动能力不足。这时候可以从硬件角度优化比如减小上拉电阻、增加串联端接、缩短走线距离。第四步看MISO回传数据。MISO在主机发送“读指令地址”之后应该由从设备驱动输出数据。如果MISO一直是高电平或者低电平没有变化大概率是命令字写错了或者CS时序不对从设备根本没有正确解析指令。除了调试物理波形逻辑分析仪在验证协议逻辑上也很关键。我调试一块ST7789驱动的屏幕时用逻辑分析仪解码了SPI通信数据发现初始化序列中有一个寄存器值写反了导致屏幕颜色反转但不花屏。这种问题靠肉眼盯波形几乎不可能发现但逻辑分析仪直接吐出十六进制数据流一眼就看到是第几条配置命令出错。还有一个非常实用的技巧很多MCU的SPI外设支持loopback模式自发自收。在没有示波器和逻辑分析仪、又怀疑硬件连接有问题时可以把MOSI和MISO短接或者启用外设的内部控制环回然后发送一串已知数据并读回比较。数据完全一致说明SPI外设初始化没问题问题出在外部硬件连接或从设备配置数据不一致则说明外设配置或时钟有问题。这个技巧最大的价值在于可以在无示波器环境下快速完成“主机侧自测”。7. SPI、I2C、UART、CAN到底怎么选不吹标准看场景做设计选型时经常被问到一个问题既然SPI通信这么好用为什么还要有I2C、UART、CAN这些协议它们之间存在竞争关系吗答案是不存在简单的优劣之分每种协议都有自己的适用边界。在“双机短距离高速传输”场景中SPI通信有明显优势在“一主多从、设备多、需要地址区分”的场景中I2C更省IO在“长距离抗干扰”的场景中CAN和RS485是首选在“调试串口、日志输出、模块通信”的场景中UART最简单通用。我整理过一个选择对照表贴出来供参考维度SPII2CUARTCAN线数4根SCLK/MOSI/MISO/CS2根SCL/SDA2根TX/RX2根CANH/CANL通信方式同步全双工同步半双工异步全双工异步半双工差分速率常见范围最高可达几十MHz标准100/400kHz高速可达3.4MHz通常9600bps~数Mbps最高可达1Mbps常用500k多设备寻址片选线独立每设备一根7位/10位地址总线挂多设备点对点为主报文ID仲裁机制抗干扰能力一般一般一般强差分信号从设备数量成本每多一个设备多一根CS线地址区分不额外占IO一般不支持多挂总线仲裁可挂多节点典型应用Flash、屏、SD卡、ADC、DSP传感器、EEPROM、实时时钟蓝牙模块、GPS、调试日志汽车电子、工业控制从这个表能看出来几类协议各自占据了不同的生态位。SPI通信的优势在于高吞吐和全双工适合大数据量连续传输的场景比如图像数据、固件存储、音频流。缺点是线多、一从一CS设备一多线缆就很壮观而且SPI没有标准应用层协议没有地址概念链路双方必须约定好数据格式。I2C的优势在于用两根线就能挂几十个设备每个设备有自己的地址协议里自带ACK应答能实时反馈从设备是否成功接收。缺点是速率上限低、半双工、实现复杂一些数据吞吐量远不如SPI通信。UART最大的优势是简单没有时钟线两个设备各一根数据线就能通几乎所有MCU都带UART外设非常利于跟PC调试工具对接。缺点是异步通信要求双方精确约定波特率且一般只能点对点没有标准多设备支持。CAN专为恶劣电磁环境设计差分信号配合载波监听和仲裁机制可靠性非常高广泛用在汽车、工业总线。协议里没有主从之分节点随时可以发送报文适合分布式系统但需要收发器芯片和专门的协议栈。选型建议非常直接如果你的数据量是几十KB级别以上、对吞吐率有要求选SPI通信如果你的需求是挂一堆低速率传感器和存储芯片、IO口紧张选I2C如果有两个模块要“对话”而且距离不超过一米UART够用如果场景在工业现场或车里数据需要经过长距离强干扰环境不要犹豫选CAN。8. 几个SPI通信的实战心得你早晚也会踩到最后分享一段我在代码层面的实战心得这些不是理论推演全都是踩过坑之后总结出来的。第一个心得CS和SCLK的初始化顺序非常关键。如果你的CS引脚对应的GPIO默认状态是低电平而这个GPIO在MCU复位后、软件还没开始配置SPI外设之前就已经处于输出模式那么被这个CS选中的从设备会在主控软件还没准备好时就被错误地唤醒。如果该从设备要执行一条写命令而MOSI线上此时是随机电平极有可能把从设备的非易失性存储区写坏。正确做法是把所有从设备的CS引脚在系统上电后第一时间配置为高电平输出再去初始化SPI外设和SCLK引脚。第二个心得SPI通信的缓冲区对齐问题。DMA要求缓冲区地址按照外设总线宽度对齐虽然很多Cortex-M系列MCU在非对齐地址下也能工作但性能会有损失个别外设直接报错。我习惯把所有SPI DMA缓冲区用__attribute__((aligned(4)))声明一劳永逸。第三个心得SPI发送和接收可以同时进行。如果用标准HAL库的HAL_SPI_TransmitReceive_IT()或DMA版本同时准备发送数据和接收缓冲区一次函数调用就能完成全双工交换。很多工程师操作支持全双工的从设备时先调发送函数、再调接收函数多花一倍的传输时间而且容易在两次传输之间出现CS操作遗漏。对于要求读写数据的传感器和Flash直接用TransmitReceive效率提升明显。第四个心得速率不是越高越好。从设备数据手册上标的最大SCLK频率只是理论极限实际系统还要考虑信号走线长度、接口电平、时钟抖动等因素。我在实际项目里一般会把SPI时钟配置为从设备允许最大频率的50%到80%除非你确认布线质量非常好、电源非常干净否则没必要顶满跑。把速率从20MHz降到16MHz一帧图像传输多了20%的时间但系统稳定性提升了一个量级这笔账很划算。第五个心得备份一份从设备时序参数的测试代码。每拿到一个新的SPI从设备如果它对时序比较敏感花半小时写一段遍历测试代码把CPOL、CPHA、速率、CS延时几个参数做排列组合每个组合跑一遍功能测试记录下哪些组合能正常通信、哪些不能。测试一遍之后你以后调这个设备时根本不用翻手册直接看测试记录就知道最稳妥的参数组合。在我自己的项目里这份参数测试表还帮了另一个大忙新来的同事调试屏幕时总是抱怨画质闪烁、偶尔花屏他怀疑主控CPU频繁被中断抢占导致刷新线程不稳定。我把SPI测试记录发给他他照着最优参数一改花屏问题当场消失。所以别嫌写测试代码费时间这有时候比示波器还管用。