
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。这个流程里有几个容易让人忽略的细节。第一标志位判断是基于单字节的所以如果你传入了Size4循环会跑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决定设备在时钟的上升沿还是下降沿采样数据。两个组合起来共四种模式模式CPOLCPHA特点Mode 000空闲低电平上升沿采样Mode 101空闲低电平下降沿采样Mode 210空闲高电平下降沿采样Mode 311空闲高电平上升沿采样如果你只挂一个设备查询数据手册把CPOL/CPHA配对一般不会有问题。但如果你像我前面说的那样一条总线上挂了三四个设备就可能出现A设备用Mode 0、B设备用Mode 3的情况。这时你要么在切换设备时重新初始化SPI要么给不同设备接不同外设。很多开发板例程只写好了一种模式你以为“都差不多”一换芯片就什么都读不出来。判断方式其实很简单找一片示波器先看空闲电平再看数据位的采样沿。如果你手里没有示波器就老老实实翻手册。手册上通常会画时序图如果时序图给的是“数据在上升沿稳定”那大概率是CPHA0。这个配置错位之后主机发出的命令从设备能收到但从设备回的数据主机在错误的时间点去采样收到的就全是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时钟1MHz10字节数据理论耗时约80us算上系统和从设备响应延迟给个10ms绰绰有余。如果用到RTOS更好的是把超时和任务处理的周期关联起来不要搞成孤立参数。5. 实操复盘三个高频场景的完整走查5.1 用HAL_SPI_TransmitReceive读取W25Q64 Flash IDW25Q64是常见的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越多读回的数据才会越多。如果你只发一个0x9FSize1那你最多只能读到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. 常见问题快速排查表现象可能原因排查方向读回来的数据全是0xFFSPI模式不匹配或CS没拉低或MISO接线错误检查CPOL/CPHA模式用示波器看CS和MISO电平读回来的数据全是0x00从设备没有数据输出或发送内容方向不对确认发送缓冲区的命令和虚拟字节是否正确确认从设备供电和复位第一次能读第二次以后卡死SPI状态停留BUSY或CS提前拉高或DMA未完成打印句柄State值检查BSY标志确认DMA回调是否触发返回值一直是HAL_TIMEOUTSPI从设备没响应或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驱动里的时序细节收敛到一个可靠封装里不要在每个调用点各写各的时序这种东西最怕的就是“这次随手写一下”。一个小封装能给你省下的调试时间往往远超你写它花掉的时间。