1. 先把问题摊开:为什么我建议好好研究这个函数
先交代一个背景。我之前接手过一个采集板项目,主控是STM32F103,外设挂了一片SPI接口的加速度计、一片W25Q64存储芯片,还有一块SD卡。三样东西全挂在同一条SPI总线上,CS分开控制。项目本身不算复杂,但BSP调试阶段却浪费了我快三天时间。问题不在电路,不在芯片,翻来覆去,最后都回到同一个函数上:HAL_SPI_TransmitReceive()。
所以这篇内容不是从SPI协议科普开始的,而是从“我到底怎么用HAL库把SPI数据弄对”这个角度切入的。标题里提到的这个函数,几乎可以覆盖日常SPI调试中80%的困惑:为什么读写不到数据、为什么返回值总是HAL_TIMEOUT、为什么第一包正常后面就卡死、为什么用DMA就丢数据、为什么CS时序看着不对。如果你也有类似疑问,不妨把这篇文章当成一份排查手册。
无论你是刚接触STM32 HAL库的新手,还是已经在产品上跑过几套SPI驱动的工程师,只要你的代码里出现过HAL_SPI_TransmitReceive、HAL_SPI_TransmitReceive_IT或HAL_SPI_TransmitReceive_DMA,这篇文章都值得仔细看一遍。核心就四件事:函数内部逻辑、SPI收发时序、超时机制、以及我在实际项目里踩过和看到别人踩过的一堆坑。
2. 先别急着抄代码,搞清楚HAL_SPI_TransmitReceive到底干了什么
2.1 全双工才是SPI的主场
很多开发者在第一次看到HAL_SPI_TransmitReceive()这个函数名时,容易把它理解成“先发送,再接收”。这是第一个大误会。
SPI是同步全双工总线,主设备发送数据的同时,会从MISO线上采样接收数据。换句话说,主设备每输出一个时钟周期,就会移动一位发送数据,同时也移入一位接收数据。所以“发送”和“接收”不是先后关系,而是同一个时钟驱动下的两个并行动作。HAL_SPI_TransmitReceive()这个名字对应的正是这种“左手发右手收”的模型:你给我一块发送缓冲区,再给我一块接收缓冲区,函数在底层用同一个SPI外设完成一轮同步收发。
如果你只想读取从设备的数据,比如读Flash的ID,也不能干巴巴地调用一个“只接收”函数,而是要主动发送与从设备约定好的命令字节或虚拟字节。从设备收到主设备发来的命令后,才会把数据放到MISO线上回给主设备。所以就算你的核心目的是“读”,也得先准备好要发出去的字节。这也是不少刚入门的朋友死活读不到数据的根本原因——他以为有一根万能函数叫“SPI读”,实际上没有。
2.2 HAL库的阻塞式实现带了个“计数器”
现在来看底层逻辑。HAL_SPI_TransmitReceive()这个阻塞式接口,执行流程大致是:
- 调用前检查SPI句柄的状态,如果状态不是
HAL_SPI_STATE_READY,直接返回HAL_BUSY。 - 把SPI状态切换到
HAL_SPI_STATE_BUSY。 - 硬件上清空发送和接收标志位,拉高/拉低NSS由用户代码控制。
- 进入一个while循环,通过判断
TXE(发送缓冲区空)和RXNE(接收缓冲区非空)这两个标志来搬运每个字节。 - 循环里每次都会检查超时时间是否到了。
- 全部数据发送接收完毕后,把状态切回
HAL_SPI_STATE_READY,返回HAL_OK。
这个流程里有几个容易让人忽略的细节。第一,标志位判断是基于单字节的,所以如果你传入了Size=4,循环会跑4轮;第二,状态切换是在非常靠前的位置完成的,一旦函数进去,无论成败,最终都要走完再切状态,除非你在途中调用别的SPI操作引起污染;第三,Timeout参数并不是你感觉里的“发送字节之间允许的最大间隔”,而是整个阻塞操作内部每次检查点位的累积参考,通过HAL_GetTick()来比较。
2.3 中断版和DMA版不是换了个写法
HAL库为SPI收发提供了三套接口:
HAL_SPI_TransmitReceive():阻塞式,CPU全程参与。HAL_SPI_TransmitReceive_IT():中断式,每收发一个字节触发一次中断。HAL_SPI_TransmitReceive_DMA():DMA式,数据搬运不占用CPU,通过DMA完成。
三套接口的函数名只差一个后缀,但内部逻辑差异非常大。阻塞式简单粗暴,适合短数据、低频调用场景;中断式在每字节中断和主流程调度之间容易被其他高优先级中断挤压,如果SPI时钟很高,中断响应不及时就会出错;DMA式效率最高,也最容易踩到“数据还没处理完就被下一个传输覆盖”的坑。
我见过很多项目,一上来就用DMA,结果莫名其妙收不到完整数组,最后退回阻塞式才正常。其实不是DMA不能用,而是很多人没理解DMA传输完成中断和SPI外设状态之间还有一个“尾巴”:DMA搬运完数据后,SPI移位寄存器里可能还有最后几个比特没移出来,你没等SPI_FLAG_BSY清掉就操作CS或者其他寄存器,数据自然就丢了。
3. 收发时序里的那些坑,一个比一个隐蔽
3.1 CPOL和CPHA错了,数据全白搭
SPI时序最基础也最关键的两个配置参数是时钟极性(CPOL)和时钟相位(CPHA)。CPOL决定空闲时SCK是高还是低,CPHA决定设备在时钟的上升沿还是下降沿采样数据。两个组合起来共四种模式:
| 模式 | CPOL | CPHA | 特点 |
|---|---|---|---|
| Mode 0 | 0 | 0 | 空闲低电平,上升沿采样 |
| Mode 1 | 0 | 1 | 空闲低电平,下降沿采样 |
| Mode 2 | 1 | 0 | 空闲高电平,下降沿采样 |
| Mode 3 | 1 | 1 | 空闲高电平,上升沿采样 |
如果你只挂一个设备,查询数据手册把CPOL/CPHA配对,一般不会有问题。但如果你像我前面说的那样,一条总线上挂了三四个设备,就可能出现A设备用Mode 0、B设备用Mode 3的情况。这时你要么在切换设备时重新初始化SPI,要么给不同设备接不同外设。很多开发板例程只写好了一种模式,你以为“都差不多”,一换芯片就什么都读不出来。
判断方式其实很简单:找一片示波器,先看空闲电平,再看数据位的采样沿。如果你手里没有示波器,就老老实实翻手册。手册上通常会画时序图,如果时序图给的是“数据在上升沿稳定”,那大概率是CPHA=0。这个配置错位之后,主机发出的命令从设备能收到,但从设备回的数据主机在错误的时间点去采样,收到的就全是0xFF、0x00这类明显不对的值。你从逻辑上怀疑任何问题都可以,但查到最后一定是CPOL/CPHA配置不对,这情况我遇到过太多次了。
3.2 时钟分频不能拍脑袋定
SPI时钟频率也不是越大越好。主设备输出的SCK频率需要满足从设备的最大时钟要求,同时还要考虑PCB走线长度和信号完整性。F103的SPI外设最高可跑到18MHz,但如果你用的从设备最高只支持10MHz,跑18MHz可能就会随机出错。
另外,不同设备的“最高时钟”描述方式也五花八门,有些写的是f_SCK max,有些直接在一堆寄存器配置参数里给了,有些还区分了读命令和写命令的不同最大频率。比如W25Q64的数据手册就明确写了读命令时钟和写命令时钟的上限不同。因此在配SPI预分频时,不要只算“系统时钟除以多少等于多少MHz”,还要回头看实际模拟出的波形。正常说要留至少10%~20%的余量,不要顶格跑。
3.3 软件片选最容易被人忽略
HAL库的SPI驱动默认没有帮你把CS管脚管理进去,这一脚得自己在应用层拉低和拉高。大部分人的写法是:
HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, len, 1000); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);这个写法看似没问题,但在某些从设备上就会有兼容性风险。问题出在CS拉低到SCK第一个有效边沿之间需要一段“建立时间”,某些器件对此很敏感。如果你的CS拉低后马上拉高,但实际上SCK还没完全停止,数据可能没被正确捕获。
更稳妥的做法是在CS拉低后做一个小小的延时,或者在函数返回后先清SPI的BSY位,再拉高CS。比如:
HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET); for (volatile int i = 0; i < 10; i++); // 具体次数按实际时钟来 HAL_SPI_TransmitReceive(&hspi1, tx_buf, rx_buf, len, 1000); while (__HAL_SPI_GET_FLAG(&hspi1, SPI_FLAG_BSY)); HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);这里的SPI_FLAG_BSY判断尤其重要。BSY位为1表示SPI还在忙,如果这时候你去拉高CS,从设备可能认为传输被异常终止,下一次通信的起始状态就是乱的。我在调试Flash读数据时,明明读取ID一直成功,但读内容偶尔失败,后来查就是这个BSY没有等待导致CS提前拉高造成的。
3.4 16位数据帧长度和8位数据帧长度混用
HAL库里SPI句柄有个成员叫Init.DataSize,可以配SPI_DATASIZE_8BIT或SPI_DATASIZE_16BIT。有些设备要求按16位发送,比如某些触摸屏控制器或音频芯片。这时如果还按8位方式去填充数据,字节序和位序都不会对。
16位模式下,函数传入的缓冲区指针类型是uint8_t*,但底层每次收发的是两个字节,也就是一个完整的16位帧。数据在内存里的排列必须按照大端方式,即高位在前。很多人在这里被搞晕:明明是uint8_t数组,怎么就乱序了。实际上,你传入的是uint8_t数组的首地址,HAL库在内部把两个字节拼成一个半字去写数据寄存器,拼的顺序是“高字节先、低字节后”。因此如果你想发送0x1234,数组必须是{0x12, 0x34},反了就读写全乱。
4. 超时处理:别以为超时只是随便填个1000就完事
4.1 超时到底是靠什么计时
HAL_SPI_TransmitReceive()的最后一个参数是Timeout,很多人在CubeMX生成的模板里看到这个参数,就随手填个1000。但真到了排查“为什么一直返回HAL_TIMEOUT”的时候,反而不知道从哪下手。
HAL库中的阻塞式接口在进入收发主循环之后,每一轮字节处理前都会获取一次HAL_GetTick()返回值。HAL_GetTick()的来源默认是SysTick定时器,也就是uwTick这个全局变量,它每毫秒自增一次。函数内部会这样判断:
while (条件没满足) { if (某个条件持续不满足) { if (((HAL_GetTick() - tickstart) >= Timeout)) { return HAL_TIMEOUT; } } }注意,它比较的是“当前tick减去开始tick”是否大于等于Timeout,而不是类似于“倒计时”的计数。所以Timeout的单位就是毫秒,1000就是1000ms。
4.2 为什么你的超时判断会失灵
有一种极其隐蔽的情况:你在中断回调里做耗时操作,或者你把某个外部中断的优先级设得比SysTick还高。由于HAL_GetTick()本身是靠SysTick中断触发的,它只有在中断被正常响应的情况下才会增加。如果SysTick中断一直被另一个更高优先级的中断抢占,那么uwTick就不增长了,HAL_GetTick()返回值始终停留在原来的位置,你写的超时等于形同虚设,整个程序就会卡死在SPI的while循环里。
我遇到过一个真实案例:一个同事在串口接收中断里写了一段非常耗时的解析,同时串口中断优先级被配置到了0,比SysTick的优先级还高。结果SPI并不忙,但因为外部一直在打串口数据,SysTick始终被抢占,SPI那边的超时判断永远不成立,整个系统看起来像死机了。我们用示波器一抓,发现SPI时钟早就停下来了,但程序就是卡在阻塞式函数的循环里出不来。
解决办法通常有两个方向。第一是降低其他中断的优先级,保证SysTick能被及时响应;第二是如果你的RTOS需要精细管理,或者你自己维护了一套时间基准,可以重写HAL_GetTick()或者用HAL_GetTickFreq()校准。但对大多数场景来说,最重要的原则就是:高优先级中断里不要做耗时操作,优先级分配要优先保证时间基准可靠。
4.3 超时时间到底填多少合适
Timeout不能填得太小。在慢速SPI时钟(比如几十kHz)下,一个8位字节传输就需要几百微秒甚至更久,如果你传了8字节,整轮收发时间可能超过10ms,而Timeout填了5ms,那就妥妥报错。但Timeout也不能一味填个HAL_MAX_DELAY(即0xFFFFFFFF),否则一旦从设备异常拉低时钟线或者CS锁死,你永远等不到返回,整个系统就卡在阻塞接口里,连看门狗都不一定能救。
比较合理的做法:估算出最坏情况下完整传输需要的时间,再乘2~3倍作为Timeout。比如SPI时钟1MHz,10字节数据,理论耗时约80us,算上系统和从设备响应延迟,给个10ms绰绰有余。如果用到RTOS,更好的是把超时和任务处理的周期关联起来,不要搞成孤立参数。
5. 实操复盘:三个高频场景的完整走查
5.1 用HAL_SPI_TransmitReceive读取W25Q64 Flash ID
W25Q64是常见的SPI NOR Flash,它读JEDEC ID的命令是0x9F,之后需要再发送3个虚拟字节,才能把24位ID完整读回来。常规代码如下:
uint8_t tx[4] = {0x9F, 0x00, 0x00, 0x00}; uint8_t rx[4] = {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_StatusTypeDef status = HAL_SPI_TransmitReceive(&hspi1, tx, rx, 4, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); if (status == HAL_OK) { // ID = (rx[1] << 16) | (rx[2] << 8) | rx[3] }这段代码的关键点是:tx并不是只有“命令”,后面还必须带足虚拟填充字节,因为从设备只有在主机发出SCK时才会把数据移出来。你发的0x00越多,读回的数据才会越多。如果你只发一个0x9F,Size=1,那你最多只能读到rx[0]里的第一个字节(这个字节其实还是命令回显值或无效值),根本拿不到完整的24位ID。
另外,CS拉低和拉高之间的范围内,不能夹带任何其他SPI总线的操作。如果中途有更高优先级任务也去抢这个SPI句柄,就会乱套。这也是为什么在做这类共享SPI总线的项目时,给每笔SPI交易加一个互斥信号量会安全得多。裸机时用关中断或临界区保护;RTOS里用互斥量或二值信号量把“CS拉低、收发、等待BSY、CS拉高”这四步包住。
5.2 一条SPI总线上挂多个设备:设备切换的正确姿势
如果总线上有Flash还有传感器,两个设备的SPI模式不一样,比如Flash用Mode 0,传感器用Mode 3。你最好做的是每次切换前重新配置SPI句柄:
hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; HAL_SPI_Init(&hspi1); // 对Flash操作 hspi1.Init.CLKPolarity = SPI_POLARITY_HIGH; hspi1.Init.CLKPhase = SPI_PHASE_2EDGE; HAL_SPI_Init(&hspi1); // 对传感器操作HAL_SPI_Init()在重新初始化时并不会自动去管SPI_ENABLE,所以你先要调用HAL_SPI_DeInit()然后再HAL_SPI_Init(),否则某些Cortex-M内核上SPI配置寄存器的修改可能不会立刻生效。但DeInit会把SPI的外设时钟配置整个复位掉,包括GPIO复用配置也会被清掉,所以每次重新初始化之后,需要重新配置GPIO。
这种切换方式有个缺点:慢。每次切换等于做一次完整的外设重新初始化,性能和瞬时性都不好。如果项目对性能要求高,我建议直接用寄存器操作写CR1寄存器里的CPOL和CPHA位,在SPI不使能状态下更新这两个位,然后重新使能SPI,比调用HAL的DeInit/Init要轻量得多。
5.3 DMA方式读取连续数据流的注意事项
当你用SPI DMA接收传感器的连续数据流时,常见的配置是HAL_SPI_TransmitReceive_DMA()。这块有几条经验值得记牢。
开启DMA之前,一定要确认DMA通道没有被其他外设占用。F103这种芯片,DMA1和DMA2的通道有限,不同外设的请求映射是固定的。如果你把USART和SPI配置到了同一个DMA通道,那就别想着同时工作,能通纯属运气。
DMA接收数据的“大小”必须和SPI实际传输的大小严格一致。比如你要读128字节,DMA配置的传输数据长度也必须是128。如果SPI从设备实际只返回了120字节,DMA会一直等,直到超时或出异常。
另外,在HAL_SPI_TxRxCpltCallback()中,处理完数据后不要去重复调用HAL_SPI_TransmitReceive_DMA(),除非你确认上一次传输已经完全结束。更安全的做法是,在回调里先把接收缓冲区指针指向新缓冲区,再启动下一轮传输。如果直接双击函数,第二轮的DMA配置可能覆盖掉第一轮还没有存走的结尾数据。
我调试过一个AD7606类的高速采样板,DMA接收模式下总是出现数据整体偏移几个字节,后来发现SPI和DMA的中断优先级没有配好,DMA传输完成中断在读取末尾几个字节时被SPI的FIFO中断打断,导致末尾数据溢出。把DMA中断优先级调高、SPI中断优先级调低之后,问题直接消失。
6. 常见问题快速排查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 读回来的数据全是0xFF | SPI模式不匹配,或CS没拉低,或MISO接线错误 | 检查CPOL/CPHA模式,用示波器看CS和MISO电平 |
| 读回来的数据全是0x00 | 从设备没有数据输出,或发送内容方向不对 | 确认发送缓冲区的命令和虚拟字节是否正确,确认从设备供电和复位 |
| 第一次能读,第二次以后卡死 | SPI状态停留BUSY,或CS提前拉高,或DMA未完成 | 打印句柄State值,检查BSY标志,确认DMA回调是否触发 |
| 返回值一直是HAL_TIMEOUT | SPI从设备没响应,或SysTick中断被抢占 | 测量SCK是否有输出,检查中断优先级配置 |
| CS时序看起来不对 | 拉低CS与SCK之间缺少建立时间,或收发结束后未等BSY | 在CS操作附近加延时,增加BSY等待 |
| 数据有随机错位 | SPI时钟频率过高,或PCB走线过长,或DMA中断优先级不合理 | 降低分频,缩短接线,调整DMA/SPI中断优先级 |
| 中断模式频繁进入HardFault | 缓冲区指针失效,或回调里修改了结构体 | 保证缓冲区生命周期覆盖整个传输过程 |
这个排查表是我在实际项目中沉淀下来的,不一定覆盖所有情况,但大概率能覆盖你在HAL_SPI_TransmitReceive()这条路上遇到的主要坑。
7. 写在最后的经验补充
调试SPI,最忌讳的就是凭现象猜问题。我见过有人把“读不到数据”归结为芯片坏了,换了好几片芯片,最后发现是软件片选引脚写错了;也有人把“时序不对”归结为电源纹波,折腾半天,其实只是CPOL/CPHA没配对。SPI难吗?协议本身不难,难的是HAL库封装之后,你很难一眼看出底层状态机和标志位到底发生了什么。
我个人的经验是,第一次跑通SPI通信之后,一定不要急着上头直接开始上层业务。先在主循环里写个最简单的回环测试:把SPI的MOSI和MISO短接,用HAL_SPI_TransmitReceive()发送一串递增字节,检查接收缓冲区是不是原样返回。这一步能帮你证明GPIO配置、时钟初始化、DMA映射、中断优先级这些基础环境全部正常。基础环境验证完之后,再挂真实从设备,按数据手册要求逐条核对命令字和时序。
如果你接下来还要做更复杂的项目,强烈建议提前把超时的处理逻辑想清楚。不要在所有SPI调用里都填一个固定的大数,也不要全用HAL_MAX_DELAY。根据不同从设备、不同命令的实际耗时,分别定义超时上限,在调试时打开HAL_SPI_TransmitReceive的返回值打印,让它把失败原因直接暴露在你眼前。
说到这,再把当初那个采集板项目拉回来说两句。最后我定位到的根因,其实是两个坑叠加:一个是我给传感器配错了CPHA,另一个是CS拉高前没有等BSY清位。两个都不算高级问题,但都极其隐蔽。修完之后,我在CS操作和SPI传输之间加了一个统一的宏封装,后续所有SPI设备都走同一套流程,再没出过类似的时序事故。这也是我给所有做STM32项目的人的建议:把SPI驱动里的时序细节收敛到一个可靠封装里,不要在每个调用点各写各的,时序这种东西最怕的就是“这次随手写一下”。一个小封装能给你省下的调试时间,往往远超你写它花掉的时间。