ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SPI全双工深度解析:从移位寄存器到时序实战,彻底搞懂主从同拍收发

SPI全双工深度解析:从移位寄存器到时序实战,彻底搞懂主从同拍收发 我一直觉得SPI 的“全双工”是协议里最容易被一句话带过、却又最值得深挖的特性。很多人看到“全双工”三个字第一反应是“能同时发和收”然后就没了下文。结果真去调一块 Flash、一颗无线芯片时MISO 采不到数据、读回来全是 0xFF、波形看起来没问题逻辑却完全不对——这时候才意识到自己对 SPI 全双工的底层行为并没那么清楚。这篇文章我想把 SPI 全双工从时序机制到工程实现完整拆一遍为什么主从设备能在同一个时钟沿上各发各的、硬件移位寄存器到底是怎么完成“左右互搏”的以及实际调 W25Q64、NRF24L01 这类器件时你应该怎么安排收发流程。无论你是准备用 CubeMX 配置硬件 SPI还是打算 GPIO 软模拟这篇都值得你花几分钟从头看到尾。1. 打破直觉为什么SPI能同时收发数据1.1 更像“交换数据”而不是“发完再收”先想象一个生活场景两个人面对面站着各自手里拿着一张卡片在伸出手递出自己卡片的同时接住对方递过来的卡片。SPI 主从通信差不多就是这么一回事。主机的 MOSI 线连接到从机的 MOSI 输入脚主机移位寄存器里的 bit 从 MOSI 移出从机在时钟沿上把这个 bit 收进自己的移位寄存器与此同时从机移位寄存器里的 bit 从 MISO 线移出主机在同一个时钟沿上把它接住。SCK 每跳一次主从双方就“交换一个比特”。所以 SPI 全双工的本质是“同时发送 同时接收”而不是“发送完成后再接收”。很多入门图喜欢把收发画成两条独立管道但 SPI 的真实情况是收发永远绑定在同一个时钟节拍上。主设备发出一个 bit 的同时一定会接收一个 bit从设备接收一个 bit 的同时也一定会发出一个 bit。这种同步交换才是全双工真正的底层魔法。1.2 移位寄存器全双工的硬件核心从再看底层SPI 主机的移位寄存器和从机的移位寄存器通过 MOSI 和 MISO 两条线串在了一起构成一个“环”。主机发送寄存器的最高位被移出到 MOSI从机在采样沿抓取它放进自己的移位寄存器反过来从机发送寄存器的最高位移到 MISO主机也在同一个采样沿抓取它。你可以把两端的移位寄存器想象成一条 8 位的环形传送带两个设备各拿着传送带的一端SCK 每转动一格两边的数据就互换一个 bit。这就是为什么主机向从机写一字节的同时也会从从机那里读回一字节。SPI 只用了 4 根线如果不算电源和地通常 SCK、MOSI、MISO、CS却能实现双向同时传输核心就是这个“移位寄存器串环”设计。它省掉了一根独立的读时钟线代价则是收发永远同步不能只发不收或只收不发。1.3 全双工的代价总有“无用字节”在路上因为收发严格同步主从双方谁都不能单独决定“这一拍我只收不发”。主机想从从机读数据就必须主动给出时钟而给时钟就意味着主机自己也要往 MOSI 上放数据——通常放全 1 也就是 0xFF当“空操作脉冲”。主机想往从机写数据那从机也会同时往 MISO 输出东西哪怕这个输出对当前操作毫无意义。这个设计带来一个非常关键的现象SPI 事务里的很多字节是“陪跑”的。体现在实际工程中就是读 Flash 时主机每想读回一字节就得先发出去一个 0xFF读传感器寄存器时主机发完寄存器地址后后续时钟全是 0xFF写数据时从机返回的状态字节往往是上一次命令的残留而不是本次写操作的即时响应。理解“无用字节”背后的同步机制后你会发现 SPI 所有看似讲究的读操作、状态回读、连续读本质上都是在安排这些“陪跑”字节的位置。这也是为什么很多从机数据手册里的时序图总是把一个“写命令读数据”画成连续一整条时钟串而不是像 I2C 那样有明确的写读分段。2. 时序图背后的物理学CPOL和CPHA如何决定采样边沿2.1 采样沿从机手册里最关键的参数很多 SPI 从机的数据手册开头都会给一张时序图上面标着 SCK 空闲电平、数据建立时间、数据保持时间。你真正要关心的只有两件事SCK 空闲时是高还是低以及数据是在 SCK 上升沿被采样还是在下升沿被采样。这两个参数就是常说的 CPOL 和 CPHA。CPOLClock Polarity决定 SCK 空闲电平CPOL0 表示空闲时 SCK 为低CPOL1 表示空闲时 SCK 为高。CPHAClock Phase决定采样边沿是第几个边沿CPHA0 表示在第一个边沿采样CPHA1 表示在第二个边沿采样。这里有个容易误解的点采样沿是“从机采样 MOSI”的沿同时也是“主机采样 MISO”的沿。因为全双工要求收和发在同一拍完成所以主从双方必须共用同一个采样沿。如果主机配置的模式和从机不匹配那么时钟照跑、数据线照传但主从采样的时刻正好落在数据线上电平还没稳定或已经变化完的区域结果就是读到全 0、全 1或者偶发乱码。最迷惑的是如果你只看波形MOSI 和 MISO 上的数据其实都是完整的只是采样的“那一瞬间”选错了。2.2 四种模式边沿和数据的配合SPI 的 CPOL 和 CPHA 组合出四种模式我建议你背下这张表比每次翻手册快得多模式CPOLCPHA空闲时钟采样边沿说明Mode 000低电平上升沿第一个边沿最常见多数 Flash、传感器支持Mode 101低电平下降沿第二个边沿部分传感器、音频芯片Mode 210高电平下降沿第一个边沿少见部分 EEPROMMode 311高电平上升沿第二个边沿常见很多器件同时支持 Mode 0 和 Mode 3拿 Mode 0 举例SCK 空闲为低主机在 SCK 拉低时把数据放到 MOSI 上然后 SCK 拉高从机在上升沿采样 MOSI同时主机在上升沿采样 MISO。等 SCK 再次拉低之前数据线上会切换下一位。整体时序在逻辑分析仪上看起来就是“电平变化总是发生在采样沿的相邻边沿”。这实际上是在“跳变沿”和“采样沿”之间做了一个错相保证采样时数据已经稳定。如果你用逻辑分析仪抓到波形后发现读回的数据和预期相比整体往左或往右偏了一位基本就是 CPHA 设置反了。比如模式 0 采的是上升沿如果设成了模式 1主机会改为在下降沿采样而 MOSI 上的数据刚好在一个 SCK 周期里被移到了下一个 bit于是读回的数据全是上一位的那部分表现出来就是整体移位。2.3 建立保持时间为什么速率高了会不稳定全双工要求“采样瞬间数据稳定”这个稳定不是读出来的是物理上保证的。从机在某个边沿把数据放到 MISO 上经过 PCB 走线或杜邦线传到主机引脚中间有传播延迟主机内部输入触发器也有建立时间要求数据必须在采样沿到来前已经稳定至少几十纳秒。低频下这些延迟相对周期可以忽略但当你把 SCK 提到几十 MHz或者用一捆十几厘米的杜邦线连接主从设备时线间电容会让信号边沿变缓数据可能直到采样沿附近还在爬坡。结果就是低频一切正常高频偶发错误、时好时坏。我见过最典型的案例用杜邦线驱动 W25Q64SPI 时钟配到 12MHz读 ID 偶发返回 0xFFFFFF降到 4MHz 之后稳定得一批。问题就出在 MISO 线上的上升时间太长主机采样时电平还没越过输入阈值。这种问题排查起来很费劲因为波形上看数据没问题只是“采样点刚好卡在边沿中间”。对付它的常规手段就是降频、缩短杜邦线、改用屏蔽线或 PCB 走线、适当调大主机 GPIO 驱动能力。3. 从软件模拟到硬件外设全双工实现的分层视角3.1 软件模拟 SPI 时的全双工逻辑如果你用 GPIO 软件模拟 SPI全双工的概念会变得非常具体。下面这段伪代码是标准 Mode 0 下的单字节收发uint8_t soft_spi_transfer(uint8_t tx_byte) { uint8_t rx_byte 0; for (int i 7; i 0; i--) { // 先把本 bit 放到 MOSI if (tx_byte (1 i)) { MOSI_HIGH(); } else { MOSI_LOW(); } // 产生 SCK 上升沿 SCK_HIGH(); // 上升沿从机采样 MOSI同时主机采样 MISO if (MISO_READ()) { rx_byte | (1 i); } // 拉低 SCK准备下一位 SCK_LOW(); } return rx_byte; }这段代码的关键在 SCK_HIGH() 之后立即读 MISO而没有先做任何别的操作。从软件角度看“同时”就是在同一段时钟相位里去读 MISO。因为 GPIO 操作是顺序执行的实际上主机读 MISO 的时刻比从机采样 MOSI 的时刻要晚几个指令周期但只要电气时序上满足建立保持时间这个延迟是无所谓的。软件模拟 SPI 的好处是你完全掌控时序想怎么停、怎么等都可以坏处是你的 CPU 被时钟生成占满没法做别的事。而且如果模拟时把顺序写反比如先 SCK_HIGH() 再放 MOSI或者先读 MISO 再拉高时钟都会导致采样点错误这也是新手最容易犯的错。3.2 硬件外设移位寄存器和缓冲区的自动流水线硬件 SPI 外设比如 STM32 的 SPI、NXP 的 DSPI、以及各种 MCU 自带的 SPI 控制器在内部实现了一个和软件模拟完全等价的机制一个发送移位寄存器、一个接收移位寄存器有些是共用移位寄存器加两个缓冲器以及控制 SCK 波特率、CPOL/CPHA 的硬件逻辑。你只需要往数据寄存器里写入要发送的字节硬件就会自动产生 SCK 时钟序列在移出 TX 数据的同时把 MISO 上的 bit 移进接收移位寄存器完成后置一个标志位比如 RXNECPU 再读数据寄存器得到刚才交换回来的字节。所以硬件 SPI 的全双工对 CPU 来说体验是“我写了一个字节然后读回一个字节”这两个动作在时间上不是先后关系而是同一个硬件动作的两个面。很多人误以为要先等待发送完成、再启动接收结果代码写成了“先 Transmit 再 Receive”白白多了半拍甚至多了一整个时钟的间隔跟从机的状态机错开。正确做法是用硬件外设提供的“同时收发”接口比如 STM32 HAL 里的HAL_SPI_TransmitReceive(hspi, tx_buf, rx_buf, len, timeout);这个函数内部会同时启动发送和接收每接收完一个字节就存进 rx_buf同时从 tx_buf 取出下一个发送字节。它是 SPI 全双工在软件 API 层面的标准形式。3.3 数据寄存器读写的隐藏陷阱硬件 SPI 虽然省心但缓冲区管理里藏着一个常见问题当你在连续传输中不及时读数据寄存器接收移位寄存器里完成的字节就会一直被新数据覆盖。很多库函数对TransmitOnly的情况做了处理但当你只是调用Transmit时硬件仍然会把接收数据放到 RX 寄存器里置位 RXNE。如果你不读它下一字节来了之后就可能触发溢出错误OVR外设会自动停止工作后面的数据全部丢。反过来如果你只是想读数据也必须先写一个“假发送字节”到 TX 寄存器。绝大多数芯片的 SPI 外设不会因为你没有写发送数据就停止时钟而是会一直发 0x00 或 0xFF。但为了规范还是应该显式发送 0xFF。另外注意在同一个字节完成前后读数据寄存器和写数据寄存器的顺序不要写反。通常流程是写 TX 寄存器触发传输等待 RXNE 置位读 RX 寄存器如果还有下一个字节回到第 1 步。如果你在步骤 3 之前又写了 TX 寄存器有些外设的发送缓冲会先把旧数据覆盖掉导致当前时钟周期里 MOSI 线上送出的不是你想要的字节。这也是全双工“同拍交换”带来的一个反直觉点你以为写 TX 是“下一次发送”但实际上在有的外设里它会影响当前正在进行的数据。4. 工程中的全双工实战读写W25Q64、驱动NRF24L01时的细节4.1 读 Flash 的“假读真写”流程以 W25Q64 这种最常见的 SPI NOR Flash 为例读数据命令是 0x03后面跟 3 字节地址然后就是连续的数据输出。问题是SPI 从机不会主动把数据放到 MISO 上它必须在每个 SCK 边沿被触发后才把一位数据移出来。也就是说你给多少时钟它才回多少数据。所以标准读数据流程是uint8_t tx_buf[10] {0x03, 0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF}; uint8_t rx_buf[10] {0}; HAL_SPI_TransmitReceive(hspi, tx_buf, rx_buf, 10, 1000); // rx_buf[0..3] 是命令和地址阶段的返回值无用 // rx_buf[4..9] 才是读到的 Flash 数据你可能会问为什么地址后面要跟这么多 0xFF因为每个 0xFF 都是给从机提供 8 个时钟从机每收到 8 个时钟就往外吐一个字节数据。0xFF 本身不是命令只是一个“空操作”用来满足 SPI 全双工“必须同时发送”的硬条件。同样读状态寄存器也要先发送 0x05然后再发一个 0xFF 拿回状态字节。很多新手第一次写读状态时只发送 0x05 一个字节然后立刻读 MISO结果读到的是命令字节期间从机输出的垃圾数据。正确做法永远是“发送多少个字节就接收多少个字节”。4.2 软件片选与硬件片选在全双工场景的坑SPI 的 CS片选决定了从机是否把 MISO 驱动起来。从机在 CS 无效时MISO 引脚必须呈高阻态否则多个从机共用一条 MISO 总线会打架。硬件自动片选硬件 NSS在某些 MCU 上可以配置成由外设自动控制。听起来很方便但有个坑如果你的 SPI 外设在传输过程中因为 FIFO 空等原因停止了 SCK而硬件 NSS 的控制逻辑又把 CS 拉高了正在进行的读操作就会被打断。因为全双工要求整个命令数据序列期间 CS 一直有效一旦 CS 中途拉高从机马上释放 MISO之后你读到的数据就会变成高阻态被上拉的电平常见表现是一串 0xFF。我个人的习惯是在大多数多字节、多步骤操作场景下优先用软件控制 CS/GPIO。不要迷信硬件 NSS尤其是需要连续读一大片 Flash 数据时软件 CS 能保证片选信号和你的缓冲区长度完全同步。软件模拟 SPI 时还有一个非常容易踩的坑片选拉低之前SCK 的默认电平必须已经就位。举个例子如果用 Mode 0空闲低你得先把 SCK 引脚配置输出并拉低再把 CS 拉低。如果先拉 CS再把 SCK 初始化为低从机会在 CS 刚拉低时看到 SCK 还是高电平以为是 Mode 2/3 的一个边沿可能触发一次错误的移位导致后续数据整体错位。4.3 无线芯片NRF24L01中的“滞后一字节”问题NRF24L01 是很典型的 SPI 全双工从机它的状态机响应机制也很能说明问题。比如你想读它的 STATUS 寄存器按照数据手册要先发送命令字节 0x07读取 STATUS然后从 MISO 读回数据。但如果你只发一个命令字节并试图在同一个字节的传输过程中读回数据你会发现读到的根本不是你要的状态。原因就是SPI 是同步交换从机在收到命令字节的过程中只能一边把当前状态寄存器的值移出来一边接收命令。当你在第一个字节期间读 MISO 时从机移出的其实是“上一个 SPI 事务”留下的状态而不是这条命令对应的结果。想要获得本次命令的 STATUS你必须在发完 0x07 之后再发送一个 NOP0xFF在第二个字节的时钟里从机才会把 STATUS 真正输出。所以读 NRF24L01 寄存器的标准套路是uint8_t tx[2] {0x07, 0xFF}; uint8_t rx[2] {0}; HAL_SPI_TransmitReceive(hspi, tx, rx, 2, 100); // rx[0] 是上一条命令的 STATUSrx[1] 才是本次命令读到的 STATUS很多人在这个坑里卡一整天根因就是没理解“全双工的返回数据总是滞后一拍”。在调试无线模块时遇到读回来的数据像是“上一次操作的结果”不要怀疑芯片坏了先看看你的读序列是不是多给了至少一个 0xFF。5. 全双工陷阱排查为什么我的数据会乱5.1 时钟速率太高导致的采样点偏移我自己的排查经验里SPI 数据乱码百分之六十以上和时序冗余不够有关。比如用杜邦线连接 STM32 和 W25Q64SPI 时钟配到 18MHz读 ID 时偶尔返回 0xFFFFFF有时又是 0xEFEFEF。用逻辑分析仪抓 MISO看起来波形是对的啊数据位也是对的——问题在于主机采样的那一刻MISO 上的电平还没稳定到可靠范围。数字电路里有个概念叫建立时间setup time意思是采样沿到来之前数据必须稳定一段时间。当 SCK 频率升高每一位的周期变短留给数据稳定的窗口就变窄。线缆和引脚寄生电容会进一步拖慢边沿导致窗口被吃掉。遇到这种情况先别怀疑从机有问题按下面顺序排查降低 SPI 时钟频率比如从 18MHz 降到 4MHz看问题是否消失缩短杜邦线或者换成双绞线/排线检查主机的 GPIO 输出速度配置是否过低适当调高驱动等级检查 MISO 线上是不是有对地电容过大有些开发板 MISO 引脚还挂了大电容滤波会影响高速信号。5.2 全双工下主从同时发送的冲突与回环测试很多人对“全双工”有个错误理解以为主从可以同时主动发送所以 MOSI 和 MISO 上会互相干扰。实际上 SPI 设计得很明确MOSI 永远由主机驱动MISO 永远由被选中的从机驱动两边在自己的线上是唯一的驱动器不会存在两个设备抢一条线的问题。真正可能冲突的场景是多个从机共用 MISO 总线而某个从机的 MISO 不是真正的三态输出或者它的 CS 逻辑接反导致它一直在驱动 MISO。这时候只要有一个从机的片选没有控制好就会和当前选中的从机抢 MISO出现数据“粘在一起”的乱码。用万用表量 MISO 电平也许看不出名堂最好把每个从机的 CS 都断开单独测试。另一种常见的排查手段是回环测试把主机的 MOSI 和 MISO 短接然后调用TransmitReceive发送一串已知数据。正常情况下你发送什么就会收到什么因为主机发出的 bit 直接从 MOSI 回到了自己的 MISO 采样点。如果回环测试都收不到自己发的东西那问题一定出在主机这边——SPI 模式配置、GPIO 复用、时钟配置、DMA 配置先解决主机再说。5.3 逻辑分析仪看波形的几个关键检查点调试 SPI 全双工时四通道逻辑分析仪是比示波器更趁手的工具因为你要同时看 CS、SCK、MOSI、MISO 四路信号。抓完波形后按这个顺序检查CS 是否在完整事务期间保持低电平有没有在中间被意外拉高SCK 空闲电平是否和配置的 CPOL 一致在 SCK 的采样沿比如 Mode 0 的上升沿上MOSI 和 MISO 电平是否都已经稳定而不是正在跳变数据是从高位先发MSB first还是低位先发LSB first看第一位数据是否和手册一致MISO 在 CS 拉低之后才变为有效输出CS 拉高之前应当保持最后一位的电平或进入高阻。如果检查完发现数据整体移了一位重点检查 CPHA。如果发现 CS 中间有毛刺重点检查软件 CS 的 GPIO 配置和 DMA 传输完成回调。如果发现 MISO 电平和你预期完全相反检查是不是把 MISO 和 MOSI 接反了——这个问题在杜邦线连接时特别常见接反之后由于全双工特性主机还是能收到数据只是收到的是自己发出去的数据这很容易让人误以为 SPI 是“自说自话”。最后说一个我自己的调试习惯每次连接一颗新的 SPI 从机第一件事不是读寄存器而是先做一次“读 ID”操作。W25Q64 读 0x9F 能回 0xEF4018NRF24L01 读 0x00 能回 0x08只要设备 ID 读对了说明你的全双工链路时序、模式、片选全部 OK后面再折腾功能才有意义。如果 ID 都读不对先把时序搞定别急着查上层协议。
返回列表