☰
STM32F103硬件SPI驱动W25Q64 Flash全流程实战
2026/10/21 20:02:37 网站建设 项目流程

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 封装,引脚定义如下:

引脚名称功能说明
1CS片选,低电平有效
2DO数据输出(MISO)
3WP写保护,低有效
4GND地
5DI数据输入(MOSI)
6CLK时钟
7HOLD保持,低有效
8VCC电源 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 Enable0x06写使能写/擦除前必须发
Write Disable0x04写禁止操作完成后可选
Read Status Reg10x05读状态寄存器轮询 BUSY 位
Read Data0x03读数据通用读取
Page Program0x02页编程写入数据
Sector Erase0x20扇区擦除(4KB)写入前擦除
Block Erase0xD8块擦除(64KB)大批量擦除
Chip Erase0xC7全片擦除初始化
JEDEC ID0x9F读厂商ID检测芯片
Fast Read0x0B快速读高速读取

这里重点说三个新手最容易搞错的点。

第一,写之前必须发 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 个字节传输。

我实测过不同操作的耗时,用逻辑分析仪抓的:

操作典型耗时最大耗时
扇区擦除 4KB45ms400ms
块擦除 64KB150ms2s
全片擦除20s100s
页编程 256B0.7ms3ms
读 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 写使能、擦除、写入、校验的标准时序

把上面的操作串起来,一个完整的"写一段数据"流程是这样的:

  1. 计算目标地址所在的扇区,读出整个扇区到 RAM 缓冲区
  2. 在缓冲区里修改要写的数据
  3. 发 Write Enable (0x06)
  4. 发 Sector Erase (0x20),轮询 BUSY 直到完成
  5. 发 Write Enable (0x06)
  6. 分页调用 Page Program (0x02),每次轮询 BUSY
  7. 读回数据比对校验

这个"读-改-写"模式是 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 返回 0xFFFFFFMISO 断线、CS 未拉低万用表测通断
读 ID 返回 0x000000MOSI/CLK 问题示波器看波形
写入无效未发 Write Enable检查 0x06 指令
数据错位SPI 模式错误改 CPOL/CPHA
跨页数据覆盖未处理页边界检查地址对齐
偶发数据错误时钟过快降低分频
程序卡死未轮询 BUSY加超时机制

7. 我在这颗料上踩过的坑和一点经验

W25Q64 这颗料我用了快八年,从最早的 SPI 轮询到后来的 DMA、QSPI,坑踩了不少。最大的体会是:数据手册一定要看,而且要对着时序图看。很多问题不是代码写错了,而是对芯片行为的理解有偏差。比如 Page Program 的回卷行为、擦除的异步特性、状态寄存器的位定义,这些都在手册里写得清清楚楚,但很多人习惯抄代码不看手册,出了问题就抓瞎。

另一个经验是分层设计。把底层 SPI 读写、中间命令封装、上层数据管理分成三层,底层只负责字节收发,中间层实现擦除/写入/读取,上层做缓存和校验。这样换芯片的时候只需要改中间层,上层逻辑不动。我后来把 W25Q64 换成 W25Q128、换成 GD25Q64,上层代码一行没改。

最后说个实用技巧:调试阶段在 Flash 里划一块区域专门存调试日志,记录每次操作的地址、长度、返回值。出问题的时候读出来一看就知道哪一步错了,比单步调试快得多。这块区域固定用最后一个扇区,不参与业务数据管理,量产前再关掉。

如果你也在用这颗料,或者正准备用,希望这篇东西能帮你少走点弯路。SPI Flash 不难,难的是把细节做扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询