STM32 SPI通信实战:从原理到驱动W25Q128 Flash的完整指南

📅 2026/7/30 7:30:13 👁️ 阅读次数 📝 编程学习
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根线:

  1. SCK (Serial Clock):时钟信号,由主机产生,所有从设备都听它的指挥。
  2. MOSI (Master Out Slave In):主机输出,从机输入。主机通过这根线发送数据给从机。
  3. MISO (Master In Slave Out):主机输入,从机输出。主机通过这根线接收从机发来的数据。
  4. 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的四种工作模式:

模式CPOLCPHA空闲时钟采样边沿更新边沿常见应用
Mode 000低电平上升沿下降沿最常见,如很多Flash芯片、SD卡
Mode 101低电平下降沿上升沿-
Mode 210高电平下降沿上升沿-
Mode 311高电平上升沿下降沿如一些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图形化配置详解

  1. 启用SPI外设:在Pinout & Configuration标签页中,找到你需要使用的SPI(如SPI1)。
  2. 选择模式:在Mode下拉框中,选择Full-Duplex Master(全双工主机模式)。这是最常用的模式。
  3. 配置参数:这是核心步骤,每一项都对应着原理部分的知识。
    • Clock Parameters (时钟参数):
      • Prescaler:预分频器。决定了SPI的通信速率SCK频率 = APB总线时钟 / Prescaler。初始调试时建议选一个较大的分频(如256分频),降低速率以保证稳定性,后续再调整。
      • Clock PolarityClock 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通常只能管理一个引脚,而多个从设备需要多个片选。
  4. 配置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数据手册。

  1. 找SPI模式:在“AC CHARACTERISTICS”或“SPI Instructions”章节,找到时序图。W25Q128支持Mode 0和Mode 3。我们选择最通用的Mode 0 (CPOL=0, CPHA=0)
  2. 找指令集:SPI Flash不是简单的读写存储单元,而是通过发送命令字来操作。我们需要几个基本命令:
    • 0x06:写使能(WREN)。在写入数据前必须发送。
    • 0x05:读状态寄存器1(RDSR)。用于查询芯片是否忙。
    • 0x03:读数据(READ)。后跟24位地址。
    • 0x02:页编程(PP)。后跟24位地址和数据,一次最多写256字节。
    • 0x20:扇区擦除(SE)。擦除4KB。
  3. 看时序:注意命令、地址、数据的传输顺序,以及片选信号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:

  1. SPI1的配置页,找到DMA Settings
  2. 点击Add,添加SPI1_TXSPI1_RX的DMA流(Stream)。对于只发送的场景(如LCD刷屏),可以只添加TX。
  3. 配置DMA流:
    • ModeNormal(单次传输)或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的速率配置(预分频器)不是越大越好。提高速率可以提升性能,但可能带来信号完整性问题,导致通信错误。

  • 影响因素
    1. PCB布线:长导线、过孔、直角走线都会增加信号反射和衰减。
    2. 从设备性能:每个SPI从设备都有最高时钟频率限制,超频工作会不稳定。
    3. 电源噪声:不干净的电源会耦合到信号线上。
  • 调试技巧
    • 初始低速:调试阶段,务必使用低时钟频率(如系统时钟的256分频)。
    • 逐步提速:通信稳定后,逐步减小分频系数,同时进行压力测试(连续读写大量数据),找到稳定工作的最高频率。
    • 使用示波器:这是最强大的调试工具。观察SCK、MOSI、MISO和CS的波形。检查上升/下降沿是否陡峭,有无过冲、振铃,电平是否达到标准。确保数据在正确的时钟边沿保持稳定。

6. 常见问题排查与调试心法

SPI通信失败是常态,成功是结果。下面是我总结的排查清单和心法,能帮你快速定位问题。

6.1 硬件连接检查

  1. 电源与地:确保STM32和从设备共地,且电源电压符合要求。
  2. 引脚连接:用万用表蜂鸣档,逐根检查SCK、MOSI、MISO、CS是否连通,有没有虚焊、短路。
  3. 上拉电阻:SPI协议本身不要求上拉电阻,但有些从设备(或为了抗干扰)需要在MOSI、MISO甚至SCK上加上拉电阻(通常4.7k-10k)。如果波形不理想,可以尝试添加。

6.2 软件配置排查

  1. 模式不匹配这是头号杀手!反复确认STM32的CPOL和CPHA设置与从设备手册要求完全一致。
  2. 时钟频率过高:降低SPI波特率到最低,看是否能通信。如果能,再逐步调高。
  3. 片选信号时序:确保在发送数据前拉低CS,在数据发送完成后拉高CS。用示波器看CS信号是否干净,拉低和拉高的时机是否正确。
  4. 数据位序:检查First Bit设置是MSB还是LSB。
  5. SPI外设使能:确认在初始化函数中,SPI外设已被使能(__HAL_SPI_ENABLE(&hspi1))。

6.3 利用工具进行调试

  1. 逻辑分析仪:如果你没有示波器,一个几十块钱的USB逻辑分析仪(配合Saleae Logic或PulseView软件)是调试数字通信的神器。它能同时捕获多路信号,并解码SPI协议,直接显示出你发送和接收的十六进制数据,一目了然。
  2. printf大法:在关键位置(如发送命令后、接收数据后)通过串口打印出状态标志、发送的数据和接收的数据。对比发送和接收是否一致。
  3. HAL库状态检查:HAL库函数会返回状态(HAL_OK,HAL_ERROR,HAL_BUSY,HAL_TIMEOUT)。务必检查这些返回值!超时错误往往意味着从设备无响应,可能是硬件或片选问题。

6.4 一个典型的调试案例:能发不能收

现象:用逻辑分析仪看到MOSI上有正确的数据波形,但MISO线上一直是高电平或低电平,接收到的数据全是0xFF或0x00。

排查思路

  1. 检查MISO线路:硬件是否连接正确?从设备的MISO引脚是否损坏或配置为输入?
  2. 检查从设备电源和使能:从设备是否真的上电并处于工作状态?有些传感器需要额外的使能引脚。
  3. 检查从设备模式:确认从设备是否处于正确的SPI从机模式。有些设备上电后默认是I2C或其他接口,需要先发送一个配置命令切换到SPI模式。
  4. 发送虚拟时钟:对于某些类型的设备(如Flash、ADC),主机必须在接收数据时继续提供SCK时钟。确保你在调用HAL_SPI_Receive时,SPI时钟是在运行的。实际上,HAL_SPI_Receive内部会先发送一个虚拟数据(通常是0xFF或0x00)来产生时钟,从而读取MISO的数据。如果这个机制有问题,可以尝试使用HAL_SPI_TransmitReceive,明确指定发送的数据。

最后,保持耐心。调试通信问题是一个系统的过程,从电源、地线、信号线等硬件基础,到模式、时序、代码逻辑等软件配置,逐一排除。每一次成功的通信背后,都是对这些细节的深刻理解和把控。当你掌握了SPI,再去学习I2C、UART甚至更复杂的协议时,会发现底层逻辑都是相通的,举一反三,触类旁通。