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是高电平还是低电平。CPOL=0表示空闲时时钟线是低电平,开始传输时第一个跳变是上升沿;CPOL=1表示空闲时时钟线是高电平,开始传输时第一个跳变是下降沿。
CPHA决定数据是在时钟的第一个边沿采样还是第二个边沿采样。CPHA=0表示在第一个边沿采样数据,CPHA=1表示在第二个边沿采样数据。
把这两个参数两两组合,就产生了四种模式:
| 模式 | CPOL | CPHA | 采样边沿(以CPOL=0为例) | 典型应用 |
|---|---|---|---|---|
| Mode 0 | 0 | 0 | 上升沿采样 | 绝大多数Flash、显示屏、SD卡 |
| Mode 1 | 0 | 1 | 下降沿采样 | 部分传感器、音频芯片 |
| Mode 2 | 1 | 0 | 下降沿采样 | 特定DSP外设、个别AD芯片 |
| Mode 3 | 1 | 1 | 上升沿采样 | 部分射频芯片、工业接口 |
硬件上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”,那基本就是CPOL=0、CPHA=0,也就是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传输卡死一大截。
DMA(Direct 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上升沿之间应该有足够的保持时间。这两段时间太短,从设备就会采样出错或者丢掉最后一两位数据。很多从设备数据手册里会给出tCSSU(CS建立时间)和tCSH(CS保持时间),肉眼观察波形时如果觉得很难精确比较,可以用示波器的光标测量。
第三步,看数据线电平稳定性。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最简单通用。
我整理过一个选择对照表,贴出来供参考:
| 维度 | SPI | I2C | UART | CAN |
|---|---|---|---|---|
| 线数 | 4根(SCLK/MOSI/MISO/CS) | 2根(SCL/SDA) | 2根(TX/RX) | 2根(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测试记录发给他,他照着最优参数一改,花屏问题当场消失。所以别嫌写测试代码费时间,这有时候比示波器还管用。