STM32F103硬件SPI驱动W25Q64 Flash全流程实战
1. 为什么还要折腾 W25Q64 这颗老 FlashW25Q64 这颗 8MB 的 SPI Flash放在今天看参数确实不算亮眼但它几乎是每个嵌入式工程师绕不开的一颗料。价格便宜、供货稳定、驱动简单、资料满地都是做数据记录、字库存储、固件备份、参数保存随手就能用上。我手上好几个量产项目主控从 F103 换到 F407 再换到国产替代存储这颗料一直没动过。这次要聊的是 STM32F103 通过硬件 SPI 驱动 W25Q64 的完整流程。为什么强调完整因为网上大部分教程只告诉你发个 0x9F 读 ID 就完事了真到项目里你会发现读出来全是 0xFF、写进去读出来对不上、跨页写数据丢失、擦除要等好几秒、DMA 一开就死机。这些问题不是靠抄一段代码能解决的得把时序、状态机、页边界这些底层逻辑吃透。这篇文章适合三类人刚学完 SPI 协议想找个真实器件练手的初学者手上有项目要存日志、存图片、存配置但不知道怎么选型的中级开发者以及被 W25Q64 各种玄学问题折磨过、想系统梳理一遍的老手。我会从硬件连接讲到寄存器操作从单字节读写讲到跨页处理和状态轮询把每一步的为什么讲清楚代码可以直接抄去用。需要提前说明的是下面涉及的引脚、时钟配置、参数计算都基于常见工程实践具体到你的板子可能要微调但思路是通用的。2. 硬件层先把线接对再谈代码2.1 W25Q64 的引脚定义与最小系统连接W25Q64 是标准的 8 脚 SOP8 封装引脚定义如下引脚名称功能说明1CS片选低电平有效2DO数据输出MISO3WP写保护低有效4GND地5DI数据输入MOSI6CLK时钟7HOLD保持低有效8VCC电源 2.7V~3.6V和 STM32F103 连接时我一般这样分配CS 接 PA4普通 GPIO 推挽输出CLK 接 PA5SPI1_SCKMISO 接 PA6SPI1_MISOMOSI 接 PA7SPI1_MOSIWP 和 HOLD 直接上拉到 3.3V这里有个新手最容易踩的坑WP 和 HOLD 悬空。很多人觉得这两个脚不用就不接结果写数据死活写不进去或者读出来全是乱码。WP 低电平会锁死状态寄存器HOLD 低电平会让芯片忽略时钟。最稳妥的做法就是各接一个 10K 上拉到 VCC或者直接在代码里不使能这两个功能的前提下硬件上拉。注意W25Q64 是 3.3V 器件虽然有些模块标称兼容 5V但 IO 口耐压一般只到 VCC0.3V。如果你用 5V 主控务必加电平转换否则用不了多久 IO 就挂了。2.2 SPI 模式选择为什么必须是 Mode 0 或 Mode 3W25Q64 支持 SPI Mode 0CPOL0, CPHA0和 Mode 3CPOL1, CPHA1。这两个模式的共同点是数据在时钟上升沿采样下降沿输出。区别只是空闲时时钟是高还是低。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 标准读命令最高支持 50MHz18MHz 完全够用且稳定。如果你追求速度可以降到分频 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 写使能、擦除、写入、校验的标准时序把上面的操作串起来一个完整的写一段数据流程是这样的计算目标地址所在的扇区读出整个扇区到 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 通道 2RX和通道 3TX。配置好 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 模式配错。排查顺序先读 IDID 对说明通信正常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 不难难的是把细节做扎实。