STM32CubeMX配置SPI读写FLASH实战:从原理图到代码实现全流程
在嵌入式开发的世界里,外部存储器扩展是绕不开的课题。无论是存储设备固件、记录运行日志,还是缓存传感器数据,一块可靠的非易失性存储器都至关重要。W25Qxx系列SPI FLASH芯片以其高性价比、大容量和简单的四线接口,成为了STM32开发者最常打交道的“老朋友”之一。然而,从看懂原理图到在代码中流畅地读写数据,中间往往横亘着配置、时序、调试等多重关卡。对于习惯了HAL库和图形化工具的现代开发者而言,如何高效地打通这条路径,将硬件能力转化为稳定的软件功能,正是本文要解决的核心问题。
本文不会停留在通信协议的枯燥对比上,而是聚焦于一个完整的、可复现的工程实践。我们将手把手地,从硬件连接分析开始,借助STM32CubeMX这一强大工具完成SPI外设的图形化配置,并深入HAL库函数,编写出健壮的读写、擦除代码。更重要的是,我们会探讨如何利用DMA解放CPU,以及如何应对实际调试中那些令人头疼的常见问题,比如数据错位、读写失败等。无论你是刚刚接触STM32的初学者,还是希望优化现有驱动代码的进阶者,这篇融合了工具链实操与真实硬件调试经验的内容,都将为你提供清晰的路线图。
1. 硬件连接与原理图解析
在敲下第一行代码之前,我们必须和硬件“对话”。理解原理图上的每一个连接,是后续所有软件工作的基石。SPI(Serial Peripheral Interface)作为一种同步、全双工的串行通信协议,其硬件连线相比UART(异步)更为固定,通常包含四条信号线。
1.1 SPI信号线定义与W25Qxx引脚
一个标准的SPI接口包含以下四条线:
- SCK (Serial Clock): 串行时钟线,由主设备(这里是STM32)产生,用于同步数据位传输。
- MOSI (Master Out Slave In): 主设备输出、从设备输入数据线。
- MISO (Master In Slave Out): 主设备输入、从设备输出数据线。
- CS/SS (Chip Select / Slave Select): 片选线,低电平有效。主设备通过拉低此线来选中特定的从设备进行通信。
对于W25Q128FV这款常见的128Mbit(16MB)FLASH芯片,我们关注其与SPI相关的引脚。通常,它的封装是SOIC-8,引脚定义如下表所示:
| 引脚编号 | 引脚名称 | 类型 | 功能描述 |
|---|---|---|---|
| 1 | CS# | 输入 | 片选信号,低电平有效。 |
| 2 | DO (IO1) | 输出 | 数据输出(MISO),在标准SPI模式下为输出。 |
| 3 | WP# (IO2) | 输入 | 写保护,低电平有效。通常接高电平(VCC)禁用保护。 |
| 4 | GND | 电源 | 地。 |
| 5 | DI (IO0) | 输入 | 数据输入(MOSI),在标准SPI模式下为输入。 |
| 6 | CLK | 输入 | 串行时钟输入(SCK)。 |
| 7 | HOLD# (IO3) | 输入 | 保持信号,低电平有效。通常接高电平(VCC)禁用。 |
| 8 | VCC | 电源 | 电源(2.7V - 3.6V)。 |
注意:WP#和HOLD#引脚在标准SPI模式下并非必须,为了简化,通常通过一个10K电阻上拉到VCC,使其保持无效状态。但在使用QPI或Dual SPI等高速模式时,这些引脚会复用为数据IO。
1.2 与STM32的连接实战
现在,我们将W25Qxx的引脚映射到STM32的SPI外设上。假设我们使用STM32F407系列,并选择SPI1。一个典型的连接方式如下:
- W25Qxx CS -> STM32任意GPIO (如
PA4)。这里非常关键:片选引脚不能直接连接到STM32 SPI外设硬件NSS引脚并配置为硬件模式,除非你非常清楚硬件NSS的复杂行为(自动管理、多主模式等)。对于大多数单主单从的应用,更推荐使用软件控制,即将CS配置为普通的推挽输出GPIO,由代码手动拉低或拉高,这样控制最灵活、最直观。 - W25Qxx CLK -> STM32 SPI1_SCK (
PA5)。 - W25Qxx DO (MISO) -> STM32 SPI1_MISO (
PA6)。 - W25Qxx DI (MOSI) -> STM32 SPI1_MOSI (
PA7)。 - W25Qxx VCC -> 3.3V。
- W25Qxx GND -> GND。
- W25Qxx WP# 和 HOLD# -> 通过10K电阻上拉到 3.3V。
检查原理图时,务必确认电源去耦电容(通常一个0.1uF和一个10uF的电容靠近芯片VCC引脚放置)已正确添加,这是保证芯片稳定工作的基础。
2. 使用STM32CubeMX进行工程配置
硬件了然于胸后,我们转向软件配置的利器——STM32CubeMX。它将繁琐的寄存器配置转化为直观的图形操作。
2.1 创建工程与SPI外设初始化
首先,在CubeMX中选择你的目标芯片型号(例如STM32F407ZGTx)。在Pinout & Configuration标签页下,找到Connectivity -> SPI1。
- 模式选择:将
Mode设置为“Full-Duplex Master”。这意味着STM32作为主设备,可以同时收发数据。 - 硬件参数配置:切换到
Configuration标签下的Parameter Settings子标签。- Clock Prescaler: 时钟预分频器,决定了SCK的频率。SCK频率 =
APBx总线时钟 / Prescaler。W25Qxx在标准SPI模式下最高支持约50MHz(3.3V供电时)。对于初始调试,建议选择一个较低的分频,如PCLK2 / 16,确保通信稳定。 - Data Size: 数据大小,选择
8 Bits。W25Qxx的指令、地址、数据都以8位为单位传输。 - First Bit: 首比特顺序,选择
MSB First。这是SPI设备最常见的规定。 - Clock Polarity (CPOL) 和 Clock Phase (CPHA): 这是SPI配置的核心,决定了数据采样的时钟边沿。W25Qxx芯片手册规定,它支持模式0 (CPOL=0, CPHA=0) 和模式3 (CPOL=1, CPHA=1)。我们通常选择模式0。简单理解:CPOL=0表示SCK空闲时为低电平;CPHA=0表示数据在SCK的第一个边沿(即上升沿)被采样。
- Clock Prescaler: 时钟预分频器,决定了SCK的频率。SCK频率 =
- 片选引脚(CS)配置:回到
Pinout视图,找到我们计划用作CS的引脚(如PA4)。右键点击它,选择GPIO_Output。然后在左侧System Core->GPIO中配置其初始输出电平为高电平(High),因为CS是低电平有效。
2.2 时钟树与DMA配置(可选但推荐)
时钟树配置:在Clock Configuration标签页,确保为SPI1提供时钟的APB2总线(对于F4系列)运行在你期望的频率上。一个常见的配置是HSE外部晶振8MHz,经过PLL倍频到168MHz系统时钟,APB2总线时钟为84MHz。这样,之前配置的PCLK2 / 16分频将得到约5.25MHz的SCK,非常适合调试。
DMA配置:对于大量数据的读写(如写入一整个页),使用DMA可以极大减轻CPU负担,实现“后台”传输。在Connectivity -> SPI1的DMA Settings标签页中,点击Add添加DMA请求。
- 为
SPI1_TX添加一个DMA流(如DMA2 Stream3),方向为Memory To Peripheral。 - 为
SPI1_RX添加一个DMA流(如DMA2 Stream0),方向为Peripheral To Memory。 - 模式均选择
Normal(单次传输),优先级Medium,内存数据宽度为Byte。
最后,生成工程代码,选择你熟悉的IDE(如Keil MDK或STM32CubeIDE)。CubeMX会自动生成SPI和GPIO的初始化代码,以及DMA的初始化代码(如果配置了)。
3. HAL库驱动代码编写与解析
工程生成后,我们进入Core/Src目录下的main.c或自己创建的flash_driver.c/h文件中,开始编写W25Qxx的驱动层代码。
3.1 底层通信函数封装
首先,我们需要封装最基本的SPI收发函数。由于我们使用软件片选,需要先控制CS引脚。
/* 定义CS引脚操作宏,假设CS连接在PA4 */
#define FLASH_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET)
#define FLASH_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)
/**
* @brief 向FLASH发送一个字节并接收一个字节
* @param tx_data: 要发送的数据
* @retval 接收到的数据
*/
uint8_t SPI_FLASH_SendByte(uint8_t tx_data)
{
uint8_t rx_data;
HAL_SPI_TransmitReceive(&hspi1, &tx_data, &rx_data, 1, HAL_MAX_DELAY);
return rx_data;
}
/**
* @brief 读取FLASH的状态寄存器
* @retval 状态寄存器值
*/
uint8_t W25Qxx_ReadStatusReg(void)
{
uint8_t status;
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x05); // 发送读状态寄存器1指令
status = SPI_FLASH_SendByte(0xFF); // 发送dummy字节,同时读取状态
FLASH_CS_HIGH();
return status;
}
提示:
HAL_SPI_TransmitReceive是一个阻塞式函数,会等待传输完成。HAL_MAX_DELAY表示无限等待。在实际产品代码中,应考虑使用超时机制。
3.2 核心功能实现:读、写、擦除
W25Qxx的操作基于指令集。我们需要实现几个最关键的指令:写使能、读数据、页编程、扇区擦除。
/**
* @brief 写使能
*/
void W25Qxx_WriteEnable(void)
{
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x06); // WREN 指令
FLASH_CS_HIGH();
}
/**
* @brief 等待芯片空闲(检查BUSY位)
*/
void W25Qxx_WaitForBusy(void)
{
while ((W25Qxx_ReadStatusReg() & 0x01) == 0x01) // 检查状态寄存器第0位(BUSY)
{
// 可以加入超时退出机制
}
}
/**
* @brief 读取数据
* @param pBuffer: 数据存储缓冲区指针
* @param ReadAddr: 读取起始地址
* @param NumByteToRead: 要读取的字节数
*/
void W25Qxx_ReadData(uint8_t* pBuffer, uint32_t ReadAddr, uint32_t NumByteToRead)
{
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x03); // 发送读数据指令
SPI_FLASH_SendByte((ReadAddr >> 16) & 0xFF); // 发送24位地址的高8位
SPI_FLASH_SendByte((ReadAddr >> 8) & 0xFF); // 中8位
SPI_FLASH_SendByte(ReadAddr & 0xFF); // 低8位
// 连续读取数据
for (uint32_t i = 0; i < NumByteToRead; i++)
{
pBuffer[i] = SPI_FLASH_SendByte(0xFF); // 发送dummy时钟,读取数据
}
FLASH_CS_HIGH();
}
/**
* @brief 页编程(写入数据)。注意:写入前必须确保目标区域已被擦除(值为0xFF)。
* @param pBuffer: 要写入的数据缓冲区指针
* @param WriteAddr: 写入起始地址
* @param NumByteToWrite: 要写入的字节数(不能超过256,且不能跨页)
*/
void W25Qxx_PageProgram(uint8_t* pBuffer, uint32_t WriteAddr, uint32_t NumByteToWrite)
{
W25Qxx_WriteEnable(); // 第一步:写使能
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x02); // 页编程指令
SPI_FLASH_SendByte((WriteAddr >> 16) & 0xFF);
SPI_FLASH_SendByte((WriteAddr >> 8) & 0xFF);
SPI_FLASH_SendByte(WriteAddr & 0xFF);
for (uint32_t i = 0; i < NumByteToWrite; i++)
{
SPI_FLASH_SendByte(pBuffer[i]);
}
FLASH_CS_HIGH();
W25Qxx_WaitForBusy(); // 等待编程完成
}
/**
* @brief 扇区擦除(4KB)
* @param SectorAddr: 扇区地址(必须是4KB对齐的)
*/
void W25Qxx_SectorErase(uint32_t SectorAddr)
{
W25Qxx_WriteEnable();
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x20); // 扇区擦除指令
SPI_FLASH_SendByte((SectorAddr >> 16) & 0xFF);
SPI_FLASH_SendByte((SectorAddr >> 8) & 0xFF);
SPI_FLASH_SendByte(SectorAddr & 0xFF);
FLASH_CS_HIGH();
W25Qxx_WaitForBusy(); // 等待擦除完成,擦除时间较长(几十ms)
}
3.3 使用DMA进行大数据量传输
当需要读写几KB甚至几十KB的数据时,使用DMA效率更高。以下是一个使用DMA进行数据读取的示例:
/**
* @brief 使用DMA读取数据(非阻塞式)
* @param pBuffer: 数据存储缓冲区指针
* @param ReadAddr: 读取起始地址
* @param NumByteToRead: 要读取的字节数
* @retval HAL status
*/
HAL_StatusTypeDef W25Qxx_ReadData_DMA(uint8_t* pBuffer, uint32_t ReadAddr, uint32_t NumByteToRead)
{
uint8_t cmd_and_addr[4];
cmd_and_addr[0] = 0x03; // Read指令
cmd_and_addr[1] = (ReadAddr >> 16) & 0xFF;
cmd_and_addr[2] = (ReadAddr >> 8) & 0xFF;
cmd_and_addr[3] = ReadAddr & 0xFF;
FLASH_CS_LOW();
// 1. 先使用阻塞式发送发送指令和地址
if (HAL_SPI_Transmit(&hspi1, cmd_and_addr, 4, 100) != HAL_OK)
{
FLASH_CS_HIGH();
return HAL_ERROR;
}
// 2. 然后启动DMA接收数据
if (HAL_SPI_Receive_DMA(&hspi1, pBuffer, NumByteToRead) != HAL_OK)
{
FLASH_CS_HIGH();
return HAL_ERROR;
}
// 注意:CS需要在DMA传输完成回调函数中拉高!
// 我们需要在SPI接收完成回调函数里处理CS。
return HAL_OK;
}
// 在stm32f4xx_hal_msp.c或主程序中重写SPI接收完成回调函数
void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
if (hspi->Instance == SPI1)
{
FLASH_CS_HIGH(); // DMA接收完成,拉高CS
// 可以在这里设置一个标志位,通知主程序数据已就绪
}
}
使用DMA写入的流程类似,但需要先发送写使能指令和页编程指令及地址,然后启动DMA发送数据。务必处理好指令/地址阶段和数据阶段的CS电平控制,以及等待BUSY的时机。
4. 实战调试与常见问题排查
代码写好了,下载到板子上,却可能发现读写不对。这是硬件调试中最常见的阶段。下面是一些典型的“坑”和排查思路。
4.1 基础通信检查:能否读取到设备ID?
首先实现一个读取JEDEC ID的函数。这是验证SPI物理层通信是否成功的最直接方法。W25Q128FV的JEDEC ID通常是0xEF 0x40 0x18。
void W25Qxx_ReadID(uint8_t *ID)
{
FLASH_CS_LOW();
SPI_FLASH_SendByte(0x9F); // JEDEC ID指令
ID[0] = SPI_FLASH_SendByte(0xFF);
ID[1] = SPI_FLASH_SendByte(0xFF);
ID[2] = SPI_FLASH_SendByte(0xFF);
FLASH_CS_HIGH();
}
在main函数初始化后调用它,并通过串口打印出来。如果读出的ID是0xFF 0xFF 0xFF或0x00 0x00 0x00,说明根本就没通信上。
排查步骤:
- 电源与地:用万用表测量FLASH芯片VCC和GND引脚电压是否为稳定的3.3V。
- 信号线连接:确认SCK、MOSI、MISO、CS四根线是否与STM32连接正确,没有虚焊或接错。
- SPI模式:这是最高频的错误来源! 确认CubeMX中SPI的
CPOL和CPHA设置与芯片手册要求一致(模式0或模式3)。用逻辑分析仪或示波器抓取SCK和MOSI的波形是最权威的方法。观察空闲时SCK电平(CPOL),以及数据在哪个边沿稳定(CPHA)。 - 片选时序:确保在发送指令前CS已拉低,在整个指令-地址-数据周期完成后才拉高。用逻辑分析仪看CS的波形是否完整包裹了SPI时钟。
- 引脚配置:确认STM32的SPI引脚是否被正确复用到SPI功能,而不是普通的GPIO。
4.2 读写数据异常排查
如果ID能正确读取,但写入后再读出的数据不对,问题可能出在软件逻辑或FLASH操作流程上。
-
现象:写入成功,但读出全是0xFF。
- 原因:可能根本没有成功执行“写使能”指令。在每次页编程或擦除前,必须发送
0x06指令。检查W25Qxx_WriteEnable函数是否被正确调用,并且期间CS的时序正确。 - 排查:在写使能后,可以读取状态寄存器(指令
0x05),检查第1位(WEL,写使能锁存位)是否被置1。如果没有,说明写使能失败。
- 原因:可能根本没有成功执行“写使能”指令。在每次页编程或擦除前,必须发送
-
现象:写入的数据错位或部分错误。
- 原因1:地址计算错误。FLASH的地址是24位的,确保发送地址的三字节顺序正确(先发最高字节)。
- 原因2:跨页写入。页编程指令最多连续写入256字节,且不能跨越物理页边界(每个页的起始地址是256字节对齐的)。如果你的写入操作起始地址是250,要写20个字节,这20个字节会被写入到地址250-255和0-13(下一页的开头),这通常会导致非预期的结果。驱动代码中应加入地址边界检查。
- 原因3:未擦除直接写入。FLASH的特性是只能将位从1变成0,不能从0变成1。擦除操作会将整个扇区(4KB)的所有位置1(变为0xFF)。因此,在向一个非0xFF的地址写入数据前,必须先进行擦除。一个常见的错误是,第一次写入正常(因为出厂时全是0xFF),第二次在同一个地址写入不同数据时失败。
-
现象:擦除或写入后,读出的数据是随机的,或者操作后芯片无响应。
- 原因:可能没有正确等待芯片内部操作完成。页编程和扇区擦除都是耗时操作(us到ms级),指令发完后必须轮询状态寄存器的BUSY位,直到其为0,才能进行下一次操作。
W25Qxx_WaitForBusy函数中的轮询循环是必须的。 - 排查:在擦除或编程后,增加一个延时(如100ms)再读取,或者严格使用
W25Qxx_WaitForBusy函数。同时检查函数中读取状态寄存器的逻辑是否正确。
- 原因:可能没有正确等待芯片内部操作完成。页编程和扇区擦除都是耗时操作(us到ms级),指令发完后必须轮询状态寄存器的BUSY位,直到其为0,才能进行下一次操作。
4.3 使用工具辅助调试
工欲善其事,必先利其器。在调试SPI这类时序接口时,有几个工具能极大提升效率:
- 逻辑分析仪:几十块钱的USB逻辑分析仪配合软件(如Saleae Logic)就能图形化地显示SPI的波形。你可以清晰地看到CS、SCK、MOSI、MISO四路信号,解码出传输的每一个字节,直接对比与你代码预期是否一致。这是定位时序、模式问题的最强手段。
- 示波器:可以观察信号质量,检查是否有过冲、振铃或电平不标准的问题,特别是SCK频率较高时。
- 串口打印:在代码关键节点(如发送指令前、等待忙后)通过串口打印状态信息或变量值,是最基础的调试方法。确保你的
printf重定向到了串口。
调试过程就像破案,需要根据现象(读不出ID、数据错误)提出假设(线接错了、模式不对、没等忙),然后用工具(逻辑分析仪看波形、串口打印状态)去验证假设。我个人的习惯是,任何新的FLASH芯片,第一件事就是用逻辑分析仪抓取一遍完整的读ID和读数据波形,确保物理层通信100%正确,然后再去啃上层的数据逻辑问题,这样能避免很多无谓的折腾。
1177



被折叠的 条评论
为什么被折叠?



