4. 轮询模式收发:HAL_SPI_Transmit()/Receive()/TransmitReceive() 函数详解、超时机制与错误处理

轮询模式,说白了就是 CPU 死等。你调用一个发送函数,CPU 就卡在那,直到数据发完或者超时返回。这种方式简单、直接,没有中断上下文切换的麻烦,非常适合短数据块或者对实时性要求不高的场景。

我个人习惯在初始化阶段、或者调试时先用轮询模式把通信调通。等逻辑确认无误了,再换成中断或 DMA 去优化性能。你想想看,如果轮询都跑不通,上中断只会让问题更难排查。

4.1 三大核心函数:各司其职

HAL 库提供了三个轮询收发函数,名字很直观,但细节上有不少坑。我列个表,你一眼就能看明白它们的区别:

函数原型 功能 典型场景
HAL_SPI_Transmit() 只发送,接收到的数据直接丢弃 写寄存器、发配置命令
HAL_SPI_Receive() 只接收,发送端会持续输出 0xFF 或 0x00 读传感器数据、读 FIFO
HAL_SPI_TransmitReceive() 同时收发,全双工模式 读写 Flash、操作 SD 卡

这里有个关键点:SPI 是同步全双工协议。你发一个字节的同时,必定会收到一个字节。所以 HAL_SPI_Transmit() 并不是真的“只发不收”,它只是把收到的数据扔掉了。同样,HAL_SPI_Receive() 也必须发点东西才能把时钟产生出来,默认发的是 0xFF。

核心原则: 如果你的从机需要主机发送特定命令码才能返回数据,那就必须用 TransmitReceive(),不能先 Transmit 再 Receive。因为两次调用之间 CS 片选可能会被拉高,从机就复位了。

4.2 函数参数详解:别小看 Timeout

这三个函数的参数结构几乎一样,我以 HAL_SPI_TransmitReceive() 为例拆开讲:

HAL_StatusTypeDef HAL_SPI_TransmitReceive(
    SPI_HandleTypeDef *hspi,
    uint8_t *pTxData,
    uint8_t *pRxData,
    uint16_t Size,
    uint32_t Timeout
);
  • hspi:SPI 句柄指针,初始化时配置好的那个结构体。
  • pTxData / pRxData:发送和接收缓冲区指针。注意,这两个缓冲区不能是局部变量(除非你确定函数返回前不会出作用域),我见过有人把数组定义在 if 块里,结果传出去的指针悬空了。
  • Size:要收发数据的字节数。单位是字节,不是字,也不是位。
  • Timeout:超时时间,单位是毫秒。这个参数很多人随便填,结果出了问题。

⚠ 我曾经踩过的坑: 把 Timeout 设成了 HAL_MAX_DELAY(0xFFFFFFFF),想着“反正不会超时”。结果从机挂了,主机就永远卡死在轮询里,看门狗都救不回来。从那以后,我所有轮询调用都给了具体超时值,比如 100ms 或 1000ms。

4.3 超时机制:内部是怎么工作的?

HAL 库的超时实现其实很简单,就是 HAL_GetTick() 的差值比较。每次进入函数时记录起始 tick,然后在等待标志位的循环里不断检查:

// 伪代码逻辑
uint32_t tickstart = HAL_GetTick();
while (__HAL_SPI_GET_FLAG(hspi, SPI_FLAG_TXE) == RESET) {
    if ((HAL_GetTick() - tickstart) > Timeout) {
        return HAL_TIMEOUT;
    }
}

这里有个隐藏问题:HAL_GetTick() 的精度是 1ms。如果你的 Timeout 设成 1ms,很可能因为系统 tick 刚好错过而直接超时。我个人建议 Timeout 至少设 10ms,除非你非常确定传输能在 1ms 内完成。

另外,如果系统中有其他中断长时间占用 CPU,HAL_GetTick() 不会更新,轮询超时就会不准。这种情况我遇到过,当时是 USB 中断把 SPI 传输卡了 200ms,Timeout 设了 100ms 却没触发——因为 tick 根本没涨。

4.4 错误处理:别只检查返回值

这三个函数返回 HAL_StatusTypeDef,取值有四种:

返回值 含义 常见原因
HAL_OK 传输成功 一切正常
HAL_ERROR 参数错误或硬件错误 句柄未初始化、DMA 配置冲突
HAL_BUSY 外设正忙 上次传输未完成,又发起了新传输
HAL_TIMEOUT 超时 从机无响应、时钟频率太低、线没接好

很多人的代码只检查 HAL_OK,其他情况直接忽略。我的做法是:

if (HAL_SPI_TransmitReceive(&hspi1, tx, rx, 10, 100) != HAL_OK) {
    // 先打印错误码
    Error_Handler();
    // 然后复位 SPI 外设
    HAL_SPI_DeInit(&hspi1);
    HAL_SPI_Init(&hspi1);
}

为什么要复位?因为一旦发生超时或错误,SPI 外设的内部状态机可能处于异常状态。不清除的话,下一次传输大概率还是失败。我早期调试时吃过这个亏,连续超时三次才意识到需要重新初始化。

💡 调试小技巧: 在 Error_Handler() 里点亮一个 LED,或者通过串口打印 hspi1->ErrorCode。这个字段会记录具体的错误类型,比如 HAL_SPI_ERROR_MODF(模式错误)或 HAL_SPI_ERROR_OVR(溢出),能帮你快速定位问题。

4.5 轮询模式的核心流程图

下面这张图把轮询收发的完整流程画出来了,从调用函数到最终返回,每一步都标清楚了:

轮询模式收发流程图 调用收发函数 检查参数 & 外设状态 参数有效? 或外设空闲? 否 返回 HAL_ERROR 是 循环发送/接收 超时? 是 返回 HAL_TIMEOUT 否 传输完成? 继续下一字节 是 返回 HAL_OK ⚠ 注意 超时后必须复位外设 否则状态机卡死 建议调用 DeInit + Init

4.6 实际项目中的避坑清单

最后,我把这些年用轮询模式踩过的坑整理成清单,你写代码时对照着检查一遍:

  1. CS 片选要手动控制:HAL 库的轮询函数不会自动拉高拉低 CS。你必须在调用前后自己操作 GPIO,否则从机可能不响应。
  2. 缓冲区对齐:如果 SPI 配置了 16 位数据帧,缓冲区指针必须按半字对齐。我遇到过用 uint8_t 数组强转成 uint16_t* 导致硬件错误的情况。
  3. 不要在中断里调用轮询函数:中断服务函数应该尽量短。如果你在中断里调 HAL_SPI_Transmit() 并设了较长的 Timeout,整个系统都会被拖死。
  4. 多任务环境下加互斥:如果 RTOS 中多个任务共享同一个 SPI 外设,轮询函数必须用信号量保护。否则两个任务同时调用,会返回 HAL_BUSY。
  5. 调试时先降低 SPI 时钟:我习惯先把波特率降到 1MHz 以下调试通信,通了再慢慢提上去。高频下很多问题其实是信号完整性问题,不是代码逻辑问题。

💡 我的个人习惯: 每个轮询调用后面都跟一个 __HAL_SPI_CLEAR_FLAG() 清除可能的残留标志位。虽然 HAL 库内部会清,但多清一次没坏处,尤其是在从机热插拔的场景下。