1. 项目缘起与整体设计思路
1.1 为什么选 SPI Flash 作为存储方案
做过嵌入式项目的人大概都有这种体会:MCU 自带的 Flash 空间永远不够用。以 STM32F103C8T6 为例,64KB 的 Flash 里塞完协议栈、驱动和业务逻辑之后,能留给数据存储的空间往往只剩几 KB。一旦需要记录运行日志、保存配置参数、缓存传感器采样数据,这点空间立刻捉襟见肘。
这时候外挂一颗 SPI Flash 是最经济实惠的选择。W25Q64 这颗芯片在圈子里几乎是“标配”级别的存在——8MB 容量(64Mbit),SPI 接口,2.7V~3.6V 供电,价格便宜到可以忽略不计,随便一个电子市场或者网购平台都能买到。它支持标准 SPI、Dual SPI 和 Quad SPI 三种模式,标准 SPI 模式下最高时钟可以跑到 80MHz(快速读模式下),对于 STM32F103 这种最高 SPI 时钟 18MHz(APB2 72MHz 六分频)的芯片来说完全够用。
我选它的理由很直接:容量够、接口简单、驱动成熟、资料多。你不需要去啃什么复杂的协议栈,只要理解 SPI 的基本时序和 Flash 的几条核心指令,就能把它跑起来。对于初学者来说,这是理解“存储器操作”这件事最好的切入点之一。
1.2 整体方案架构与数据流
整个系统的数据流其实很清晰:STM32F103 作为 SPI 主机,W25Q64 作为从机。主机通过 SPI 总线发送指令、地址和数据,从机根据指令执行对应的操作——读状态寄存器、写使能、擦除扇区、页编程、读数据等等。
硬件连接上,除了 SPI 的四根线(SCK、MISO、MOSI、CS),W25Q64 还有 WP(写保护)和 HOLD(保持)两个引脚。在实际项目中,WP 一般直接拉高(禁用硬件写保护),HOLD 也拉高(禁用保持功能),这样软件层面完全控制读写操作,避免硬件层面的意外干扰。
软件架构我习惯分成三层:底层 SPI 硬件驱动层、中间 W25Q64 指令封装层、上层应用接口层。底层负责 SPI 外设的初始化和字节收发;中间层把 Flash 的各种操作封装成函数,比如W25Q64_ReadID()、W25Q64_SectorErase()、W25Q64_PageWrite();上层就是业务逻辑调用,比如保存配置、读取日志。这样分层的好处是,以后换一颗 Flash 芯片,只需要改中间层,上层业务代码基本不用动。
注意:W25Q64 的页大小是 256 字节,扇区大小是 4KB,块大小是 64KB。一次页编程最多写 256 字节,且不能跨页。这是新手最容易踩的坑——以为可以连续写任意长度,结果数据在页边界处回卷覆盖了。
1.3 关键参数选型与计算依据
SPI 时钟频率的选择需要平衡速度和稳定性。STM32F103 的 SPI1 挂在 APB2 总线上,时钟源为 72MHz。通过预分频器可以设置 2、4、8、16、32、64、128、256 分频。我一般选 8 分频,得到 9MHz 的 SPI 时钟。为什么不用更高的 4 分频(18MHz)?因为 W25Q64 在标准 SPI 模式下,除了“快速读”指令支持 80MHz 以外,普通读指令最高只支持 50MHz。9MHz 留足了余量,走线稍微长一点也不会出问题。
W25Q64 的供电范围是 2.7V~3.6V,STM32F103 是 3.3V 供电,两者可以直接对接,不需要电平转换。但要注意,如果你的开发板上有 5V 器件共用 SPI 总线,一定要加电平转换芯片或者至少串一个电阻,否则可能烧毁 Flash 芯片。
2. 核心细节解析与实操要点
2.1 SPI 模式配置与时序匹配
W25Q64 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。这两种模式的区别在于空闲时 SCK 的电平不同。我一般用模式 0,因为这是最常用的配置,STM32 的 SPI 外设默认也是模式 0。
配置的时候有几个关键点:数据帧格式设为 8 位,MSB 先行,软件控制 NSS 引脚(因为我们要手动控制片选时序),波特率预分频器设为 8。这里特别说一下 NSS 引脚——如果你用硬件 NSS 模式,STM32 会在每个字节传输间隙自动拉高片选,导致多字节传输时 Flash 误判指令结束。所以必须用软件 NSS 模式,手动在传输开始前拉低 CS,传输结束后拉高。
SPI_InitTypeDef spi; spi.SPI_Direction = SPI_Direction_2Lines_FullDuplex; spi.SPI_Mode = SPI_Mode_Master; spi.SPI_DataSize = SPI_DataSize_8b; spi.SPI_CPOL = SPI_CPOL_Low; spi.SPI_CPHA = SPI_CPHA_1Edge; spi.SPI_NSS = SPI_NSS_Soft; spi.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; spi.SPI_FirstBit = SPI_FirstBit_MSB; spi.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &spi); SPI_Cmd(SPI1, ENABLE);2.2 W25Q64 指令集与状态寄存器解读
W25Q64 的指令集不算复杂,常用的就那么十来条。我整理了一个速查表,方便你写驱动的时候对照:
| 指令名称 | 指令码 | 功能说明 | 地址字节数 | 数据字节数 |
|---|---|---|---|---|
| Write Enable | 0x06 | 写使能 | 0 | 0 |
| Write Disable | 0x04 | 写禁止 | 0 | 0 |
| Read Status Reg1 | 0x05 | 读状态寄存器1 | 0 | 1~N |
| Read Data | 0x03 | 读数据 | 3 | 1~N |
| Page Program | 0x02 | 页编程 | 3 | 1~256 |
| Sector Erase | 0x20 | 扇区擦除(4KB) | 3 | 0 |
| Block Erase | 0xD8 | 块擦除(64KB) | 3 | 0 |
| Chip Erase | 0xC7 | 全片擦除 | 0 | 0 |
| JEDEC ID | 0x9F | 读制造商和设备ID | 0 | 3 |
| Fast Read | 0x0B | 快速读数据 | 3+1 | 1~N |
状态寄存器 1 的 bit0 是 BUSY 位,这是整个驱动里最重要的一个标志位。任何擦除或编程操作开始后,BUSY 位会置 1,操作完成后自动清零。你在发下一条指令之前,必须轮询等待 BUSY 位变 0,否则指令会被忽略。我见过太多人在这里翻车——擦除还没完成就急着写数据,结果写进去的全是 0xFF。
2.3 页编程的边界陷阱与地址计算
前面提过页编程不能跨页,这里展开说一下。W25Q64 的页大小是 256 字节,地址的低 8 位决定页内偏移,高位移位决定页号。如果你从地址 0x0000F0 开始写 32 字节,写到 0x0000FF 正好是页尾,没问题。但如果你从 0x0000F0 开始写 64 字节,写到 0x0000FF 之后,下一个字节会回卷到 0x000000,而不是自动跳到下一页 0x000100。这就是所谓的“页回卷”现象。
正确的做法是:在写数据之前,先计算当前地址到页尾还剩多少字节,如果剩余空间不够,就分多次写。我一般写一个W25Q64_Write()函数,内部自动处理跨页逻辑:
void W25Q64_Write(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t page_remain = 256 - (addr % 256); if (len <= page_remain) { W25Q64_PageWrite(addr, buf, len); } else { W25Q64_PageWrite(addr, buf, page_remain); W25Q64_Write(addr + page_remain, buf + page_remain, len - page_remain); } }这个递归写法简洁明了,但要注意递归深度——最坏情况下 len 很大,递归层数会比较多。实际项目中我一般用循环代替递归,避免栈溢出风险。
提示:擦除操作的最小单位是扇区(4KB),不能按字节擦除。这意味着如果你要修改一个字节,必须先把整个扇区的数据读到 RAM 里,修改后再擦除整个扇区,最后把修改后的数据写回去。这个“读-改-写”流程是 Flash 存储管理的核心逻辑。
3. 完整实操流程与核心环节实现
3.1 硬件连线与上电检查
先确认硬件连接。以 STM32F103C8T6 最小系统板为例:
| STM32 引脚 | W25Q64 引脚 | 说明 |
|---|---|---|
| PA5 | CLK | SPI1 时钟 |
| PA6 | DO | SPI1 MISO |
| PA7 | DI | SPI1 MOSI |
| PA4 | CS | 片选(软件控制) |
| 3.3V | VCC | 电源 |
| GND | GND | 地 |
| 3.3V | WP | 写保护禁用 |
| 3.3V | HOLD | 保持禁用 |
连线完成后,先别急着写代码。用万用表量一下 VCC 和 GND 之间有没有短路,确认 3.3V 供电正常。然后上电,用示波器或者逻辑分析仪看一下 SCK 引脚有没有信号——如果没有,说明 SPI 初始化有问题;如果有信号但 Flash 没反应,检查 CS 引脚是否在传输期间正确拉低。
我个人的习惯是,第一次调试的时候先不接 Flash,用逻辑分析仪抓 STM32 发出的波形,确认指令码和地址字节都正确,然后再接上 Flash 看响应。这样可以排除是主机问题还是从机问题。
3.2 SPI 底层驱动编写与调试
底层驱动就两个函数:一个字节发送,一个字节接收。但 SPI 是全双工,发送和接收是同时进行的。所以通常写一个SPI_ReadWriteByte()函数,发送一个字节的同时接收一个字节:
uint8_t SPI_ReadWriteByte(uint8_t tx_data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, tx_data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); return SPI_I2S_ReceiveData(SPI1); }这个函数看起来简单,但有两个地方容易出问题。第一,等待 TXE 标志的时候不能加超时,否则在 Flash 忙的时候会误判。第二,读 DR 寄存器之前必须等 RXNE 置位,否则读到的数据是旧的。我早期写驱动的时候为了“提高效率”,把两个等待合并成一个,结果读出来的数据偶尔会错位,查了半天才发现是时序问题。
调试的时候,可以先发一条Read Status Register指令(0x05),然后读一个字节。如果返回 0x00 或者 0xFF,说明通信基本正常。如果返回其他值,检查一下 SPI 模式配置和 CS 时序。
3.3 读 ID 验证通信链路
读 JEDEC ID 是验证通信链路最直接的方法。W25Q64 的 JEDEC ID 是 0xEF4017——0xEF 是制造商 ID(Winbond),0x4017 是设备 ID。操作流程是:拉低 CS,发送 0x9F,然后连续读 3 个字节,最后拉高 CS。
uint32_t W25Q64_ReadID(void) { uint32_t id = 0; W25Q64_CS_LOW(); SPI_ReadWriteByte(0x9F); id |= SPI_ReadWriteByte(0xFF) << 16; id |= SPI_ReadWriteByte(0xFF) << 8; id |= SPI_ReadWriteByte(0xFF); W25Q64_CS_HIGH(); return id; }如果读出来是 0xEF4017,恭喜你,通信链路没问题。如果读出来是 0x000000 或者 0xFFFFFF,检查 CS 引脚是否正常工作、SPI 时钟是否太快、供电是否稳定。我遇到过一种情况:读出来是 0xEF4017 但偶尔会变成 0xEF0017,后来发现是杜邦线太长导致信号完整性不好,换短一点的线就稳定了。
3.4 扇区擦除与数据写入完整流程
擦除和写入是 Flash 操作的核心。完整流程是这样的:
- 发送写使能指令(0x06)
- 发送扇区擦除指令(0x20)+ 24 位地址
- 轮询状态寄存器等待 BUSY 位清零
- 再次发送写使能指令(0x06)
- 发送页编程指令(0x02)+ 24 位地址 + 数据
- 轮询状态寄存器等待 BUSY 位清零
- 发送读数据指令(0x03)+ 24 位地址,读取验证
这里有个细节:每次擦除或编程之前都必须发写使能指令,因为 W25Q64 在执行完一次擦除或编程后会自动清除写使能锁存器。如果你忘了发,操作会被直接忽略,而且状态寄存器不会报错——数据就是写不进去,你会很困惑。
void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); W25Q64_CS_LOW(); SPI_ReadWriteByte(0x20); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }W25Q64_WaitBusy()函数就是不断读状态寄存器,直到 BUSY 位为 0。这里建议加一个超时计数器,防止 Flash 损坏时死循环:
void W25Q64_WaitBusy(void) { uint32_t timeout = 0xFFFFFF; while (timeout--) { W25Q64_CS_LOW(); SPI_ReadWriteByte(0x05); uint8_t status = SPI_ReadWriteByte(0xFF); W25Q64_CS_HIGH(); if ((status & 0x01) == 0) break; } }3.5 数据读取与校验方法
读数据相对简单,发送 0x03 + 24 位地址,然后连续读 N 个字节。但要注意,读操作没有 BUSY 等待的问题,可以直接读。读完之后,我习惯做一次校验——把写入的数据和读出的数据逐字节比较,确认无误。
void W25Q64_Read(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q64_CS_LOW(); SPI_ReadWriteByte(0x03); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); for (uint16_t i = 0; i < len; i++) { buf[i] = SPI_ReadWriteByte(0xFF); } W25Q64_CS_HIGH(); }校验的时候,我一般写一个W25Q64_Verify()函数,把写入缓冲区和读出缓冲区用memcmp()比较。如果返回非零,说明写入失败,需要检查擦除是否完成、写使能是否发送、地址是否越界。
4. 常见问题与排查技巧实录
4.1 读出来全是 0xFF 或 0x00
这是最常见的问题。0xFF 通常意味着擦除成功但写入失败,0x00 通常意味着通信完全没建立。排查顺序如下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读出全 0xFF | 写使能未发送 | 检查每次编程前是否发 0x06 |
| 读出全 0xFF | 扇区未擦除 | 先擦除再写,Flash 只能 1 变 0 |
| 读出全 0x00 | CS 引脚未拉低 | 用逻辑分析仪看 CS 波形 |
| 读出全 0x00 | SPI 模式错误 | 确认 CPOL/CPHA 配置 |
| 读出全 0x00 | MISO 未接 | 检查硬件连线 |
我踩过最坑的一次是:读出来全是 0xFF,查了半天代码没问题,最后发现是 WP 引脚悬空了。W25Q64 的 WP 引脚内部有弱上拉,但悬空时电平不确定,导致写保护状态随机。把 WP 直接接到 3.3V 之后问题消失。
4.2 写入数据偶尔出错或错位
数据错位通常和 CS 时序有关。SPI 传输要求 CS 在整個指令序列期间保持低电平,如果在字节之间 CS 被拉高,Flash 会认为指令结束,后续数据被当作新指令解析。用软件 NSS 模式时,确保W25Q64_CS_LOW()和W25Q64_CS_HIGH()成对出现,中间不要插入其他操作。
另一个可能是 SPI 时钟太快。虽然 W25Q64 标称支持 50MHz 普通读,但在实际布线上,9MHz 以上就可能因为线间电容导致波形畸变。如果你用的是杜邦线连接,建议把预分频调到 16 甚至 32,先保证稳定,再逐步提高。
4.3 擦除时间过长导致看门狗复位
W25Q64 的扇区擦除典型时间是 45ms,最大 400ms;块擦除典型 150ms,最大 2s;全片擦除典型 20s,最大 100s。如果你在擦除期间喂狗不及时,看门狗就会复位。解决方法有两个:一是擦除前先喂狗,擦除后立即喂狗;二是把擦除操作放在低优先级任务里,或者用中断方式处理。
我一般会在W25Q64_WaitBusy()里面加喂狗操作,确保长时间等待不会触发复位。但要注意,喂狗频率不能太高,否则看门狗形同虚设。
4.4 Flash 寿命与磨损均衡的考量
W25Q64 的每个扇区擦写寿命是 10 万次。如果你频繁写同一个地址,比如每秒写一次配置参数,不到 28 小时就能把一个扇区写坏。对于需要频繁写入的场景,必须做磨损均衡。
最简单的磨损均衡是“日志式写入”:每次写新数据都追加到下一个空闲位置,而不是覆盖旧数据。记录一个写指针,当写指针到达扇区末尾时,擦除整个扇区,把有效数据搬到扇区开头,写指针重置。这样每个扇区的擦写次数就均匀了。
当然,对于大多数应用场景,配置参数不会频繁修改,10 万次寿命完全够用。但如果你做的是数据记录仪,每秒采样一次,那就必须考虑这个问题。
4.5 常见问题速查表
| 问题现象 | 优先排查 | 解决方案 |
|---|---|---|
| 读 ID 返回 0x000000 | 硬件连线 | 检查 VCC、GND、CS、SCK |
| 读 ID 返回 0xFFFFFF | MISO 未接或 CS 未拉低 | 检查 MISO 连线,确认 CS 时序 |
| 写入后读回全 0xFF | 写使能未发或擦除未完成 | 补发 0x06,等待 BUSY 清零 |
| 数据在页边界错乱 | 页编程跨页 | 分页写入,每页不超过 256 字节 |
| 擦除后仍能读出旧数据 | 擦除指令地址错误 | 确认地址按 4KB 对齐 |
| 偶尔通信失败 | SPI 时钟太快或线太长 | 降低时钟,缩短连线 |
| 看门狗复位 | 擦除等待时间过长 | 在等待循环中喂狗 |
5. 驱动封装与项目集成建议
5.1 分层封装与接口设计
在实际项目中,我习惯把 W25Q64 驱动封装成独立的模块,对外只暴露几个简单接口:
// w25q64.h void W25Q64_Init(void); uint32_t W25Q64_ReadID(void); void W25Q64_Read(uint32_t addr, uint8_t *buf, uint32_t len); void W25Q64_Write(uint32_t addr, uint8_t *buf, uint32_t len); void W25Q64_EraseSector(uint32_t addr); void W25Q64_EraseChip(void);上层应用不需要知道 SPI 怎么配置、指令怎么发,只需要调用这些接口就行。这样以后换芯片,比如换成 W25Q128,只需要改驱动层,上层代码一行不用动。
5.2 与文件系统或参数管理模块的对接
如果你的项目需要管理多个参数,可以考虑在 W25Q64 上实现一个简单的键值存储。比如用前 4KB 作为参数区,每个参数占 16 字节:4 字节键、4 字节长度、8 字节数据。读取的时候遍历查找,写入的时候追加或覆盖。
更复杂的场景可以移植 FatFS 或者 LittleFS。LittleFS 特别适合 SPI Flash,因为它自带磨损均衡和掉电保护。不过对于 STM32F103 这种资源有限的芯片,LittleFS 的 RAM 占用需要仔细评估。我实测下来,LittleFS 在 STM32F103 上跑起来大概需要 2KB RAM 和 16KB Flash,如果你的芯片是 C8T6(64KB Flash、20KB RAM),勉强够用。
5.3 调试经验与效率提升技巧
最后分享几个提高调试效率的小技巧。第一,用逻辑分析仪抓 SPI 波形,比用 printf 打印调试信息直观得多。你可以清楚地看到每一条指令的字节序列,快速定位是主机发错了还是从机没响应。
第二,写一个W25Q64_Dump()函数,把指定地址范围的数据以十六进制打印出来。调试的时候看一眼就知道数据对不对。
第三,养成“先擦后写再读校验”的习惯。每次写入操作后都读回来比较,虽然多花一点时间,但能及早发现问题,避免数据错乱了还不知道。
第四,如果条件允许,在 Flash 的 VCC 和 GND 之间并一个 0.1uF 的陶瓷电容,越靠近芯片引脚越好。SPI 通信对电源噪声比较敏感,这个电容能显著提高稳定性。我遇到过好几次通信偶发失败,加了电容之后问题就消失了。
这个 W25Q64 的驱动我前后改了好几版,从最开始只能读 ID,到后来能稳定读写几 MB 数据,中间踩的坑基本都写在上面了。你如果照着这个流程走,应该能少走不少弯路。