ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32 HAL库SPI通信实战:8位与16位数据发送详解及避坑指南

2026/9/28 17:41:51 拓冰建站 浏览量
STM32 HAL库SPI通信实战:8位与16位数据发送详解及避坑指南 1. SPI通信基础与STM32 HAL库架构解析1.1 为什么SPI在嵌入式开发中如此重要SPISerial Peripheral Interface是嵌入式系统中最常用的同步串行通信协议之一。我第一次接触SPI是在驱动一块SPI Flash芯片的时候当时用软件模拟时序折腾了一整天后来切换到STM32的硬件SPI外设才发现原来通信可以这么省心。SPI的核心优势在于全双工、高速、协议简单——它没有像I2C那样的地址仲裁机制也没有复杂的起始/停止条件本质上就是一个移位寄存器在时钟驱动下不断交换数据。在实际项目中SPI几乎无处不在SPI Flash存储固件、OLED显示屏刷新画面、ADXL345读取加速度、MT6701获取磁编码器角度、AS7341采集光谱数据……这些场景都依赖SPI完成数据交互。而STM32的HAL库把SPI的底层寄存器操作封装成了几个函数让开发者不用再对着参考手册一行行配置CR1、CR2、SR寄存器。但封装带来便利的同时也带来了困惑。我见过太多人在论坛上问“为什么我HAL_SPI_Transmit发出去的数据不对”“16位数据怎么发”“DMA模式怎么配”这些问题的根源往往不是HAL库本身有bug而是开发者没有理解HAL库SPI抽象层的设计逻辑。1.2 HAL库SPI驱动的文件结构与调用链路要搞清楚16位和8位数据的发送问题必须先理解HAL库SPI驱动的文件组织方式。在STM32CubeF1固件包中SPI相关的文件主要有这几个stm32f1xx_hal_spi.h头文件定义了SPI_HandleTypeDef结构体、所有API函数原型、各种宏定义stm32f1xx_hal_spi.c源文件包含HAL_SPI_Init、HAL_SPI_Transmit、HAL_SPI_Receive等函数的实现stm32f1xx_hal_spi_ex.h/.c扩展文件主要包含高级配置如TI模式、CRC计算等SPI_HandleTypeDef是整个驱动的核心结构体它长这样简化版typedef struct { SPI_TypeDef *Instance; // 寄存器基地址如SPI1 SPI_InitTypeDef Init; // 初始化参数 uint8_t *pTxBuffPtr; // 发送缓冲区指针 uint16_t TxXferSize; // 发送数据量 __IO uint16_t TxXferCount; // 发送计数器 uint8_t *pRxBuffPtr; // 接收缓冲区指针 uint16_t RxXferSize; // 接收数据量 __IO uint16_t RxXferCount; // 接收计数器 DMA_HandleTypeDef *hdmatx; // 发送DMA句柄 DMA_HandleTypeDef *hdmarx; // 接收DMA句柄 HAL_LockTypeDef Lock; // 锁定状态 __IO HAL_SPI_StateTypeDef State; // 当前状态 __IO uint32_t ErrorCode; // 错误码 } SPI_HandleTypeDef;注意pTxBuffPtr的类型是uint8_t *这是一个关键细节。HAL库在传输时会根据Init.DataSize的值来决定每次从缓冲区取1个字节还是2个字节。这就是为什么同一个HAL_SPI_Transmit函数既能发8位数据也能发16位数据——它内部做了类型判断。调用链路大致是这样的HAL_SPI_Transmit→ 检查状态和参数 → 根据DataSize进入8位或16位传输分支 → 等待TXE标志 → 写入DR寄存器 → 等待BSY清除。理解这条链路后面排查问题就有方向了。1.3 8位与16位数据格式的硬件差异STM32的SPI外设支持数据帧长度配置通过SPI_CR1寄存器的DFF位控制DFF0表示8位数据帧DFF1表示16位数据帧。这个配置直接决定了数据在移位寄存器中的组织方式。8位模式下每次传输一个字节MOSI线上依次移出bit7到bit0。16位模式下每次传输两个字节但高位先出——先移出bit15到bit8再移出bit7到bit0。这一点非常关键很多人在用16位模式读取传感器数据时发现高低字节反了就是因为没有理解这个顺序。还有一个容易忽略的点16位模式下FIFO和DR寄存器的访问方式不同。在F1系列中SPI没有FIFO每次访问DR寄存器会触发一次传输。而在F4/F7/H7等带FIFO的系列中16位数据需要确保FIFO阈值配置正确否则会出现数据错位。我个人的经验是如果外设寄存器是8位地址8位数据的结构比如大多数SPI Flash就用8位模式如果外设是16位寄存器结构比如某些ADC、磁编码器就用16位模式。不要为了省事统一用8位模式去拼凑16位数据那样代码可读性差而且容易在DMA传输时出错。2. CubeMX配置SPI的关键参数与避坑指南2.1 SPI外设模式与参数配置详解用CubeMX配置SPI是最快捷的方式但里面的参数如果选错后面调试会非常痛苦。我以STM32F103C8T6的SPI1为例逐个说明关键参数。打开CubeMX在Connectivity里找到SPI1Mode选择Full-Duplex Master全双工主机。如果你只需要发送不需要接收可以选Half-Duplex Master或Simplex Master但大多数场景下全双工更灵活。Hardware NSS Signal这个选项要特别注意。如果选DisableNSS引脚不占用片选由软件控制推荐如果选Hardware NSS OutputNSS引脚会自动拉低但只能控制一个从设备。我一般选Disable然后用普通GPIO控制片选这样一条SPI总线上可以挂多个从设备。在Parameter Settings里几个核心参数的含义如下参数常用值说明Frame FormatMotorola标准SPI时序绝大多数芯片都用这个Data Size8 Bits / 16 Bits根据外设寄存器宽度选择First BitMSB First高位先出SPI标准Clock Polarity (CPOL)Low / High空闲时时钟电平Clock Phase (CPHA)1 Edge / 2 Edge采样边沿Prescaler2~256分频系数决定SCK频率Baud Rate自动计算实际SCK频率CPOL和CPHA的组合决定了SPI的四种模式Mode 0~3。Mode 0CPOLLow, CPHA1Edge是最常用的但具体要看从设备手册。比如W25Q64 Flash支持Mode 0和Mode 3而有些ADC只支持Mode 1或Mode 2。选错了模式读回来的数据全是0xFF或0x00。2.2 时钟频率与分频系数的计算逻辑SPI的SCK频率由APB总线时钟和分频系数决定。以STM32F103为例SPI1挂在APB2上系统时钟72MHz时APB2也是72MHz。如果Prescaler选8SCK 72MHz / 8 9MHz。这里有个经验公式SCK频率不要超过从设备手册规定的最大值同时要考虑信号完整性。比如W25Q64在快速读模式下支持80MHz但如果你用杜邦线连接9MHz以上就可能出现数据错误。我一般先用低速比如2MHz调通功能再逐步提高频率测试稳定性。CubeMX会自动计算Baud Rate并显示出来但要注意分频系数是2的幂次2, 4, 8, 16, 32, 64, 128, 256不能任意设置。如果你需要非标准频率只能通过调整APB时钟或换用其他SPI外设来实现。还有一个坑SPI1和SPI2的时钟源不同。SPI1在APB2上最高72MHzSPI2/SPI3在APB1上最高36MHz。同样的分频系数SPI2的SCK频率只有SPI1的一半。配置时一定要看清楚用的是哪个SPI。2.3 DMA配置与中断优先级设置如果数据量大或者需要CPU同时处理其他任务DMA模式是必须的。在CubeMX的DMA Settings里点击Add添加SPI1_TX和SPI1_RX两个通道。DMA模式选择很关键Normal模式传输完指定数量后停止需要重新启动。适合单次大批量传输。Circular模式传输完自动重新开始适合连续采集场景比如持续读取ADC数据。我一般用Normal模式因为大多数SPI外设的读写是有明确起止的。Circular模式在SPI上用得少除非你在做连续的数据流采集。中断优先级方面DMA中断优先级要高于SPI中断否则可能出现DMA传输完成但SPI还在等待的情况。在NVIC Settings里把DMA通道的抢占优先级设为1SPI全局中断设为2。注意CubeMX生成的DMA配置代码在MX_DMA_Init()中而SPI的DMA关联在HAL_SPI_MspInit()中。如果你手动修改了DMA通道记得同步更新这两个地方。3. 8位与16位数据发送的代码实现与细节剖析3.1 8位数据发送的完整流程与代码示例先看最基础的8位发送。假设我们要向SPI Flash发送一个命令字节0x9F读取ID命令然后读取3个字节的ID。uint8_t tx_cmd 0x9F; uint8_t rx_id[3] {0}; // 拉低片选 HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET); // 发送命令 HAL_SPI_Transmit(hspi1, tx_cmd, 1, 100); // 接收ID HAL_SPI_Receive(hspi1, rx_id, 3, 100); // 拉高片选 HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET);这段代码看起来简单但有几个细节值得展开。第一个细节超时时间的选择。HAL_SPI_Transmit的最后一个参数是Timeout单位是毫秒。我一般设100ms对于大多数SPI外设足够了。但如果你的SPI时钟极低比如100kHz传输大量数据时可能需要更长时间。超时设太短会导致HAL_TIMEOUT错误设太长又会在外设故障时卡住程序。第二个细节片选的控制时机。片选必须在发送第一个字节之前拉低在接收完最后一个字节之后拉高。中间不能有间隙否则从设备会认为传输结束。我见过有人在Transmit和Receive之间加了延时结果读回来的数据全是0——因为片选被意外释放了。第三个细节HAL_SPI_Transmit和HAL_SPI_Receive的配合。在8位模式下这两个函数可以分开调用但要注意SPI是全双工的发送的同时也在接收。如果你先Transmit再Receive实际上Transmit阶段接收到的数据被丢弃了。对于Flash的读ID操作命令字节发送后从设备才开始输出ID所以先Transmit再Receive是正确的。但对于某些需要同时收发的场景比如SPI显示屏应该用HAL_SPI_TransmitReceive。3.2 16位数据发送的缓冲区类型陷阱16位模式是问题最多的场景。先看一段“看起来没问题”的代码uint16_t tx_data 0x1234; HAL_SPI_Transmit(hspi1, (uint8_t *)tx_data, 1, 100);这段代码能编译通过但运行结果可能不对。原因在于HAL_SPI_Transmit的第二个参数类型是uint8_t *而TxXferSize是1。在16位模式下HAL库内部会检查Init.DataSize如果是16位它会从缓冲区取2个字节组成一个16位数据发送。但这里TxXferSize1HAL库会认为你要发送1个“数据单元”每个单元2字节所以总共发送2字节——这恰好是0x1234的两个字节。但如果你写成这样uint16_t tx_data[2] {0x1234, 0x5678}; HAL_SPI_Transmit(hspi1, (uint8_t *)tx_data, 2, 100);TxXferSize2HAL库会发送2个16位数据共4字节。这是正确的。关键点在16位模式下TxXferSize的单位是“16位数据的个数”而不是字节数。这一点和8位模式不同8位模式下TxXferSize就是字节数。很多人在这里搞混导致发送的数据量不对。还有一个更隐蔽的坑缓冲区对齐。uint16_t数组在内存中是2字节对齐的但如果你把uint8_t数组强制转换成uint16_t *可能遇到对齐问题。在Cortex-M3/M4上非对齐访问会触发HardFault。所以16位模式下缓冲区一定要用uint16_t类型定义。3.3 使用TransmitReceive同时收发16位数据对于需要同时收发的16位外设比如某些ADC用HAL_SPI_TransmitReceive更合适uint16_t tx_buf[2] {0x0000, 0x0000}; // 发送 dummy 数据 uint16_t rx_buf[2] {0}; HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, (uint8_t *)tx_buf, (uint8_t *)rx_buf, 2, 100); HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET);这里tx_buf的内容不重要通常是dummy数据因为从设备会在时钟驱动下输出数据。rx_buf会收到从设备返回的16位数据。注意在16位模式下TransmitReceive的Size参数也是16位数据的个数。上面代码发送2个16位数据接收2个16位数据共4字节的时钟周期。我实测下来用TransmitReceive比分开调用Transmit和Receive更稳定因为片选在传输过程中一直保持有效不会出现中间释放的问题。3.4 DMA模式下的16位数据传输配置DMA模式能大幅降低CPU占用但配置起来更复杂。以SPI1发送16位数据为例// CubeMX中配置SPI1_TX的DMA为Normal模式数据宽度Half Word uint16_t dma_tx_buf[4] {0x1111, 0x2222, 0x3333, 0x4444}; HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit_DMA(hspi1, (uint8_t *)dma_tx_buf, 4); // 等待传输完成 while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY); HAL_GPIO_WritePin(SPI_CS_GPIO_Port, SPI_CS_Pin, GPIO_PIN_SET);DMA配置的关键点DMA数据宽度必须和SPI数据宽度匹配。SPI是16位DMA就要配成Half WordSPI是8位DMA配成Byte。DMA模式选Normal传输完4个数据后自动停止。DMA中断要开启否则HAL_SPI_GetState可能一直返回BUSY。我踩过的一个坑DMA配置成Byte宽度SPI配置成16位结果发送的数据高低字节错位。因为DMA每次搬1字节SPI却按2字节组装数据自然就乱了。DMA和SPI的数据宽度必须一致这是铁律。4. 常见问题排查与实战经验总结4.1 SPI通信不生效的排查思路SPI调不通是嵌入式开发中最常见的问题之一。我总结了一套排查流程按顺序检查能解决90%的问题。第一步检查硬件连接。用万用表测SCK、MOSI、MISO、CS四根线是否导通有没有虚焊。我遇到过好几次是杜邦线内部断了外表看不出来。第二步用示波器或逻辑分析仪看波形。这是最直接的方法。重点看SCK有没有输出频率对不对CS有没有拉低时序对不对MOSI上的数据是不是你发送的如果SCK没有输出说明SPI外设没启动或者配置错误。如果CS没拉低检查GPIO配置和代码逻辑。第三步检查SPI模式。CPOL和CPHA是否和从设备手册一致我见过有人用Mode 0去驱动只支持Mode 3的芯片数据全是0xFF。第四步检查数据宽度。8位外设用16位模式或者反过来都会导致数据错位。第五步检查片选逻辑。有些芯片要求片选在传输结束后保持低电平一段时间有些要求片选在字节之间不能拉高。仔细看手册的时序图。4.2 16位数据高低字节颠倒的解决方法这是16位模式下的经典问题。现象是发送0x1234从设备收到的是0x3412。原因通常有两个SPI配置成了LSB First。检查CubeMX中First Bit是否设为MSB First。缓冲区类型转换错误。如果你用uint8_t数组存16位数据然后强制转换成uint16_t *在小端模式下字节顺序会颠倒。解决方法统一用uint16_t类型定义缓冲区确保First Bit为MSB First。如果从设备确实需要LSB First那就在软件里做字节交换。4.3 DMA传输卡死的几种原因DMA传输卡死表现为程序停在while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY)这一行或者DMA中断一直不进。常见原因DMA通道配置冲突。两个外设用了同一个DMA通道CubeMX会报错但手动改代码时容易忽略。DMA中断优先级太低。被其他高优先级中断打断后DMA传输完成标志没及时清除。SPI的DMA请求没使能。在HAL_SPI_MspInit中要调用__HAL_SPI_ENABLE和__HAL_SPI_ENABLE_IT。缓冲区地址不对。DMA要求缓冲区地址在RAM范围内如果指向Flash或外设区域会出错。我一般会在DMA传输完成后加一个超时计数避免死等uint32_t timeout 100000; while (HAL_SPI_GetState(hspi1) ! HAL_SPI_STATE_READY) { if (timeout-- 0) { // 超时处理 break; } }4.4 常见问题速查表现象可能原因解决方法数据全是0xFFMISO悬空或从设备未响应检查片选和从设备供电数据全是0x00SCK无输出或模式错误检查SPI配置和时钟高低字节颠倒LSB First或缓冲区类型错误改为MSB First用uint16_t缓冲区DMA卡死通道冲突或中断未使能检查DMA配置和NVIC设置高速时数据错误信号完整性差降低SCK频率缩短连线片选释放过早代码逻辑错误确保片选在传输完成后才拉高4.5 几个提升稳定性的实操技巧技巧一片选之间加短延时。有些从设备在片选拉高后需要几微秒的恢复时间连续操作时在片选拉高后加HAL_Delay(1)或几个NOP。技巧二SPI时钟先低后高。调试时先用低速比如1MHz调通再逐步提高到从设备支持的最大值。我一般以2MHz为起点每次翻倍测试。技巧三用DMA空闲中断接收不定长数据。对于SPI从设备返回不定长数据的场景可以开启SPI的RXNE中断配合DMA的Circular模式实现类似串口空闲中断的效果。技巧四16位模式下优先用TransmitReceive。即使只需要发送也用TransmitReceive并传入一个dummy接收缓冲区这样能保证时钟周期完整避免某些从设备因为时钟不连续而出错。技巧五CubeMX生成的代码要检查MspInit。HAL_SPI_MspInit中包含了GPIO、DMA、时钟的初始化如果手动改了引脚或DMA通道一定要同步更新这个函数否则会出现“配置看起来对但就是不工作”的情况。我在实际项目中最深的体会是SPI本身不复杂复杂的是从设备的时序要求。每次拿到一个新芯片先花10分钟仔细看它的SPI时序图确认CPOL、CPHA、数据宽度、片选极性、最大时钟频率这几个参数比盲目调试半天效率高得多。另外逻辑分析仪是SPI调试的利器几百块的投资能省下大量抓狂的时间。