1. 从“点灯”到“通信”:为什么SPI是嵌入式开发的必修课
很多朋友从点亮第一个LED开始接触STM32,这确实是入门的第一步。但当你真正想做一个能“动”起来的项目,比如驱动一块屏幕、读取一个传感器、或者连接一个无线模块时,你会发现,仅仅会操作GPIO是远远不够的。这时,通信协议就成了你绕不开的坎。在众多通信协议中,SPI(Serial Peripheral Interface)以其高速、全双工、硬件简单的特点,成为了连接微控制器与各种外设(如Flash、SD卡、显示屏、传感器)最常用的桥梁之一。我见过不少项目卡在SPI通信上,不是时序对不上,就是数据读回来全是0xFF,折腾半天才发现是模式配置错了。这篇文章,我就结合自己这些年调试各种SPI外设的经验,从最根本的时序波形讲起,带你彻底搞懂STM32的SPI,并解决几个实际开发中高频出现的问题。
2. 理解SPI的本质:四根线背后的时钟舞蹈
很多人学SPI,一开始就去翻库函数,看怎么配置寄存器,结果云里雾里。我的建议是,先忘掉代码,拿起示波器(或者在脑海里想象),看清楚这四根线(SCK, MOSI, MISO, CS)到底在跳什么舞。SPI通信的核心是主从设备间基于一个共享时钟的同步数据交换。这个“同步”是关键,它意味着数据位的采样和输出都由时钟边沿严格触发,这带来了高速度和确定性,但也带来了配置的复杂性。
2.1 四线职责与硬件连接要点
- SCK (Serial Clock):时钟线,由主机产生。它是一切节奏的指挥棒。它的频率、极性和相位直接决定了数据交换的规则。
- MOSI (Master Out Slave In):主机输出,从机输入。主机通过这根线向从机发送数据。
- MISO (Master In Slave Out):主机输入,从机输出。主机通过这根线从从机读取数据。
- CS/SS (Chip Select / Slave Select):片选线,由主机控制。通常低电平有效。这是SPI总线能够挂载多个从设备的基础——主机通过拉低对应从机的CS线来“选中”它,与之通信。其他未被选中的从机必须将其MISO线置于高阻态,以避免总线冲突。
注意:在实际连接时,务必确认从机设备是3.3V还是5V电平。STM32通常是3.3V I/O,直接连接5V设备可能损坏引脚。对于5V从机,需要使用电平转换芯片(如TXB0104)或选择兼容5V容忍引脚(查看芯片数据手册的“FT”标识)。
2.2 破解SPI模式的奥秘:CPOL与CPHA
这是SPI最让人困惑,也最容易出错的地方。SPI有4种模式,由时钟极性(CPOL)和时钟相位(CPHA)两个参数组合而成。很多资料只给定义,我更喜欢用波形图来记忆。
- CPOL (Clock Polarity):时钟极性。它定义的是SCK线在空闲状态(即两次传输之间,CS为高时)的电平。
- CPOL=0:SCK空闲时为低电平。
- CPOL=1:SCK空闲时为高电平。
- CPHA (Clock Phase):时钟相位。它定义的是数据在时钟的哪个边沿被采样(捕获)。
- CPHA=0:数据在时钟的第一个边沿(对于CPOL=0是上升沿,对于CPOL=1是下降沿)被采样,在下一个边沿切换。
- CPHA=1:数据在时钟的第二个边沿被采样,在第一个边沿切换。
光看定义很抽象,我们结合最常见的SPI模式0和模式3来看。假设我们要传输一个字节0xAA(二进制10101010)。
模式0 (CPOL=0, CPHA=0):
- 空闲时,SCK为低,CS拉低后通信开始。
- 第一个SCK边沿(上升沿),从机(和主机)采样数据线。因此,主机必须在SCK上升沿之前就将第一位数据放到MOSI上。对于CPHA=0,数据在时钟边沿变化前就绪。
- 第二个SCK边沿(下降沿),数据位切换,为下一次采样做准备。
- 波形看起来是:数据在SCK上升沿稳定,在下降沿变化。
模式3 (CPOL=1, CPHA=1):
- 空闲时,SCK为高,CS拉低后通信开始。
- 第一个SCK边沿(下降沿),数据位切换。
- 第二个SCK边沿(上升沿),从机(和主机)采样数据线。因此,主机在SCK下降沿切换数据,在随后的上升沿被采样。对于CPHA=1,数据在时钟边沿变化后就绪。
- 波形看起来是:数据在SCK下降沿变化,在上升沿稳定。
如何为你的外设选择正确的模式?答案是:严格遵循你的外设芯片数据手册(Datasheet)中的时序图!通常,传感器、Flash芯片的手册里都会明确写明要求的SPI模式。比如,常见的NOR Flash(如W25Q系列)通常使用模式0或模式3。猜模式是行不通的,必须看手册。
2.3 软件片选 vs. 硬件片选:灵活性与效率的权衡
STM32的SPI外设通常支持硬件NSS(即硬件片选)管理,但我在绝大多数项目中都使用软件控制GPIO来模拟片选。原因如下:
- 灵活性:硬件NSS有时序要求,在复杂的多从机系统中或与某些“不标准”的从机通信时可能不匹配。软件控制可以精确控制CS拉低和拉高的时机,甚至在两次传输之间插入延时(比如有些老式SD卡需要)。
- 调试友好:你可以随时在代码中插入
HAL_Delay()或断点,观察CS信号,而硬件NSS是自动的,不便于观察。 - 避免冲突:在多主机配置中,硬件NSS用于检测总线冲突,但在单一主机的常见场景下用处不大。
软件片选的实现很简单:初始化一个GPIO引脚(如PA4)为推挽输出,默认置高(无效)。在通信前拉低,通信完成后拉高。
// 初始化CS引脚 GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 初始状态为高(不选中) // 通信片段 HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET); // 选中从机 HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, size, timeout); HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET); // 取消选中3. 使用STM32CubeMX与HAL库配置SPI:从零到一
理解了原理,我们动手配置。STM32CubeMX+HAL库极大简化了初始化过程,但“简化”不代表可以无脑下一步,几个关键配置点决定了通信的成败。
3.1 CubeMX图形化配置详解
- 选择SPI外设:根据你的硬件连接,选择SPI1、SPI2等。注意引脚是否与其他功能冲突(比如调试接口)。
- 配置模式:
- Full-Duplex Master:全双工主机模式,最常用,同时收发。
- Transmit Only Master:仅发送模式。
- Receive Only Master:仅接收模式。
- Hardware NSS Signal:谨慎使用,如非必要选择“Disable”。
- 基本参数:
- Clock Prescaler:分频系数,决定SCK频率。SCK频率 = APB总线时钟 / 分频值。初始调试时,建议选择较低频率(如大于256分频),稳定后再提高。
- CPOL 与 CPHA:根据从机手册设置。
- Data Size:数据帧长度,通常为8位或16位。绝大多数外设是8位。
- First Bit:数据传输顺序,MSB(最高位)在前最为常见,但有些设备是LSB在前(例如某些音频芯片),务必核对手册。
- CRC Calculation:循环冗余校验,普通应用不需要开启。
- 生成代码:配置完成后生成初始化代码。
3.2 关键HAL库函数解析与避坑
生成的代码会初始化hspi1这样的句柄。通信主要使用以下几个函数:
HAL_SPI_Transmit():阻塞式发送。HAL_SPI_Receive():阻塞式接收。HAL_SPI_TransmitReceive():阻塞式同时收发。这是最常用的函数,因为SPI是全双工,即使你只想读数据,也需要发送数据(通常是哑元0xFF或0x00)来产生时钟。HAL_SPI_Transmit_IT(),HAL_SPI_Receive_IT(),HAL_SPI_TransmitReceive_IT():中断方式。HAL_SPI_Transmit_DMA(),HAL_SPI_Receive_DMA(),HAL_SPI_TransmitReceive_DMA():DMA方式。
避坑点1:关于“仅接收”如果你想从SPI设备读取数据,不能简单地调用HAL_SPI_Receive。因为SPI的时钟由主机产生,主机必须在SCK上提供时钟脉冲,从机才会把数据放到MISO上。而主机提供时钟的方式就是“发送数据”。因此,标准的读操作流程是:
uint8_t cmd = 0x9F; // 假设是读JEDEC ID的命令 uint8_t rx_buf[3] = {0}; uint8_t tx_dummy[3] = {0xFF, 0xFF, 0xFF}; // 发送哑元数据产生时钟 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); // 1. 发送命令字 HAL_SPI_TransmitReceive(&hspi1, tx_dummy, rx_buf, 3, 100); // 2. 同时发送哑元并接收数据 HAL_GPIO_WritePin(CS_GPIO_Port, CS_Pin, GPIO_PIN_SET);很多新手在这里卡住,读回来的总是0xFF,就是因为没有在读取阶段发送数据来驱动时钟。
避坑点2:超时时间HAL库的阻塞函数都有一个Timeout参数。如果设置过短,在SPI时钟频率很低或从机响应慢时,可能导致函数返回HAL_TIMEOUT错误。初期可以设大一点(如1000毫秒),稳定后再优化。
避坑点3:CubeMX的“代码生成后”设置在CubeMX的Project Manager -> Code Generator里,有一个“Generate peripheral initialization as a pair of ‘.c/.h’ files per peripheral”选项。如果勾选,SPI的初始化代码会独立到spi.c和spi.h中,这样结构更清晰,推荐使用。
4. 进阶实战:驱动SPI LCD(如ILI9341)与DMA应用
驱动SPI接口的LCD(如常见的ILI9341)是一个综合性的练习,它涉及到发送初始化命令序列、发送像素数据,并且对刷新率有要求。这里就能体现出DMA(直接存储器访问)的巨大价值。
4.1 软件模拟SPI与硬件SPI的选择
对于低分辨率屏幕或要求不高的场合,有人会用GPIO模拟SPI(即Bit-Banging),因为引脚分配更灵活。但我强烈建议使用硬件SPI。硬件SPI由外设控制器处理,不占用CPU时间,速度稳定且快得多。STM32的SPI硬件可以轻松跑到几十MHz,而软件模拟能达到1MHz就不错了,刷屏效率天差地别。
4.2 使用DMA提升刷屏性能
刷屏的本质是向LCD的GRAM(显存)连续写入大量的像素数据(比如320x240的16位色屏幕,一帧就是3202402=153600字节)。如果使用阻塞式HAL_SPI_Transmit,CPU会被完全占用在等待发送完成上。使用DMA,CPU只需要启动传输,就可以去处理其他任务,DMA控制器会自动将内存中的数据搬运到SPI数据寄存器中发送出去。
配置步骤:
- CubeMX中启用DMA:在SPI配置页的
DMA Settings选项卡,点击Add,选择SPIx_TX(对于刷屏,主要是发送)。模式选择Normal(单次传输)或Circular(循环传输,用于连续刷新)。优先级根据系统设置。 - 代码中使用DMA传输:
// 准备一帧或一行像素数据到 buffer uint16_t lcd_buffer[SCREEN_WIDTH * SCREEN_HEIGHT]; // ... 填充buffer ... // 启动DMA传输 HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_RESET); HAL_GPIO_WritePin(LCD_DC_GPIO_Port, LCD_DC_Pin, GPIO_PIN_SET); // 切换到数据模式 HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)lcd_buffer, sizeof(lcd_buffer)); // 此时CPU被释放 // 等待DMA传输完成(如果需要同步) while (HAL_SPI_GetState(&hspi1) != HAL_SPI_STATE_READY) { // 可以在这里执行其他轻量级任务 } HAL_GPIO_WritePin(LCD_CS_GPIO_Port, LCD_CS_Pin, GPIO_PIN_SET); - 处理DMA传输完成中断:你可以使能DMA或SPI的传输完成中断(TC),在中断回调函数(如
HAL_SPI_TxCpltCallback)中通知主程序刷屏完成,以便准备下一帧数据。
4.3 解决STM32H750驱动SPI LCD的DMA难题
在热词中看到了“stm32h750 dma 驱动 spi lcd 问题”,这是一个经典问题。STM32H7系列(包括H750)性能强大,但内存架构复杂,引入了Cache(缓存)。当你使用DMA时,DMA控制器直接从内存(RAM)中取数据,而CPU操作的数据可能在Cache里。如果CPU修改了lcd_buffer中的数据,但Cache没有写回到内存(Write-Back),那么DMA读到的就是旧数据,导致屏幕上显示错误。
解决方案是进行Cache一致性维护:
#include “stm32h7xx_hal.h” // 确保包含HAL库头文件 // 在启动DMA传输前,清理Cache,确保DMA能看到CPU最新写入的数据 SCB_CleanDCache_by_Addr((uint32_t*)lcd_buffer, sizeof(lcd_buffer)); // 启动DMA传输 HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)lcd_buffer, sizeof(lcd_buffer));同样,如果DMA将数据写入内存(例如从SPI接收数据),CPU读取前需要**无效化(Invalidate)**对应的Cache行,以确保CPU读到的是DMA刚写入的新数据。这是H7系列高性能下的一个关键配置点,忽略它会导致各种匪夷所思的数据不一致问题。
5. 调试SPI通信:当数据流沉默时,你该如何倾听
通信调不通是常态。一套系统化的调试方法比盲目修改代码有效得多。
5.1 硬件检查清单
- 电源与地:确保主从设备共地,这是所有通信的基础。用万用表测量。
- 连线:检查四根线(SCK, MOSI, MISO, CS)是否连接正确、牢固。特别是杜邦线容易接触不良。
- 上拉电阻:SPI协议本身不要求上拉,但有些从机设备内部是开漏输出,或者为了增强抗干扰能力,需要在SCK、MOSI、MISO上加上拉电阻(通常4.7kΩ-10kΩ)。查看从机手册。
- 电平兼容:再次强调,确认电压匹配。
5.2 软件与逻辑分析
- 示波器或逻辑分析仪是终极武器:这是最直观的方法。同时抓取SCK、MOSI、MISO、CS四路信号。
- 看CS:是否在通信前后有正确的跳变?
- 看SCK:频率是否符合配置?是否存在?(如果CS有效后SCK没波形,说明SPI外设可能没正确启动或时钟配置错误)。
- 看MOSI:主机发送的数据是否正确?第一个bit是MSB还是LSB?
- 看MISO:从机是否有数据输出?是一直高电平、一直低电平、还是变化的?
- 对照时序图:将抓到的波形与从机数据手册的时序图对比,检查CPOL/CPHA是否匹配,数据建立和保持时间是否满足从机要求。
- 简化测试代码:先不进行复杂业务,只测试单字节收发。
如果发送正确但接收总是0xFF或0x00,问题可能出在从机未被正确选中、从机供电或初始化、或模式不匹配。uint8_t tx = 0xA5; // 一个容易辨认的模式,如10100101 uint8_t rx = 0; HAL_SPI_TransmitReceive(&hspi1, &tx, &rx, 1, 1000); printf(“Sent: 0x%02X, Received: 0x%02X\r\n”, tx, rx); - 检查从机状态:有些设备(如Flash)有状态寄存器,可以先发送读状态命令,看看设备是否就绪。
- 利用STM32的“回环”模式:在CubeMX中,将SPI的
Basic Parameters中的Mode设置为Transmit and Receive Master,并勾选下方的Hardware NSS Signal为Disable,然后在代码中配置hspi1.Init.Mode = SPI_MODE_MASTER;和hspi1.Init.Direction = SPI_DIRECTION_2LINES;,但更重要的是,可以测试回环:hspi1.Init.Mode = SPI_MODE_MASTER;和hspi1.Init.Direction = SPI_DIRECTION_2LINES;保持不变,但通过__HAL_SPI_ENABLE(&hspi1)和SPI_CR1_LOOPBACK位设置硬件回环(具体请参考参考手册)。或者在软件上,将MOSI和MISO短接,发送的数据应该能被原样接收。这可以快速验证STM32自身的SPI外设和配置是否正确。
5.3 常见问题速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 接收数据全是0xFF | 1. 从机未响应(CS错误、电源、地) 2. 模式(CPOL/CPHA)不匹配 3. 从机需要特定命令序列唤醒 4. MISO线连接错误或断路 | 查硬件连接;用逻辑分析仪看MISO波形;核对从机手册模式;发送从机识别命令(如读ID) |
| 接收数据全是0x00 | 1. MISO引脚被意外拉低(冲突) 2. 从机输出驱动能力不足 | 检查总线上是否有其他设备冲突;测量MISO引脚电压;确认从机电源 |
| 数据错位(如0xA5收到0x5A) | 数据帧格式(MSB/LSB)设置错误 | 核对从机手册,修改First Bit设置 |
| 通信速度很慢或不稳定 | 1. SCK时钟分频过大 2. 使用了软件片选但控制代码有额外延时 3. 总线负载过重(线太长、寄生电容大) | 提高时钟频率;优化片选控制代码;缩短连线,或适当降低频率 |
| DMA传输数据错误(H7系列常见) | Cache一致性问题 | 在DMA传输前后使用SCB_CleanDCache_by_Addr和SCB_InvalidateDCache_by_Addr |
调试是一个需要耐心和逻辑的过程。从电源、地线等最基础的开始,用最简单的代码测试,逐步缩小问题范围。掌握了SPI,你就打开了连接大多数外部传感器和存储器的大门,你的STM32项目才能真正“活”起来。