SPI全双工原理详解:从移位寄存器到STM32 HAL库实战
2026/9/12 1:24:26 网站建设 项目流程

做嵌入式开发这么多年,SPI大概是我用得最多的通信协议之一,但说实话,周围不少刚接触的同学第一次听到“全双工”三个字,都会愣一下:两根数据线就能同时收和发?那我读Flash的数据,怎么感觉像是“写”出去的?这篇东西我就把这个全双工的底层机制彻底拆开,从移位寄存器说到STM32的HAL库实际代码,再到软件模拟、片选设计的各种坑,一次讲清楚。不管是刚入门的学生、做产品的工程师,还是玩单片机的创客,这篇都值得花十分钟认真看。

1. SPI全双工的底层机制

1.1 四根线各司其职

SPI全称Serial Peripheral Interface,串行外设接口。它的硬件连接极其直观,本质上就四根线:SCLK时钟线、MOSI主出从入、MISO主入从出、CS片选线。这四根线在物理上决定了它和I2C、UART的根本差异——通信双方各自拥有一条独立的数据通道。

先别急着背缩写,理解这对“出生就对向而行的双车道”才是关键。MOSI这条线上,主设备把数据发出去,从设备在同一个时钟边沿把数据读进来;MISO这条线正好相反,主设备读数据,从设备发数据。因为两条数据线互不干扰,所以在一个时钟周期内,信息能同时向两个方向流动,这就是全双工的物理基础。

CS片选线就不用多说了,低电平有效,主设备把它拉低,等于跟某一个从设备说“我找的就是你”。这也是SPI能挂多个从设备的核心:大家共享SCLK、MOSI、MISO,但CS各自独立,谁被拉低谁应答。

有一点新手特别容易懵:SPI没有地址的概念。I2C要发一个7位地址才能找到设备,SPI只要通过物理的CS线“点名”就行了。所以它天然就比I2C快,没有地址帧、没有ACK应答位,纯粹就是时钟打多少拍,数据就走多少bit。

1.2 移位寄存器才是全双工的本质

很多人以为全双工就是“一边发一边收”,这个理解没错,但太表层了。SPI全双工真正神奇的地方在于,主设备和从设备之间隐藏着一个“环形移位寄存器”。

学通信或者微机原理时都接触过移位寄存器,就是一个n位的数据容器,每个时钟脉冲到来时,所有位整体向右移一位。SPI正是把主设备的移位寄存器和从设备的移位寄存器首尾相连,从逻辑上构成一个环。

主设备每输出一个时钟脉冲,主设备移位寄存器里的最高位就从MOSI移出去,进入从设备移位寄存器的最低位;与此同时,从设备移位寄存器的最高位从MISO移回来,进入主设备移位寄存器的最低位。一个时钟周期内,两个寄存器各自移出1bit、移入1bit,主设备发出去的同时也收回来了相同数量的数据。

这正是SPI读操作看起来很“叛逆”的原因。你发一条“读取数据”的命令给Flash,主设备先往MOSI上推命令字节,从设备在MISO上推回来的其实是无意义的垃圾数据。命令发完、地址也发完之后,你想继续读数据,可时钟不能停,SPI又没有“纯接收模式”,于是主设备必须继续往MOSI上发送“哑数据”(通常全发0x00或0xFF),才能“撬动”从设备把真正的数据从MISO上推回来。

想彻底理解这套机制,我建议手里有逻辑分析仪的朋友,把MOSI和MISO两根线同时抓下来看,你会清楚地看到:读操作的MOSI上一堆0x00,MISO上则是一串有效数据。那一刻你就明白,所谓“读”,其实是“交换”。这也就是全双工的魔法所在——你永远在同时输出和输入,只是你的注意力在哪个方向上而已。

2. 时钟极性、相位与四种模式

2.1 CPOL、CPHA的组合关系

SPI通信要稳定,主设备和从设备必须对“什么时候把数据放到线上、什么时候去采样”达成一致。这个约定由两个参数决定:CPOL(时钟极性)和CPHA(时钟相位)。

CPOL决定SCLK在空闲状态下的电平:CPOL=0,空闲电平为低;CPOL=1,空闲电平为高。CPHA决定数据在第一个时钟边沿还是第二个时钟边沿被采样:CPHA=0,数据在第一个边沿被采样;CPHA=1,数据在第二个边沿被采样。

这两个参数四种组合,就是SPI通信里经典的四种模式:

模式CPOLCPHA空闲时钟电平数据采样沿
Mode 000低电平上升沿采样
Mode 101低电平下降沿采样
Mode 210高电平下降沿采样
Mode 311高电平上升沿采样

这里要特别注意一个小知识点:CPHA=0时,数据先准备好,然后时钟边沿到来采样,数据变化(切换)发生在第二个边沿;CPHA=1时则反过来,数据在第一个边沿变化,第二个边沿被采样。所以CPHA=1对主设备的要求更高——数据建立和保持时间窗口更紧张。

实际项目里,Mode 0和Mode 3最常见。Mode 0在上电默认时被大多数从设备采用,Mode 3因为高电平空闲,在部分双供电或特定芯片上有抗干扰优势。但真正选什么模式,务必打开从设备数据手册的时序图,对着看。

2.2 模式不匹配会怎样

模式不匹配是SPI调试中非常隐蔽的问题。如果你配置成Mode 0,从设备按Mode 1工作,时钟空闲电平一致,但采样边沿差了一个节拍,表现出来就是数据整体往后错半拍。从设备采样到的每一位,恰好是主设备上一个时钟周期才放上去的数据位。最终结果往往是你发0x55,对方收到0xAA,或者数据中出现多bit翻转。

这个状态特别坑人,因为通信不是完全没有反应,设备有响应但数据完全乱。多数人第一反应是怀疑电平不对、接线错了,很少有人第一时间想到模式问题。排查的时候,最好的工具还是逻辑分析仪,抓SCLK、MOSI、MISO三根线,对比从设备手册里时序图,数一下数据变化和采样沿是否对齐,很快就能锁定问题。

2.3 全双工对比半双工

要说清楚SPI的优势,拿半双工协议来对比最直观。典型半双工就是I2C和单线UART。I2C只有一根SDA双向数据线,同一时刻要么主机发从机收,要么从机发主机收,必须通过总线仲裁和方向切换。方向切换本身有时间开销,所以同样时钟频率下I2C的吞吐率就要打个折扣。

SPI两条独立数据线,收发同时进行,不需要方向切换。假设SCLK跑10MHz,理论上每位每秒都能同时双向传输,数据吞吐量是同样频率单线协议的2倍。这还只是理论值,实际因为少了方向切换的等待时间,优势更明显。

另外像RS485这类接口,虽然也有全双工变体(需要两对差分线),但它是串行总线应用层协议,和SPI这种板级芯片间通信协议用途完全不同。SPI在全双工的同时,接线比RS485简单太多,速度又比I2C高一个数量级以上,所以在NOR Flash、SD卡、显示屏、ADC/DAC、射频收发芯片等场景几乎是标配。

还有一点值得提:后来衍生出的DSPI(双SPI)和QSPI(四线SPI),本质是在SPI全双工基础上,把MOSI/MISO两条线复用成双向数据线,一个时钟周期同时传输2bit或4bit。比如QSPI读NOR Flash时,命令阶段还是单向,数据传输阶段就四条线全部输出数据,速度进一步提升。但它已经放弃了“同时收发”的全双工特性,变成高速半双工模式。理解原版SPI的全双工原理,再去看DSPI/QSPI,就能知道这些扩展能力从哪里来。

3. 实操:基于STM32 HAL库驱动W25Q64

3.1 CubeMX配置SPI的关键点

我用STM32F103系列加W25Q64(8Mbit SPI NOR Flash)作为演示,这也是热词搜索里出现频率非常高的组合。第一步是STM32CubeMX里配置SPI外设。

进入SPI1的配置界面后,有几个参数需要重点确认。Mode选Full-Duplex Master,这是全双工主模式;Data Size按W25Q64手册要求选8bit;Prescaler,也就是分频系数,决定了SPI通信时钟,我建议先保守一点,选32分频,APB2总线72MHz分频后就是2.25MHz,对绝大多数SPI从设备来说是绝对安全的速度。

硬件片选还是软件片选,这一步非常关键。直接用CubeMX默认的Hardware NSS会踩坑,我刚学的时候也被坑过,推荐在CubeMX里直接禁用NSS硬件控制,把CS对应的GPIO配置成普通推挽输出,自己写代码控制。原因后面第4节我会详细展开。

GPIO配置上,SCK、MOSI、CS配成推挽输出,MISO配成输入(如果需要速度就选高速)。有人会问,STM32的SPI引脚明明有复用功能,为什么不选AF模式?其实在CubeMX的Pinout视图中,你把SPI1的功能分配给对应引脚后,它自动把SCK、MOSI、MISO配置为复用功能,CS如果禁用硬件NSS,自己另拉一个GPIO配置普通输出就好。引脚分配完成后,生成代码,第一步就结束了。

3.2 HAL_SPI_TransmitReceive是理解全双工的关键API

HAL库把SPI操作封装成三个高频函数:HAL_SPI_Transmit发送、HAL_SPI_Receive接收、HAL_SPI_TransmitReceive同时收发。刚接触时很多人误认为Transmit是只发不收,Receive是只收不发,大错特错。

硬件层面,只要时钟在跑,MOSI和MISO一定同时工作。HAL_SPI_Transmit内部实现时,虽然应用层只关心发出去的数据,但每个时钟周期从MISO收回来的数据会被丢弃。HAL_SPI_Receive内部实现时,虽然应用层只关心收进来的数据,但主设备必须在MOSI上输出数据(通常是0xFF)来维持时钟运转。真正的硬件全双工行为从未改变,只是软件API层面的语义让你觉得“单向传输”。

这对实际代码有什么影响?读W25Q64的标准流程是:

  1. CS拉低
  2. 发送0x03(Read Data命令)
  3. 发送3字节地址(高字节在前)
  4. 连续接收N字节数据
  5. CS拉高

用HAL库写读函数时,可以这样操作:

uint8_t spi_tx_buf[4]; uint8_t spi_rx_buf[4]; uint8_t data_buf[256]; // 发送命令和地址 spi_tx_buf[0] = 0x03; spi_tx_buf[1] = (addr >> 16) & 0xFF; spi_tx_buf[2] = (addr >> 8) & 0xFF; spi_tx_buf[3] = addr & 0xFF; // 这4个字节发送过程中,MISO返回的数据我们不关心 HAL_SPI_Transmit(&hspi1, spi_tx_buf, 4, HAL_MAX_DELAY); // 读取数据:每收1字节必须发送1字节“哑数据”来产生时钟 memset(spi_tx_buf, 0x00, sizeof(spi_tx_buf)); HAL_SPI_Receive(&hspi1, data_buf, 256, HAL_MAX_DELAY);

这段代码在逻辑上是通的,但我更建议直接用HAL_SPI_TransmitReceive,一步到位:

// 用同一个缓冲区,前4字节是命令和地址,后面填充0x00作为读时钟驱动 uint8_t spi_buf[256 + 4]; spi_buf[0] = 0x03; spi_buf[1] = (addr >> 16) & 0xFF; spi_buf[2] = (addr >> 8) & 0xFF; spi_buf[3] = addr & 0xFF; memset(&spi_buf[4], 0x00, 256); HAL_SPI_TransmitReceive(&hspi1, spi_buf, spi_buf, 256 + 4, HAL_MAX_DELAY); // 此时spi_buf[4]开始的就是读回来的Flash内容

两个函数在常规速率下效果差不多,但TransmitReceive有一个隐藏优势:整条CS拉低期间的MOSI时钟节奏更均匀、更稳定。我之前遇到过一个奇怪问题,用Transmit+Receive组合读某款国产Flash,偶尔数据闪现0xFF,换成TransmitReceive后完全消失,原因就是接收启动瞬间的时钟沿不够干净。建议新项目一律直接用TransmitReceive,省心。

3.3 验证芯片是否正常通信:读ID

拿到一块新板子,第一步永远不是搞复杂的读写擦除,而是先读芯片的JEDEC ID。W25Q64的读ID命令是0x9F,正常会返回3字节:厂商ID(华邦为0xEF)、存储类型(0x40)、容量代码(0x17对应64Mbit)。

用HAL库验证的核心代码:

uint8_t cmd[4] = {0x9F, 0x00, 0x00, 0x00}; uint8_t resp[4] = {0}; CS_LOW(); HAL_SPI_TransmitReceive(&hspi1, cmd, resp, 4, HAL_MAX_DELAY); CS_HIGH(); printf("Manufacturer: 0x%02X\r\n", resp[1]); printf("Memory Type: 0x%02X\r\n", resp[2]); printf("Capacity: 0x%02X\r\n", resp[3]);

读ID返回值里,第一个字节resp[0]通常是0xFF,因为命令发送的同时从设备还没准备好返回数据,返回的是无意义的初始值,从resp[1]开始的三个字节才是真正的ID。如果你读到的是0xFF 0xFF 0xFF,大概率就是接线错误、CS没控制住,或者时钟配置不对。读到0xEF 0x40 0x17,恭喜,硬件链路完全跑通了,可以进入下一步读写测试。

这种验证方式成本极低,却能在一个小时内排除约70%的硬件连通问题,比拿着万用表一根一根量线效率高得多。

4. 软件片选与硬件片选,工程里的决策题

4.1 为什么我坚持用软件片选

前面我提到CubeMX里不要用Hardware NSS,这里把原因说透。

软件片选就是自己用GPIO控制CS引脚,完全手动管理电平。看起来“土”,但在绝大多数SPI工程中是最稳的做法。原因有三点:

第一,控制时机完全可控。某些从设备对CS时序有严格要求,比如NRF24L01在进入发送模式前,CS需要拉低一段时间再拉高,硬件NSS绑定的自动行为没法灵活满足这类特殊时序。

第二,多从设备扩展简单。用硬件NSS时,每增加一个从设备就要分配一个SPI外设的NSS引脚,而SPI外设数量十分有限。用软件片选,CS接到哪几个GPIO完全由你定,挂10个从设备只用一个SPI外设也毫无压力。

第三,避免NSS引脚冲突。STM32某些型号的硬件NSS常和SPI复用引脚绑定,你要把那个引脚让给其他功能时,硬件NSS就失效了。软件片选完全没有这种问题。

4.2 硬件片选的适用场景

说了软件片选这么多好处,是不是硬件NSS一无是处?也不全是。在极高速传输场景,比如SPI时钟跑到几十兆赫兹以上时,软件片选的GPIO拉低拉高的延迟开始变得明显,每次操作前还要自己写函数翻转电平,时序一致性不如硬件NSS来得自然。另外,DMA批量传输时,硬件NSS能和SPI外设配合,在传输开始时自动拉低、结束时自动拉高,省去CPU干预。

但它有一个很致命的隐藏问题:硬件NSS在传输结束后,CS拉高的时机可能和从设备内部处理数据的时间窗口不一致。部分从设备在CS拉高后立刻进入内部编程状态,如果你的主设备侧CS拉得太快,可能会打断从设备的数据写入过程。所以即使全程使用了DMA+硬件NSS,我仍然建议在关键操作点手动加一点延时。

4.3 片选时序的坑:拉低延迟与毛刺

片选设计里最常见、也最容易忽略的问题是GPIO初始化顺序和毛刺。

我踩过的坑是这样的:某次挂了一块SPI显示屏,初始化时先把CS配成输出并默认拉高,然后才初始化和SPI复用功能相关的时钟。结果上电瞬间,CS引脚处于浮空状态,电平随机,屏幕偶尔出现乱码。原因就是在GPIO被正确配置为推挽输出之前,CS可能会有几十毫秒的不确定电平。解决方法是:在所有外设初始化代码之前,先把CS GPIO配置好,并明确输出高电平,再去使能SPI外设。

另一个常见的毛刺问题是硬件NSS自动模式下的CS高电平毛刺。某些主控在SPI外设初始化瞬间,NSS会短暂拉低再恢复,从设备收到一个错误帧。软件片选在这种情况下又赢一局,因为GPIO的输出状态完全由代码掌控,不存在外设自带的不确定行为。

5. SPI通信的常见问题与排查记录

5.1 首字节丢失

SPI调试中,首字节丢失大概是被问得最多的问题。表现是:发送一串数据时,从设备收到的第一个字节总是错的或直接没收到,但后面的字节全部正常。

原因通常在CS拉低到第一个时钟沿之间的建立时间太短。很多从设备检测到CS下降沿后,需要一定的稳定时间才能真正准备好接收。尤其是带内部状态机的芯片,CS刚拉低就去发时钟,芯片内部还没完成“上电就绪”或“命令解码初始化”,第一个字节就丢失了。

解决办法是在CS拉低之后、发送第一个字节之前,加一点延时。根据从设备手册的tCSSU(CS setup time)参数来,一般是几十纳秒到几微秒。在低速单片机系统中,直接加一个几条NOP指令的延时通常就能解决。另外,如果用的是HAL_SPI_TransmitReceive,首字节之前HAL库内部可能还会做一些状态检查,这段延迟往往正好掩盖了此问题,这也是用TransmitReceive的又一个隐性好处。

5.2 收不到数据,MISO永远是高电平

SPI调试里最让人抓狂的就是MISO始终是1,读什么芯片都返回0xFF。

排查顺序我建议固定为:接线 → 供电 → CS → 时钟 → 模式。先用万用表确认MISO确实连到了主控对应引脚;确认从设备供电正常,有条件的话示波器看电源纹波;用逻辑分析仪抓CS引脚,确认它在通信期间真的被拉低了。做过一次调试,查了半天发现CS连到了另一个GPIO,程序里操作的根本不是物理连接的那根线。

当CS和供电都没问题时,抓波形看SCLK时钟是否正常输出。如果SCLK完全没波形,检查SPI外设有没有正确使能,CubeMX配置的分频系数会不会导致时钟为0。最后,如果时钟正常、CS正常,那就是模式不匹配了,对照手册检查CPOL和CPHA,用逻辑分析仪观察MISO线上数据变化的位置和采样沿是否一致。

5.3 全双工变成了“半双工”的错觉

有些芯片的MISO在空闲状态下是高阻态,要让MISO输出数据,必须由主设备持续输出时钟来驱动。有些初学者用HAL_SPI_Receive去读这类芯片,发现返回的全是垃圾或0x00,怀疑全双工出了问题。其实问题在于:HAL_SPI_Receive在接收期间,MOSI上发送的哑数据是0xFF还是0x00,对某些从设备的内部状态机有影响。

比如部分传感器芯片,主机在读取测量值时,MOSI上的数据位其实会被解析成寄存器地址或特殊命令。这时你发送的“哑数据”不能随便填,必须按照手册填入预定的命令码或0x00。换句话说,SPI的全双工是双向通道,有时候从设备也会偷听主设备发过来的数据。这要求写驱动时,即使目的只是“收”,发送缓冲区里的内容也要认真填写,不能无条件memset成0xFF。

5.4 电平不匹配与上下拉电阻

SPI通信的设备如果工作电压不一致,比如主控3.3V、从设备5V,即便从设备的逻辑电平阈值恰好能被3.3V识别,长期运行也会损伤引脚。MISO这条线尤其危险,因为从设备输出高电平可能是5V,直接倒灌进3.3V的主控引脚。

最简单的方案是加电平转换芯片或者用MOS管搭建双向电平转换电路。如果从设备只是5V供电但对输入引脚兼容3.3V(很多SPI外设芯片是5V供电又标明VIL/VIH兼容3.3V),可以考虑给MOSI和SCLK串联一个100Ω到1kΩ的限流电阻来保护主控。MISO方向则要看从设备手册,不能想当然。

还有一个易被忽视的细节:如果MISO线上没有接上拉电阻,某些从设备在进入高阻态时,MISO会浮空,主控读到随机电平。这种情况下,给MISO加一个10kΩ上拉电阻,能让总线的空闲状态更稳定,排查时判断也更直观。

我在实际项目里被坑得最多的一次,是给一个模块供电时把地线接错,导致MISO、MOSI之间出现压差,波形表现为一片杂乱。接线前先做一次通断测试,这个习惯救了我很多次。SPI全双工虽然原理清晰、接线简单,但真正决定通信质量的往往就是这些最不起眼的细节:一个延时、一根地线、一个上拉电阻。把这些经验沉淀下来,比背再多的协议文档都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询