ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

STM32 HAL库SPI通信实战:8位与16位数据高效发送及DMA配置避坑指南

STM32 HAL库SPI通信实战:8位与16位数据高效发送及DMA配置避坑指南 1. SPI通信实战的核心设计思路1.1 为什么SPI配置看起来简单实际用起来却容易翻车SPI这东西刚接触的时候觉得特别简单——四根线时钟、数据进、数据出、片选配好波特率就能通。但真正在项目里用起来尤其是跟各种传感器、Flash、显示屏打交道的时候你会发现坑一个接一个。我见过太多人在调试SPI的时候示波器抓了半天波形最后发现是数据位宽配错了或者片选信号没控制好。STM32的HAL库把SPI的底层寄存器操作封装得很漂亮HAL_SPI_Transmit()、HAL_SPI_Receive()、HAL_SPI_TransmitReceive()这几个函数看起来人畜无害但一旦涉及到8位和16位数据宽度的切换问题就来了。很多从标准库转过来的朋友习惯了直接操作SPI_I2S_SendData()转到HAL库之后发现同样的代码逻辑数据就是不对。核心原因在于HAL库对数据位宽的处理方式跟标准库有本质区别。标准库是你自己往DR寄存器写数据写8位还是16位由你决定而HAL库在初始化阶段就把数据位宽固定了hspi1.Init.DataSize这个参数决定了后续所有传输的基本单位。你设成SPI_DATASIZE_8BIT那每次传输就是8位设成SPI_DATASIZE_16BIT每次就是16位。这个设定在HAL_SPI_Init()之后就不能随便改了除非你重新初始化SPI外设。所以高效发送16位和8位数据的关键不在于发送函数本身而在于你对SPI外设数据位宽的理解和配置策略。这篇文章就是要把这个点讲透让你在遇到不同位宽的从设备时能快速做出正确的配置选择。1.2 8位和16位数据宽度的本质区别先把这个概念理清楚。SPI的数据位宽指的是一次数据传输中时钟脉冲的数量。8位数据宽度就是每个片选周期内产生8个时钟脉冲数据线上传输8个bit16位数据宽度就是产生16个时钟脉冲传输16个bit。对于STM32的SPI外设来说数据位宽决定了DR寄存器的有效位数。当你配置为8位模式时你往DR寄存器写数据只有低8位有效当你配置为16位模式时低16位都有效。发送的时候硬件会根据你配置的位宽自动决定移出多少个bit。这里有个很容易混淆的点数据位宽跟你要发送的数据本身是多少位没有必然关系。比如你要发送一个16位的传感器读数你可以用16位模式一次发出去也可以用8位模式分两次发先发高8位再发低8位。两种方式都能达到目的但适用的场景不一样。我个人的经验是如果从设备的数据帧格式是固定16位的优先用16位模式如果从设备的数据帧是8位对齐的或者需要频繁切换读写方向用8位模式更灵活。这个判断逻辑后面会详细展开。1.3 HAL库SPI发送函数的内部机制要理解怎么高效发送得先看看HAL库这几个发送函数到底干了什么。以HAL_SPI_Transmit()为例它的函数原型是HAL_StatusTypeDef HAL_SPI_Transmit(SPI_HandleTypeDef *hspi, uint8_t *pData, uint16_t Size, uint32_t Timeout);注意pData的类型是uint8_t *Size是uint16_t。这里就有个问题如果你配置的是16位数据模式pData指向的数据该怎么组织Size又代表什么HAL库的处理逻辑是这样的Size表示要传输的数据项个数每个数据项的大小由Init.DataSize决定。如果你配置的是8位模式Size4就是发4个字节如果你配置的是16位模式Size4就是发4个半字也就是8个字节。但pData指针始终是uint8_t *类型所以在16位模式下HAL库内部会把这个指针强转为uint16_t *来读取数据。这就引出了一个非常关键的实操要点在16位模式下你传给HAL_SPI_Transmit()的缓冲区必须是uint16_t类型的数组或者你手动把数据按半字对齐组织好。如果你传了一个uint8_t数组进去HAL库会按照半字的方式去读读出来的数据就全乱了。我当初就踩过这个坑。用16位模式驱动一个SPI接口的ADC数据缓冲区定义成了uint8_t adc_buf[10]结果读出来的数据高低字节完全错位。后来改成uint16_t adc_buf[5]问题立刻解决。这个细节在ST的官方文档里其实有写但很容易被忽略。2. 核心细节解析与实操要点2.1 CubeMX中SPI数据位宽的配置陷阱用CubeMX配置SPI的时候Data Size这个选项就在SPI的Parameter Settings里面可选8 Bits和16 Bits。看起来很简单但这里有几个隐藏的坑。第一个坑CubeMX生成的代码里数据位宽的宏定义是SPI_DATASIZE_8BIT和SPI_DATASIZE_16BIT但如果你在代码里想动态切换不能直接改hspi1.Init.DataSize然后指望它生效。SPI外设的CR1寄存器里的DFF位在SPI使能状态下是只读的你必须先HAL_SPI_DeInit()改配置再HAL_SPI_Init()。这个过程比较耗时不适合在频繁切换位宽的场景下使用。第二个坑DMA模式下数据位宽的配置会影响DMA的传输单位。如果你用DMA来搬运SPI数据DMA的PeripheralDataAlignment和MemoryDataAlignment必须跟SPI的数据位宽匹配。SPI配16位DMA的外设端和内存端都得配成半字对齐SPI配8位DMA就配字节对齐。这个如果配错了DMA传输的数据会错位而且这种错误往往不会报错只是数据不对排查起来很头疼。第三个坑NSS片选信号的管理方式会影响数据传输的连续性。CubeMX里有个NSS Signal Type选项可以选Hardware或Software。硬件片选由SPI外设自动控制软件片选需要你手动拉低拉高GPIO。在16位模式下如果你用软件片选一定要确保在两次传输之间不要过早拉高片选否则从设备可能会把一次完整的16位传输拆成两个8位来处理。我一般建议如果是单一从设备用硬件片选最省事如果是多从设备共享SPI总线用软件片选更灵活但要注意片选信号的时序。软件片选的具体操作后面会给出代码示例。2.2 8位模式下的高效发送策略8位模式是SPI最常用的配置因为绝大多数SPI从设备Flash、OLED、传感器的数据帧都是8位对齐的。在8位模式下HAL_SPI_Transmit()的pData直接传uint8_t数组就行Size就是要发送的字节数。但这里有个效率问题如果你要发送多个字节是用一次HAL_SPI_Transmit()发完还是分多次发答案很明确能用一次发完就不要分多次。因为每次调用HAL_SPI_Transmit()都有函数调用开销而且在非DMA模式下HAL库是阻塞式发送每个字节都要等待TXE标志位置位。分多次调用意味着多次进入和退出阻塞等待效率明显降低。我实测过在STM32F103上SPI时钟设为18MHz发送100个字节一次HAL_SPI_Transmit()调用约56微秒分100次调用每次发1个字节约180微秒差距超过3倍。所以批量发送时一定要把数据组织成一个缓冲区一次性发出去。但有一种情况必须分多次发需要在数据之间插入延时或者切换片选。比如驱动某些SPI Flash时发送命令字之后需要等待一段时间才能发送地址。这种场景下分多次调用是不可避免的但你可以通过合理组织数据来减少调用次数。另外8位模式下有个很实用的技巧如果你要发送的数据量很大比如刷OLED屏幕用DMA模式可以彻底解放CPU。配置好DMA之后HAL_SPI_Transmit_DMA()调用一次数据自动搬运CPU可以去处理其他任务。DMA模式下的数据位宽配置前面已经说了8位模式配字节对齐就行。2.3 16位模式下的数据组织方式16位模式主要用在两类场景一是从设备本身就是16位数据帧比如某些高精度ADC、DAC二是为了提高吞吐量把两个8位数据打包成一个16位发送。在16位模式下数据缓冲区的组织方式非常关键。正确的做法是uint16_t tx_buf[4]; tx_buf[0] 0x1234; tx_buf[1] 0x5678; tx_buf[2] 0x9ABC; tx_buf[3] 0xDEF0; HAL_SPI_Transmit(hspi1, (uint8_t *)tx_buf, 4, 100);注意这里把uint16_t *强转成了uint8_t *因为函数原型要求uint8_t *。Size传4表示4个16位数据项总共8个字节。如果你手头的数据是uint8_t数组想用16位模式发有两种处理方式方式一手动拼接成16位。比如uint8_t data[8]你可以定义uint16_t temp[4]然后temp[0] (data[0] 8) | data[1]以此类推。这种方式适合数据本身有明确的高低位关系。方式二直接用8位模式发。如果数据本身就是8位对齐的强行用16位模式发反而容易出错不如就用8位模式。我个人的判断标准是如果从设备的时序图明确标注了每个时钟周期对应16位数据那就用16位模式如果时序图是按字节标注的就用8位模式。不要为了追求所谓的“效率”而强行用16位模式发8位数据那样只会增加出错概率。2.4 数据位宽与时钟极性和相位的配合SPI的时钟极性CPOL和时钟相位CPHA决定了数据在时钟的哪个边沿采样和移出。这两个参数跟数据位宽没有直接关系但在16位模式下如果CPHA配错了数据会整体偏移一个时钟周期导致读出来的16位数据高低位错乱。常见的四种SPI模式模式CPOLCPHA采样边沿移出边沿Mode 000第一个边沿上升第二个边沿下降Mode 101第二个边沿下降第一个边沿上升Mode 210第一个边沿下降第二个边沿上升Mode 311第二个边沿上升第一个边沿下降大多数SPI从设备支持Mode 0或Mode 3。我一般先用Mode 0试不通再换Mode 3。如果从设备手册明确写了模式那就按手册来。在16位模式下CPHA0和CPHA1的区别特别明显。CPHA0时数据在第一个时钟边沿就被采样所以从设备必须在片选有效之前就把数据准备好CPHA1时数据在第二个时钟边沿采样从设备有更多准备时间。如果你的16位数据读出来总是差一个时钟周期优先检查CPHA配置。3. 实操过程与核心环节实现3.1 硬件连接与CubeMX基础配置先说一下硬件连接。以STM32F103C8T6和一款SPI接口的16位ADC为例STM32的SPI1_SCKPA5接ADC的SCLKSTM32的SPI1_MISOPA6接ADC的SDOSTM32的SPI1_MOSIPA7接ADC的SDISTM32的GPIO比如PA4接ADC的CSCubeMX里的配置步骤在Connectivity里选SPI1Mode选Full-Duplex Master。Parameter Settings里Data Size先选8 Bits后面会讲怎么切16位。Clock Polarity选LowClock Phase选1 Edge对应Mode 0。NSS Signal Type选Software这样片选由GPIO控制。Baud Rate Prescaler根据从设备支持的最高时钟来选。STM32F103的SPI1挂在APB2上72MHz分频系数选8的话就是9MHz。一般SPI ADC支持到10MHz没问题。生成代码后CubeMX会自动初始化SPI1和GPIO。但片选GPIO的初始状态需要手动设置确保上电时片选是拉高的不选中从设备。3.2 8位模式发送代码实现先看8位模式的完整发送流程。假设我们要向一个SPI Flash发送写使能命令0x06然后发送页编程命令0x02 24位地址 数据。// 片选拉低 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 发送写使能命令 uint8_t cmd 0x06; HAL_SPI_Transmit(hspi1, cmd, 1, 100); // 片选拉高 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 稍作延时等待Flash内部完成 HAL_Delay(1); // 片选拉低 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 组织页编程数据命令 地址 数据 uint8_t tx_buf[4 256]; tx_buf[0] 0x02; // 页编程命令 tx_buf[1] (addr 16) 0xFF; // 地址高8位 tx_buf[2] (addr 8) 0xFF; // 地址中8位 tx_buf[3] addr 0xFF; // 地址低8位 memcpy(tx_buf[4], data, 256); // 256字节数据 // 一次性发送 HAL_SPI_Transmit(hspi1, tx_buf, 4 256, 1000); // 片选拉高 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);这段代码的关键点把命令、地址、数据组织成一个连续的缓冲区一次发送。这样片选在整个传输过程中保持低电平符合Flash的时序要求。如果分多次发送每次发送之间片选会拉高Flash会认为一次命令结束导致后续数据被忽略。3.3 16位模式发送代码实现16位模式的代码结构类似但数据组织方式不同。假设我们要向一个16位DAC发送两个通道的数据// 片选拉低 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 组织16位数据 uint16_t tx_buf[2]; tx_buf[0] 0x0FFF; // 通道A数据 tx_buf[1] 0x0AAA; // 通道B数据 // 发送注意Size是2表示2个16位数据项 HAL_SPI_Transmit(hspi1, (uint8_t *)tx_buf, 2, 100); // 片选拉高 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET);这里Size2因为tx_buf有2个元素每个元素16位。HAL库内部会按照16位的方式去读取tx_buf然后通过SPI发送出去。如果你需要动态切换8位和16位模式比如同一个SPI总线上挂了8位的Flash和16位的ADC那就需要在访问不同设备之前重新初始化SPI。代码框架如下void SPI_SwitchTo8Bit(void) { HAL_SPI_DeInit(hspi1); hspi1.Init.DataSize SPI_DATASIZE_8BIT; HAL_SPI_Init(hspi1); } void SPI_SwitchTo16Bit(void) { HAL_SPI_DeInit(hspi1); hspi1.Init.DataSize SPI_DATASIZE_16BIT; HAL_SPI_Init(hspi1); }注意HAL_SPI_DeInit()之后SPI外设会被复位之前配置的波特率、时钟极性等参数都会恢复默认值。所以HAL_SPI_Init()之前必须把所有参数都重新设置一遍。我一般会把SPI的初始化参数放在一个结构体里切换的时候整体赋值避免遗漏。3.4 DMA模式下的高效数据传输当数据量比较大或者需要CPU并行处理其他任务时DMA模式是首选。以8位模式为例配置DMA的步骤在CubeMX的DMA Settings里给SPI1_TX添加一个DMA通道方向选Memory To Peripheral。Priority选HighMode选Normal单次传输或Circular循环传输。Data WidthPeripheral和Memory都选Byte8位模式或Half Word16位模式。生成代码后发送函数换成HAL_SPI_Transmit_DMA(hspi1, tx_buf, size);DMA模式下的注意事项DMA传输完成之前不能修改发送缓冲区的内容。因为DMA是异步搬运的你改了缓冲区DMA搬出去的数据就变了。如果需要连续发送多组数据要么等上一次DMA完成要么用双缓冲区交替。DMA传输完成会触发回调函数HAL_SPI_TxCpltCallback()你可以在这个回调里拉高片选或者启动下一次传输。16位模式下DMA的Memory Data Width必须配成Half Word否则DMA会按字节搬运导致数据错位。我实测过用DMA发送1024字节数据CPU占用率从阻塞模式的接近100%降到不到5%效果非常明显。4. 常见问题与排查技巧实录4.1 SPI通信不生效的排查流程SPI调不通是家常便饭我总结了一套排查流程按顺序走下来90%的问题都能定位。第一步检查硬件连接。用万用表量一下四根线是否导通有没有虚焊。特别是自己画的板子SPI线走得太长或者没有地线伴随高速下波形会严重畸变。第二步用示波器或逻辑分析仪抓波形。这是最直接的手段。重点看SCK有没有输出没有的话检查SPI外设是否使能GPIO是否配置为复用推挽。片选信号有没有拉低没有的话检查GPIO控制代码。MOSI上的数据跟你要发的是否一致不一致的话检查数据位宽和缓冲区类型。MISO上从设备有没有响应没有的话检查从设备是否上电、复位是否正常。第三步检查SPI模式。CPOL和CPHA配错是最常见的问题。如果波形看起来正常但数据就是不对先把四种模式都试一遍。第四步检查时钟频率。从设备支持的最高SPI时钟是多少STM32的SPI时钟分频后不能超过这个值。我遇到过用18MHz去驱动一个最高只支持10MHz的Flash结果读出来的ID全是0xFF。降低到9MHz就正常了。第五步检查数据位宽。8位和16位配错数据会整体错位。如果读出来的数据是你要的数据的高低位互换那基本就是位宽配错了。4.2 16位数据高低位错乱的解决方法这个问题太常见了单独拿出来说。现象是你发送0x1234从设备收到的是0x3412或者读回来的数据高低字节反了。原因通常有三个原因一数据位宽配置与数据组织方式不匹配。SPI配了16位但缓冲区是uint8_t数组HAL库按半字读取把两个相邻的字节拼成了一个16位数据导致高低位错乱。解决方法16位模式下缓冲区必须用uint16_t数组。原因二CPHA配置错误。CPHA0时数据在第一个时钟边沿采样如果从设备是在第二个边沿才把数据准备好采样到的就是上一个bit。表现出来就是数据整体偏移一位。解决方法切换CPHA试试。原因三大小端问题。有些从设备是大端格式高字节在前有些是小端格式低字节在前。STM32是小端格式如果你用16位模式发送硬件会先发低字节还是高字节取决于SPI的配置。STM32的SPI默认是MSB First也就是先发最高位。但如果你把16位数据直接赋给DR寄存器硬件会先发高字节还是低字节需要查参考手册确认。稳妥的做法是手动把16位数据拆成两个8位按从设备要求的高低字节顺序发送。4.3 DMA传输中的数据错位问题DMA模式下的数据错位排查起来比阻塞模式更麻烦因为你看不到实时的传输过程。常见原因DMA的Data Width配置与SPI数据位宽不匹配。SPI配16位DMA配字节DMA会把一个16位数据拆成两个字节搬运导致数据错位。DMA传输完成中断里过早修改了缓冲区。DMA传输完成中断触发时实际上最后一个数据可能还在SPI的移位寄存器里没发完。如果这时候修改缓冲区最后一个数据可能会出错。稳妥的做法是在中断里先拉高片选等片选稳定后再修改缓冲区。DMA的Circular模式下缓冲区被覆盖。Circular模式下DMA会循环搬运如果CPU处理速度跟不上缓冲区里的数据会被新数据覆盖。这种场景下要用双缓冲区或者加大缓冲区深度。4.4 常见问题速查表现象可能原因排查方法解决方法完全无波形SPI未使能、GPIO配置错误检查SPI使能位、GPIO模式重新初始化SPIGPIO配为复用推挽有SCK无数据MOSI配置错误、数据缓冲区为空检查MOSI波形确认GPIO配置检查缓冲区指针数据整体偏移CPHA配置错误切换CPHA试改为正确的SPI模式高低字节互换数据位宽配置错误检查DataSize和缓冲区类型统一位宽和缓冲区类型数据偶尔出错时钟频率过高、线太长降低时钟频率降低分频系数缩短走线DMA传输不完成DMA通道配置错误检查DMA中断标志重新配置DMA检查优先级片选信号异常GPIO控制逻辑错误检查片选GPIO电平确保传输前后片选正确拉高拉低4.5 几个容易被忽略的实操心得心得一SPI的GPIO速度等级要设对。CubeMX里GPIO的Maximum output speed默认是Low在高速SPI下会导致波形上升沿变缓数据出错。我一般把SCK和MOSI的GPIO速度设为High或Very High。心得二软件片选时片选拉低到第一个时钟之间要留一点时间。有些从设备需要片选有效后等待几个纳秒才能接收时钟。我一般在片选拉低后加一个__NOP()或者几个空指令周期。心得三16位模式下如果从设备只支持8位数据帧不要强行用16位模式。有些从设备虽然数据是16位的但时序要求是按8位帧传输的这时候必须用8位模式分两次发。心得四用逻辑分析仪抓SPI波形时把采样率设高一点。SPI时钟9MHz的话逻辑分析仪采样率至少要到50MHz以上否则抓出来的波形全是锯齿根本看不清数据。心得五HAL库的SPI发送函数在超时后会返回HAL_TIMEOUT但SPI外设可能还处于忙状态。这时候直接再次调用发送函数会失败。稳妥的做法是超时后先HAL_SPI_DeInit()再HAL_SPI_Init()把外设复位。我在实际项目里用SPI驱动过Flash、OLED、ADC、数字电位器各种奇葩问题都遇到过。最深刻的一次教训是一个SPI接口的16位ADC数据总是偶尔跳变排查了两天才发现是DMA的Memory Data Width配成了字节导致高低字节偶尔错位。改成半字之后数据稳如磐石。所以SPI通信的稳定性往往就藏在这些看似不起眼的配置细节里。把数据位宽、DMA对齐、片选时序这三件事做扎实SPI基本就不会再给你添乱了。
返回列表