1. SPI通信基础与STM32 HAL库架构解析
1.1 为什么SPI在嵌入式开发中如此重要
SPI(Serial 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位控制:DFF=0表示8位数据帧,DFF=1表示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里找到SPI1,Mode选择Full-Duplex Master(全双工主机)。如果你只需要发送不需要接收,可以选Half-Duplex Master或Simplex Master,但大多数场景下全双工更灵活。
Hardware NSS Signal这个选项要特别注意。如果选Disable,NSS引脚不占用,片选由软件控制(推荐);如果选Hardware NSS Output,NSS引脚会自动拉低,但只能控制一个从设备。我一般选Disable,然后用普通GPIO控制片选,这样一条SPI总线上可以挂多个从设备。
在Parameter Settings里,几个核心参数的含义如下:
| 参数 | 常用值 | 说明 |
|---|---|---|
| Frame Format | Motorola | 标准SPI时序,绝大多数芯片都用这个 |
| Data Size | 8 Bits / 16 Bits | 根据外设寄存器宽度选择 |
| First Bit | MSB First | 高位先出,SPI标准 |
| Clock Polarity (CPOL) | Low / High | 空闲时时钟电平 |
| Clock Phase (CPHA) | 1 Edge / 2 Edge | 采样边沿 |
| Prescaler | 2~256 | 分频系数,决定SCK频率 |
| Baud Rate | 自动计算 | 实际SCK频率 |
CPOL和CPHA的组合决定了SPI的四种模式(Mode 0~3)。Mode 0(CPOL=Low, CPHA=1Edge)是最常用的,但具体要看从设备手册。比如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选8,SCK = 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上(最高72MHz),SPI2/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通道的抢占优先级设为1,SPI全局中断设为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位数据发送。但这里TxXferSize=1,HAL库会认为你要发送1个“数据单元”,每个单元2字节,所以总共发送2字节——这恰好是0x1234的两个字节。
但如果你写成这样:
uint16_t tx_data[2] = {0x1234, 0x5678}; HAL_SPI_Transmit(&hspi1, (uint8_t *)tx_data, 2, 100);TxXferSize=2,HAL库会发送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 Word;SPI是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 常见问题速查表
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 数据全是0xFF | MISO悬空或从设备未响应 | 检查片选和从设备供电 |
| 数据全是0x00 | SCK无输出或模式错误 | 检查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调试的利器,几百块的投资能省下大量抓狂的时间。