
1. 先把SPI这四根线彻底搞明白1.1 四线各司其职SCLK、MOSI、MISO、CS分别干什么SPI全称Serial Peripheral Interface串行外设接口由Motorola在二十世纪八十年代提出。名字听着正式实际拆开看就是几根线的事儿。它有四根核心信号线SCLK、MOSI、MISO、CS这四根线决定了整个协议的骨架。SCLK是时钟线由主机产生用来同步所有数据传输。MOSI全称Master Output Slave Input主机输出、从机输入数据方向是主机到从机。MISO全称Master Input Slave Output从机输出、主机输入数据方向是从机到主机。CS是片选线低电平有效主机把某个从机的CS拉低就等于告诉这个从机“准备好我要跟你通信了”。这个结构很好理解像一间办公室里只有一个领导在发号施令SCLK就是领导敲桌子的节奏MOSI是领导往下属递纸条的通道MISO是下属往领导那儿递纸条的通道CS则是领导点名叫哪位下属来汇报。SPI是典型的主从架构整个总线上只有一个主机多个从机之间不直接通信所有数据都必须经过主机中转。购买传感器、Flash芯片、屏幕模组时看数据手册第一件事就是确认它支不支持SPI接口。支持的话基本都能在这四根线上找到对应的引脚。当然有些从机是半双工器件比如单线SPI Flash或者部分温湿度传感器它们的MOSI和MISO会合并成一根双向数据线这是SPI的变体后面遇到了再单独说。1.2 为什么说SPI是全双工移位寄存器的运转逻辑很多人第一次接触SPI会被“全双工”这个概念搞晕。UART也是全双工I2C是半双工SPI也是全双工那它们到底有什么区别SPI的全双工体现在“发送和接收同时进行、互不干扰”上而这一切靠的是主从机内部各有一个移位寄存器。数据传输时主机和从机各自维护一个8位或者16位、32位的移位寄存器。以8位为例主机把要发送的数据装入自己的移位寄存器从机也把自己要回传的数据装入它的移位寄存器。时钟每来一个上升沿或下降沿两个移位寄存器同时向左移动一位主机最高位通过MOSI线移出去给从机从机最高位通过MISO线移出去给主机。8个时钟脉冲之后两个寄存器里的数据正好交换完毕。所以SPI里有个经典说法一次传输等于一次交换。主机写出去一个字节的同时必然收回来一个字节。如果主机只关心发送、不关心从机回什么那么读回来的那个字节通常会丢弃如果主机只想读取从机的数据那也必须先随便发一个字节通常是0x00或0xFF用来产生时钟脉冲把从机的数据“踢”出来。这个特性在驱动Flash时特别明显读数据的指令发完地址之后后续每发一个0xFFFlash就往MISO线上推一个字节时钟停数据就停。我当年第一次调SPI Flash时死活读不出数据后来才意识到自己漏了“读操作也必须发送虚拟字节”这个关键点。如果你也卡在类似的读不出来、回的全是0xFF或0x00先别急着怀疑硬件想一想主机到底有没有把时钟持续给够。2. 四种工作模式时钟极性和相位的坑2.1 CPOL和CPHA到底是什么SPI不像UART那样有波特率误差协商也不像I2C有地址仲裁它的时序完全由主机定但有一个参数必须跟从机对上否则数据全乱。这个参数就是CPOL时钟极性和CPHA时钟相位。CPOL决定SCLK在空闲状态时是高电平还是低电平。CPOL为0时空闲状态SCLK是低电平CPOL为1时空闲状态SCLK是高电平。这个很好理解就是“不干活的时候时钟线趴在哪边”。CPHA决定数据采样时刻CPHA为0时数据在时钟的第一个边沿被采样也就是第一个跳变沿此时通常是在边沿后锁存CPHA为1时数据在第二个边沿被采样。打个比方SCLK就像一扇推拉门每开关一次就传递一个bit。CPOL决定门默认是关着还是开着CPHA决定你是在门刚打开一条缝时递东西还是等门完全打开后再递。门的状态和递东西的时点错一个数据就会错位。这两者组合起来就是四种模式Mode 0到Mode 3。绝大多数SPI从机芯片都能支持其中一种或几种但不同厂家默认推荐模式不一样好多翻车现场就是主从模式没对上现象还特别隐蔽。2.2 四模式对照表与实际选型思路这里把四种模式列出来方便你对照芯片数据手册里的时序图查。模式CPOLCPHA空闲SCLK采样边沿发送边沿Mode 000低电平上升沿下降沿Mode 101低电平下降沿上升沿Mode 210高电平下降沿上升沿Mode 311高电平上升沿下降沿实际选型时有一个比较粗略的经验如果是Flash芯片比如W25Q系列、MX25L系列厂家通常推荐Mode 0或Mode 3多数情况下用Mode 0就能跑通。如果是LCD屏幕驱动芯片比如ST7789、ST7735、ILI9341同样Mode 0或Mode 3都能用但有些屏幕厂商的例程默认Mode 0有些默认Mode 3移植的时候要仔细看初始化代码里对SPI控制寄存器的配置。更可靠的方法永远是看从机数据手册里的时序图。手册会画SCLK空闲电平、数据在哪个边沿稳定、从机在哪个边沿采样。主机配置的原则就一条保证从机采样数据的那一刻数据线上已经是稳定状态。如果有示波器把SCLK和MOSI同时抓出来观察会非常直观模式对不对眼图上一目了然。2.3 时钟极性配错了会怎样一次真实的翻车记录我之前调一款国产加速度传感器芯片手册写的是Mode 3。当时图省事直接在原有工程模板上把SPI初始化参数CtrlC过来跑了半天读出来的数据总是“拧巴”的高字节对、低字节错错得还很规律。后来拿逻辑分析仪抓波形才发现SCLK空闲电平被配成了低电平主机用的是Mode 0。传感器的数据手册里明确写着“data is latched on rising edge of SCLK while SCLK idle high”主机在上升沿采样从机也在上升沿送出数据两边动作都在同一个边沿上这种“边沿打架”导致每一位数据的建立时间都不够采回来的数自然是乱的。排查时走了不少弯路先是怀疑焊接问题又怀疑供电不稳最后看波形才锁定时序模式。从那以后我养成一个习惯拿到一颗新的SPI芯片第一件事就是去数据手册里搜“SPI Mode”或者“CPOL CPHA”这几个关键词把推荐模式记在笔记本上再开始写驱动。这个习惯帮我避开了后面很多莫名其妙的通信故障。3. STM32环境下的SPI配置实操3.1 CubeMX中的关键参数怎么选如果你用STM32开发大概率会借助STM32CubeMX生成初始化代码。打开芯片的SPI外设页面会看到一堆下拉框和编辑框很多人直接照着别人的配置填填完也不知道每个参数是干嘛的。这里把最重要的几个参数过一遍。第一个是Frame Format也就是帧格式通常选8位对应一个字节。如果跟Flash做页编程或者跟某些音频芯片通信可能需要16位甚至32位帧这时候要注意发送缓冲区的数据类型也要跟着改。第二个是Data Size8位还是16位它和Frame Format是一回事不同CubeMX版本叫法不一样。第三个是First Bit多数情况下选MSB First也就是最高位先发。少数芯片要求LSB First比如某些RF模块和音频编解码器这个也要看数据手册。接下来是Prescaler也就是预分频器它决定SCLK的实际频率。STM32的APB外设时钟通常是几十到上百MHzPrescaler把它分频到目标频率。SPI时钟频率能跑多高不是主机单方面决定的而是要看从机支持的最大SCLK频率。W25Q256手册里写着支持最高104MHz的读时钟但实际PCB走线、信号完整性不到位时跑这么高很容易出错。作为一个偏保守的调法我先用4分频或者8分频把通信跑通再逐步提高分频系数。这个思路我后面还会再提很多SPI通信不稳定罪魁祸首就是分频配得太激进。CS引脚在CubeMX里也有讲究。如果使用硬件NSS需要把NSS引脚配置成GPIO输出并拉高或者启用硬件NSS输出功能。如果使用软件片选那么NSS引脚只需要配置成普通GPIO输出CubeMX里的SPI配置里有个NSS Signal Type一般选Disable或者Software然后把某个普通GPIO手动控制拉低拉高。我自己的习惯是绝大多数场景都用软件片选原因后面单独说。3.2 HAL库收发代码要点CubeMX生成的HAL库代码里收发函数主要有这么几个HAL_SPI_Transmit负责只发送HAL_SPI_Receive负责只接收HAL_SPI_TransmitReceive负责同时收发。还有一个比较常用的是HAL_SPI_TransmitReceive_IT这个是中断版本配合回调函数使用。只写不读的场景很常见比如驱动ST7789屏幕时初始化命令和像素数据都只往MISO方向走从机不需要回数据但SPI在硬件层面上依然会在MISO上收到东西。这些冗余收进来的字节驱动里一般直接忽略。只读不写的场景前面提过必须先发送虚拟字节。HAL_SPI_Receive函数内部也会处理这个问题它会自动发送0xFF来产生时钟所以不用自己额外再发虚拟字节。下面给一段使用硬件SPI发送数据的参考代码以STM32 W25Q256为例读取芯片ID的完整流程uint8_t spi_tx_buf[4]; uint8_t spi_rx_buf[4]; // 拉低片选选中Flash HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); // 发送读ID命令 0x9F后面跟3个虚拟字节读回3字节ID spi_tx_buf[0] 0x9F; spi_tx_buf[1] 0x00; spi_tx_buf[2] 0x00; spi_tx_buf[3] 0x00; HAL_SPI_TransmitReceive(hspi1, spi_tx_buf, spi_rx_buf, 4, 100); // 拉高片选释放Flash HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); // spi_rx_buf[1] 是厂商IDspi_rx_buf[2] 是设备ID这段代码有个容易忽略的细节片选拉低之后发送和接收是同时进行的数组长度必须一致HAL库按同一长度管理收发Tx和Rx的指针不能为空否则会直接报错。很多人写代码时只调HAL_SPI_Transmit发现没问题换成TransmitReceive就开始HardFault多是因为没处理好接收缓冲区。DMA方式又是另一个话题。大批量读写Flash或者刷新LCD时DMA能大幅减少CPU占用。用DMA时特别要注意缓冲区生命周期HAL_SPI_TransmitReceive_DMA是异步的函数返回时传输还没结束如果此时局部缓冲区已经被释放DMA就会踩到野指针上。正确做法是定义全局数组或者使用静态数组并在传输完成回调里设置标志位。踩过几次坑之后我现在只要看到SPI和DMA同时出现第一反应就是检查缓冲区生命周期。3.3 硬件片选与软件片选我为什么更推荐后者SPI的片选有硬件NSS和软件片选两种玩法。硬件NSS模式下主机在每次传输前自动拉低NSS传输结束后自动拉高看起来省心但实际用起来限制很多。最直接的问题是多个从机挂在一条SPI总线上时硬件NSS只管理一个引脚无法自动切换多个从机。如果从机数量多最常见的做法还是把NSS引脚配置成普通GPIO自己写代码控制片选芯片的NSS功能只用它的输入模式检测总线占用状态其他时间IO口自己控制。另外一个容易踩坑的点是有些STM32型号在SPI从机模式下NSS引脚的硬件管理会导致片选信号被内部逻辑占用如果同时又想用这个引脚做别的功能就会冲突。软件片选的做法很简单片选引脚就是普通GPIO通信前拉低通信结束后拉高。代码上多两行HAL_GPIO_WritePin换来的是极大的灵活性。多从机切换、时序微调、调试时随时手动拉高拉低检查全都方便。所以我现在的项目里除非从机数量极少且引脚特别紧张否则一律软件片选。4. 一主多从与总线复用屏幕、SD卡、Flash共存方案4.1 共享SPI总线的基本盘片选和MISO实际项目里一块主控同时接屏幕、SD卡、Flash的情况特别多。比如ESP32接一块ST7789屏幕再挂一张TF卡很多人会问屏幕和SD卡能不能共享同一条SPI总线。答案是可以而且是很常见的做法但有几个前提必须满足。第一所有从机的SCLK、MOSI可以共用但CS必须各自独立接到主控的不同GPIO上。这是SPI一主多从最标准的接法。第二MISO线能不能共用取决于从机的输出驱动方式。像SD卡、SPI Flash这类器件MISO输出是三态门控制没有被片选选中时MISO引脚处于高阻态不会影响其他从机的通信。但有些简单器件或老式芯片的MISO不是三态或者有些屏幕模块的MISO引脚压根没引出甚至个别模组会把它接到别的地方去。这种情况下直接把两条MISO并到一起就可能出问题稳妥起见每个从机MISO线上串一个100欧姆到1千欧姆的电阻再并联。屏幕和SD卡共享总线时还有一个细节SD卡的SPI模式初始化比较挑剔它要求主机速率不能太高通常建议不超过400kHz初始化成功之后再提高速率。但屏幕初始化时又需要高速推数据。如果一条总线上同时挂两个设备一个要求慢速初始化另一个需要高速刷屏那就得用软件动态修改SPI分频系数跑屏幕时高频切到SD卡时降频。用标准库的话直接改SPI的CR1寄存器再重新使能用HAL库可以直接调HAL_SPI_Init重新初始化。4.2 屏幕和SD卡共享一条SPI的实测心得我自己在ESP32上一个项目里同时挂了ST7789屏幕和TF卡模块硬件设计上SCLK和MOSI直接并联MISO线也并联CS各自独立。实际跑下来发现问题往往不在“能不能共用总线”而在“切换的时候会不会互相干扰”。最典型的现象是单独刷屏正常单独读写TF卡也正常但刷完屏立刻读写TF卡偶尔会读到错误数据。排查到最后原因出在屏幕驱动里。有些LCD驱动在写完一帧数据之后没有及时把CS拉高或者拉高了之后SCLK还在继续跑TF卡这边片选还没低呢但SCLK线上的毛刺已经让SD卡内部状态机错乱了。解决办法有两个方向。一个是在切换设备之前确保上一个设备的CS已经稳定拉高并且让SCLK空闲几个周期最好再留一点延时比如几个微秒。另一个是给CS信号加上拉电阻因为CS线悬空时容易受干扰上拉电阻能保证空闲时CS被稳定拉高不会因为噪声被误选中。我后来在CS线上加了10k欧姆上拉切换时的偶发错误明显少了很多。还有一点要特别提醒ESP32的SPI外设较特殊它的GPSPI支持DMA和多设备绑定但不能像STM32那样简单地把多个设备挂在同一条SPI总线上随便切换。ESP32的SPI主机驱动里每个设备有独立的配置结构体SPI bus上允许挂多个device只要CS不同就行。但如果屏幕和SD卡对SPI模式、速率要求相差太大ESP-IDF驱动里可以定义两个device结构体各自设置自己的模式、时钟频率在通信时分别取用这个设计非常方便避免了手动切分频的麻烦。4.3 速率上不去的瓶颈在哪SPI能跑多快理论上由主从双方共同决定但实际往往卡在信号完整性上。很多人上来就想把SCLK配到几十MHz结果发现数据偶尔错、屏幕有雪花、Flash校验不过。SCLK跑高之后影响最大的几个因素PCB走线长度、线间耦合、上下拉电阻阻值、从机端输入电容。杜邦线连接面包板时几十MHz的SCLK基本是灾难因为线间电容和电感让方波变成了圆波。老老实实降频或者换用短而粗的连接线。PCB板上跑SPISCLK线要尽量短尽量避免过孔MOSI和MISO不要平行走太长距离中间铺地隔开效果更好。还有一个经常被忽视的点从机的输入电容会跟主控输出阻抗组成低通滤波器SCLK频率一高上升沿变缓采样点数据就不稳定。处理办法一是降频二是在从机端的SCLK对地加一个小电容比如10皮法到22皮法可以滤掉一部分高频毛刺但同时也会进一步减缓边沿所以值不能加太大。另外如果从机是模块而非裸芯片模块上往往已经带有电平转换芯片或限流电阻这些器件本身也会增加信号延迟模块的SPI最高速率往往比芯片手册低很多。这一点在淘宝常见的SPI屏幕模组和TF卡模块上尤其明显。5. 硬件SPI不够用时的替代方案IO口模拟SPI5.1 什么时候需要用IO模拟硬件SPI外设数量是有限的有时候工程里同时接了Flash、屏幕、SD卡、传感器占用了两三条SPI总线还不够这时候IO口模拟SPI就成了常用兜底方案。有些MCU本身就低端比如CH32V003、STM8系列或者某些8位单片机硬件SPI根本没有或者只有一个不够用就得用软件模拟。IO口模拟SPI的另一个常见原因是管脚复用冲突。有些引脚既要用作PWM输出又要接传感器中断还要做SPI时钟硬件SPI外设的引脚不能随便映射冲突时就只能拿两个普通GPIO来模拟。模拟SPI的适用场景包括低速传感器读取、LCD屏幕初始化、Flash的小批量读写、低速EEPROM读写等等。它的缺点是占用CPU时间、速度有限通常工作在几百kHz到几MHz之间不适合大批量数据传输。对很多应用来说这个速度足够用而且代码轻量、便于移植甚至在某些情况下比硬件SPI还好调试。5.2 模拟SPI的代码模型与注意事项模拟SPI的思路很简单SCLK线用GPIO翻转MOSI线按bit输出MISO线按bit读取。以Mode 0为例空闲时SCLK为低数据在上升沿被从机采样所以主机先把要发送的bit放到MOSI上然后拉高SCLK再拉低SCLK这样完成一个bit的发送。读取时主机拉高SCLK后延时一小段时间再读取MISO引脚电平然后拉低SCLK这样完成一个bit的接收。下面这段代码模拟的是Mode 0下的SPI主模式收发一个字节uint8_t soft_spi_read_write(uint8_t tx_data) { uint8_t rx_data 0; for (uint8_t i 0; i 8; i) { // 发送先放数据再拉高时钟 if (tx_data 0x80) MOSI_GPIO_HIGH(); else MOSI_GPIO_LOW(); // 上升沿从机在此采样 SCLK_GPIO_HIGH(); soft_spi_delay(); // 接收从机在下降沿更新数据所以我们读数据要等时钟稳定后 // 实际调试中发现读操作放在拉高后比较稳妥这里看器件要求 rx_data 1; if (MISO_GPIO_READ()) rx_data | 0x01; // 下降沿 SCLK_GPIO_LOW(); soft_spi_delay(); tx_data 1; } return rx_data; }注意这段代码里读MISO的位置我是放在SCLK拉高之后的但不同器件要求不一样。有的器件在上升沿更新数据有的在下降沿更新数据有的要求在SCLK高电平中间读取。最可靠的方式是查看数据手册里发送端的时序图看看从机到底在哪个边沿更新MISO然后主机的采样点避开这个边沿就行。模拟SPI的时序延时选择也很关键。最无脑的做法是在每次翻转后加一个delay但delay太长了速度上不去太短了信号不稳定。如果MCU跑在几十MHz可以用几个空的for循环做几个机器周期的延时能跑到1MHz左右。如果要更精确可以用定时器或者硬件延时函数精度更高。模拟SPI跟硬件SPI混用时还要注意总线空闲状态。模拟SPI的GPIO在空闲时如果配置成推挽输出且拉高SCLK会被强行拉高这会影响总线上其他硬件SPI设备。正确做法是空闲时把模拟SCLK配置成输入模式或者把整根线拉高到空闲电平。更稳妥的方案是干脆把模拟SPI占用单独的引脚不走共享总线省得互相干扰。6. 调试SPI的常用方法和问题排查6.1 用示波器抓时序的几个观察点SPI调试最重要的工具就是示波器或逻辑分析仪。没示波器时靠printf打印寄存器的值也能排查出一部分问题但遇到时序级别的故障波形就是最直接的证据。抓波形时先看SCLK有没有输出频率是否跟预期一致。很多SPI通信不工作的原因就是SCLK压根没波形这时候问题在主机的时钟配置或GPIO初始化跟从机无关。再看CS信号通信期间CS有没有被正确拉低有没有毛刺有没有在传输中途意外拉高。CS如果中途抖动从机就可能把一帧数据截成两段收到的内容自然是乱的。接着看MOSI上的数据是否稳定。理想状态下MOSI数据应该在SCLK边沿前后保持稳定建立时间和保持时间都要满足从机要求。如果MOSI在SCLK边沿附近变化说明代码里发送数据的时机不对或者是SPI模式不匹配。最后看MISO特别是从机回数据时。如果MISO线在CS拉低后一直是高电平可能是从机没有响应或者CS引脚没接对。如果MISO有波形但数据不对要结合SCLK边沿来确认主机采样点是否正确这多半是CPOL/CPHA配置问题。有逻辑分析仪的话直接在软件里解出数据跟寄存器里读到的值对比排查会快很多。6.2 常见故障速查与解决办法我把这几年调SPI踩过的坑整理成一个速查表遇到问题可以直接对着查。现象可能原因解决办法SCLK无波形SPI外设未使能、GPIO复用错误、CubeMX配置没生成检查初始化代码、分频系数、GPIO复用处CS无法拉低CS引脚被配置成复用功能、被其他外设占用确保CS引脚配置为GPIO输出读回全0xFF从机没有数据、MISO接线错误、CS没选通从机检查接线、用示波器抓CS和MISO读回全0x00从机被拉低占用、MISO被拉低、时钟频率过高检查MISO线上拉、降低时钟频率数据高低字节对调MSB/LSB设置反了修改First Bit配置数据错的很有规律CPOL/CPHA模式不匹配查从机手册确认模式并修改偶发数据错误时钟频率过高、信号完整性差、CS毛刺降低分频系数、优化走线、CS加上拉DMA传输HardFault缓冲区生命周期结束、DMA未关闭就访问数据使用全局缓冲区传输完成回调中处理数据这里特别说一下“读回全0xFF”和“读回全0x00”的区分。MISO线上有上拉电阻时如果从机没有被选中或者内部没数据返回MISO保持高电平读回的就是0xFF。如果MISO线上没有上拉电阻且内部无驱动读回可能是0x00。如果从机明明选通了但回的还是0xFF或0x00就要考虑从机供电、复位引脚、或者初始化时序是否正确。有些芯片上电后需要等待几毫秒稳定再通信太早发指令就会被忽略。6.3 进阶自己写SPI从机需要注意的事有些场景下需要把FPGA、MCU配置成SPI从机去配合另一个主机工作。比如我最近在FPGA里写了一个SPI从机模块配合ARM读取FPGA内部寄存器。自己写从机时最容易踩的坑是边沿选择和时序约束。从机模式下SCLK是外部输入的主机那边的极性一改从机的采样点就得跟着改。FPGA做SPI从机时建议用时钟上升沿采样、下降沿输出或反之具体取决于主机的模式。如果是纯逻辑写从机还要考虑外部SCLK的跨时钟域问题需要用两级触发器同步或者直接用PLL把SCLK引入避免亚稳态。另一个常见问题是CS信号的处理。从机在CS拉低时要同步初始化内部移位计数器不能在MID-stream时期重置否则数据就乱了。我见过一个同事写的从机模块CS一拉低就清空移位寄存器结果主机发送中途CS有点毛刺整包数据就废了。正确的做法是CS在整个传输过程中只作为帧同步信号在CS下降沿准备接收在CS上升沿检查字节是否完整。FPGA做SPI从机的Verilog实现并不复杂核心状态机就是IDLE、接收、发送、完成这四个状态但细节决定了可靠性。跟主机联调时不要把两边的代码都想当然最好先写个简单的回环测试让主机发0xA5、0x5A这些特定模式从机原样返回验证无误后再接正常业务逻辑。写从机的还有一个容易被忽略的问题是MISO的释放时机。从机在CS未选中时MISO必须处于高阻态不能一直驱动。如果从机把MISO一直拉低或拉高会直接影响其他挂在同一总线上的设备通信。FPGA里可以用三态缓冲器来实现MCU从机模式则通常有硬件自动处理但如果自己用GPIO模拟从机就要手动控制MISO的输出使能。我调SPI这些年最大的体会是不要凭直觉猜问题一定要先看波形把SCLK、CS、MOSI、MISO四根线都看清了再去改代码。很多时候硬件接线的问题被误判成软件Bug就是因为懒得动手接示波器。SPI本身并不复杂但“好用”和“用得对”之间隔着一堆不起眼的小细节。把这些细节处理好SPI其实是非常省心的通信方式。