STM32 SPI通信实战:从原理到驱动W25Q128 Flash的完整指南
1. 项目概述:为什么SPI对STM32开发者如此重要?
如果你正在用STM32做项目,无论是驱动一块LCD屏幕、读取一个传感器,还是与另一个微控制器对话,SPI(Serial Peripheral Interface)几乎是一个绕不开的通信协议。相比于UART的异步和I2C的双线制,SPI以其全双工、高速、协议简单的特点,在需要快速传输数据的场景中占据着绝对优势。我接触过很多初学者,他们往往在配置GPIO、理解时序模式上卡壳,或者在实际项目中遇到数据错乱、通信失败却无从下手。这篇文章,我就以一个过来人的身份,结合我踩过的那些坑,把STM32的SPI通信从原理到实战,掰开揉碎了讲清楚。无论你是刚拿到STM32开发板的新手,还是正在调试一个棘手外设的老手,这里的内容都能给你提供直接的参考和解决问题的思路。我们的目标很简单:让你不仅能配通SPI,更能理解每一个配置项背后的意义,并具备独立排查问题的能力。
2. SPI协议核心原理与STM32的硬件实现
要玩转STM32的SPI,死记硬背配置代码是没用的,必须从根上理解SPI协议和STM32如何用硬件来实现它。这就像开车,你得先知道油门、刹车、方向盘是干什么的,而不是只记住“先踩这个,再转那个”。
2.1 SPI协议的“一主多从”与四线制
SPI是一种同步、全双工、主从式的串行通信协议。关键词是“同步”和“全双工”。同步意味着通信双方需要一根时钟线(SCK)来同步数据节奏;全双工意味着数据可以同时双向流动,这是它比I2C和UART快的重要原因。
一个最基本的SPI连接需要4根线:
- SCK (Serial Clock):时钟信号,由主机产生,所有从设备都听它的指挥。
- MOSI (Master Out Slave In):主机输出,从机输入。主机通过这根线发送数据给从机。
- MISO (Master In Slave Out):主机输入,从机输出。主机通过这根线接收从机发来的数据。
- SS/CS (Slave Select / Chip Select):片选信号,低电平有效。主机通过控制这根线的电平,来选择要与哪个从机通信。这是实现“一主多从”架构的关键。
注意:很多资料会提到“3线制SPI”,即关闭全双工,只使用单向数据传输。但在STM32的硬件SPI外设中,我们通常讨论的是标准的4线全双工模式。软件模拟时为了节省引脚,有时会采用半双工方式。
2.2 理解SPI的四种模式:CPOL与CPHA
这是SPI最核心也最容易混淆的概念——时钟极性(CPOL)和时钟相位(CPHA)。它们共同定义了数据在时钟信号的哪个边沿被采样(捕获)和哪个边沿被更新(输出)。
- CPOL (Clock Polarity):时钟极性。
- CPOL=0:时钟空闲状态为低电平。
- CPOL=1:时钟空闲状态为高电平。
- CPHA (Clock Phase):时钟相位。
- CPHA=0:数据在时钟的第一个边沿(即SCK从空闲状态跳变到相反状态的边沿)被采样,在第二个边沿更新。
- CPHA=1:数据在时钟的第二个边沿被采样,在第一个边沿更新。
将CPOL和CPHA组合,就得到了SPI的四种工作模式:
| 模式 | CPOL | CPHA | 空闲时钟 | 采样边沿 | 更新边沿 | 常见应用 |
|---|---|---|---|---|---|---|
| Mode 0 | 0 | 0 | 低电平 | 上升沿 | 下降沿 | 最常见,如很多Flash芯片、SD卡 |
| Mode 1 | 0 | 1 | 低电平 | 下降沿 | 上升沿 | - |
| Mode 2 | 1 | 0 | 高电平 | 下降沿 | 上升沿 | - |
| Mode 3 | 1 | 1 | 高电平 | 上升沿 | 下降沿 | 如一些ADC芯片 |
如何选择模式?答案完全取决于你的从设备(Slave)!你必须查阅从设备的数据手册(Datasheet),找到关于SPI接口时序图的部分,它会明确要求CPOL和CPHA是0还是1。主机(STM32)的模式必须与从设备严格匹配,否则通信必然失败。没有例外。
2.3 STM32的硬件SPI外设架构
STM32的SPI外设是一个高度集成的硬件模块,它帮你处理了底层的时序生成、数据移位和寄存器管理,极大地解放了CPU。
- 通信引脚:SPI1、SPI2等外设会映射到特定的GPIO引脚(如PA5、PA6、PA7 for SPI1)。使用CubeMX或查阅芯片数据手册的“Alternate function”表格可以快速找到。
- 时钟与控制逻辑:负责生成SCK信号,控制通信的启动和停止。
- 发送/接收缓冲区(DR):这是一个共同的寄存器。当你写入数据时,它被放入发送缓冲区;当接收到数据时,从这里读取。硬件会自动处理数据的移入移出。
- 状态寄存器(SR):这是调试时最需要关注的寄存器!它包含了关键标志位:
TXE:发送缓冲区空。为1时表示可以写入下一个要发送的数据。RXNE:接收缓冲区非空。为1时表示有数据可以读取。BSY:忙标志。为1表示SPI正忙于通信。在重新配置SPI或关闭前,必须等待BSY变为0。
- 数据寄存器与移位寄存器:写入的数据从发送缓冲区移到移位寄存器,在SCK控制下一位一位地从MOSI线移出;同时,MISO线上的数据也被一位一位地移入接收移位寄存器,攒满一帧后存入接收缓冲区。
实操心得:很多通信失败,是因为程序没有正确判断这些状态标志。比如在BSY为1时强行操作SPI,或者没等TXE就狂发数据,都会导致不可预知的错误。可靠的代码必须包含对这些状态的检查。
3. 使用STM32CubeMX与HAL库配置SPI
对于大多数应用,使用ST官方提供的HAL库和CubeMX图形化工具是最高效的方式。它能自动生成初始化代码,并处理底层寄存器操作,让我们更专注于应用逻辑。
3.1 CubeMX图形化配置详解
- 启用SPI外设:在
Pinout & Configuration标签页中,找到你需要使用的SPI(如SPI1)。 - 选择模式:在
Mode下拉框中,选择Full-Duplex Master(全双工主机模式)。这是最常用的模式。 - 配置参数:这是核心步骤,每一项都对应着原理部分的知识。
- Clock Parameters (时钟参数):
Prescaler:预分频器。决定了SPI的通信速率SCK频率 = APB总线时钟 / Prescaler。初始调试时建议选一个较大的分频(如256分频),降低速率以保证稳定性,后续再调整。Clock Polarity和Clock Phase:根据你的从设备手册,选择正确的模式(如Mode 0)。
- Data Parameters (数据参数):
Data Size:数据帧长度,通常为8位或16位。必须与从设备匹配。First Bit:数据传输顺序。MSB First(最高位先行)是最常见的,但有些设备要求LSB First,需查手册。
- NSS Signal (片选信号管理):
Hardware NSS Input Signal:如果你希望STM32作为从机,并由外部主机控制片选,则选择这个。Hardware NSS Output Signal:STM32作为主机时,可以用硬件自动管理一个片选引脚。但更常见的做法是将其设置为Disable,然后用普通的GPIO口来软件控制片选。因为硬件NSS通常只能管理一个引脚,而多个从设备需要多个片选。
- Clock Parameters (时钟参数):
- 配置GPIO:CubeMX会自动将SPI所需的SCK、MOSI、MISO引脚配置为正确的复用功能模式。你只需要额外初始化一个GPIO引脚(如PA4)作为软件片选(CS),并将其初始化为推挽输出模式,默认置高(不选中)。
3.2 生成的代码分析与关键函数
生成代码后,在main.c中,你会看到MX_SPI1_Init()函数。这个函数完成了SPI外设的所有硬件初始化。我们的主要工作是在应用代码中调用HAL库的通信函数。
HAL库提供了几个层级的SPI传输函数:
阻塞式传输:
HAL_SPI_Transmit(),HAL_SPI_Receive(),HAL_SPI_TransmitReceive()。- 特点:函数会一直等待,直到整个数据传输完成(或超时)才返回。在此期间CPU被挂起。
- 优点:编程简单,逻辑清晰。
- 缺点:效率低,在传输大量数据时会影响系统实时性。
- 适用场景:初始化配置、传输少量数据、对实时性要求不高的场合。
// 示例:阻塞式发送一个字节数组 uint8_t tx_data[10] = {0x01, 0x02, 0x03}; uint8_t rx_data[10] = {0}; CS_GPIO_Port->BSRR = (uint32_t)CS_Pin << 16U; // 拉低片选,选中从设备 HAL_SPI_Transmit(&hspi1, tx_data, 3, 100); // 发送3个字节,超时100ms // 如果需要同时接收,使用 TransmitReceive // HAL_SPI_TransmitReceive(&hspi1, tx_data, rx_data, 3, 100); CS_GPIO_Port->BSRR = CS_Pin; // 拉高片选,释放从设备中断式传输:
HAL_SPI_Transmit_IT(),HAL_SPI_Receive_IT(),HAL_SPI_TransmitReceive_IT()。- 特点:函数启动传输后立即返回,传输完成后会产生中断,在中断回调函数
HAL_SPI_TxRxCpltCallback()中处理后续工作。 - 优点:传输期间CPU可以处理其他任务,提高了效率。
- 缺点:编程复杂度增加,需要处理中断和全局状态。
- 适用场景:中等数据量传输,需要兼顾效率和代码复杂度的场合。
- 特点:函数启动传输后立即返回,传输完成后会产生中断,在中断回调函数
DMA传输:
HAL_SPI_Transmit_DMA(),HAL_SPI_Receive_DMA(),HAL_SPI_TransmitReceive_DMA()。- 特点:由DMA控制器在内存和SPI数据寄存器之间搬运数据,完全不需要CPU干预。传输完成后通过中断或标志位通知CPU。
- 优点:效率最高,极大解放CPU,尤其适合连续、大批量数据传输(如刷新LCD屏、读写SD卡)。
- 缺点:配置最复杂,需要额外初始化DMA通道,并小心处理缓存一致性问题(尤其在Cortex-M7内核或使用D-Cache时)。
- 适用场景:高速、实时、大数据量传输的场合。
选择建议:对于初学者,从阻塞式开始,先确保通信链路正常。当遇到性能瓶颈或需要处理多任务时,再考虑升级到中断式。对于像驱动LCD屏这类有持续刷屏需求的应用,DMA是必选项。
4. SPI实战:驱动一个SPI Flash芯片(W25Q128)
理论说得再多,不如动手调一个设备。我们以常见的SPI Flash芯片W25Q128(16MB容量)为例,走一遍完整的驱动流程。你会遇到几乎所有SPI应用都会碰到的问题。
4.1 硬件连接与从设备手册解读
首先,将W25Q128与STM32连接:
- STM32 SPI1_SCK (PA5) -> Flash CLK
- STM32 SPI1_MOSI (PA7) -> Flash DI (Data Input)
- STM32 SPI1_MISO (PA6) -> Flash DO (Data Output)
- STM32 GPIO PA4 -> Flash CS (Chip Select)
关键一步:查阅W25Q128数据手册。
- 找SPI模式:在“AC CHARACTERISTICS”或“SPI Instructions”章节,找到时序图。W25Q128支持Mode 0和Mode 3。我们选择最通用的Mode 0 (CPOL=0, CPHA=0)。
- 找指令集:SPI Flash不是简单的读写存储单元,而是通过发送命令字来操作。我们需要几个基本命令:
0x06:写使能(WREN)。在写入数据前必须发送。0x05:读状态寄存器1(RDSR)。用于查询芯片是否忙。0x03:读数据(READ)。后跟24位地址。0x02:页编程(PP)。后跟24位地址和数据,一次最多写256字节。0x20:扇区擦除(SE)。擦除4KB。
- 看时序:注意命令、地址、数据的传输顺序,以及片选信号
CS#的拉低和拉高时机。
4.2 底层读写函数封装
基于HAL库,我们先封装几个最基础的函数。
// spi_flash.c #include "spi_flash.h" // 假设 hspi1 和 FLASH_CS_GPIO_Port, FLASH_CS_Pin 已外部定义 /** * @brief 读取Flash状态寄存器 * @retval 状态寄存器值 */ uint8_t SPI_FLASH_ReadStatusReg(void) { uint8_t cmd = 0x05; // RDSR命令 uint8_t status; FLASH_CS_LOW(); // 拉低片选 HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); HAL_SPI_Receive(&hspi1, &status, 1, 100); FLASH_CS_HIGH(); // 拉高片选 return status; } /** * @brief 等待Flash空闲(BUSY位为0) */ void SPI_FLASH_WaitForReady(void) { uint8_t status; do { status = SPI_FLASH_ReadStatusReg(); } while (status & 0x01); // 检查状态寄存器的BUSY位(bit0) } /** * @brief 发送写使能命令 */ void SPI_FLASH_WriteEnable(void) { uint8_t cmd = 0x06; FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, &cmd, 1, 100); FLASH_CS_HIGH(); } /** * @brief 读取数据 * @param pBuffer: 数据存储缓冲区 * @param ReadAddr: 开始读取的地址 * @param NumByteToRead: 要读取的字节数 */ void SPI_FLASH_ReadBuffer(uint8_t* pBuffer, uint32_t ReadAddr, uint32_t NumByteToRead) { uint8_t cmd[4] = {0x03}; // READ命令 // 将24位地址放入指令数组,大端格式(MSB first) cmd[1] = (ReadAddr >> 16) & 0xFF; cmd[2] = (ReadAddr >> 8) & 0xFF; cmd[3] = ReadAddr & 0xFF; FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); // 发送命令+地址 HAL_SPI_Receive(&hspi1, pBuffer, NumByteToRead, 1000); // 接收数据 FLASH_CS_HIGH(); }4.3 实现页编程与扇区擦除
写操作比读操作复杂,因为需要先擦除(Flash的特性,只能从1写0,擦除是将整个扇区置1),然后使能写,最后发送数据。
/** * @brief 擦除一个扇区(4KB) * @param SectorAddr: 扇区地址(必须是4K对齐的) */ void SPI_FLASH_EraseSector(uint32_t SectorAddr) { uint8_t cmd[4] = {0x20}; // Sector Erase命令 cmd[1] = (SectorAddr >> 16) & 0xFF; cmd[2] = (SectorAddr >> 8) & 0xFF; cmd[3] = SectorAddr & 0xFF; SPI_FLASH_WriteEnable(); // 1. 写使能 FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); // 2. 发送擦除命令+地址 FLASH_CS_HIGH(); SPI_FLASH_WaitForReady(); // 3. 等待擦除完成 } /** * @brief 写入数据(页编程,最多256字节) * @param pBuffer: 要写入的数据 * @param WriteAddr: 开始写入的地址 * @param NumByteToWrite: 要写入的字节数(<=256) */ void SPI_FLASH_WritePage(uint8_t* pBuffer, uint32_t WriteAddr, uint32_t NumByteToWrite) { uint8_t cmd[4] = {0x02}; // Page Program命令 cmd[1] = (WriteAddr >> 16) & 0xFF; cmd[2] = (WriteAddr >> 8) & 0xFF; cmd[3] = WriteAddr & 0xFF; SPI_FLASH_WriteEnable(); // 1. 写使能 FLASH_CS_LOW(); HAL_SPI_Transmit(&hspi1, cmd, 4, 100); // 2. 发送写命令+地址 HAL_SPI_Transmit(&hspi1, pBuffer, NumByteToWrite, 500); // 3. 发送数据 FLASH_CS_HIGH(); SPI_FLASH_WaitForReady(); // 4. 等待写入完成 }实操心得:Flash的写操作必须严格遵守“使能->命令->等待”的流程。SPI_FLASH_WaitForReady()函数至关重要,如果在芯片忙的时候进行下一步操作,会导致数据丢失或命令不被响应。另外,注意页编程不能跨页(256字节边界),写之前需要做好地址计算和分段。
5. 高级话题:SPI的DMA传输与性能优化
当你需要驱动SPI接口的LCD屏进行刷屏时,阻塞式传输的CPU占用率会高得无法接受,画面会出现严重卡顿。这时,DMA就是你的救星。
5.1 DMA配置要点(以STM32F4为例)
在CubeMX中配置SPI的DMA:
- 在
SPI1的配置页,找到DMA Settings。 - 点击
Add,添加SPI1_TX和SPI1_RX的DMA流(Stream)。对于只发送的场景(如LCD刷屏),可以只添加TX。 - 配置DMA流:
Mode:Normal(单次传输)或Circular(循环传输,适用于持续刷屏)。Data Width:选择Byte(8位)或Half Word(16位),与SPI的数据位宽匹配。Increment Address:对于内存端(Memory)通常选Enable,因为我们要发送一个数组;对于外设端(Peripheral)选Disable,因为地址固定为SPI数据寄存器。
5.2 DMA发送代码示例与缓存一致性
配置完成后,发送数据变得极其简单:
uint16_t lcd_buffer[320]; // 假设一行320像素的LCD数据 // 填充lcd_buffer... HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)lcd_buffer, 320*2); // 发送320个16位数据 // 函数立即返回,CPU可以去干别的天坑预警:缓存一致性问题!如果你的STM32是Cortex-M7内核(如STM32H7系列),或者你启用了数据缓存(D-Cache),那么lcd_buffer数组在CPU写入后,可能还留在缓存里,并没有真正写入物理内存。而DMA是从物理内存直接取数据的,这会导致DMA发送出去的是陈旧或错误的数据。
解决方案:
- 对于Cortex-M7:使用
SCB_CleanDCache_by_Addr()函数,在启动DMA传输前,清理(Clean)缓存中对应缓冲区地址范围的数据,确保数据写回内存。#include “core_cm7.h” SCB_CleanDCache_by_Addr((uint32_t*)lcd_buffer, sizeof(lcd_buffer)); HAL_SPI_Transmit_DMA(&hspi1, (uint8_t*)lcd_buffer, 320*2); - 通用做法(或M3/M4):将DMA传输缓冲区定义在非缓存区域。例如,使用
__attribute__((section(“.dma_buffer”)))将数组链接到特定的内存段(如SRAM2),并在链接脚本中配置该段为非缓存。
5.3 SPI时钟速度与信号完整性的权衡
SPI的速率配置(预分频器)不是越大越好。提高速率可以提升性能,但可能带来信号完整性问题,导致通信错误。
- 影响因素:
- PCB布线:长导线、过孔、直角走线都会增加信号反射和衰减。
- 从设备性能:每个SPI从设备都有最高时钟频率限制,超频工作会不稳定。
- 电源噪声:不干净的电源会耦合到信号线上。
- 调试技巧:
- 初始低速:调试阶段,务必使用低时钟频率(如系统时钟的256分频)。
- 逐步提速:通信稳定后,逐步减小分频系数,同时进行压力测试(连续读写大量数据),找到稳定工作的最高频率。
- 使用示波器:这是最强大的调试工具。观察SCK、MOSI、MISO和CS的波形。检查上升/下降沿是否陡峭,有无过冲、振铃,电平是否达到标准。确保数据在正确的时钟边沿保持稳定。
6. 常见问题排查与调试心法
SPI通信失败是常态,成功是结果。下面是我总结的排查清单和心法,能帮你快速定位问题。
6.1 硬件连接检查
- 电源与地:确保STM32和从设备共地,且电源电压符合要求。
- 引脚连接:用万用表蜂鸣档,逐根检查SCK、MOSI、MISO、CS是否连通,有没有虚焊、短路。
- 上拉电阻:SPI协议本身不要求上拉电阻,但有些从设备(或为了抗干扰)需要在MOSI、MISO甚至SCK上加上拉电阻(通常4.7k-10k)。如果波形不理想,可以尝试添加。
6.2 软件配置排查
- 模式不匹配:这是头号杀手!反复确认STM32的CPOL和CPHA设置与从设备手册要求完全一致。
- 时钟频率过高:降低SPI波特率到最低,看是否能通信。如果能,再逐步调高。
- 片选信号时序:确保在发送数据前拉低CS,在数据发送完成后拉高CS。用示波器看CS信号是否干净,拉低和拉高的时机是否正确。
- 数据位序:检查
First Bit设置是MSB还是LSB。 - SPI外设使能:确认在初始化函数中,SPI外设已被使能(
__HAL_SPI_ENABLE(&hspi1))。
6.3 利用工具进行调试
- 逻辑分析仪:如果你没有示波器,一个几十块钱的USB逻辑分析仪(配合Saleae Logic或PulseView软件)是调试数字通信的神器。它能同时捕获多路信号,并解码SPI协议,直接显示出你发送和接收的十六进制数据,一目了然。
- printf大法:在关键位置(如发送命令后、接收数据后)通过串口打印出状态标志、发送的数据和接收的数据。对比发送和接收是否一致。
- HAL库状态检查:HAL库函数会返回状态(
HAL_OK,HAL_ERROR,HAL_BUSY,HAL_TIMEOUT)。务必检查这些返回值!超时错误往往意味着从设备无响应,可能是硬件或片选问题。
6.4 一个典型的调试案例:能发不能收
现象:用逻辑分析仪看到MOSI上有正确的数据波形,但MISO线上一直是高电平或低电平,接收到的数据全是0xFF或0x00。
排查思路:
- 检查MISO线路:硬件是否连接正确?从设备的MISO引脚是否损坏或配置为输入?
- 检查从设备电源和使能:从设备是否真的上电并处于工作状态?有些传感器需要额外的使能引脚。
- 检查从设备模式:确认从设备是否处于正确的SPI从机模式。有些设备上电后默认是I2C或其他接口,需要先发送一个配置命令切换到SPI模式。
- 发送虚拟时钟:对于某些类型的设备(如Flash、ADC),主机必须在接收数据时继续提供SCK时钟。确保你在调用
HAL_SPI_Receive时,SPI时钟是在运行的。实际上,HAL_SPI_Receive内部会先发送一个虚拟数据(通常是0xFF或0x00)来产生时钟,从而读取MISO的数据。如果这个机制有问题,可以尝试使用HAL_SPI_TransmitReceive,明确指定发送的数据。
最后,保持耐心。调试通信问题是一个系统的过程,从电源、地线、信号线等硬件基础,到模式、时序、代码逻辑等软件配置,逐一排除。每一次成功的通信背后,都是对这些细节的深刻理解和把控。当你掌握了SPI,再去学习I2C、UART甚至更复杂的协议时,会发现底层逻辑都是相通的,举一反三,触类旁通。
