STM32F103硬件SPI驱动W25Q64 Flash实战:擦除、写入与跨页处理
1. 项目缘起与整体设计思路1.1 为什么选 SPI Flash 作为存储方案做过嵌入式项目的人大概都有这种体会MCU 自带的 Flash 空间永远不够用。以 STM32F103C8T6 为例64KB 的 Flash 里塞完协议栈、驱动和业务逻辑之后能留给数据存储的空间往往只剩几 KB。一旦需要记录日志、保存配置参数、存储字库或者图片资源这点空间立刻捉襟见肘。这时候外挂一颗 SPI Flash 是最经济实惠的选择。W25Q64 这颗芯片在圈子里几乎是“标配”级别的存在——8MB 容量64MbitSPI 接口2.7V~3.6V 供电价格便宜供货稳定资料丰富。无论是做数据记录仪、OTA 升级缓存、还是 UI 资源存储它都能胜任。我这次的项目需求很明确用 STM32F103 通过硬件 SPI 驱动 W25Q64实现扇区擦除、页写入、数据读取的完整流程并且要跑通一个可复现的测试用例。这个需求看起来简单但实际操作中涉及 SPI 时序配置、Flash 状态轮询、地址对齐处理、跨页写入等多个容易踩坑的环节。1.2 硬件连接与引脚分配先看硬件层面。STM32F103 的 SPI1 挂载在 APB2 总线上最高时钟可达 72MHz 的二分频即 36MHz。W25Q64 支持最高 104MHz 的 SPI 时钟所以 STM32F103 的 SPI1 全速跑完全没问题。不过实际项目中我建议先降到低速调试跑通后再提速。引脚连接如下STM32 引脚功能W25Q64 引脚说明PA5SPI1_SCKCLK时钟线PA6SPI1_MISODO主入从出PA7SPI1_MOSIDI主出从入PA4GPIO 输出CS片选软件控制3.3V电源VCC供电GND地GND共地片选引脚我选择用普通 GPIO 而不是硬件 NSS原因很简单硬件 NSS 在多主设备或者需要精细控制片选时序的场景下反而添乱。软件控制 CS 可以精确把握拉低和拉高的时机调试起来也直观。注意W25Q64 的 WP 和 HOLD 引脚如果不用的话建议分别上拉到 VCC。悬空虽然大多数情况下也能工作但在电磁环境复杂的场合可能引发意外写保护或通信中断。1.3 软件架构分层设计软件层面我分成三层来写底层驱动层SPI 初始化、字节收发、CS 控制W25Q64 命令层封装读 ID、写使能、扇区擦除、页写入、读数据等操作应用接口层提供按地址读写任意长度数据的接口内部处理擦除和跨页逻辑这样分层的好处是如果以后换用 W25Q128 或者别的 SPI Flash只需要改动命令层的少量参数应用层代码完全不用动。而且调试的时候可以逐层验证先确认 SPI 能收发字节再确认 Flash ID 能读对最后测试读写问题定位会快很多。2. SPI 底层配置与 W25Q64 命令解析2.1 STM32F103 SPI1 初始化详解SPI 的配置看起来简单但几个关键参数如果设错通信直接失败。我逐个说明。void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // SCK, MOSI 复用推挽输出 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 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // CS 推挽输出默认拉高 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); 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_High; SPI_InitStructure.SPI_CPHA SPI_CPHA_2Edge; SPI_InitStructure.SPI_NSS SPI_NSS_Soft; SPI_InitStructure.SPI_BaudRatePrescaler SPI_BaudRatePrescaler_8; SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial 7; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }几个关键点展开说CPOL 和 CPHA 的选择。W25Q64 支持 SPI Mode 0CPOL0, CPHA0和 Mode 3CPOL1, CPHA1。我选的是 Mode 3即 CPOLHigh、CPHA2Edge。这意味着空闲时 SCK 为高电平数据在第二个边沿下降沿采样。两种模式 W25Q64 都支持选哪个取决于个人习惯和系统里其他 SPI 设备的兼容性。如果总线上还挂了别的 SPI 从设备要确保大家的模式一致。波特率预分频。SPI_BaudRatePrescaler_8 意味着 72MHz/8 9MHz。调试阶段我建议先用 256 分频281.25KHz确认通信正常后再逐步提高。直接上高速如果失败很难判断是时序问题还是接线问题。数据位宽。W25Q64 所有命令、地址、数据都是 8 位格式所以 SPI_DataSize_8b 是唯一选择。MSB 先行。SPI_FirstBit_MSB 必须设置W25Q64 要求高位先出。2.2 SPI 字节收发函数STM32F103 的标准库提供了 SPI_I2S_SendData 和 SPI_I2S_ReceiveData但直接调用需要配合标志位轮询。我封装了一个字节交换函数uint8_t SPI1_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); }这个函数是 SPI 通信的核心。发送一个字节的同时接收一个字节这是 SPI 全双工的特性决定的。即使你只想读数据也必须发送一个 dummy 字节通常是 0xFF来产生时钟。实操心得超时重试机制非常重要。我在早期版本里没加超时结果有一次 MISO 线接触不良程序直接死在 while 循环里看门狗都救不回来。加上 retry 计数后至少能返回错误码让上层处理。2.3 W25Q64 关键命令码梳理W25Q64 的命令集不算复杂常用的就十来条。我把核心命令整理成表格命令名称命令码功能说明Write Enable0x06写使能擦除/写入前必须调用Write Disable0x04写禁止Read Status Register 10x05读取状态寄存器判断 BUSY 位Read Data0x03读取数据地址 24 位Page Program0x02页写入每页 256 字节Sector Erase (4KB)0x20扇区擦除最小擦除单位Block Erase (64KB)0xD8块擦除Chip Erase0xC7全片擦除JEDEC ID0x9F读取厂商和设备 IDRelease Power Down0xAB唤醒这里有几个必须记住的规则擦除和写入前必须发写使能0x06。W25Q64 内部有一个写使能锁存器WEL每次擦除或写入操作前都要置位。操作完成后 WEL 会自动清零。如果忘了发 0x06数据写不进去而且不会报错只是默默失败。擦除的最小单位是 4KB 扇区。你不能擦除一个字节。这意味着如果要修改已写入区域中的某几个字节必须先把整个扇区读到 RAM修改后再擦除整个扇区最后写回去。这是 Flash 存储的通用规则不是 W25Q64 独有的。写入的最小单位是页256 字节。一次 Page Program 命令最多写 256 字节而且不能跨页。如果从地址 0x0000FF 开始写 10 个字节会写到 0x0000FF 就回卷到 0x000000而不是继续到 0x000109。这个坑我后面会详细说。状态寄存器的 BUSY 位bit0。擦除和写入操作是异步的发完命令后芯片内部在忙BUSY 位为 1。必须轮询等待 BUSY 变 0 才能发下一条命令。扇区擦除典型耗时 45ms页写入典型耗时 0.7ms全片擦除可能长达 20 秒。3. 完整读写流程实现与代码拆解3.1 读 JEDEC ID 验证通信上电后第一件事不是急着读写数据而是读 ID 确认通信链路正常。W25Q64 的 JEDEC ID 是 0xEF4017其中 0xEF 是厂商代码0x4017 是设备型号。uint32_t W25Q64_ReadID(void) { uint32_t temp 0; W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x9F); temp | SPI1_ReadWriteByte(0xFF) 16; temp | SPI1_ReadWriteByte(0xFF) 8; temp | SPI1_ReadWriteByte(0xFF); W25Q64_CS_HIGH(); return temp; }如果读回来是 0xFFFFFF 或者 0x000000说明通信有问题。0xFFFFFF 通常是 MISO 一直为高可能是芯片没供电或者 CS 没拉低0x000000 则是 MISO 一直为低可能是接线短路或者芯片损坏。正常读到 0xEF4017 才能继续往下走。注意读 ID 命令 0x9F 不需要写使能也不需要等待 BUSY。这是最安全的通信测试命令。3.2 状态寄存器轮询与忙等待擦除和写入操作发起后必须等待芯片内部完成。我封装了一个等待函数void W25Q64_WaitBusy(void) { uint8_t status 0; do { W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x05); status SPI1_ReadWriteByte(0xFF); W25Q64_CS_HIGH(); } while ((status 0x01) 0x01); }这个函数会一直轮询直到 BUSY 位为 0。实际项目中建议加一个超时计数防止芯片异常导致死循环。状态寄存器的其他位也值得关注bit0 BUSY忙标志bit1 WEL写使能锁存bit2~bit6BP0~BP4块保护位bit7 SRP状态寄存器保护如果发现写使能发了但 WEL 位不置位可能是芯片被硬件写保护了检查 WP 引脚电平和状态寄存器的 BP 位。3.3 扇区擦除操作擦除是写入的前提。Flash 的物理特性决定了写入只能把 bit 从 1 变成 0要变回 1 必须擦除。所以每次写入新数据前目标地址所在的扇区必须是已擦除状态所有字节为 0xFF。void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); W25Q64_WaitBusy(); W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x20); SPI1_ReadWriteByte((addr 16) 0xFF); SPI1_ReadWriteByte((addr 8) 0xFF); SPI1_ReadWriteByte(addr 0xFF); W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }地址是 24 位的因为 W25Q64 有 8MB 空间需要 23 位地址线2^23 8M。分三个字节发送高位在前。擦除操作耗时较长4KB 扇区典型 45ms最坏情况 400ms。如果项目对实时性有要求可以考虑用 Block Erase64KB典型 150ms来减少擦除次数但会牺牲灵活性。3.4 页写入与跨页处理页写入是最容易出问题的地方。W25Q64 每页 256 字节Page Program 命令一次最多写 256 字节且不能跨页。void W25Q64_PageWrite(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q64_WriteEnable(); W25Q64_WaitBusy(); W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x02); SPI1_ReadWriteByte((addr 16) 0xFF); SPI1_ReadWriteByte((addr 8) 0xFF); SPI1_ReadWriteByte(addr 0xFF); for (uint16_t i 0; i len; i) { SPI1_ReadWriteByte(buf[i]); } W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }这个函数本身没问题但调用者必须保证 addr 到 addrlen-1 在同一个页内。如果从地址 0x0000F0 开始写 32 字节会写到 0x0000F0~0x00010F跨越了页边界0x0000FF 是第 0 页最后一字节0x000100 是第 1 页第一字节。实际结果是 0x0000F0~0x0000FF 写入 16 字节然后地址回卷到 0x000000 继续写剩下的 16 字节把第 0 页开头的数据覆盖了。正确的做法是在应用层封装一个跨页写入函数void W25Q64_Write(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t pageRemain 256 - (addr % 256); uint32_t writeLen (len pageRemain) ? len : pageRemain; while (len 0) { W25Q64_PageWrite(addr, buf, writeLen); addr writeLen; buf writeLen; len - writeLen; writeLen (len 256) ? len : 256; } }这个函数先计算当前地址到页尾还剩多少字节写完这一页后再循环写下一页。逻辑不复杂但少了这一步数据就会莫名其妙被覆盖。实操心得我曾经在一个数据记录项目里忘了处理跨页结果每 256 字节就丢一段数据排查了一整天才定位到问题。后来养成了习惯凡是写 Flash先问自己“会不会跨页”。3.5 数据读取与任意长度读取读取比写入简单得多Read Data 命令 0x03 没有页限制可以从任意地址连续读取任意长度地址会自动递增。void W25Q64_Read(uint32_t addr, uint8_t *buf, uint32_t len) { W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x03); SPI1_ReadWriteByte((addr 16) 0xFF); SPI1_ReadWriteByte((addr 8) 0xFF); SPI1_ReadWriteByte(addr 0xFF); for (uint32_t i 0; i len; i) { buf[i] SPI1_ReadWriteByte(0xFF); } W25Q64_CS_HIGH(); }注意发送的是 0xFF 而不是 0x00。虽然理论上发送什么字节都行因为 MOSI 在读取阶段是无关的但发 0xFF 有个好处如果 MISO 线断了读回来全是 0xFF和擦除后的状态一致容易发现异常。发 0x00 的话读回来全是 0x00反而不好判断。3.6 完整测试用例把上面的函数串起来写一个完整的测试void W25Q64_Test(void) { uint8_t writeBuf[64]; uint8_t readBuf[64]; uint32_t testAddr 0x000100; uint32_t id; id W25Q64_ReadID(); printf(JEDEC ID: 0x%06X\r\n, id); if (id ! 0xEF4017) { printf(W25Q64 not detected!\r\n); return; } for (int i 0; i 64; i) writeBuf[i] i; W25Q64_SectorErase(testAddr); W25Q64_Write(testAddr, writeBuf, 64); W25Q64_Read(testAddr, readBuf, 64); if (memcmp(writeBuf, readBuf, 64) 0) printf(Test PASS\r\n); else printf(Test FAIL\r\n); }这个测试覆盖了 ID 读取、扇区擦除、跨页写入64 字节从 0x000100 开始不跨页但验证了基本流程、数据读取和校验。如果要测试跨页把 testAddr 改成 0x0000F0写入 64 字节就能覆盖跨页场景。4. 常见问题排查与避坑指南4.1 通信失败类问题速查现象可能原因排查方法读 ID 返回 0xFFFFFFMISO 未连接、芯片未供电、CS 未拉低万用表测电压和通断读 ID 返回 0x000000MISO 短路到地、芯片损坏检查焊接更换芯片读 ID 返回 0xEF4017 但读写失败写使能未发、扇区未擦除检查 WEL 位和擦除流程写入后读回数据错乱跨页写入未处理检查地址和长度偶尔通信失败SPI 速度过快、线太长降低波特率缩短接线4.2 数据写入不成功的排查思路写入失败是最常见的问题排查顺序建议如下第一步确认写使能。发完 0x06 后读状态寄存器看 bit1WEL是否为 1。如果为 0说明写使能没生效可能是 CS 时序问题或者芯片被保护了。第二步确认扇区已擦除。读一下目标地址的数据如果是 0xFF 说明已擦除。如果不是先执行擦除再写。第三步确认 BUSY 位已清零。擦除和写入后必须等 BUSY 变 0 才能进行下一步操作。如果没等就发下一条命令芯片会忽略。第四步检查跨页。如果写入长度超过页边界确认应用层是否正确处理。4.3 SPI 时序与信号完整性注意事项SPI 虽然是有线通信但在高速下也会出现信号完整性问题。我遇到过几个典型案例SCK 过冲导致误码。当 SPI 时钟跑到 18MHz 以上时如果杜邦线较长超过 10cmSCK 边沿可能出现振铃。解决办法是降低速度或者在 SCK 上串一个 22Ω~100Ω 的电阻。CS 建立时间不足。CS 拉低后要等一小段时间再发时钟给芯片反应时间。STM32 的 GPIO 翻转很快如果 CS 拉低后立即发 SCK某些批次的 W25Q64 可能来不及响应。可以在 CS 拉低后加几个 NOP。MISO 高阻态。CS 拉高后 MISO 应该进入高阻态。如果总线上有多个 SPI 从设备要确保同一时刻只有一个 CS 有效否则会出现总线冲突。4.4 擦除寿命与磨损均衡W25Q64 的擦除寿命是 10 万次每个扇区。如果项目需要频繁记录数据比如每秒写一次日志同一个扇区很快就会写坏。这时候需要做磨损均衡Wear Leveling。最简单的磨损均衡是轮流使用扇区把 8MB 分成若干块每次写入换一个块写满一圈后再回到第一个块擦除重写。这样每个扇区的擦除次数被均摊寿命可以延长几十倍。如果数据量不大也可以考虑用 RAM 缓存 定时批量写入的方式减少擦除次数。比如每 10 分钟把缓存的数据一次性写入 Flash而不是每秒写一次。实操心得我在一个环境监测项目里最初设计是每采集一次数据就写一次 Flash结果三个月后某个扇区就出现了坏块。后来改成 RAM 缓存 1 小时的数据批量写入同时做简单的扇区轮换运行两年多没再出问题。4.5 低功耗场景下的注意事项如果项目是电池供电W25Q64 的功耗也需要考虑。正常读写时电流约 4mA~15mA待机时约 10uA掉电模式Power Down可以降到 1uA 以下。进入掉电模式发 0xB9 命令唤醒发 0xAB。唤醒后需要等 3us 左右才能发下一条命令。如果系统大部分时间在休眠只在需要存储时才唤醒 Flash可以显著延长电池寿命。5. 性能优化与进阶扩展5.1 提高读写速度的几种手段SPI 时钟是最直接的提速手段。STM32F103 的 SPI1 最高 36MHzW25Q64 支持 104MHz所以瓶颈在 MCU 这边。把预分频从 8 改成 2时钟从 9MHz 提到 36MHz读写速度直接翻两番。但提速的前提是硬件布线要跟上。如果用的是面包板或者长杜邦线36MHz 下大概率通信失败。建议在 PCB 上走线尽量短SCK 和 MOSI 远离干扰源。另一个手段是用 Quad SPIQSPI模式。W25Q64 支持标准 SPI、Dual SPI 和 Quad SPI。Quad 模式下用 4 条数据线并行传输理论速度是标准 SPI 的 4 倍。不过 STM32F103 没有硬件 QSPI 控制器需要用 GPIO 模拟实现复杂度较高适合对速度有极致要求的场景。5.2 用 DMA 解放 CPU在大数据量读写时CPU 轮询 SPI 标志位会占用大量时间。比如读 64KB 数据按 9MHz 时钟算大约需要 58ms这期间 CPU 几乎被占满。用 DMA 可以把数据搬运的工作交给 DMA 控制器CPU 只需要配置好 DMA 通道和 SPI 的 DMA 请求然后等待传输完成中断即可。STM32F103 的 SPI1 有专门的 DMA 通道RX 用 DMA1_Channel2TX 用 DMA1_Channel3。配置 DMA 的要点外设地址设为 SPI1-DR内存地址设为数据缓冲区传输方向根据收发设置数据宽度都是 8 位开启传输完成中断用 DMA 后读 64KB 数据 CPU 占用率从接近 100% 降到几乎为 0可以同时处理其他任务。5.3 文件系统与数据管理如果项目需要存储大量小文件或者需要按文件名访问裸读写 Flash 就不够用了。这时候可以移植一个轻量级文件系统比如 FatFs 或者 LittleFS。FatFs 兼容性好可以在电脑上直接读取 Flash 内容配合 USB 读卡器或者把 Flash 做成 U 盘但需要较大的 RAM 缓存且掉电时容易损坏文件系统。LittleFS 是专为嵌入式设计的文件系统支持掉电保护、磨损均衡和动态坏块管理RAM 占用小适合资源受限的场景。移植 LittleFS 需要提供底层块设备接口读、写、擦除和配置参数块大小、块数量、缓存大小。以 W25Q64 为例块大小设为 4KB一个扇区块数量 20488MB/4KB缓存 256 字节就可以跑起来。LittleFS 会自动处理磨损均衡和掉电恢复比裸写省心很多。5.4 数据校验与可靠性保障Flash 存储的数据可能因为各种原因出错擦除不彻底、写入中断、芯片老化等。为了保证数据可靠性建议加入校验机制。最简单的校验是 CRC32。每次写入数据时计算 CRC 并一起存储读取时重新计算并比对。如果 CRC 不匹配说明数据损坏可以尝试重读或者从备份恢复。更高级的做法是存储两份数据一份主数据一份备份读取时两份都读出来比对。如果主数据损坏就用备份恢复。这样占用双倍空间但可靠性大幅提升。对于关键配置参数我通常会在 Flash 里存三份读取时取多数一致的值。虽然浪费空间但能有效防止单点故障。5.5 从 W25Q64 迁移到其他型号如果项目后期需要更大容量比如换成 W25Q12816MB或者 W25Q25632MB代码改动其实很小。W25Q128 的 JEDEC ID 是 0xEF4018W25Q256 是 0xEF4019。命令集基本兼容主要区别是地址位数W25Q64 用 24 位地址W25Q256 需要 32 位地址或者用 4 字节地址模式命令 0xB7 切换。迁移步骤修改 ID 判断值如果容量超过 16MB把地址从 24 位改成 32 位调整扇区数量和文件系统参数重新测试擦除和读写我实际迁移过 W25Q64 到 W25Q128 的项目除了改 ID 和容量参数驱动代码几乎没动。这也是分层设计的好处。6. 调试工具与实操建议6.1 逻辑分析仪抓包实战调试 SPI 最有效的工具是逻辑分析仪。几十块钱的 8 通道 24MHz 采样率版本就够用。接线很简单通道 0 接 CS通道 1 接 SCK通道 2 接 MOSI通道 3 接 MISO地线共地。抓包时设置 SPI 解码器配置 CPOL、CPHA、位顺序和 CS 极性软件会自动把波形解析成字节。这样能直观看到发了什么命令、地址对不对、数据是否正确。我遇到过一个问题读 ID 时逻辑分析仪显示发送了 0x9F但读回来是 0x00。看波形发现 MISO 一直是低电平检查硬件发现 MISO 焊盘虚焊。如果没有逻辑分析仪这种问题可能要查很久。6.2 串口打印调试信息逻辑分析仪看时序串口打印看逻辑。在关键步骤加打印语句能快速定位问题出在哪一步。printf(Step 1: Read ID 0x%06X\r\n, id); printf(Step 2: Erase sector 0x%06X\r\n, addr); printf(Step 3: Write %d bytes\r\n, len); printf(Step 4: Read back %d bytes\r\n, len); printf(Step 5: Compare result %s\r\n, match ? PASS : FAIL);打印信息要精简不要每写一个字节就打印一次否则串口输出会拖慢整个流程。在关键节点打印即可。6.3 分阶段验证策略不要一次性写完所有代码再测试而是分阶段验证第一阶段只写 SPI 初始化和字节收发函数用示波器或者逻辑分析仪确认 SCK 有波形MOSI 数据正确。第二阶段加读 ID 函数确认能读到 0xEF4017。这一步通过说明 SPI 通信完全正常。第三阶段加擦除和读数据函数。先擦除一个扇区然后读出来看是否全 0xFF。第四阶段加页写入函数写几个字节再读回来比对。第五阶段加跨页写入和任意长度读写做完整测试。每个阶段都确认无误后再进入下一阶段这样出问题时排查范围小定位快。6.4 常见误区与经验总结误区一认为 SPI 配置好就能通。SPI 涉及硬件接线、时序配置、芯片状态多个环节任何一个出问题都不通。要养成从硬件到软件逐层排查的习惯。误区二忽略 BUSY 等待。擦除和写入后不等待 BUSY 清零就发下一条命令芯片会忽略新命令导致数据丢失。这个错误很隐蔽因为不会报错只是默默失败。误区三忘记写使能。每次擦除和写入前都要发 0x06操作完成后 WEL 自动清零。如果连续写多个页每页之前都要发写使能。误区四跨页写入不处理。这是最常见的坑前面已经详细说过。记住一句话Page Program 不能跨页跨页必须分段。误区五擦除前不检查。Flash 只能把 1 写成 0如果目标区域不是 0xFF写入的数据会和原有数据做与运算结果不可预期。所以写入前必须确保区域已擦除。6.5 代码组织与可维护性建议最后说几点代码组织上的建议把 W25Q64 相关的宏定义、结构体、函数声明放在单独的头文件里源文件只放实现。这样其他模块引用时只需要 include 头文件不需要关心底层细节。命令码用宏定义而不是魔法数字。比如#define W25Q64_CMD_PAGE_PROGRAM 0x02比直接写 0x02 可读性好得多。关键操作加错误返回值和超时机制。比如擦除函数返回 0 表示成功非 0 表示超时或失败。上层可以根据返回值决定重试还是报错。如果项目用 RTOSSPI 操作要考虑互斥。多个任务同时访问 Flash 会导致 CS 信号混乱需要用互斥锁保护。或者把 Flash 操作封装成一个独立任务其他任务通过消息队列请求服务。这个项目我从开始调试到稳定运行大概花了两天时间其中大部分时间用在排查跨页写入和 BUSY 等待这两个问题上。希望这篇拆解能帮你少走一些弯路。W25Q64 本身不复杂把时序、状态轮询、地址对齐这几个关键点吃透基本就能稳定运行了。