1. 项目概述:从零上手SPI与W25Q64
很多刚开始玩STM32的朋友,一听到SPI、FLASH这些词,心里可能就有点发怵,觉得是“高级货”,门槛不低。我刚开始接触的时候也是这种感觉,数据手册上那些时序图、命令字看得人头大。但实际摸爬滚打下来,你会发现,一旦把SPI通信和W25Q64这颗常见的FLASH芯片搞明白,就像是拿到了一把打开外部存储世界的万能钥匙。无论是存储日志、保存配置参数,还是做字库、图片库,都离不开它。这个项目,咱们不玩虚的,就实实在在地用STM32,通过SPI总线去读写W25Q64这颗FLASH芯片。而且,我会带你走两条路:一条是“高速公路”——用STM32硬件SPI外设;另一条是“乡间小道”——用普通IO口模拟SPI时序。两条路都走通了,你才算真正把SPI吃透了。无论你是用标准库、HAL库还是LL库,这里面的核心思想都是相通的。
2. 核心原理与方案选型
2.1 SPI总线协议精讲
SPI,全称Serial Peripheral Interface,是一种高速、全双工、同步的串行通信总线。它用起来比I2C简单粗暴,没有复杂的地址概念和应答机制,核心就是“主从设备”之间通过四根线来同步交换数据。
这四根线你必须牢记:
- SCK (Serial Clock):时钟信号线,由主机产生,用于同步数据。时钟的极性和相位决定了数据采样和锁存的边沿,这是SPI模式的核心。
- MOSI (Master Out Slave In):主机输出,从机输入。主机通过这根线发送数据给从机。
- MISO (Master In Slave Out):主机输入,从机输出。从机通过这根线发送数据给主机。
- CS/SS (Chip Select / Slave Select):片选信号线,低电平有效。主机通过拉低这根线来选中要通信的从机。一个主机可以接多个从机,每个从机需要一根独立的片选线。
SPI有四种工作模式,由时钟极性(CPOL)和时钟相位(CPHA)两个参数组合而成:
- 模式0 (CPOL=0, CPHA=0):时钟空闲时为低电平,数据在时钟的上升沿被采样(捕获)。
- 模式1 (CPOL=0, CPHA=1):时钟空闲时为低电平,数据在时钟的下降沿被采样。
- 模式2 (CPOL=1, CPHA=0):时钟空闲时为高电平,数据在时钟的下降沿被采样。
- 模式3 (CPOL=1, CPHA=1):时钟空闲时为高电平,数据在时钟的上升沿被采样。
关键理解:这里的“采样”是对主机而言的。对于主机发送(MOSI),它会在SCK的某个边沿(根据模式)将数据位放到MOSI线上;对于主机接收(MISO),它会在SCK的另一个边沿(通常是相反的边沿)去读取MISO线上的数据位。W25Q64通常工作在模式0或模式3,因为这两种模式下,数据在时钟边沿稳定后变化,可靠性更高。我们项目里默认使用模式0,这也是最常见的选择。
2.2 W25Q64 FLASH芯片揭秘
W25Q64是Winbond公司生产的一款64M-bit(也就是8M-Byte)的串行FLASH存储器。它内部结构是统一的4KB扇区(Sector),256字节页(Page),擦除和写入操作都以这些单位为基础。
几个关键特性你必须了解:
- 通信接口:标准SPI,支持标准、双线和四线模式,我们只用到标准SPI(单线)。
- 操作单位:
- 读:可以任意地址、任意字节读取,无需擦除。
- 写:必须以“页”为单位进行编程(写入),每页256字节。写入前,目标存储区域必须是“空”的(即全为0xFF),否则写入会失败。这就引出了擦除操作。
- 擦除:有三种粒度:扇区擦除(4KB)、块擦除(32KB/64KB)、整片擦除。擦除后,对应区域所有位变为1(0xFF)。
- 状态寄存器:芯片内部有一个状态寄存器,其中
BUSY位最重要。在执行写、擦除等操作时,BUSY位会置1,此时芯片不响应新的命令(除了读状态寄存器命令)。你必须轮询这个位,等待它变为0,才能进行下一步操作。 - 写保护:芯片有硬件(WP引脚)和软件(状态寄存器)写保护机制,防止误操作。我们做实验时,通常把WP引脚拉高(禁用硬件保护),并确保软件写保护位被清除。
2.3 硬件SPI vs 模拟SPI:如何选择?
这是本项目的核心对比点,选择哪种方式取决于你的具体需求。
硬件SPI方案:
- 优点:速度极快(可达几十MHz),不占用CPU时间(DMA更是如虎添翼),时序由硬件保证,绝对精确可靠,代码简洁。
- 缺点:受限于MCU的SPI外设引脚,引脚分配不灵活。如果SPI外设被其他设备占用,就需要切换。
- 适用场景:对速度要求高、主频高的应用,或者需要同时处理其他任务的系统。
模拟SPI(软件SPI)方案:
- 优点:引脚任意指定,极度灵活。任何一个GPIO都可以用来模拟,不受硬件外设限制。代码直观,便于理解底层时序。
- 缺点:速度慢,受限于CPU主频和软件延时精度,大量数据传输时会严重拖累CPU。
- 适用场景:引脚资源紧张需要复用、学习SPI时序原理、调试阶段快速验证、驱动不常见的非标准SPI设备。
对于W25Q64这类典型的SPI设备,我个人的建议是:优先使用硬件SPI。它的性能和可靠性是模拟SPI无法比拟的。模拟SPI更适合作为理解原理、应急和调试的手段。我们这个项目会把两种方式都实现,让你彻底掌握。
3. 硬件电路设计与连接
动手之前,先把电路搭好。这里以STM32F103C8T6(蓝色pill开发板)为例,其他型号引脚可能不同,但连接逻辑一致。
硬件SPI连接方式(以SPI1为例):
- STM32 SPI1_MOSI (PA7)->W25Q64 DI (IO0)(数据输入)
- STM32 SPI1_MISO (PA6)->W25Q64 DO (IO1)(数据输出)
- STM32 SPI1_SCK (PA5)->W25Q64 CLK(时钟)
- STM32 GPIO (如PA4)->W25Q64 CS(片选,注意是任意GPIO,不一定是SPI的NSS引脚)
- W25Q64 VCC->3.3V(绝对不要接5V!)
- W25Q64 GND->GND
- W25Q64 WP->3.3V(拉高,禁用硬件写保护)
- W25Q64 HOLD->3.3V(拉高,禁用保持功能)
重要提示:W25Q64是3.3V器件,STM32的IO口也必须设置为3.3V电平。如果你用的开发板是5V系统,必须使用电平转换电路,否则会烧坏芯片!
模拟SPI连接方式:连接更灵活,你只需要定义四个普通的GPIO口:
- GPIO_MOSI-> W25Q64 DI
- GPIO_MISO-> W25Q64 DO
- GPIO_SCK-> W25Q64 CLK
- GPIO_CS-> W25Q64 CS 其他电源和配置引脚连接不变。你可以把这四个信号接到任意空闲的GPIO上,比如PB12, PB13, PB14, PB15。
4. 软件驱动实现:硬件SPI篇
我们使用STM32的HAL库来演示,因为它目前最通用。标准库和LL库的思路完全一致。
4.1 硬件SPI初始化
首先,通过STM32CubeMX配置SPI1:
- 将SPI1模式设置为“Full-Duplex Master”。
- 设置数据大小为“8 Bits”。
- 设置时钟极性(CPOL)为“Low”,时钟相位(CPHA)为“1 Edge”,即模式0。
- 设置预分频器,决定SCK时钟频率。W25Q64最高支持104MHz(在Fast Read模式下),但为了稳定,初期可以设置低一些,比如
PCLK2 / 8(如果系统时钟72MHz,则SPI时钟为9MHz)。 - 设置片选信号(NSS)为“Software Output”,这样我们就可以用软件控制一个普通GPIO作为CS,而不是使用硬件NSS引脚,控制更灵活。
生成代码后,我们主要关心以下几个用户函数:
// spi.c 中自动生成的初始化函数 void MX_SPI1_Init(void) { hspi1.Instance = SPI1; hspi1.Init.Mode = SPI_MODE_MASTER; hspi1.Init.Direction = SPI_DIRECTION_2LINES; hspi1.Init.DataSize = SPI_DATASIZE_8BIT; hspi1.Init.CLKPolarity = SPI_POLARITY_LOW; hspi1.Init.CLKPhase = SPI_PHASE_1EDGE; hspi1.Init.NSS = SPI_NSS_SOFT; hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8; hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB; hspi1.Init.TIMode = SPI_TIMODE_DISABLE; hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE; hspi1.Init.CRCPolynomial = 10; if (HAL_SPI_Init(&hspi1) != HAL_OK) { Error_Handler(); } }4.2 GPIO与片选控制
我们需要手动初始化一个GPIO作为片选CS。
// 初始化CS引脚 void W25Qxx_CS_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); // 假设CS接在PA4 GPIO_InitStruct.Pin = GPIO_PIN_4; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); W25Qxx_CS_HIGH(); // 初始化为高电平(不选中) } // 片选控制宏 #define W25Qxx_CS_LOW() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define W25Qxx_CS_HIGH() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET)4.3 基础读写函数与命令发送
SPI通信的本质是交换数据。主机发送一个字节的同时,也会从从机那里接收一个字节。对于W25Q64,很多命令(如读ID、读状态寄存器)只需要主机发送,从机返回数据。而像读数据命令,则需要主机先发送命令和地址,然后持续产生时钟来读取数据。
// 使用硬件SPI读写一个字节 uint8_t W25Qxx_ReadWriteByte(uint8_t TxData) { uint8_t RxData; HAL_SPI_TransmitReceive(&hspi1, &TxData, &RxData, 1, 1000); // 超时1秒 return RxData; } // 读取FLASH芯片的制造商和设备ID(命令:0x90) uint16_t W25Qxx_ReadID(void) { uint16_t ID = 0; W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x90); // 发送读ID命令 W25Qxx_ReadWriteByte(0x00); // 发送3个字节的哑元地址 W25Qxx_ReadWriteByte(0x00); W25Qxx_ReadWriteByte(0x00); ID |= W25Qxx_ReadWriteByte(0xFF) << 8; // 读取制造商ID(Winbond: 0xEF) ID |= W25Qxx_ReadWriteByte(0xFF); // 读取设备ID (W25Q64: 0x4017) W25Qxx_CS_HIGH(); return ID; }4.4 等待芯片空闲
在执行任何写或擦除操作后,都必须等待芯片内部操作完成。
// 读取状态寄存器1(命令:0x05),并检查BUSY位(bit0) uint8_t W25Qxx_ReadSR(void) { uint8_t sr; W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x05); // 读状态寄存器命令 sr = W25Qxx_ReadWriteByte(0xFF); W25Qxx_CS_HIGH(); return sr; } // 等待芯片空闲(BUSY位为0) void W25Qxx_WaitBusy(void) { while ((W25Qxx_ReadSR() & 0x01) == 0x01) { HAL_Delay(1); // 短暂延时,避免过度轮询 } }4.5 扇区擦除与页编程(写入)
这是最核心的两个操作。
// 写使能(命令:0x06),在执行写/擦除操作前必须调用 void W25Qxx_WriteEnable(void) { W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x06); W25Qxx_CS_HIGH(); } // 扇区擦除(4KB)(命令:0x20) void W25Qxx_EraseSector(uint32_t SectorAddr) { SectorAddr *= 4096; // 将扇区号转换为实际地址 W25Qxx_WriteEnable(); // 1. 写使能 W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x20); // 2. 发送扇区擦除命令 // 3. 发送24位地址(只发高24位,低8位被忽略,因为擦除以扇区为单位) W25Qxx_ReadWriteByte((SectorAddr >> 16) & 0xFF); W25Qxx_ReadWriteByte((SectorAddr >> 8) & 0xFF); W25Qxx_ReadWriteByte(SectorAddr & 0xFF); W25Qxx_CS_HIGH(); W25Qxx_WaitBusy(); // 4. 等待擦除完成 } // 页编程(写入一页,最多256字节)(命令:0x02) void W25Qxx_WritePage(uint8_t* pBuffer, uint32_t WriteAddr, uint16_t NumByteToWrite) { if (NumByteToWrite > 256) NumByteToWrite = 256; // 页写入不能跨页 W25Qxx_WriteEnable(); // 1. 写使能 W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x02); // 2. 发送页编程命令 // 3. 发送24位地址 W25Qxx_ReadWriteByte((WriteAddr >> 16) & 0xFF); W25Qxx_ReadWriteByte((WriteAddr >> 8) & 0xFF); W25Qxx_ReadWriteByte(WriteAddr & 0xFF); // 4. 循环写入数据 for (uint16_t i = 0; i < NumByteToWrite; i++) { W25Qxx_ReadWriteByte(pBuffer[i]); } W25Qxx_CS_HIGH(); W25Qxx_WaitBusy(); // 5. 等待写入完成 }4.6 数据读取
读取操作相对简单,无需擦除,也无需等待。
// 读取数据(命令:0x03),可以从任意地址开始读取任意长度 void W25Qxx_Read(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead) { W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x03); // 发送标准读数据命令 // 发送24位地址 W25Qxx_ReadWriteByte((ReadAddr >> 16) & 0xFF); W25Qxx_ReadWriteByte((ReadAddr >> 8) & 0xFF); W25Qxx_ReadWriteByte(ReadAddr & 0xFF); // 连续读取数据 for (uint16_t i = 0; i < NumByteToRead; i++) { pBuffer[i] = W25Qxx_ReadWriteByte(0xFF); // 发送哑元时钟以读取数据 } W25Qxx_CS_HIGH(); }5. 软件驱动实现:模拟SPI篇
模拟SPI的核心就是用GPIO的“置高”、“置低”和“读取”操作,严格按照SPI的时序图来“画”出时钟和数据波形。我们同样以实现模式0(CPOL=0, CPHA=0)为例。
5.1 GPIO初始化与宏定义
首先,定义并初始化四个GPIO引脚。
// 引脚定义(以PB12,13,14,15为例) #define W25Qxx_SPI_CS_PIN GPIO_PIN_12 #define W25Qxx_SPI_CS_PORT GPIOB #define W25Qxx_SPI_SCK_PIN GPIO_PIN_13 #define W25Qxx_SPI_SCK_PORT GPIOB #define W25Qxx_SPI_MOSI_PIN GPIO_PIN_14 #define W25Qxx_SPI_MOSI_PORT GPIOB #define W25Qxx_SPI_MISO_PIN GPIO_PIN_15 #define W25Qxx_SPI_MISO_PORT GPIOB // 初始化函数 void SPI_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOB_CLK_ENABLE(); // SCK, MOSI, CS 配置为推挽输出 GPIO_InitStruct.Pin = W25Qxx_SPI_SCK_PIN | W25Qxx_SPI_MOSI_PIN | W25Qxx_SPI_CS_PIN; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_PULLUP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // MISO 配置为上拉输入 GPIO_InitStruct.Pin = W25Qxx_SPI_MISO_PIN; GPIO_InitStruct.Mode = GPIO_MODE_INPUT; GPIO_InitStruct.Pull = GPIO_PULLUP; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // 初始状态:CS高(不选中),SCK低(模式0空闲状态) W25Qxx_CS_HIGH(); W25Qxx_SCK_LOW(); } // 引脚操作宏(提高代码可读性和执行效率) #define W25Qxx_CS_LOW() HAL_GPIO_WritePin(W25Qxx_SPI_CS_PORT, W25Qxx_SPI_CS_PIN, GPIO_PIN_RESET) #define W25Qxx_CS_HIGH() HAL_GPIO_WritePin(W25Qxx_SPI_CS_PORT, W25Qxx_SPI_CS_PIN, GPIO_PIN_SET) #define W25Qxx_SCK_LOW() HAL_GPIO_WritePin(W25Qxx_SPI_SCK_PORT, W25Qxx_SPI_SCK_PIN, GPIO_PIN_RESET) #define W25Qxx_SCK_HIGH() HAL_GPIO_WritePin(W25Qxx_SPI_SCK_PORT, W25Qxx_SPI_SCK_PIN, GPIO_PIN_SET) #define W25Qxx_MOSI_LOW() HAL_GPIO_WritePin(W25Qxx_SPI_MOSI_PORT, W25Qxx_SPI_MOSI_PIN, GPIO_PIN_RESET) #define W25Qxx_MOSI_HIGH() HAL_GPIO_WritePin(W25Qxx_SPI_MOSI_PORT, W25Qxx_SPI_MOSI_PIN, GPIO_PIN_SET) #define W25Qxx_MISO_READ() HAL_GPIO_ReadPin(W25Qxx_SPI_MISO_PORT, W25Qxx_SPI_MISO_PIN)5.2 模拟SPI字节读写函数
这是模拟SPI的“心脏”。它要严格按照模式0的时序,在SCK的上升沿采样数据(对于从机输出),在SCK的下降沿之后变化数据(对于主机输出)。通常我们让主机在SCK的下降沿改变MOSI数据,这样在上升沿时数据已经稳定,符合从机的采样要求。
// 模拟SPI读写一个字节(模式0) uint8_t SPI_SW_ReadWriteByte(uint8_t TxData) { uint8_t i; uint8_t RxData = 0; for (i = 0; i < 8; i++) { // 先拉低SCK(下降沿),主机在下降沿之后设置数据位 W25Qxx_SCK_LOW(); // 短暂延时,确保数据稳定。这里的延时决定了SPI速度。 // 可以用__NOP()或简单的循环。延时太短可能不稳定,太长则速度慢。 Delay_us(1); // 假设有一个微秒级延时函数 // 设置MOSI输出当前要发送的位(从最高位MSB开始) if (TxData & 0x80) { W25Qxx_MOSI_HIGH(); } else { W25Qxx_MOSI_LOW(); } TxData <<= 1; // 左移,准备下一个位 // 拉高SCK(上升沿),从机在上升沿采样数据,主机也在上升沿读取MISO W25Qxx_SCK_HIGH(); Delay_us(1); // 保持高电平时间 // 读取MISO位,并组合到接收数据中 RxData <<= 1; if (W25Qxx_MISO_READ()) { RxData |= 0x01; } } // 循环结束后,SCK处于高电平。为了保持模式0的空闲状态,最后再拉低SCK W25Qxx_SCK_LOW(); return RxData; }关键细节与避坑:
- 位顺序:SPI协议可以配置为先发送最高位(MSB)或最低位(LSB)。W25Q64默认是MSB first,所以我们的代码从
0x80(二进制10000000)开始判断。- 延时:
Delay_us(1)是关键。在72MHz的STM32F1上,一个__NOP()大约14ns,循环几次就能达到微秒级延时。你需要根据主频调整。延时决定了SCK的频率。例如,高低电平各延时1us,则周期为2us,频率为500kHz。这是模拟SPI的速度瓶颈。- 时序严格性:必须保证“设置数据”在“SCK上升沿”之前有足够的稳定时间(Setup Time),以及在“SCK上升沿”之后有足够的保持时间(Hold Time)。我们的代码在下降沿后设置数据,在上升沿前有约1us稳定时间,是合理的。
- 最后SCK状态:为了让时序完整并回到空闲状态,最后将SCK拉低。
5.3 驱动函数适配
有了SPI_SW_ReadWriteByte函数,之前为硬件SPI编写的所有上层函数(W25Qxx_ReadID,W25Qxx_Read,W25Qxx_WritePage等)几乎可以完全复用!你只需要把函数内部调用的W25Qxx_ReadWriteByte替换成SPI_SW_ReadWriteByte即可。这就是抽象层的好处——底层通信方式改变,上层应用逻辑不变。
例如,模拟SPI的读ID函数:
uint16_t W25Qxx_ReadID_SW(void) { // 加个后缀以示区别 uint16_t ID = 0; W25Qxx_CS_LOW(); SPI_SW_ReadWriteByte(0x90); SPI_SW_ReadWriteByte(0x00); SPI_SW_ReadWriteByte(0x00); SPI_SW_ReadWriteByte(0x00); ID |= SPI_SW_ReadWriteByte(0xFF) << 8; ID |= SPI_SW_ReadWriteByte(0xFF); W25Qxx_CS_HIGH(); return ID; }擦除、写入、读取函数依此类推。这样,你就拥有了一套完整的、基于模拟SPI的W25Q64驱动。
6. 项目实战:数据存储与读取测试
理论说再多,不如实际跑一遍。我们来设计一个简单的测试流程,验证我们的驱动是否工作正常。
测试步骤:
- 初始化:初始化硬件SPI(或模拟SPI的GPIO)和CS引脚。
- 设备检测:调用
W25Qxx_ReadID()函数,应该返回0xEF17(制造商ID 0xEF,设备ID 0x4017的低8位是0x17)。这是成功的第一步。 - 擦除测试:选择第一个扇区(地址0x000000)进行擦除。擦除后,读取这个扇区的前几个字节,应该全是0xFF。
- 写入测试:准备一个字符串,比如
"Hello, W25Q64!",调用W25Qxx_WritePage函数,将其写入到扇区起始地址。 - 读取验证:调用
W25Qxx_Read函数,从同一地址读取相同长度的数据到一个缓冲区。 - 比较与输出:将读取到的缓冲区内容通过串口打印出来,并与原字符串比较。同时,可以再读取该扇区其他未写入的区域,确认它们仍然是0xFF,证明页写入没有越界。
一个简单的测试代码框架:
uint8_t write_buffer[] = "Hello, W25Q64! This is a SPI test."; uint8_t read_buffer[100]; int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); // 初始化串口用于打印 MX_SPI1_Init(); // 初始化硬件SPI // 或者调用 SPI_GPIO_Init(); // 初始化模拟SPI GPIO W25Qxx_CS_Init(); printf("W25Q64 SPI Test Start...\r\n"); // 1. 读取ID uint16_t flash_id = W25Qxx_ReadID(); printf("Flash ID: 0x%04X\r\n", flash_id); if (flash_id != 0xEF17) { printf("Flash ID Error! Check connection.\r\n"); while(1); } // 2. 擦除第0扇区 printf("Erasing Sector 0...\r\n"); W25Qxx_EraseSector(0); printf("Erase Done.\r\n"); // 3. 写入数据 uint16_t len = strlen((char*)write_buffer); printf("Writing %d bytes to address 0...\r\n", len); W25Qxx_WritePage(write_buffer, 0, len); // 4. 读取数据 printf("Reading data back...\r\n"); W25Qxx_Read(read_buffer, 0, len); read_buffer[len] = '\0'; // 添加字符串结束符 printf("Read Data: %s\r\n", read_buffer); // 5. 数据比对 if (memcmp(write_buffer, read_buffer, len) == 0) { printf("SUCCESS: Data match perfectly!\r\n"); } else { printf("FAIL: Data mismatch!\r\n"); } while (1) { // 主循环 } }7. 深度优化与高级话题
基本的读写跑通后,我们可以探讨一些更深入的话题,让你的驱动更健壮、更高效。
7.1 使用DMA加速硬件SPI传输
当需要读写大量连续数据时(例如读取整个字库),CPU不断搬运数据会成为瓶颈。此时可以使用DMA(直接存储器访问)来解放CPU。
思路:将SPI的发送和接收都配置为DMA模式。对于读操作,主机需要先发送命令和地址(这部分可以用CPU),然后启动SPI接收DMA,持续产生时钟并接收数据到指定内存。HAL库提供了HAL_SPI_Transmit_DMA和HAL_SPI_Receive_DMA函数。
关键点:
- 需要正确配置DMA通道和流。
- 在DMA传输完成中断回调函数中,拉高CS片选信号。
- 注意DMA传输的数据长度单位(字节、半字、字)要与SPI数据宽度匹配。
7.2 实现更快的读取指令
W25Q64支持0x03标准读指令,也支持0x0B快速读指令。快速读指令在发送命令和地址后,需要一个额外的“哑元字节”(Dummy Byte),之后的数据输出速率可以更高。在高速SPI时钟下,使用快速读指令能获得更好的性能。
void W25Qxx_FastRead(uint8_t* pBuffer, uint32_t ReadAddr, uint16_t NumByteToRead) { W25Qxx_CS_LOW(); W25Qxx_ReadWriteByte(0x0B); // 快速读命令 W25Qxx_ReadWriteByte((ReadAddr >> 16) & 0xFF); W25Qxx_ReadWriteByte((ReadAddr >> 8) & 0xFF); W25Qxx_ReadWriteByte(ReadAddr & 0xFF); W25Qxx_ReadWriteByte(0xFF); // 哑元字节 for (uint16_t i = 0; i < NumByteToRead; i++) { pBuffer[i] = W25Qxx_ReadWriteByte(0xFF); } W25Qxx_CS_HIGH(); }7.3 磨损均衡与坏块管理(进阶)
虽然W25Q64是NOR FLASH,没有NAND FLASH那样的坏块问题,但它仍有擦写次数限制(典型10万次)。如果频繁擦写同一个扇区,该扇区会率先损坏。对于需要频繁更新数据的应用(如日志存储),实现简单的磨损均衡算法可以大幅延长芯片寿命。
简单思路:在多个扇区上循环写入。用一个固定的“索引区”记录当前写入位置。每次写数据时,写到下一个可用位置,并更新索引。当所有扇区轮询一遍后,再擦除最早的那个扇区继续使用。这样就将擦写次数平均分布到了所有扇区上。
7.4 文件系统移植(如FATFS, LittleFS)
如果需要在W25Q64上存储复杂的文件(如图片、文本、配置文件),手动管理扇区和页会非常麻烦。移植一个轻量级文件系统是更好的选择。
- FATFS:通用性强,兼容性好,但为磁盘设计,对FLASH特性优化不足。
- LittleFS:专为嵌入式FLASH设计的文件系统,具有掉电安全、磨损均衡等特性,非常适合W25Q64。
移植文件系统的核心是实现底层的“磁盘IO层”接口,即disk_read,disk_write,disk_ioctl等函数。在这些函数内部,调用我们写好的W25Qxx_Read,W25Qxx_WritePage,W25Qxx_EraseSector函数即可。一旦移植成功,你就可以使用f_open,f_write,f_read等标准C文件操作函数来管理FLASH了,极大提升开发效率。
8. 常见问题与调试心得实录
搞嵌入式,没有不踩坑的。下面是我在调试SPI和W25Q64过程中遇到的一些典型问题及解决办法,希望能帮你快速排雷。
问题1:读取ID正确,但无法写入/擦除。
- 可能原因1:写保护未解除。检查状态寄存器(命令
0x05)的BP2, BP1, BP0位和SRP1, SRP0位。确保它们都是0。可以发送“写状态寄存器”命令(0x01)将状态寄存器清零(注意,需要先写使能)。 - 可能原因2:未等待芯片空闲。每次写使能、页编程、扇区擦除后,必须调用
W25Qxx_WaitBusy()等待操作完成,才能进行下一步。这是新手最常犯的错误。 - 可能原因3:电源或地线不稳定。确保电源纹波小,并在芯片VCC和GND之间就近放置一个0.1uF的退耦电容。
问题2:写入的数据读取出来是乱码或部分错误。
- 可能原因1:写入地址未对齐。页编程不能跨页。如果你从地址250开始写入20字节,这20字节会先写入地址250-255(第1页尾部),剩下的字节会从地址0(第2页头部)开始写,从而覆盖第2页开头的数据。务必确保单次页编程操作不跨越256字节的页边界。如果需要写入的数据跨页,必须分成两次页编程操作。
- 可能原因2:目标区域未擦除。FLASH写入只能将1变为0,不能将0变为1。写入前必须确保目标区域是全0xFF(即已擦除状态)。如果你在原有数据上写入,必须先擦除整个扇区。
- 可能原因3:SPI时钟太快或不稳定。尝试降低SPI的时钟分频(如从
PCLK/2降到PCLK/8),或者检查PCB走线,SCK和MOSI/MISO线不要走得太长,避免干扰。
问题3:模拟SPI可以,硬件SPI不行(或反之)。
- 可能原因1:SPI模式不匹配。用逻辑分析仪或示波器抓取SCK, MOSI, MISO的波形。重点看SCK空闲电平(CPOL)和数据的采样边沿(CPHA)。确保MCU的SPI配置与W25Q64期望的模式一致(通常为模式0或3)。
- 可能原因2:片选信号时序问题。硬件SPI的NSS引脚有时会自动管理,但在我们使用软件CS的方案中,要确保CS在通信开始前拉低,在通信结束后拉高。并且,在发送一串命令/数据的过程中,CS要保持低电平。
- 可能原因3:硬件SPI的引脚复用冲突。检查一下SPI引脚是否被其他外设(如JTAG)占用了。例如STM32F1的PA5, PA6, PA7默认是SPI1,但PA5也可能是调试接口的时钟。如果遇到问题,尝试在CubeMX中关闭JTAG,仅启用SWD调试模式。
问题4:使用DMA读取时数据错位。
- 可能原因:DMA传输方向或数据宽度错误。SPI接收数据时,DMA的方向应设为外设到存储器。同时,确保SPI的数据宽度(8位)与DMA传输的数据宽度(字节)匹配。如果DMA设置为半字或字传输,会导致数据搬运错位。
调试必备工具建议:
- 逻辑分析仪:几十块钱的8通道逻辑分析仪配合软件(如Saleae Logic),是调试SPI、I2C等数字通信协议的神器。可以直观地看到每一位数据、每一个命令,对比时序图,一切问题无所遁形。
- 串口调试助手:打印日志、ID、状态寄存器值和读写的数据,是最基本的调试手段。
- 万用表/示波器:检查电源电压是否稳定在3.3V,检查引脚连接是否牢固,有无虚焊。
最后,分享一个我个人的深刻体会:嵌入式驱动开发,数据手册是你最好的朋友。W25Q64的数据手册有几十页,不要只看例程代码。把“指令集”那一页(包含所有命令的表格)和“时序图”打印出来,放在手边。遇到任何问题,首先去核对时序和命令格式。当你养成了查阅数据手册的习惯,你会发现大部分问题都能自己找到答案。从“模拟SPI”入手理解波形,再用“硬件SPI”实现高效驱动,这条路径能帮你打下最扎实的底层硬件通信基础。