1. 为什么我劝你先搞懂W25Q64的脾气再动手写代码市面上讲STM32驱动W25Q64的文章一抓一大把但大部分都是丢一段代码出来告诉你“烧进去就能跑”。我刚开始接触这颗Flash芯片的时候也是这么想的结果踩了一连串坑读出来的ID是0xFFFFFF写进去的数据读回来全变成0xFF擦除一个扇区等了半天以为程序卡死了。后来才明白问题根本不在代码本身而在于我没搞清楚W25Q64的工作逻辑和STM32 SPI外设之间的配合关系。W25Q64是一颗8MB容量的SPI NOR Flash64Mbit分成128个块Block每块64KB每个块又分成16个扇区Sector每个扇区4KB。这个层级结构不是随便分的它直接决定了你的擦除粒度和写入策略。NOR Flash有个物理特性写入只能把bit从1变成0不能从0变成1。所以每次写入之前对应区域必须处于擦除状态全0xFF。擦除的最小单位是扇区4KB不是字节也不是页。这个特性如果你不提前理解写代码的时候就会遇到“为什么我写了新数据读出来是旧数据和新数据按位与的结果”这种让人抓狂的问题。这篇文章我打算把STM32F103通过SPI驱动W25Q64的完整流程拆开讲从硬件连接到SPI配置从命令时序到读写擦除的实操代码再到我实际调试中遇到的几个典型坑。不管你是刚接触SPI和Flash的新手还是已经能跑通但想搞清楚底层细节的开发者应该都能从里面找到有用的东西。我用的芯片是STM32F103C8T6SPI1引脚是PA5SCK、PA6MISO、PA7MOSI片选CS用PA4软件控制。这套配置很经典换成其他F103型号或者SPI2也大同小异。2. 硬件连接与SPI模式选择别小看这两根线的接法2.1 引脚连接与电平匹配的注意事项先说过硬件这一关。W25Q64的引脚不多VCC、GND、CS、CLK、DIMOSI、DOMISO、WP、HOLD。其中WP是写保护HOLD是暂停通信。如果你只是做基本的读写测试WP接VCC禁用写保护HOLD接VCC禁用暂停功能就行。但这里有个细节WP和HOLD不能悬空悬空的话芯片内部状态不确定可能出现写不进去或者通信异常的情况。我一开始就是HOLD没接读ID能读到但写数据死活写不进去查了半天才发现是HOLD悬空导致芯片间歇性进入暂停状态。CS片选我用的是PA4软件控制。为什么不用硬件NSS因为STM32的硬件NSS在多主机或者某些配置下容易出问题而且W25Q64的CS需要在整个命令序列期间保持低电平软件控制更直观可控。接线的时候注意CS走线尽量短如果杜邦线太长高速SPI下可能出现片选信号边沿抖动导致命令解析错误。电源方面W25Q64的工作电压是2.7V到3.6VSTM32F103的IO也是3.3V直接连没问题。但如果你用的是5V的单片机中间必须加电平转换否则Flash会被打坏。这个坑虽然基础但每年都有不少人踩。2.2 SPI模式0还是模式3时序图里藏着答案W25Q64支持SPI模式0CPOL0CPHA0和模式3CPOL1CPHA1。这两种模式的区别在于时钟极性和相位。模式0下时钟空闲为低电平数据在第一个边沿上升沿采样模式3下时钟空闲为高电平数据在第二个边沿下降沿采样。我一般用模式0因为STM32的SPI配置成模式0最直观而且大部分例程也是这么做的。但你要知道模式0和模式3在W25Q64上都能正常工作关键是主机和从机必须匹配。如果你发现读出来的数据总是移位或者错位先检查SPI模式是否和Flash要求的一致。SPI时钟频率方面W25Q64支持最高80MHz快速读模式下但STM32F103的SPI1最高只能到72MHzAPB2时钟72MHz分频系数最小2。我一般设置在18MHz到36MHz之间也就是分频系数4或2。为什么不用最高速因为杜邦线飞线的情况下高速SPI信号完整性很难保证容易出现误码。如果你画了PCB走线阻抗控制得好可以往上提。但调试阶段建议先用低速比如4.5MHz分频系数16确保通信稳定后再提速。// SPI1初始化配置模式0主机8位数据分频164.5MHz void SPI1_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // PA5 SCK, PA7 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); // PA6 MISO 浮空输入 GPIO_InitStructure.GPIO_Pin GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, GPIO_InitStructure); // PA4 CS 推挽输出 GPIO_InitStructure.GPIO_Pin GPIO_Pin_4; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; 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_16; SPI_InitStructure.SPI_FirstBit SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial 7; SPI_Init(SPI1, SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }这段代码里有个点值得说SPI_FirstBit_MSB。W25Q64的命令、地址、数据都是MSB先行这个必须设对否则整个通信全乱。我见过有人设成LSB结果读ID读出来是反的查了一下午。3. 命令时序拆解从读ID到页写入的完整链路3.1 读ID命令验证通信是否正常的第一个里程碑拿到一块新Flash第一步不是急着写数据而是读ID。W25Q64的制造商ID是0xEF设备ID是0x4017对于W25Q64或者0x4018W25Q128。读ID有两个命令0x90读制造商/设备ID和0x9F读JEDEC ID。我一般用0x9F因为它返回三个字节制造商ID、内存类型、容量。正常返回应该是0xEF、0x40、0x17。读ID的时序很简单CS拉低发送0x9F然后连续读三个字节CS拉高。但这里有个细节在发送命令和读取数据之间不需要额外的等待周期。W25Q64在0x9F命令下时钟连续输出数据你只要继续给时钟就能读到。// 读取JEDEC ID uint32_t W25Q64_ReadID(void) { uint32_t id 0; W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x9F); id | SPI1_ReadWriteByte(0xFF) 16; id | SPI1_ReadWriteByte(0xFF) 8; id | SPI1_ReadWriteByte(0xFF); W25Q64_CS_HIGH(); return id; }如果读出来是0x000000或者0xFFFFFF先检查硬件连接再检查SPI模式。0xFFFFFF通常意味着MISO一直高电平可能是Flash没供电或者CS没拉低。0x000000则是MISO一直低可能是MISO接错或者Flash损坏。3.2 写使能每次写入和擦除前的必修课W25Q64有个状态寄存器Status Register里面的WEL位Write Enable Latch决定了芯片是否允许写入。每次执行页写入、扇区擦除、块擦除之前都必须先发送0x06命令写使能。写使能之后WEL位自动置1执行完写入或擦除操作后WEL位自动清零。如果你忘了发写使能写入命令会被芯片忽略数据写不进去而且芯片不会给你任何错误提示。我刚开始调试的时候擦除扇区一直不成功读回来还是旧数据。后来用逻辑分析仪抓波形才发现我发了擦除命令但没发写使能。这个坑很隐蔽因为代码逻辑看起来没问题但芯片就是不执行。// 写使能 void W25Q64_WriteEnable(void) { W25Q64_CS_LOW(); SPI1_ReadWriteByte(0x06); W25Q64_CS_HIGH(); }3.3 等待忙状态别在Flash干活的时候打扰它擦除和写入操作需要时间。扇区擦除典型时间45ms最大400ms页写入典型时间0.7ms最大3ms。在这段时间内Flash处于忙状态除了读状态寄存器命令0x05其他命令都不响应。所以每次擦除或写入之后必须轮询状态寄存器的BUSY位直到它变为0。// 等待Flash空闲 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); // BUSY位是bit0 }这里有个经验轮询之间加一点延时比如几微秒不要连续疯狂读。虽然SPI读状态寄存器很快但连续高频轮询会增加功耗而且对调试没什么帮助。我一般加个delay_us(10)实际影响可以忽略。3.4 扇区擦除与页写入理解4KB和256字节的边界扇区擦除命令是0x20后面跟24位地址。W25Q64的地址是24位的因为8MB容量需要23位地址2^23 8M但命令格式是3个字节所以高8位补0。擦除的最小单位是4KB你给的地址会被对齐到扇区起始地址。比如你给0x000010实际擦除的是0x000000到0x000FFF。页写入命令是0x02后面跟24位地址然后是要写入的数据。页的大小是256字节。如果你写入的数据超过256字节地址会自动回卷到页起始地址覆盖之前写入的数据。比如你在页内偏移250的位置写10个字节前6个字节写到偏移250到255后4个字节会回卷写到偏移0到3覆盖掉原来的数据。这个特性很容易被忽略导致数据莫名其妙被覆盖。// 扇区擦除4KB对齐 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(); } // 页写入不超过256字节 void W25Q64_PageWrite(uint32_t addr, uint8_t *data, uint16_t len) { if (len 256) len 256; // 安全限制 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(data[i]); } W25Q64_CS_HIGH(); W25Q64_WaitBusy(); }3.5 数据读取0x03命令的边界与高速读的区别读数据命令是0x03后面跟24位地址然后连续输出数据。地址会自动递增可以一直读到Flash末尾。这个命令支持的最高时钟频率是50MHz对于W25Q64在STM32F103的SPI1上跑36MHz没问题。但如果你需要更高速度可以用快速读命令0x0B它需要一个额外的dummy字节空周期然后才能输出数据。快速读支持80MHz。不过对于大多数应用0x03够用了代码也更简单。// 读取数据 void W25Q64_ReadData(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(); }4. 跨页写入与地址对齐实际项目里最容易翻车的地方4.1 为什么不能直接写超过256字节的数据前面说了页大小是256字节但实际项目中你要写入的数据往往超过256字节比如写一个结构体、一帧图像数据、一段日志。这时候不能直接调一次页写入就完事必须把数据拆分成多个页写入而且每个页写入的起始地址必须对齐到页边界。举个例子你要从地址0x000100开始写300字节。0x000100是页起始地址256的倍数前256字节写到0x000100到0x0001FF剩下44字节写到0x000200到0x00022B。这没问题。但如果你从0x000080开始写300字节0x000080不是页起始地址前128字节写到0x000080到0x0000FF然后地址回卷到0x000000后128字节会覆盖掉0x000000到0x00007F的数据。这就是跨页写入的坑。正确的做法是写一个跨页写入函数自动处理拆分和地址对齐// 跨页写入自动处理页边界 void W25Q64_WriteNoCheck(uint32_t addr, uint8_t *data, uint32_t len) { uint32_t pageRemain 256 - (addr % 256); if (len pageRemain) { W25Q64_PageWrite(addr, data, len); } else { W25Q64_PageWrite(addr, data, pageRemain); W25Q64_WriteNoCheck(addr pageRemain, data pageRemain, len - pageRemain); } }这个递归写法很简洁但要注意栈深度。如果写入数据特别大递归层数会很多。实际项目中我一般用循环实现避免栈溢出风险。4.2 擦除与写入的配合先擦后写顺序不能反NOR Flash的物理特性决定了写入之前必须擦除。但擦除是4KB起步的如果你只想改一个字节也得擦掉整个扇区然后重新写入整个扇区的数据。这个“读-改-写”的流程在实际项目中很常见。我一般的做法是先把整个扇区的数据读到RAM缓冲区4KB在缓冲区里修改需要改的字节然后擦除扇区再把缓冲区写回去。这个过程听起来简单但有几个细节要注意缓冲区大小至少4KB对于STM32F103C8T6这种只有20KB RAM的芯片4KB缓冲区不算小要合理规划内存。另外擦除和写入期间不能断电否则数据会丢失。如果对数据可靠性要求高可以考虑加掉电检测或者用文件系统比如LittleFS、FatFS来管理。4.3 状态寄存器保护位WP引脚和SRP位的关系W25Q64的状态寄存器有两个写保护相关的位SRP0、SRP1、BP0-BP2、WPS。这些位配合WP引脚可以实现硬件写保护。如果你把WP引脚拉低并且设置了相应的保护位那么对应的区域就无法擦除和写入。这个功能在防止误操作或者保护关键数据时很有用但调试阶段建议把WP接高电平禁用写保护否则你会遇到“为什么写不进去”的问题。我遇到过一种情况Flash之前被设置过写保护换到新板子上之后写保护位还在导致无法写入。解决办法是发送0x50命令写状态寄存器使能然后写状态寄存器清零保护位。但要注意有些保护位一旦设置需要特定的时序才能清除具体参考数据手册。5. 调试实录读出来全是0xFF和写入不生效的排查过程5.1 读ID正常但读写数据异常问题出在CS时序有一次我调一块新板子读ID正常返回0xEF4017说明SPI通信没问题。但写数据之后读回来全是0xFF擦除也不生效。用逻辑分析仪抓波形发现CS信号在命令序列中间有毛刺偶尔会跳高一下。原因是CS走线太长而且和SCK线平行走了一段产生了耦合。SCK跳变的时候CS被耦合出了毛刺Flash误以为命令结束导致后续数据被忽略。解决办法很简单CS走线尽量短远离SCK必要时加一个上拉电阻10K到VCC确保空闲时CS稳定高电平。改线之后问题消失。5.2 写入成功但读出来是旧数据忘了等待忙状态还有一次写数据之后立即读读出来是旧数据。但延时一段时间再读新数据就出来了。原因是写入之后没有等待忙状态Flash还在内部编程读命令被忽略了。加上W25Q64_WaitBusy()之后问题解决。这个坑的隐蔽性在于如果你用调试器单步执行每一步都有延时反而不会出问题。但全速运行的时候Flash还没写完你就去读读到的就是旧数据或者不确定的数据。5.3 擦除时间过长导致看门狗复位扇区擦除最大400ms如果你开了看门狗喂狗间隔小于400ms擦除期间看门狗就会复位。我一开始用独立看门狗超时设了200ms结果擦除的时候系统不断复位。后来把看门狗超时改成1s并且在擦除和写入期间增加喂狗操作问题解决。提示擦除和写入期间如果系统有实时性要求建议把擦除操作放在低优先级任务或者空闲时执行避免阻塞主循环。5.4 地址计算错误导致数据覆盖有一次我写一个日志系统每条日志固定128字节写到Flash里。日志索引从0开始地址是index * 128。写到第33条的时候发现第1条的数据被覆盖了。原因是第33条的地址是33*1284224而扇区大小是40964224已经跨到了下一个扇区。但我在擦除的时候只擦了第一个扇区第二个扇区没擦所以写入的时候地址回卷到了第一个扇区的开头覆盖了第1条日志。这个问题的根本原因是我没有按扇区管理日志而是按条管理。后来改成按扇区擦除每个扇区存32条日志4096/12832写满一个扇区再擦下一个问题解决。6. 性能优化与可靠性提升的几个实操技巧6.1 用DMA驱动SPI读写解放CPUSTM32F103的SPI支持DMA。对于大块数据读写用DMA可以显著降低CPU占用。配置SPI1_TX和SPI1_RX的DMA通道发送和接收都走DMACPU只需要在DMA传输完成中断里处理后续逻辑。但要注意W25Q64的命令阶段发送命令和地址数据量很小用DMA反而增加开销。我一般只在数据阶段用DMA命令阶段还是用轮询。另外DMA传输期间CS必须保持低电平所以CS的控制要放在DMA启动之前和完成中断之后。6.2 加入CRC校验确保数据完整性SPI通信在高速或者长走线的情况下可能出错。W25Q64本身不提供CRC校验但你可以自己在数据后面附加CRC。写入的时候计算CRC并存储读取的时候重新计算并比对。如果CRC不匹配说明数据损坏可以触发重读或者报错。我一般用CRC-16/CCITT计算速度快检错能力够用。对于关键数据还可以加备份扇区主扇区损坏时从备份恢复。6.3 扇区磨损均衡延长Flash寿命W25Q64的擦写寿命是10万次每个扇区。如果你频繁写同一个扇区比如每秒写一次日志不到两天那个扇区就报废了。解决办法是磨损均衡把写操作分散到不同扇区轮流使用。简单的实现方式是维护一个写指针每次写新数据就移动到下一个扇区写满一圈再回到第一个扇区擦除重写。这样每个扇区的擦写次数就均匀了。如果数据量不大还可以用多个扇区做环形缓冲区进一步提高寿命。6.4 低功耗场景下的注意事项如果你的设备是电池供电Flash的功耗也要考虑。W25Q64在待机模式下的电流大约是10uA读写时几mA。不用的时候可以把CS拉高Flash自动进入待机。如果对功耗要求极高还可以发送0xB9命令进入深度掉电模式电流降到1uA以下但唤醒需要额外的时间。注意深度掉电模式下除了释放深度掉电命令0xAB其他命令都不响应。唤醒之后需要等待一段时间tRES1约3us才能正常操作。7. 从裸机驱动到文件系统的过渡思路裸机读写Flash适合数据量小、结构简单的场景。但如果你的项目需要管理大量文件、支持目录结构、处理断电恢复裸机驱动就不够用了。这时候可以考虑移植一个轻量级文件系统比如LittleFS或者FatFS。LittleFS是专门为嵌入式Flash设计的支持掉电恢复和磨损均衡代码量也不大很适合STM32F103这种资源有限的芯片。移植的时候需要提供底层的读、写、擦除接口也就是我们前面写的那些函数。FatFS更通用但需要你自己实现磨损均衡和掉电保护复杂度更高。我个人的经验是如果只是存一些配置参数或者少量日志裸机驱动加简单的扇区管理就够了。如果要做数据记录仪、文件存储这类应用直接上LittleFS省心很多。移植LittleFS的时候注意块设备接口的erase函数要按扇区擦除prog函数要处理跨页写入read函数直接调0x03命令就行。最后说一个实际调试中的小技巧在Flash里留一个专门的区域存调试信息比如擦除次数、写入次数、错误计数。每次上电的时候读出来打印到串口可以帮你快速判断Flash的健康状态。这个区域不要频繁擦写可以放在最后一个扇区只在必要时更新。
阅读完成 · 觉得有帮助?