SPI全双工深度解析:从移位寄存器到时序实战,彻底搞懂主从同拍收发
2026/9/12 13:32:34 网站建设 项目流程

我一直觉得,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。

CPOL(Clock Polarity)决定 SCK 空闲电平:CPOL=0 表示空闲时 SCK 为低,CPOL=1 表示空闲时 SCK 为高。CPHA(Clock Phase)决定采样边沿是第几个边沿:CPHA=0 表示在第一个边沿采样,CPHA=1 表示在第二个边沿采样。

这里有个容易误解的点:采样沿是“从机采样 MOSI”的沿,同时也是“主机采样 MISO”的沿。因为全双工要求收和发在同一拍完成,所以主从双方必须共用同一个采样沿。如果主机配置的模式和从机不匹配,那么时钟照跑、数据线照传,但主从采样的时刻正好落在数据线上电平还没稳定或已经变化完的区域,结果就是读到全 0、全 1,或者偶发乱码。最迷惑的是,如果你只看波形,MOSI 和 MISO 上的数据其实都是完整的,只是采样的“那一瞬间”选错了。

2.2 四种模式:边沿和数据的配合

SPI 的 CPOL 和 CPHA 组合出四种模式,我建议你背下这张表,比每次翻手册快得多:

模式CPOLCPHA空闲时钟采样边沿说明
Mode 000低电平上升沿(第一个边沿)最常见,多数 Flash、传感器支持
Mode 101低电平下降沿(第二个边沿)部分传感器、音频芯片
Mode 210高电平下降沿(第一个边沿)少见,部分 EEPROM
Mode 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,或者用一捆十几厘米的杜邦线连接主从设备时,线间电容会让信号边沿变缓,数据可能直到采样沿附近还在爬坡。结果就是低频一切正常,高频偶发错误、时好时坏。

我见过最典型的案例:用杜邦线驱动 W25Q64,SPI 时钟配到 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 移进接收移位寄存器,完成后置一个标志位(比如 RXNE),CPU 再读数据寄存器得到刚才交换回来的字节。

所以硬件 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。

另外注意:在同一个字节完成前后,读数据寄存器和写数据寄存器的顺序不要写反。通常流程是:

  1. 写 TX 寄存器,触发传输;
  2. 等待 RXNE 置位;
  3. 读 RX 寄存器;
  4. 如果还有下一个字节,回到第 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 之后再发送一个 NOP(0xFF),在第二个字节的时钟里,从机才会把 STATUS 真正输出。

所以读 NRF24L01 寄存器的标准套路是:

uint8_t tx[2] = {0x07, 0xFF}; uint8_t rx[2] = {0}; HAL_SPI_TransmitReceive(&hspi, tx, rx, 2, 100); // rx[0] 是上一条命令的 STATUS,rx[1] 才是本次命令读到的 STATUS

很多人在这个坑里卡一整天,根因就是没理解“全双工的返回数据总是滞后一拍”。在调试无线模块时,遇到读回来的数据像是“上一次操作的结果”,不要怀疑芯片坏了,先看看你的读序列是不是多给了至少一个 0xFF。

5. 全双工陷阱排查:为什么我的数据会乱

5.1 时钟速率太高导致的采样点偏移

我自己的排查经验里,SPI 数据乱码百分之六十以上和时序冗余不够有关。比如用杜邦线连接 STM32 和 W25Q64,SPI 时钟配到 18MHz,读 ID 时偶尔返回 0xFFFFFF,有时又是 0xEFEFEF。用逻辑分析仪抓 MISO,看起来波形是对的啊,数据位也是对的——问题在于主机采样的那一刻,MISO 上的电平还没稳定到可靠范围。

数字电路里有个概念叫建立时间(setup time),意思是采样沿到来之前,数据必须稳定一段时间。当 SCK 频率升高,每一位的周期变短,留给数据稳定的窗口就变窄。线缆和引脚寄生电容会进一步拖慢边沿,导致窗口被吃掉。

遇到这种情况,先别怀疑从机有问题,按下面顺序排查:

  1. 降低 SPI 时钟频率,比如从 18MHz 降到 4MHz,看问题是否消失;
  2. 缩短杜邦线,或者换成双绞线/排线;
  3. 检查主机的 GPIO 输出速度配置是否过低,适当调高驱动等级;
  4. 检查 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 四路信号。抓完波形后,按这个顺序检查:

  1. CS 是否在完整事务期间保持低电平,有没有在中间被意外拉高;
  2. SCK 空闲电平是否和配置的 CPOL 一致;
  3. 在 SCK 的采样沿(比如 Mode 0 的上升沿)上,MOSI 和 MISO 电平是否都已经稳定,而不是正在跳变;
  4. 数据是从高位先发(MSB first)还是低位先发(LSB first),看第一位数据是否和手册一致;
  5. MISO 在 CS 拉低之后才变为有效输出,CS 拉高之前应当保持最后一位的电平或进入高阻。

如果检查完发现数据整体移了一位,重点检查 CPHA。如果发现 CS 中间有毛刺,重点检查软件 CS 的 GPIO 配置和 DMA 传输完成回调。如果发现 MISO 电平和你预期完全相反,检查是不是把 MISO 和 MOSI 接反了——这个问题在杜邦线连接时特别常见,接反之后,由于全双工特性,主机还是能收到数据,只是收到的是自己发出去的数据,这很容易让人误以为 SPI 是“自说自话”。

最后说一个我自己的调试习惯:每次连接一颗新的 SPI 从机,第一件事不是读寄存器,而是先做一次“读 ID”操作。W25Q64 读 0x9F 能回 0xEF4018,NRF24L01 读 0x00 能回 0x08,只要设备 ID 读对了,说明你的全双工链路时序、模式、片选全部 OK,后面再折腾功能才有意义。如果 ID 都读不对,先把时序搞定,别急着查上层协议。

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

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

立即咨询