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 轮询模式的核心流程图
下面这张图把轮询收发的完整流程画出来了,从调用函数到最终返回,每一步都标清楚了:
4.6 实际项目中的避坑清单
最后,我把这些年用轮询模式踩过的坑整理成清单,你写代码时对照着检查一遍:
- CS 片选要手动控制:HAL 库的轮询函数不会自动拉高拉低 CS。你必须在调用前后自己操作 GPIO,否则从机可能不响应。
- 缓冲区对齐:如果 SPI 配置了 16 位数据帧,缓冲区指针必须按半字对齐。我遇到过用 uint8_t 数组强转成 uint16_t* 导致硬件错误的情况。
- 不要在中断里调用轮询函数:中断服务函数应该尽量短。如果你在中断里调
HAL_SPI_Transmit()并设了较长的 Timeout,整个系统都会被拖死。 - 多任务环境下加互斥:如果 RTOS 中多个任务共享同一个 SPI 外设,轮询函数必须用信号量保护。否则两个任务同时调用,会返回
HAL_BUSY。 - 调试时先降低 SPI 时钟:我习惯先把波特率降到 1MHz 以下调试通信,通了再慢慢提上去。高频下很多问题其实是信号完整性问题,不是代码逻辑问题。
💡 我的个人习惯: 每个轮询调用后面都跟一个 __HAL_SPI_CLEAR_FLAG() 清除可能的残留标志位。虽然 HAL 库内部会清,但多清一次没坏处,尤其是在从机热插拔的场景下。