STM32硬件SPI半双工三线模式配置与驱动开发详解
2026/7/30 5:47:32 网站建设 项目流程

1. 从四线到三线:为什么我们需要半双工SPI

最近在做一个需要连接多个传感器的项目,主控用的是STM32F4系列。其中一个传感器比较特殊,它只支持三线SPI接口。刚开始我有点不以为然,心想SPI不就是四根线吗?MOSI、MISO、SCLK、CS,标准配置,库函数一调,数据不就来了?结果一上手就发现不对劲,常规的HAL库HAL_SPI_TransmitReceive根本没法用,因为硬件上只有一根数据线在来回切换方向。这才让我重新审视这个看似“简化”的三线半双工SPI。

所谓三线SPI,就是省掉了一根独立的数据线。全双工的四线SPI,主机发数据用MOSI线,收数据用MISO线,可以同时进行,效率高。而三线模式,通常只保留一根数据线(我们叫它SIMO/MISO或SDIO),这根线在主机发送时作为输出,在主机接收时作为输入,通信方向需要动态切换,这就是“半双工”。你可能会问,这不多此一举吗?直接用软件模拟SPI(Software SPI)不就行了,想怎么控制就怎么控制。确实,软件模拟最灵活,但代价是CPU占用率高,时序精度受中断和任务调度影响,在高波特率或需要同时处理其他任务时就很吃力。硬件SPI的优势在于,它的时钟和数据由专用外设生成,时序精准,不占用CPU核心去翻转GPIO,解放出来的算力可以干别的。

所以,研究STM32硬件SPI的半双工三线模式,核心目标就一个:在享受硬件SPI高效、精准的时序优势的同时,去适配那些为了节省引脚或遵循特定协议而设计的三线从设备。常见的应用场景包括一些温湿度传感器、压力传感器、Flash存储器(某些型号的QSPI也可配置为单线模式)、以及一些老式的或引脚极简的通信模块。如果你也遇到了类似的需求,或者单纯想深入了解一下STM32 SPI外设的另一种工作模式,那么我折腾的这几天的经验,或许能帮你少走点弯路。

2. 硬件配置的“陷阱”:CubeMX设置与底层原理

要使用半双工模式,第一步就是在STM32CubeMX里正确配置。这里有几个关键点,如果配错了,后面代码怎么写都可能白搭。

2.1 SPI模式与数据方向的选择

在CubeMX的SPI配置页,你会看到“Mode”选项。对于半双工,通常选择“Transmit only master”或“Receive only master”作为起点,但这只是初始方向。更关键的是下面这个“Data Size”和“Frame Format”。

  • Data Size(数据大小):这个要和你的从设备协议严格对应,通常是8位或16位。
  • Frame Format(帧格式):这里有个大坑!标准SPI通常是“Motorola”帧格式,也就是最常见的CPOL和CPHA相位控制。但有些三线设备可能使用其他格式(比如TI的SSP模式),不过绝大多数情况下我们还是选“Motorola”。重点在于“NSS Pulse Mode”和“NSS Signal Type”
    • NSS Signal Type(片选信号类型):对于三线SPI,我强烈建议选择“Software”。这意味着片选引脚(CS)你将用一个普通的GPIO来控制,而不是让SPI硬件自动管理。原因很简单:在半双工收发切换的间隙,硬件NSS可能会产生我们不希望的脉冲,导致从设备状态混乱。手动控制GPIO拉高拉低,时机更可控。
    • NSS Pulse Mode:如果你坚持用硬件NSS,这个模式会产生一个脉冲,但在半双工复杂时序下容易出问题,新手建议先关掉。

配置完成后,生成代码。你会发现HAL库生成的初始化代码里,会调用HAL_SPI_Init,并且根据你的配置,SPI外设的CR1、CR2寄存器已经被设置好了。但这仅仅是个开始,半双工模式的核心开关并不在CubeMX的图形化配置里。

2.2 关键寄存器:SPI_CR1的BIDIMODE和BIDIOE

STM32的SPI外设支持半双工的秘密,藏在控制寄存器1(SPI_CR1)的两个位里:

  • BIDIMODE(Bidirectional data mode enable):这个位必须设置为1。它告诉SPI:“嘿,咱们现在只用一根数据线了,别惦记着那两根独立的了。”
  • BIDIOE(Bidirectional data mode output enable):这个位控制着那根唯一数据线的方向。当BIDIOE = 1时,数据线处于输出模式,SPI作为发送器。当BIDIOE = 0时,数据线处于输入模式(高阻态),SPI作为接收器。

CubeMX默认生成的代码不会帮你设置这两个位。你需要手动开启半双工模式。通常的做法是在MX_SPIx_Init()函数之后,或者在你自己的SPI初始化函数里,加上这么几行:

// 假设 hspi1 是你的SPI句柄 __HAL_SPI_ENABLE(&hspi1); // 先使能SPI SET_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIMODE); // 开启双向数据模式(半双工) // 初始方向设为输出,准备发送 SET_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE);

注意BIDIOE位是可以在通信过程中动态修改的,这是我们实现先发后收的关键。但修改它需要一点技巧,最好在SPI不忙(SPI_SR寄存器的BSY位为0)的时候进行,并且要注意操作时序。

2.3 引脚复用与硬件连接

硬件连接上,三线SPI通常连接这三根线:

  1. SCLK(时钟):主机输出,从机输入。
  2. SDIO(数据输入输出):这就是那根复用线。在STM32端,你需要将这个引脚配置为复用推挽输出(Alternate Function Push-Pull)。是的,即使它也要做输入,但在半双工模式下,硬件会根据BIDIOE位自动切换方向,我们软件上只需配置为复用输出即可。千万不要配置成开漏输出!开漏模式在作为输入时无法被外部正确驱动至高电平。
  3. CS(片选):如前所述,建议用一个普通GPIO控制,配置为推挽输出,初始状态为高(不选中)。

接线图很简单,但务必确认从设备的数据线也是双向IO口。有些设备的数据线是单向的(只能输入或只能输出),那它就不是真正的三线半双工SPI,可能是其他协议。

3. 软件驱动设计:如何优雅地切换收发方向

配置好硬件,接下来就是软件逻辑的重头戏。我们最终的目标是封装出两个函数:SPI_Write()SPI_Read()。但实现它们,不能简单调用HAL库的发送和接收函数。

3.1 发送数据流程

发送相对简单,因为此时数据线方向是输出(BIDIOE=1)。流程如下:

  1. 拉低片选GPIO(CS),选中从设备。
  2. (可选)发送命令字节或寄存器地址。很多传感器需要先发一个读/写命令。
  3. 调用HAL_SPI_Transmit()发送数据。注意,此时函数内部操作的是MOSI相关的数据寄存器,但由于我们处于半双工模式,数据会从SDIO引脚发出。
  4. 发送完成后,拉高片选GPIO。

代码示例:

void SPI_Write(uint8_t *pData, uint16_t Size) { CS_LOW(); // 自定义宏,拉低片选引脚 // 确保方向为输出 SET_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE); HAL_SPI_Transmit(&hspi1, pData, Size, HAL_MAX_DELAY); CS_HIGH(); }

3.2 接收数据流程——核心难点

接收数据是半双工模式最需要小心的地方。你不能直接调用HAL_SPI_Receive,因为此时数据线方向还是输出,从设备的数据发不过来。必须先将方向切换为输入。

一个典型的“先发后收”的读取传感器数据的流程如下:

  1. 拉低片选,选中设备
  2. 设置方向为输出(BIDIOE=1),发送读取命令。例如,发送一个字节0xAA,告诉从设备:“我要读数据了”。
  3. 等待发送完成,并切换方向为输入。这是最关键的步骤。你不能发送完命令后立刻切换方向,因为SPI的移位寄存器可能还没空,最后一个bit可能还在线上。必须等待SPI_SR寄存器的TXE(发送缓冲区空)和BSY(SPI忙)标志位都变为0。
    // 发送命令后,等待发送完成 while((__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY) != RESET)); // 切换方向为输入 CLEAR_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE);

    重要提示:在切换BIDIOE后,需要插入一个小的延时(几个NOP指令或__DSB()内存屏障),让硬件状态稳定。有些应用笔记建议在切换方向后,先进行一次“哑元(Dummy)”接收来启动时钟,对于STM32,切换方向后直接开始接收即可。

  4. 开始接收数据。此时,由于方向是输入,主机需要产生时钟来读取数据。我们可以调用HAL_SPI_Receive。这个函数内部会向数据寄存器写入数据(为了产生时钟),同时读取接收到的数据。我们传入的缓冲区,在接收完成后,里面存放的就是从设备发来的数据
    uint8_t rx_buffer[3]; HAL_SPI_Receive(&hspi1, rx_buffer, 3, HAL_MAX_DELAY);
  5. 接收完成,拉高片选。在拉高片选前,可以先将方向切回输出,为下一次操作做准备。

3.3 封装与超时处理

把上述步骤封装起来,一个健壮的SPI_Read函数需要考虑超时。HAL库的收发函数本身有超时参数,但方向切换的等待while循环最好也加上超时判断,防止程序卡死。

HAL_StatusTypeDef SPI_Read(uint8_t cmd, uint8_t *pRxData, uint16_t Size) { HAL_StatusTypeDef status; uint32_t tickstart = HAL_GetTick(); CS_LOW(); // 1. 确保输出方向,发送命令 SET_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE); status = HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); if (status != HAL_OK) { CS_HIGH(); return status; } // 2. 等待发送彻底完成 while((__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY) != RESET)) { if((HAL_GetTick() - tickstart) > 100) { CS_HIGH(); return HAL_TIMEOUT; } } // 3. 切换为输入方向 CLEAR_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE); __DSB(); // 数据同步屏障,确保指令执行顺序,也可用__NOP()延时几个周期 // 4. 接收数据 status = HAL_SPI_Receive(&hspi1, pRxData, Size, 100); // 5. 恢复为输出方向(可选,为下次发送准备) SET_BIT(hspi1.Instance->CR1, SPI_CR1_BIDIOE); CS_HIGH(); return status; }

4. 实战踩坑与排错指南

理论很美好,调试很残酷。下面是我在调试过程中遇到的几个典型问题及解决办法。

4.1 问题一:数据全为0xFF或0x00

  • 现象:无论发送什么,接收到的数据总是0xFF或0x00。
  • 排查思路
    1. 检查硬件连接:用示波器或逻辑分析仪看SCLK、CS、SDIO三根线。首先确认CS有拉低,SCLK有时钟输出。这是最基本的。
    2. 检查方向切换时机:这是最可能的原因。用逻辑分析仪抓取SDIO线上的波形。如果你在发送命令后,SDIO线立刻变成了高阻态(电平可能被上拉电阻拉高,看起来像一直为1),然后主机开始产生时钟,那么从设备的数据可能无法驱动这条已经变为高阻的线。确保在发送命令的最后一个时钟边沿结束后,再切换BIDIOE。我上面代码中的等待BSY标志位清零就是为了这个。
    3. 检查从设备是否响应:有些从设备需要在命令后等待几个时钟周期(t_{CSH})才开始输出数据。查看你的传感器数据手册,确认时序要求。如果有等待要求,在切换方向为输入后,先发几个哑元时钟(比如调用HAL_SPI_Receive读一个字节但丢弃),再开始正式接收。
    4. 检查SPI相位和极性(CPOL/CPHA):这是SPI通信的经典问题。你的从设备要求时钟空闲时是高电平还是低电平(CPOL)?数据在第一个时钟边沿还是第二个时钟边沿采样(CPHA)?必须和从设备严格匹配。用逻辑分析仪看波形,对照数据手册的时序图,一个边沿一个边沿地对。

4.2 问题二:只能发送,不能接收,程序卡在接收函数

  • 现象:发送命令正常,但一执行HAL_SPI_Receive就超时。
  • 排查思路
    1. 确认BIDIOE位已正确清零:在调用接收函数前,打印或调试查看SPI_CR1寄存器的值,确认BIDIOE位是0。有时候寄存器操作没生效,可能是因为SPI还处于使能状态,某些寄存器是写保护的。尝试在修改CR1前先__HAL_SPI_DISABLE(),修改后再__HAL_SPI_ENABLE(),但这会复位SPI状态,不是最佳实践。通常确保SPI不忙(!BSY)时修改即可。
    2. 检查DMA或中断配置:如果你使用了DMA或中断,确保接收相关的DMA流或中断是正确配置和使能的。在半双工模式下,收发使用同一个数据寄存器,DMA配置要格外小心。
    3. 检查从设备是否真的在输出数据:逻辑分析仪是终极武器。看SDIO线,在主机产生接收时钟时,线上是否有数据变化?如果没有,问题出在从设备端(命令不对、供电不足、模式未进入等)。

4.3 问题三:通信不稳定,偶尔出错

  • 现象:大部分时间通信正常,但偶尔会读回错误数据。
  • 排查思路
    1. 电源与地线:检查电源是否干净,地线连接是否良好。高速SPI对电源噪声比较敏感。
    2. 上拉电阻:SDIO作为双向线,在方向切换为输入时,主机端是高阻态。如果从设备驱动能力弱,或者线路有电容,可能导致电平建立缓慢,在时钟采样点到来时电平不确定。在SDIO线上加一个4.7kΩ - 10kΩ的上拉电阻到VCC,可以显著改善信号质量,尤其是在总线空闲或方向切换的瞬间。
    3. 软件时序的“滑窗”:在方向切换和开始接收之间,如果系统有高优先级中断打断,可能会引入不可预知的延迟。确保这一小段关键代码的原子性(可以临时关闭全局中断)。
    4. 时钟频率过高:降低SPI的波特率试试。过高的时钟频率可能导致信号边沿不陡峭,容易受干扰。

5. 进阶优化:DMA与中断下的半双工

当数据量较大,或者不想让CPU等待SPI传输时,我们会考虑使用DMA或中断。在半双工模式下,这变得更具挑战性。

5.1 中断模式

中断模式相对简单。你可以使用HAL_SPI_Transmit_ITHAL_SPI_Receive_IT。关键在于在发送完成中断回调函数HAL_SPI_TxCpltCallback中,进行方向切换,然后启动接收中断。同样,在接收完成回调HAL_SPI_RxCpltCallback中,拉高片选,并可能切换回输出方向。

void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi) { if(hspi->Instance == SPI1.Instance) { // 发送完成,切换方向为输入 CLEAR_BIT(hspi->Instance->CR1, SPI_CR1_BIDIOE); // 启动接收 HAL_SPI_Receive_IT(hspi, rx_buf, RX_SIZE); } }

这种模式需要仔细管理状态机,避免回调函数重入。

5.2 DMA模式

DMA模式效率最高,但也最复杂。问题在于:发送和接收使用的是同一个物理数据线,但DMA通常配置为从内存到外设(发送)或从外设到内存(接收)的单向传输

一种可行的方案是:

  1. 配置两条DMA流:一条用于发送(Memory-to-Peripheral),一条用于接收(Peripheral-to-Memory)。但它们都连接到SPI的同一个数据寄存器(SPI_DR)。
  2. 分时复用:先启动发送DMA,发送命令。在发送DMA传输完成中断(或查询标志位)中,停止发送DMA,切换BIDIOE为输入,然后启动接收DMA。
  3. “哑元”发送HAL_SPI_Receive_DMA函数内部,实际上会先配置DMA为从外设到内存,然后使能SPI的发送器(TXE中断)。因为SPI接收数据需要主机产生时钟,而产生时钟就需要向数据寄存器(SPI_DR)写数据。在接收DMA模式下,硬件会自动向SPI_DR写入一个预定义的值(通常是0xFFFF或0x00)来驱动时钟。这个值就是“哑元”数据。你需要确认这个机制在半双工模式下是否正常工作。

个人经验:对于简单的三线SPI读操作,数据量不大时,使用阻塞模式或中断模式代码更清晰,更容易调试。除非是高速、大数据量的持续传输,否则引入DMA带来的复杂度可能得不偿失。如果一定要用DMA,务必参考ST官方对应系列芯片的参考手册中关于SPI DMA的说明,以及HAL库中BIDIMODE下的DMA例程(如果有的话)。

6. 与软件模拟SPI及标准SPI的对比思考

折腾完硬件半双工SPI,我们再来回头看看它和软件模拟SPI、标准四线SPI的对比,就能更清楚它的定位。

特性软件模拟SPI标准四线硬件SPI三线半双工硬件SPI
时序精度低,受CPU中断、任务调度影响高,由硬件时钟生成高,由硬件时钟生成
CPU占用高,需CPU持续操作GPIO低,传输由硬件/DMA完成低,传输由硬件/DMA完成
引脚占用灵活,任意GPIO固定,4根(MOSI, MISO, SCLK, CS)固定,3根(SDIO, SCLK, CS)
通信效率低,速率受限于CPU翻转速度高,全双工同时收发中,半双工,收发需切换
开发难度低,逻辑完全自控低,标准库/HAL库完善中高,需手动管理方向切换
适用场景低速、非实时、引脚受限高速、实时、标准从设备专用三线从设备、引脚节省、中高速

所以,选择哪种方式,是一个权衡的过程。如果你的从设备只支持三线,又对通信速率和CPU占用有要求,那么深入研究STM32的硬件半双工SPI就是必由之路。它绝不是最方便的那个选项,但却是能在性能和资源之间取得较好平衡的方案。

最后,调试这种底层通信,逻辑分析仪几乎是必需品。它能直观地展示时钟、数据和片选线上的每一个跳变,帮你快速定位是命令没发对、方向切早了、还是从设备根本没响应。没有它,很多问题就像在黑暗中摸索,效率极低。花点时间研究一下SPI的波形图,对照数据手册,你会发现一切问题都有迹可循。

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

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

立即咨询