ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SPI全双工原理详解:从移位寄存器到STM32 HAL库实战

SPI全双工原理详解:从移位寄存器到STM32 HAL库实战 做嵌入式开发这么多年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上一堆0x00MISO上则是一串有效数据。那一刻你就明白所谓“读”其实是“交换”。这也就是全双工的魔法所在——你永远在同时输出和输入只是你的注意力在哪个方向上而已。2. 时钟极性、相位与四种模式2.1 CPOL、CPHA的组合关系SPI通信要稳定主设备和从设备必须对“什么时候把数据放到线上、什么时候去采样”达成一致。这个约定由两个参数决定CPOL时钟极性和CPHA时钟相位。CPOL决定SCLK在空闲状态下的电平CPOL0空闲电平为低CPOL1空闲电平为高。CPHA决定数据在第一个时钟边沿还是第二个时钟边沿被采样CPHA0数据在第一个边沿被采样CPHA1数据在第二个边沿被采样。这两个参数四种组合就是SPI通信里经典的四种模式模式CPOLCPHA空闲时钟电平数据采样沿Mode 000低电平上升沿采样Mode 101低电平下降沿采样Mode 210高电平下降沿采样Mode 311高电平上升沿采样这里要特别注意一个小知识点CPHA0时数据先准备好然后时钟边沿到来采样数据变化切换发生在第二个边沿CPHA1时则反过来数据在第一个边沿变化第二个边沿被采样。所以CPHA1对主设备的要求更高——数据建立和保持时间窗口更紧张。实际项目里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库驱动W25Q643.1 CubeMX配置SPI的关键点我用STM32F103系列加W25Q648Mbit SPI NOR Flash作为演示这也是热词搜索里出现频率非常高的组合。第一步是STM32CubeMX里配置SPI外设。进入SPI1的配置界面后有几个参数需要重点确认。Mode选Full-Duplex Master这是全双工主模式Data Size按W25Q64手册要求选8bitPrescaler也就是分频系数决定了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是理解全双工的关键APIHAL库把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的标准流程是CS拉低发送0x03Read Data命令发送3字节地址高字节在前连续接收N字节数据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时钟节奏更均匀、更稳定。我之前遇到过一个奇怪问题用TransmitReceive组合读某款国产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拉低之后、发送第一个字节之前加一点延时。根据从设备手册的tCSSUCS 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全双工虽然原理清晰、接线简单但真正决定通信质量的往往就是这些最不起眼的细节一个延时、一根地线、一个上拉电阻。把这些经验沉淀下来比背再多的协议文档都管用。
返回列表