1. 为什么我劝你先搞懂W25Q64的脾气再动手写代码市面上讲STM32驱动W25Q64的文章一抓一大把,但大部分都是丢一段代码出来,告诉你“烧进去就能跑”。我刚开始接触这颗Flash芯片的时候也是这么想的,结果踩了一连串坑:读出来的ID是0xFFFFFF,写进去的数据读回来全变成0xFF,擦除一个扇区等了半天以为程序卡死了。后来才明白,问题根本不在代码本身,而在于我没搞清楚W25Q64的工作逻辑和STM32 SPI外设之间的配合关系。W25Q64是一颗8MB容量的SPI NOR Flash,64Mbit,分成128个块(Block),每块64KB,每个块又分成16个扇区(Sector),每个扇区4KB。这个层级结构不是随便分的,它直接决定了你的擦除粒度和写入策略。NOR Flash有个物理特性:写入只能把bit从1变成0,不能从0变成1。所以每次写入之前,对应区域必须处于擦除状态(全0xFF)。擦除的最小单位是扇区(4KB),不是字节,也不是页。这个特性如果你不提前理解,写代码的时候就会遇到“为什么我写了新数据,读出来是旧数据和新数据按位与的结果”这种让人抓狂的问题。这篇文章我打算把STM32F103通过SPI驱动W25Q64的完整流程拆开讲,从硬件连接到SPI配置,从命令时序到读写擦除的实操代码,再到我实际调试中遇到的几个典型坑。不管你是刚接触SPI和Flash的新手,还是已经能跑通但想搞清楚底层细节的开发者,应该都能从里面找到有用的东西。我用的芯片是STM32F103C8T6,SPI1,引脚是PA5(SCK)、PA6(MISO)、PA7(MOSI),片选CS用PA4软件控制。这套配置很经典,换成其他F103型号或者SPI2也大同小异。2. 硬件连接与SPI模式选择:别小看这两根线的接法2.1 引脚连接与电平匹配的注意事项先说过硬件这一关。W25Q64的引脚不多,VCC、GND、CS、CLK、DI(MOSI)、DO(MISO)、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.6V,STM32F103的IO也是3.3V,直接连没问题。但如果你用的是5V的单片机,中间必须加电平转换,否则Flash会被打坏。这个坑虽然基础,但每年都有不少人踩。2.2 SPI模式0还是模式3:时序图里藏着答案W25Q64支持SPI模式0(CPOL=0,CPHA=0)和模式3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟极性和相位。模式0下,时钟空闲为低电平,数据在第一个边沿(上升沿)采样;模式3下,时钟空闲为高电平,数据在第二个边沿(下降沿)采样。我一般用模式0,因为STM32的SPI配置成模式0最直观,而且大部分例程也是这么做的。但你要知道,模式0和模式3在W25Q64上都能正常工作,关键是主机和从机必须匹配。如果你发现读出来的数据总是移位或者错位,先检查SPI模式是否和Flash要求的一致。SPI时钟频率方面,W25Q64支持最高80MHz(快速读模式下),但STM32F103的SPI1最高只能到72MHz(APB2时钟72MHz,分频系数最小2)。我一般设置在18MHz到36MHz之间,也就是分频系数4或2。为什么不用最高速?因为杜邦线飞线的情况下,高速SPI信号完整性很难保证,容易出现误码。如果你画了PCB,走线阻抗控制得好,可以往上提。但调试阶段建议先用低速(比如4.5MHz,分频系数16),确保通信稳定后再提速。// SPI1初始化配置,模式0,主机,8位数据,分频16(4.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)或者0x4018(W25Q128)。读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*128=4224,而扇区大小是4096,4224已经跨到了下一个扇区。但我在擦除的时候只擦了第一个扇区,第二个扇区没擦,所以写入的时候地址回卷到了第一个扇区的开头,覆盖了第1条日志。这个问题的根本原因是我没有按扇区管理日志,而是按条管理。后来改成按扇区擦除,每个扇区存32条日志(4096/128=32),写满一个扇区再擦下一个,问题解决。6. 性能优化与可靠性提升的几个实操技巧6.1 用DMA驱动SPI读写,解放CPUSTM32F103的SPI支持DMA。对于大块数据读写,用DMA可以显著降低CPU占用。配置SPI1_TX和SPI1_RX的DMA通道,发送和接收都走DMA,CPU只需要在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的健康状态。这个区域不要频繁擦写,可以放在最后一个扇区,只在必要时更新。
1. 为什么我劝你先搞懂W25Q64的脾气再动手写代码市面上讲STM32驱动W25Q64的文章一抓一大把,但大部分都是丢一段代码出来,告诉你“烧进去就能跑”。我刚开始接触这颗Flash芯片的时候也是这么想的,结果踩了一连串坑:读出来的ID是0xFFFFFF,写进去的数据读回来全变成0xFF,擦除一个扇区等了半天以为程序卡死了。后来才明白,问题根本不在代码本身,而在于我没搞清楚W25Q64的工作逻辑和STM32 SPI外设之间的配合关系。W25Q64是一颗8MB容量的SPI NOR Flash,64Mbit,分成128个块(Block),每块64KB,每个块又分成16个扇区(Sector),每个扇区4KB。这个层级结构不是随便分的,它直接决定了你的擦除粒度和写入策略。NOR Flash有个物理特性:写入只能把bit从1变成0,不能从0变成1。所以每次写入之前,对应区域必须处于擦除状态(全0xFF)。擦除的最小单位是扇区(4KB),不是字节,也不是页。这个特性如果你不提前理解,写代码的时候就会遇到“为什么我写了新数据,读出来是旧数据和新数据按位与的结果”这种让人抓狂的问题。这篇文章我打算把STM32F103通过SPI驱动W25Q64的完整流程拆开讲,从硬件连接到SPI配置,从命令时序到读写擦除的实操代码,再到我实际调试中遇到的几个典型坑。不管你是刚接触SPI和Flash的新手,还是已经能跑通但想搞清楚底层细节的开发者,应该都能从里面找到有用的东西。我用的芯片是STM32F103C8T6,SPI1,引脚是PA5(SCK)、PA6(MISO)、PA7(MOSI),片选CS用PA4软件控制。这套配置很经典,换成其他F103型号或者SPI2也大同小异。2. 硬件连接与SPI模式选择:别小看这两根线的接法2.1 引脚连接与电平匹配的注意事项先说过硬件这一关。W25Q64的引脚不多,VCC、GND、CS、CLK、DI(MOSI)、DO(MISO)、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.6V,STM32F103的IO也是3.3V,直接连没问题。但如果你用的是5V的单片机,中间必须加电平转换,否则Flash会被打坏。这个坑虽然基础,但每年都有不少人踩。2.2 SPI模式0还是模式3:时序图里藏着答案W25Q64支持SPI模式0(CPOL=0,CPHA=0)和模式3(CPOL=1,CPHA=1)。这两种模式的区别在于时钟极性和相位。模式0下,时钟空闲为低电平,数据在第一个边沿(上升沿)采样;模式3下,时钟空闲为高电平,数据在第二个边沿(下降沿)采样。我一般用模式0,因为STM32的SPI配置成模式0最直观,而且大部分例程也是这么做的。但你要知道,模式0和模式3在W25Q64上都能正常工作,关键是主机和从机必须匹配。如果你发现读出来的数据总是移位或者错位,先检查SPI模式是否和Flash要求的一致。SPI时钟频率方面,W25Q64支持最高80MHz(快速读模式下),但STM32F103的SPI1最高只能到72MHz(APB2时钟72MHz,分频系数最小2)。我一般设置在18MHz到36MHz之间,也就是分频系数4或2。为什么不用最高速?因为杜邦线飞线的情况下,高速SPI信号完整性很难保证,容易出现误码。如果你画了PCB,走线阻抗控制得好,可以往上提。但调试阶段建议先用低速(比如4.5MHz,分频系数16),确保通信稳定后再提速。// SPI1初始化配置,模式0,主机,8位数据,分频16(4.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)或者0x4018(W25Q128)。读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*128=4224,而扇区大小是4096,4224已经跨到了下一个扇区。但我在擦除的时候只擦了第一个扇区,第二个扇区没擦,所以写入的时候地址回卷到了第一个扇区的开头,覆盖了第1条日志。这个问题的根本原因是我没有按扇区管理日志,而是按条管理。后来改成按扇区擦除,每个扇区存32条日志(4096/128=32),写满一个扇区再擦下一个,问题解决。6. 性能优化与可靠性提升的几个实操技巧6.1 用DMA驱动SPI读写,解放CPUSTM32F103的SPI支持DMA。对于大块数据读写,用DMA可以显著降低CPU占用。配置SPI1_TX和SPI1_RX的DMA通道,发送和接收都走DMA,CPU只需要在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的健康状态。这个区域不要频繁擦写,可以放在最后一个扇区,只在必要时更新。