1. 为什么还要折腾 W25Q64 这颗老 Flash
W25Q64 这颗 8MB 的 SPI Flash,放在今天看参数确实不算亮眼,但它几乎是每个嵌入式工程师绕不开的一颗料。价格便宜、供货稳定、驱动简单、资料满地都是,做数据记录、字库存储、固件备份、参数保存,随手就能用上。我手上好几个量产项目,主控从 F103 换到 F407 再换到国产替代,存储这颗料一直没动过。
这次要聊的是 STM32F103 通过硬件 SPI 驱动 W25Q64 的完整流程。为什么强调"完整"?因为网上大部分教程只告诉你发个 0x9F 读 ID 就完事了,真到项目里你会发现:读出来全是 0xFF、写进去读出来对不上、跨页写数据丢失、擦除要等好几秒、DMA 一开就死机。这些问题不是靠抄一段代码能解决的,得把时序、状态机、页边界这些底层逻辑吃透。
这篇文章适合三类人:刚学完 SPI 协议想找个真实器件练手的初学者;手上有项目要存日志、存图片、存配置但不知道怎么选型的中级开发者;以及被 W25Q64 各种玄学问题折磨过、想系统梳理一遍的老手。我会从硬件连接讲到寄存器操作,从单字节读写讲到跨页处理和状态轮询,把每一步的"为什么"讲清楚,代码可以直接抄去用。
需要提前说明的是,下面涉及的引脚、时钟配置、参数计算都基于常见工程实践,具体到你的板子可能要微调,但思路是通用的。
2. 硬件层:先把线接对,再谈代码
2.1 W25Q64 的引脚定义与最小系统连接
W25Q64 是标准的 8 脚 SOP8 封装,引脚定义如下:
| 引脚 | 名称 | 功能说明 |
|---|---|---|
| 1 | CS | 片选,低电平有效 |
| 2 | DO | 数据输出(MISO) |
| 3 | WP | 写保护,低有效 |
| 4 | GND | 地 |
| 5 | DI | 数据输入(MOSI) |
| 6 | CLK | 时钟 |
| 7 | HOLD | 保持,低有效 |
| 8 | VCC | 电源 2.7V~3.6V |
和 STM32F103 连接时,我一般这样分配:
- CS 接 PA4(普通 GPIO 推挽输出)
- CLK 接 PA5(SPI1_SCK)
- MISO 接 PA6(SPI1_MISO)
- MOSI 接 PA7(SPI1_MOSI)
- WP 和 HOLD 直接上拉到 3.3V
这里有个新手最容易踩的坑:WP 和 HOLD 悬空。很多人觉得这两个脚不用就不接,结果写数据死活写不进去,或者读出来全是乱码。WP 低电平会锁死状态寄存器,HOLD 低电平会让芯片忽略时钟。最稳妥的做法就是各接一个 10K 上拉到 VCC,或者直接在代码里不使能这两个功能的前提下硬件上拉。
注意:W25Q64 是 3.3V 器件,虽然有些模块标称兼容 5V,但 IO 口耐压一般只到 VCC+0.3V。如果你用 5V 主控,务必加电平转换,否则用不了多久 IO 就挂了。
2.2 SPI 模式选择:为什么必须是 Mode 0 或 Mode 3
W25Q64 支持 SPI Mode 0(CPOL=0, CPHA=0)和 Mode 3(CPOL=1, CPHA=1)。这两个模式的共同点是:数据在时钟上升沿采样,下降沿输出。区别只是空闲时时钟是高还是低。
STM32F103 的 SPI1 配置成 Mode 0 最省事:
SPI_InitStructure.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStructure.SPI_Mode = SPI_Mode_Master; SPI_InitStructure.SPI_DataSize = SPI_DataSize_8b; SPI_InitStructure.SPI_CPOL = SPI_CPOL_Low; SPI_InitStructure.SPI_CPHA = SPI_CPHA_1Edge; SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_4; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7;分频系数选 4,在 72MHz 系统时钟下 SPI 时钟是 18MHz。W25Q64 标准读命令最高支持 50MHz,18MHz 完全够用且稳定。如果你追求速度,可以降到分频 2 跑 36MHz,但要注意 PCB 走线质量,杜邦线飞线的话 36MHz 很容易出错。
为什么强调 Mode 0?因为很多教程抄来抄去,CPOL 和 CPHA 配错,现象就是读 ID 能读到但读数据错位。判断方法很简单:读 0x9F 返回 0xEF 0x40 0x17 就说明时序对了,如果返回 0xFF 或者 0x00,先查模式再查接线。
2.3 片选信号的软件控制细节
STM32F103 的 SPI 支持硬件 NSS 和软件 NSS。我强烈建议用软件 NSS,也就是把 CS 当普通 GPIO 控制。原因有两个:一是硬件 NSS 在多主设备场景下容易出问题,二是软件控制更灵活,可以在命令之间插入任意延时。
CS 的操作逻辑是:拉低 → 发命令 → 发数据 → 拉高。注意最后拉高之前要确保最后一个字节的时钟已经发完,否则命令可能不完整。我一般会在拉高前加一个while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET);等待总线空闲。
void W25Q64_CS_Low(void) { GPIO_ResetBits(GPIOA, GPIO_Pin_4); } void W25Q64_CS_High(void) { while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_BSY) == SET); GPIO_SetBits(GPIOA, GPIO_Pin_4); }这个 BSY 等待是很多教程漏掉的,不加的话在高速读写时会出现最后一个字节丢失,现象是偶尔读出来的数据差一位。
3. 命令层:W25Q64 的指令集与状态机
3.1 核心指令速查与使用场景
W25Q64 的指令有几十条,但真正常用的就十来条。我整理了一张表,按使用频率排序:
| 指令 | 十六进制 | 功能 | 典型场景 |
|---|---|---|---|
| Write Enable | 0x06 | 写使能 | 写/擦除前必须发 |
| Write Disable | 0x04 | 写禁止 | 操作完成后可选 |
| Read Status Reg1 | 0x05 | 读状态寄存器 | 轮询 BUSY 位 |
| Read Data | 0x03 | 读数据 | 通用读取 |
| Page Program | 0x02 | 页编程 | 写入数据 |
| Sector Erase | 0x20 | 扇区擦除(4KB) | 写入前擦除 |
| Block Erase | 0xD8 | 块擦除(64KB) | 大批量擦除 |
| Chip Erase | 0xC7 | 全片擦除 | 初始化 |
| JEDEC ID | 0x9F | 读厂商ID | 检测芯片 |
| Fast Read | 0x0B | 快速读 | 高速读取 |
这里重点说三个新手最容易搞错的点。
第一,写之前必须发 Write Enable。W25Q64 上电后默认是写保护状态,任何写或擦除操作前都要先发 0x06。而且这个使能是"一次性"的,每次写/擦除前都要重新发。我见过有人发一次 0x06 然后连续写好几个扇区,结果只有第一个成功。
第二,擦除的最小单位是扇区(4KB)。你不能直接往一个地址写数据,必须先擦除整个扇区。擦除后该扇区所有字节变成 0xFF。这意味着如果你要修改一个字节,得先把整个扇区读到 RAM,改完再擦除写回。这是 Flash 的物理特性决定的,NOR Flash 只能把 1 写成 0,不能把 0 写成 1。
第三,Page Program 一次最多写 256 字节。W25Q64 的页大小是 256 字节,跨页写会回卷到页首覆盖之前的数据。这个坑我在项目里踩过,当时写 300 字节的配置结构体,后 44 字节把前 44 字节覆盖了,查了一整天才发现问题。
3.2 状态寄存器与 BUSY 位轮询
W25Q64 的状态寄存器 1 的 bit0 是 BUSY 位,bit1 是 WEL(写使能锁存)位。擦除和写入操作是异步的,发完命令后芯片内部还在忙,这时候你发下一条命令它是不理的。
轮询 BUSY 位的标准写法:
void W25Q64_WaitForWriteEnd(void) { uint8_t status = 0; W25Q64_CS_Low(); SPI_ReadWriteByte(0x05); do { status = SPI_ReadWriteByte(0xFF); } while((status & 0x01) == 0x01); W25Q64_CS_High(); }扇区擦除典型耗时 45ms,最大 400ms;页编程典型 0.7ms,最大 3ms。如果你不轮询直接发下一条命令,轻则数据丢失,重则芯片进入异常状态需要重新上电。
实操心得:在调试阶段,我习惯在轮询里加一个超时计数器,比如循环 10 万次还没结束就返回错误。这样能避免因为硬件问题导致程序卡死在 while 里。
3.3 读 ID 验证:上电第一件事
每次上电初始化后,第一件事就是读 JEDEC ID 确认通信正常。W25Q64 的 ID 是 0xEF4017,其中 0xEF 是厂商代码,0x4017 是容量代码。
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; }如果读出来是 0xFFFFFF,说明 MISO 没接好或者 CS 没拉低;如果是 0x000000,说明 MOSI 或 CLK 有问题;如果是 0xEF4018,那是 W25Q128,容量翻倍但驱动兼容。这个自检步骤能帮你快速定位 80% 的硬件问题。
4. 读写全流程:从单字节到跨页处理
4.1 扇区擦除的完整实现与耗时实测
擦除是写入的前提。以扇区擦除为例,地址要对齐到 4KB 边界,比如 0x000000、0x001000、0x002000。
void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); W25Q64_WaitForWriteEnd(); W25Q64_CS_Low(); SPI_ReadWriteByte(0x20); SPI_ReadWriteByte((addr >> 16) & 0xFF); SPI_ReadWriteByte((addr >> 8) & 0xFF); SPI_ReadWriteByte(addr & 0xFF); W25Q64_CS_High(); W25Q64_WaitForWriteEnd(); }地址是 24 位的,因为 W25Q64 容量 8MB = 2^23 字节,需要 23 位地址,用 3 个字节传输。
我实测过不同操作的耗时,用逻辑分析仪抓的:
| 操作 | 典型耗时 | 最大耗时 |
|---|---|---|
| 扇区擦除 4KB | 45ms | 400ms |
| 块擦除 64KB | 150ms | 2s |
| 全片擦除 | 20s | 100s |
| 页编程 256B | 0.7ms | 3ms |
| 读 256B @18MHz | 约 114us | - |
全片擦除要 20 秒,这个在需要快速启动的场景里是灾难。所以我的做法是:只在首次使用或固件升级时全片擦除,平时用扇区擦除按需处理。
4.2 页编程的边界陷阱与跨页写方案
前面提到 Page Program 一次最多 256 字节且不能跨页。那如果我要写 1000 字节怎么办?答案是拆分成多次页编程,每次不超过页边界。
void W25Q64_Write(uint32_t addr, uint8_t *buf, uint16_t len) { uint16_t pageRemain = 256 - (addr % 256); while(len > 0) { uint16_t writeLen = (len < pageRemain) ? len : pageRemain; W25Q64_PageProgram(addr, buf, writeLen); addr += writeLen; buf += writeLen; len -= writeLen; pageRemain = 256; } }这段代码的核心是pageRemain的计算:当前地址距离本页末尾还有多少字节。第一次写可能不足 256,写完后地址对齐到新页,后续每次都是完整 256 字节。
注意:跨页写不是不能做,而是芯片内部会回卷。比如你在地址 0x0000F0 写 32 字节,实际会写到 0x0000F0~0x0000FF 和 0x000000~0x00000F,把开头的数据覆盖了。这个行为在数据手册里有说明,但很多人不看。
4.3 数据读取:普通读与快速读的区别
读数据用 0x03 命令,发完 24 位地址后就可以连续读,地址自动递增,可以一直读到芯片末尾。
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); while(len--) { *buf++ = SPI_ReadWriteByte(0xFF); } W25Q64_CS_High(); }0x03 普通读最高支持 50MHz,但有个限制:它需要额外的 8 个时钟周期做 dummy。0x0B 快速读也是 8 个 dummy,但支持更高频率。在 18MHz 下两者没区别,用 0x03 就行。
读操作不需要写使能,也不需要轮询 BUSY,因为读是即时的。但要注意 CS 拉高后要等 BSY 清零,否则连续读之间可能出错。
4.4 写使能、擦除、写入、校验的标准时序
把上面的操作串起来,一个完整的"写一段数据"流程是这样的:
- 计算目标地址所在的扇区,读出整个扇区到 RAM 缓冲区
- 在缓冲区里修改要写的数据
- 发 Write Enable (0x06)
- 发 Sector Erase (0x20),轮询 BUSY 直到完成
- 发 Write Enable (0x06)
- 分页调用 Page Program (0x02),每次轮询 BUSY
- 读回数据比对校验
这个"读-改-写"模式是 Flash 操作的标准范式。如果你要频繁修改小数据,建议在 RAM 里维护一个缓存,攒够一个扇区再统一写,能大幅延长 Flash 寿命。W25Q64 的擦写寿命是 10 万次,听起来很多,但如果你每秒写一次,一天就 86400 次,两天就报废一个扇区。
5. 性能优化与稳定性提升
5.1 DMA 加速:什么时候值得上
18MHz 的 SPI 读 8MB 数据需要约 3.7 秒,如果 CPU 全程参与,这 3.7 秒什么都干不了。用 DMA 可以把 CPU 解放出来。
STM32F103 的 SPI1 支持 DMA1 通道 2(RX)和通道 3(TX)。配置好 DMA 后,读数据只需要启动 DMA,然后等传输完成中断。
void W25Q64_Read_DMA(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); DMA_Cmd(DMA1_Channel2, DISABLE); DMA_SetCurrDataCounter(DMA1_Channel2, len); DMA_Cmd(DMA1_Channel2, ENABLE); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Rx, ENABLE); while(DMA_GetFlagStatus(DMA1_FLAG_TC2) == RESET); DMA_ClearFlag(DMA1_FLAG_TC2); SPI_I2S_DMACmd(SPI1, SPI_I2S_DMAReq_Rx, DISABLE); W25Q64_CS_High(); }但 DMA 不是万能的。小数据量(小于 64 字节)用 DMA 反而更慢,因为配置 DMA 本身有开销。我的经验是:超过 256 字节的连续读用 DMA,小数据直接轮询。
踩坑记录:DMA 读的时候 CS 拉高时机很关键。如果 DMA 传输完成中断里立刻拉高 CS,可能最后一个字节还没移出。正确做法是等 SPI 的 BSY 标志清零再拉高。
5.2 双缓冲与扇区缓存策略
前面说的"读-改-写"如果每次都读整个扇区,效率很低。我的做法是在 RAM 里维护一个 4KB 的扇区缓存,记录当前缓存的扇区号。写数据时先改缓存,标记脏位,等缓存满了或者主动 flush 时再写回 Flash。
这个策略在日志系统里特别有用。比如你要记录传感器数据,每条 16 字节,攒够 256 条正好 4KB,一次性擦除写入,比每条都擦写效率高几百倍。
5.3 掉电保护与数据完整性
Flash 写入过程中掉电是灾难性的,可能整个扇区数据损坏。两个应对措施:
一是写前备份。在另一个扇区保存一份旧数据,写入成功后再更新备份。这样掉电后可以从备份恢复。
二是加校验。每个数据块加 CRC16 或 CRC32,读出来先校验再使用。我一般用 CRC16-CCITT,计算快,检错率够用。
typedef struct { uint32_t magic; uint16_t len; uint16_t crc; uint8_t data[250]; } FlashBlock_t;magic 用于识别有效块,crc 用于校验数据完整性。读出来先查 magic 再算 crc,两个都过才认为数据有效。
6. 常见问题排查实录
6.1 读出来全是 0xFF 或 0x00
这是最高频的问题。0xFF 通常意味着:芯片没响应(CS 或 CLK 问题)、地址超出范围、或者数据确实没写过(擦除后就是 0xFF)。0x00 通常是 MISO 被拉死或者 SPI 模式配错。
排查顺序:先读 ID,ID 对说明通信正常;ID 不对查接线和模式。ID 对但数据不对,查地址和擦除状态。
6.2 写入后读出来不一致
九成是跨页写导致的覆盖,或者忘了发 Write Enable。还有一个隐蔽原因:写入后没等 BUSY 清零就读,读到的还是旧数据。记住每次写/擦除后都要WaitForWriteEnd。
6.3 擦除时间过长导致看门狗复位
扇区擦除最大 400ms,如果你的看门狗喂狗周期是 200ms,擦除期间没喂狗就会复位。解决办法是在轮询 BUSY 的循环里喂狗,或者临时关闭看门狗。
6.4 SPI 时钟过快导致偶发错误
杜邦线飞线的情况下,SPI 时钟超过 20MHz 就容易出错。现象是大部分数据对,偶尔几个字节错。降到 9MHz(分频 8)试试,如果稳定了就是信号完整性问题。PCB 走线短且阻抗匹配的话,36MHz 也能跑。
6.5 问题速查表
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读 ID 返回 0xFFFFFF | MISO 断线、CS 未拉低 | 万用表测通断 |
| 读 ID 返回 0x000000 | MOSI/CLK 问题 | 示波器看波形 |
| 写入无效 | 未发 Write Enable | 检查 0x06 指令 |
| 数据错位 | SPI 模式错误 | 改 CPOL/CPHA |
| 跨页数据覆盖 | 未处理页边界 | 检查地址对齐 |
| 偶发数据错误 | 时钟过快 | 降低分频 |
| 程序卡死 | 未轮询 BUSY | 加超时机制 |
7. 我在这颗料上踩过的坑和一点经验
W25Q64 这颗料我用了快八年,从最早的 SPI 轮询到后来的 DMA、QSPI,坑踩了不少。最大的体会是:数据手册一定要看,而且要对着时序图看。很多问题不是代码写错了,而是对芯片行为的理解有偏差。比如 Page Program 的回卷行为、擦除的异步特性、状态寄存器的位定义,这些都在手册里写得清清楚楚,但很多人习惯抄代码不看手册,出了问题就抓瞎。
另一个经验是分层设计。把底层 SPI 读写、中间命令封装、上层数据管理分成三层,底层只负责字节收发,中间层实现擦除/写入/读取,上层做缓存和校验。这样换芯片的时候只需要改中间层,上层逻辑不动。我后来把 W25Q64 换成 W25Q128、换成 GD25Q64,上层代码一行没改。
最后说个实用技巧:调试阶段在 Flash 里划一块区域专门存调试日志,记录每次操作的地址、长度、返回值。出问题的时候读出来一看就知道哪一步错了,比单步调试快得多。这块区域固定用最后一个扇区,不参与业务数据管理,量产前再关掉。
如果你也在用这颗料,或者正准备用,希望这篇东西能帮你少走点弯路。SPI Flash 不难,难的是把细节做扎实。