资讯动态

STM32驱动W25Q64 SPI Flash实战:从硬件连接到代码实现

发布时间:2026/10/21 2:02:41 来源:尧图企业网站定制
1. 项目缘起与整体设计思路1.1 为什么选 SPI Flash 作为存储方案做过嵌入式项目的人大概都有这种体会MCU 自带的 Flash 空间永远不够用。以 STM32F103C8T6 为例64KB 的 Flash 里塞完协议栈、驱动和业务逻辑能留给数据存储的空间往往只剩几 KB。一旦需要记录日志、保存配置参数、缓存传感器数据或者做字库、图片素材的存储片内 Flash 就捉襟见肘了。这时候外挂一颗 SPI Flash 是最经济实惠的选择。W25Q64 这颗芯片在圈子里几乎是“标配”级别的存在——8MB 容量64Mbit、SPI 接口、SOP8 封装、单价便宜、货源充足随便一个电子市场或者网购平台都能买到。它支持标准 SPI、Dual SPI 和 Quad SPI 三种模式标准 SPI 下最高时钟能跑到 80MHzSTM32F103 的 SPI1 最高 36MHzSPI2 最高 18MHz所以实际跑不到那么高对于绝大多数中小规模的数据存储需求完全够用。我这次做的项目是一个数据采集终端需要每隔几秒记录一次传感器读数同时保存设备配置参数和一段开机提示音音频数据。片内 Flash 显然扛不住这种持续写入所以选了 W25Q64 作为外部存储。整个方案的核心就是STM32F103 通过 SPI 接口驱动 W25Q64实现扇区擦除、页写入、数据读取、状态轮询等完整操作。1.2 硬件连接与引脚规划先看硬件接线。W25Q64 是 SOP8 封装8 个引脚各有分工引脚编号名称功能说明STM32 连接1CS片选低电平有效PA4软件控制2DO(IO1)数据输出MISOPA6SPI1_MISO3WP(IO2)写保护低有效接 3.3V禁用保护4GND地GND5DI(IO0)数据输入MOSIPA7SPI1_MOSI6CLK时钟PA5SPI1_SCK7HOLD(IO3)保持低有效接 3.3V禁用保持8VCC电源3.3V这里有几个细节值得展开说。WP 和 HOLD 引脚我直接拉高禁用了因为项目里不需要硬件写保护和暂停传输功能拉高是最省事的做法。如果你要做数据安全要求高的场景WP 可以接到 MCU 的 GPIO 上通过软件控制写保护状态。CS 片选我用的是 PA4 普通 GPIO 而不是 SPI 硬件 NSS原因是硬件 NSS 在多从机场景下管理起来很麻烦软件控制更灵活想什么时候拉低就什么时候拉低。SPI1 挂在 APB2 总线上时钟源是 72MHz。配置分频系数为 4得到 18MHz 的 SPI 时钟。为什么不分频到 36MHz因为 W25Q64 虽然标称支持 80MHz但实际布线如果走线较长或者没有做好阻抗匹配高速下容易出错。18MHz 是个稳妥的选择实测下来读写都很稳定速度也够用。1.3 软件架构分层设计软件层面我分了三层底层 SPI 驱动、W25Q64 驱动、应用层接口。底层 SPI 驱动负责初始化 SPI1 外设、配置 GPIO、实现字节收发函数。这一层跟具体 Flash 芯片无关换成其他 SPI 设备也能复用。W25Q64 驱动层封装了芯片的所有操作指令包括读 ID、写使能、扇区擦除、页写入、数据读取、状态寄存器读取等。每个操作都对应 W25Q64 的一条指令码比如 0x03 是读数据、0x02 是页写入、0x20 是扇区擦除。这一层向上提供简洁的函数接口应用层不需要关心底层时序细节。应用层就是具体的业务逻辑了比如“保存配置参数到 Flash 地址 0x000000”、“从 Flash 地址 0x001000 读取 100 字节数据”这种调用。这样分层的好处是换 MCU 平台时只需要改底层 SPI 驱动换 Flash 型号时只需要改驱动层应用层代码基本不动。我在另一个项目里把 MCU 从 STM32F103 换到 GD32F103底层 SPI 驱动改了几行寄存器配置就搞定了上层完全没动。2. W25Q64 核心机制与关键细节解析2.1 存储结构与地址映射W25Q64 的 8MB 空间不是随便写的它有一套严格的层级结构页Page256 字节这是写入操作的最小单位。一次页写入最多写 256 字节超过这个范围会“回卷”到页首覆盖之前的数据。扇区Sector4KB由 16 个页组成。这是擦除操作的最小单位。Flash 的特性是只能把 1 写成 0不能把 0 写成 1所以写入前必须先擦除擦除后整个扇区变成 0xFF。块Block64KB由 16 个扇区组成。块擦除指令一次擦 64KB适合大批量数据清除。整片Chip8MB整片擦除指令会把所有数据清空耗时较长典型值 20 秒左右。地址范围从 0x000000 到 0x7FFFFF共 8M 字节。计算方式很简单扇区号 × 4096 扇区起始地址。比如第 3 个扇区的起始地址是 3 × 4096 12288 0x3000。这里有个容易踩的坑很多人以为可以像 RAM 一样随意写任意地址结果发现写进去的数据不对。原因就是没有先擦除。Flash 的写入操作只能把 bit 从 1 变 0如果目标地址之前写过数据有些 bit 已经是 0直接写入会导致数据错误。所以任何写入操作之前必须确保目标区域已经被擦除。2.2 SPI 通信时序与模式选择W25Q64 支持 SPI 模式 0CPOL0CPHA0和模式 3CPOL1CPHA1。这两种模式的区别在于时钟空闲电平和采样边沿模式 0时钟空闲为低电平数据在上升沿采样下降沿变化。模式 3时钟空闲为高电平数据在下降沿采样上升沿变化。我选的是模式 0这也是最常用的配置。STM32 的 SPI_CR1 寄存器中CPOL 位设为 0CPHA 位设为 0就是模式 0。数据传输的基本流程是拉低 CS → 发送指令码 → 发送地址如果需要→ 发送/接收数据 → 拉高 CS。注意 CS 必须在整个传输过程中保持低电平如果在传输中途拉高W25Q64 会认为本次操作结束后续数据全部丢弃。SPI 的时钟频率设置也有讲究。W25Q64 的数据手册标明标准读指令0x03最高支持 50MHz快速读指令0x0B最高支持 80MHz。但实际使用中要考虑 PCB 走线质量。我一般建议初次调试时先用低速比如 4.5MHz分频系数 16确认通信正常后再逐步提高频率。如果高速下出现数据错误优先检查走线是否过长、是否有干扰源。2.3 状态寄存器与忙判断W25Q64 内部有一个状态寄存器Status Register其中 bit0 是 BUSY 位。当芯片正在执行擦除或写入操作时BUSY 位为 1操作完成后自动变为 0。这个机制非常重要。因为擦除和写入不是瞬间完成的——扇区擦除典型耗时 45ms最大 400ms页写入典型耗时 0.7ms最大 3ms。如果在 BUSY 期间发送新指令芯片会忽略该指令导致操作失败。所以每次擦除或写入之后必须轮询状态寄存器直到 BUSY 位清零才能进行下一步操作。轮询的方式是发送 0x05 指令读取状态寄存器判断 bit0 是否为 0。void W25Q64_WaitBusy(void) { uint8_t status; do { W25Q64_CS_Low(); SPI_ReadWriteByte(0x05); // 读状态寄存器指令 status SPI_ReadWriteByte(0xFF); W25Q64_CS_High(); } while (status 0x01); // bit0 为 1 表示忙 }这段代码看起来简单但有个细节每次读取状态都要重新拉低 CS 再拉高不能在一次 CS 低电平期间连续读。因为 W25Q64 的指令解析是以 CS 下降沿为起点的连续读需要重新发起指令。3. 完整实操流程与代码实现3.1 SPI 底层驱动初始化先配置 GPIO 和 SPI1 外设。PA5SCK、PA7MOSI配置为复用推挽输出PA6MISO配置为浮空输入PA4CS配置为通用推挽输出。void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // SCK PA5, MOSI PA7 GPIO_InitStructure.GPIO_Pin GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); // MISO PA6 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // CS PA4 GPIO_InitStructure.GPIO_Pin GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); // CS 默认拉高 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; // 18MHz SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial 7; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }字节收发函数是底层核心用查询方式实现uint8_t SPI_ReadWriteByte(uint8_t txData) { uint8_t retry 0; while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) RESET) { retry; if (retry 200) return 0; } SPI_I2S_SendData(SPI1, txData); retry 0; while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) RESET) { retry; if (retry 200) return 0; } return SPI_I2S_ReceiveData(SPI1); }这里的 retry 超时机制是必要的。如果 SPI 外设配置有误或者硬件连接有问题没有超时的话程序会死循环卡在这里。我调试时就遇到过 MISO 虚焊导致 RXNE 永远不置位的情况加了超时后至少能返回错误值方便定位问题。3.2 读取芯片 ID 验证通信上电后第一件事是读芯片 ID确认通信正常。W25Q64 的 JEDEC ID 是 0xEF4017厂商 ID 0xEF设备 ID 0x4017。uint32_t W25Q64_ReadID(void) { uint32_t id 0; W25Q64_CS_Low(); SPI_ReadWriteByte(0x9F); // 读 JEDEC ID 指令 id | SPI_ReadWriteByte(0xFF) 16; id | SPI_ReadWriteByte(0xFF) 8; id | SPI_ReadWriteByte(0xFF); W25Q64_CS_High(); return id; }如果读回来是 0xFFFFFF 或者 0x000000说明通信有问题。0xFFFFFF 通常是 MISO 一直高电平没接或虚焊0x000000 通常是 MISO 一直低电平短路或 CS 没拉低。正常应该读到 0xEF4017。3.3 扇区擦除操作擦除是写入的前提。以扇区擦除4KB为例void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); // 写使能 W25Q64_WaitBusy(); 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(); // 等待擦除完成 }写使能指令0x06是必须的。W25Q64 在执行任何擦除或写入操作前都要求先发送写使能指令否则操作会被忽略。这是芯片的安全机制防止误操作。写使能状态在每次擦除/写入完成后自动清除所以每次操作前都要重新发送。3.4 页写入与数据读取页写入一次最多 256 字节且不能跨页。如果写入地址是 0x0000F0写 200 字节那么前 16 字节写到页 0 的末尾剩下 184 字节会回卷到页 0 的开头覆盖之前的数据。所以写入前要计算好地址和长度。void W25Q64_PageWrite(uint32_t addr, uint8_t *buf, uint16_t len) { if (len 256) len 256; // 限制单次最多 256 字节 W25Q64_WriteEnable(); W25Q64_WaitBusy(); W25Q64_CS_Low(); SPI_ReadWriteByte(0x02); // 页写入指令 SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (uint16_t i 0; i len; i) { SPI_ReadWriteByte(buf[i]); } W25Q64_CS_High(); W25Q64_WaitBusy(); }读取就简单多了不需要写使能也不需要等待忙状态void W25Q64_ReadData(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q64_CS_Low(); SPI_ReadWriteByte(0x03); // 读数据指令 SPI_ReadWriteByte((addr 16) 0xFF); SPI_ReadWriteByte((addr 8) 0xFF); SPI_ReadWriteByte(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] SPI_ReadWriteByte(0xFF); } W25Q64_CS_High(); }读数据指令支持连续读取地址会自动递增可以一次读任意长度只要不超过芯片容量。这是 W25Q64 很方便的一个特性读大块数据时不需要分段。3.5 跨页写入的封装处理实际应用中经常需要写入超过 256 字节的数据比如保存一个 1KB 的配置结构体。这时候需要封装一个跨页写入函数void W25Q64_BufferWrite(uint32_t addr, uint8_t *buf, uint32_t len) { uint16_t pageRemain 256 - (addr % 256); // 当前页剩余空间 if (len pageRemain) { W25Q64_PageWrite(addr, buf, len); } else { W25Q64_PageWrite(addr, buf, pageRemain); W25Q64_BufferWrite(addr pageRemain, buf pageRemain, len - pageRemain); } }这个递归写法很简洁逻辑是先写满当前页剩余空间然后递归处理剩余数据。注意调用这个函数之前目标区域必须已经擦除过。4. 常见问题排查与避坑经验4.1 读写失败的典型原因调试 W25Q64 时遇到的问题五花八门我整理了一个速查表现象可能原因排查方法读 ID 返回 0xFFFFFFMISO 未连接或虚焊万用表测 PA6 到 W25Q64 引脚 2 的通断读 ID 返回 0x000000CS 未拉低或 MISO 短路检查 PA4 电平检查 MISO 是否对地短路写入后读回数据不对未擦除就写入确认写入前执行了扇区擦除擦除后读回全 0x00擦除指令未生效检查写使能指令是否发送成功高速下数据偶发错误SPI 时钟过快或走线干扰降低分频系数检查走线长度写入操作被忽略BUSY 期间发送指令确保每次操作后轮询状态寄存器4.2 写使能遗漏的隐蔽问题写使能遗漏是最难排查的问题之一因为它的表现是“有时候能写有时候不能写”。原因是写使能状态在每次擦除/写入后自动清除如果代码里只在初始化时发送了一次写使能后续操作就会随机失败。我的做法是把写使能封装成独立函数在每次擦除和写入前都调用一次。虽然多了一次 SPI 传输但可靠性大大提升。这点开销相对于擦除的几十毫秒来说完全可以忽略。4.3 电源噪声导致的数据损坏W25Q64 在擦除和写入时电流会有较大波动如果电源滤波不好可能导致操作失败甚至数据损坏。我在 PCB 上靠近 W25Q64 的 VCC 引脚放了一个 100nF 陶瓷电容和一个 10uF 钽电容问题就解决了。另外如果系统中有大功率器件比如电机、继电器和 Flash 共用电源建议给 Flash 单独加一个 LDO 或者用磁珠隔离。我有个项目里 Flash 和电机共用 3.3V电机启动时 Flash 写入必失败后来加了磁珠和电容才稳定。4.4 数据掉电保护的实用技巧Flash 写入过程中如果掉电可能导致数据损坏。对于关键配置参数我通常采用“双备份 校验”的策略在 Flash 里分配两个扇区交替写入配置数据每个扇区末尾加一个 CRC 校验值。读取时先读扇区 ACRC 校验通过就用 A 的数据不通过就读扇区 B用 B 的数据。这样即使一个扇区在写入时掉电损坏另一个扇区仍然可用。这个策略的代价是占用双倍空间和写入时间但对于配置参数这种不常写、但丢了很麻烦的数据来说完全值得。4.5 SPI 时钟相位配置错误的排查如果 SPI 模式配置错误CPOL/CPHA 不匹配表现是读 ID 返回乱码或者固定值。排查方法是先用示波器看 SCK 和 MOSI 的波形确认时钟空闲电平和数据变化边沿是否符合预期。没有示波器的话可以尝试四种模式组合看哪种能读到正确的 ID。我个人的经验是W25Q64 用模式 0 和模式 3 都能正常工作但模式 0 更常见。如果模式 0 不行先检查硬件连接而不是急着换模式。5. 性能优化与进阶用法5.1 提高读取速度的几种手段标准读指令0x03在 18MHz 时钟下理论最大读取速度是 18Mbps ÷ 8 2.25MB/s。实际因为指令和地址开销有效速度会低一些。如果觉得不够快有几个优化方向第一提高 SPI 时钟。STM32F103 的 SPI1 最高 36MHz如果 PCB 走线质量好可以试试分频系数 2。我实测在短走线小于 5cm的情况下36MHz 读取稳定。第二使用快速读指令0x0B。这个指令支持更高的时钟频率80MHz但需要额外发送一个 dummy 字节。在 18MHz 下提升不明显但在 36MHz 以上会有帮助。第三使用 Dual SPI 模式。W25Q64 支持用两根数据线同时传输理论速度翻倍。但 STM32F103 的 SPI 外设不支持 Dual 模式需要软件模拟实现起来比较复杂一般项目用不上。5.2 扇区管理与磨损均衡Flash 的每个扇区都有擦写寿命限制W25Q64 标称 10 万次擦写。如果频繁擦写同一个扇区很快就会损坏。对于需要频繁记录数据的场景比如日志需要做磨损均衡。最简单的磨损均衡是“轮询写入”把多个扇区组成一个环每次写入下一个扇区写满一圈后擦除最早的扇区。这样每个扇区的擦写次数就被平均了。实现时需要一个指针记录当前写入位置掉电后指针要能恢复所以指针本身也要存在 Flash 里并且要定期更新。更复杂的方案是加入文件系统比如 LittleFS 或 SPIFFS它们内置了磨损均衡和掉电保护。如果项目允许引入文件系统这是最省心的做法。5.3 批量数据写入的优化如果需要写入大量数据比如几 MB 的音频文件逐页写入效率很低因为每次都要等待 BUSY 清零。优化方法是使用连续页写入模式W25Q64 支持在一次 CS 低电平期间连续写入多个页只要地址连续且不跨页边界。这样可以减少 CS 切换和状态轮询的开销。不过要注意连续页写入时如果中间某页写入失败后面的数据也会受影响。所以对于关键数据还是建议逐页写入并校验。6. 项目总结与个人体会这套 W25Q64 驱动我已经在三个项目里复用了从数据采集终端到音频播放器再到配置参数存储表现都很稳定。整个方案的核心其实就三点SPI 底层驱动要健壮加超时、加错误处理、擦除写入流程要严格写使能、等忙、先擦后写、应用层要做好掉电保护和磨损均衡。踩过的坑也不少。最开始调试时读 ID 一直返回 0xFFFFFF查了半天发现是 MISO 引脚虚焊后来写入数据偶尔出错发现是忘记在每次写入前发写使能再后来长时间运行后数据丢失发现是电源噪声导致擦除失败。这些问题在数据手册里都不会写只有实际做过才会遇到。如果你正准备用 W25Q64 做项目我的建议是先用低速 SPI 把读写擦除流程跑通确认数据正确后再逐步提高时钟频率每次擦除和写入后都轮询状态寄存器不要偷懒用延时代替关键数据一定要做备份和校验Flash 掉电损坏的概率比你想象的高。这个驱动后续还可以扩展的方向包括接入 LittleFS 文件系统实现真正的文件管理、增加 Quad SPI 支持提升速度、实现后台擦除功能避免阻塞主循环。等我有空再折腾这些到时候再写一篇分享。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑