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 下最高时钟能跑到 80MHz(STM32F103 的 SPI1 最高 36MHz,SPI2 最高 18MHz,所以实际跑不到那么高),对于绝大多数中小规模的数据存储需求完全够用。我这次做的项目是一个数据采集终端,需要每隔几秒记录一次传感器读数,同时保存设备配置参数和一段开机提示音音频数据。片内 Flash 显然扛不住这种持续写入,所以选了 W25Q64 作为外部存储。整个方案的核心就是:STM32F103 通过 SPI 接口驱动 W25Q64,实现扇区擦除、页写入、数据读取、状态轮询等完整操作。1.2 硬件连接与引脚规划先看硬件接线。W25Q64 是 SOP8 封装,8 个引脚各有分工:引脚编号名称功能说明STM32 连接1CS片选,低电平有效PA4(软件控制)2DO(IO1)数据输出(MISO)PA6(SPI1_MISO)3WP(IO2)写保护,低有效接 3.3V(禁用保护)4GND地GND5DI(IO0)数据输入(MOSI)PA7(SPI1_MOSI)6CLK时钟PA5(SPI1_SCK)7HOLD(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 空间不是随便写的,它有一套严格的层级结构:页(Page):256 字节,这是写入操作的最小单位。一次页写入最多写 256 字节,超过这个范围会“回卷”到页首覆盖之前的数据。扇区(Sector):4KB,由 16 个页组成。这是擦除操作的最小单位。Flash 的特性是只能把 1 写成 0,不能把 0 写成 1,所以写入前必须先擦除,擦除后整个扇区变成 0xFF。块(Block):64KB,由 16 个扇区组成。块擦除指令一次擦 64KB,适合大批量数据清除。整片(Chip):8MB,整片擦除指令会把所有数据清空,耗时较长(典型值 20 秒左右)。地址范围从 0x000000 到 0x7FFFFF,共 8M 字节。计算方式很简单:扇区号 × 4096 = 扇区起始地址。比如第 3 个扇区的起始地址是 3 × 4096 = 12288 = 0x3000。这里有个容易踩的坑:很多人以为可以像 RAM 一样随意写任意地址,结果发现写进去的数据不对。原因就是没有先擦除。Flash 的写入操作只能把 bit 从 1 变 0,如果目标地址之前写过数据(有些 bit 已经是 0),直接写入会导致数据错误。所以任何写入操作之前,必须确保目标区域已经被擦除。2.2 SPI 通信时序与模式选择W25Q64 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟空闲电平和采样边沿:模式 0:时钟空闲为低电平,数据在上升沿采样,下降沿变化。模式 3:时钟空闲为高电平,数据在下降沿采样,上升沿变化。我选的是模式 0,这也是最常用的配置。STM32 的 SPI_CR1 寄存器中,CPOL 位设为 0,CPHA 位设为 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 外设。PA5(SCK)、PA7(MOSI)配置为复用推挽输出,PA6(MISO)配置为浮空输入,PA4(CS)配置为通用推挽输出。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 校验值。读取时先读扇区 A,CRC 校验通过就用 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 支持提升速度、实现后台擦除功能避免阻塞主循环。等我有空再折腾这些,到时候再写一篇分享。
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 下最高时钟能跑到 80MHz(STM32F103 的 SPI1 最高 36MHz,SPI2 最高 18MHz,所以实际跑不到那么高),对于绝大多数中小规模的数据存储需求完全够用。我这次做的项目是一个数据采集终端,需要每隔几秒记录一次传感器读数,同时保存设备配置参数和一段开机提示音音频数据。片内 Flash 显然扛不住这种持续写入,所以选了 W25Q64 作为外部存储。整个方案的核心就是:STM32F103 通过 SPI 接口驱动 W25Q64,实现扇区擦除、页写入、数据读取、状态轮询等完整操作。1.2 硬件连接与引脚规划先看硬件接线。W25Q64 是 SOP8 封装,8 个引脚各有分工:引脚编号名称功能说明STM32 连接1CS片选,低电平有效PA4(软件控制)2DO(IO1)数据输出(MISO)PA6(SPI1_MISO)3WP(IO2)写保护,低有效接 3.3V(禁用保护)4GND地GND5DI(IO0)数据输入(MOSI)PA7(SPI1_MOSI)6CLK时钟PA5(SPI1_SCK)7HOLD(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 空间不是随便写的,它有一套严格的层级结构:页(Page):256 字节,这是写入操作的最小单位。一次页写入最多写 256 字节,超过这个范围会“回卷”到页首覆盖之前的数据。扇区(Sector):4KB,由 16 个页组成。这是擦除操作的最小单位。Flash 的特性是只能把 1 写成 0,不能把 0 写成 1,所以写入前必须先擦除,擦除后整个扇区变成 0xFF。块(Block):64KB,由 16 个扇区组成。块擦除指令一次擦 64KB,适合大批量数据清除。整片(Chip):8MB,整片擦除指令会把所有数据清空,耗时较长(典型值 20 秒左右)。地址范围从 0x000000 到 0x7FFFFF,共 8M 字节。计算方式很简单:扇区号 × 4096 = 扇区起始地址。比如第 3 个扇区的起始地址是 3 × 4096 = 12288 = 0x3000。这里有个容易踩的坑:很多人以为可以像 RAM 一样随意写任意地址,结果发现写进去的数据不对。原因就是没有先擦除。Flash 的写入操作只能把 bit 从 1 变 0,如果目标地址之前写过数据(有些 bit 已经是 0),直接写入会导致数据错误。所以任何写入操作之前,必须确保目标区域已经被擦除。2.2 SPI 通信时序与模式选择W25Q64 支持 SPI 模式 0(CPOL=0,CPHA=0)和模式 3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟空闲电平和采样边沿:模式 0:时钟空闲为低电平,数据在上升沿采样,下降沿变化。模式 3:时钟空闲为高电平,数据在下降沿采样,上升沿变化。我选的是模式 0,这也是最常用的配置。STM32 的 SPI_CR1 寄存器中,CPOL 位设为 0,CPHA 位设为 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 外设。PA5(SCK)、PA7(MOSI)配置为复用推挽输出,PA6(MISO)配置为浮空输入,PA4(CS)配置为通用推挽输出。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 校验值。读取时先读扇区 A,CRC 校验通过就用 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 支持提升速度、实现后台擦除功能避免阻塞主循环。等我有空再折腾这些,到时候再写一篇分享。