手头一个项目要给设备增加掉电不丢失的数据存储区,最早考虑SD卡,后来想想MCU这边只存几十KB的配置和日志,SD卡属实杀鸡用牛刀——卡片座、文件系统、电源管理一堆事。于是把目光放到了SPI NOR Flash上,最终选了W25Q64JVSSIQ这颗芯片:8MB容量、SOIC-8小封装、标准三线SPI就能跑,成本几块钱,折腾坏了也不心疼。这篇文章就是我用STM32的硬件SPI读写W25Q64JVSSIQ的完整记录,涉及硬件接线、指令时序、初始化代码、读写流程,以及实际调试中踩过的坑,从零到能稳定跑通,适合正在做同类存储方案的朋友们参考。
1. 项目拆解:在STM32上挂一颗8MB Flash
1.1 W25Q64JVSSIQ 这颗芯片有什么特别的
W25Q64JVSSIQ 属于SPI NOR Flash家族里非常成熟的一代,容量是64Mbit,换算过来就是8MB。这个容量在嵌入式场景里很微妙——比EEPROM大得多,可以存字库、图片、日志、升级固件缓冲区;又比SD卡和eMMC简单得多,不用管文件系统、块设备协议这些东西,发指令就能读写,特别适合MCU直接操作。
芯片供电范围是2.7V到3.6V,和绝大多数STM32的3.3V系统直接兼容。SOIC-8封装只有8个引脚,手工焊接非常友好,原理图设计也不占地方。它支持标准SPI、双线SPI和四线SPI三种模式,我项目里用的就是最基础的标准SPI,四根信号线加电源地就能跑起来。
实际用下来,这芯片最让我满意的其实是它的时序宽容度。SPI时钟最高可以到104MHz,而STM32的SPI时钟一般也就跑到几十兆,远低于Flash上限,所以通信速率根本不会成为瓶颈。命令集也不复杂,读、写、擦除、状态查询,核心指令就那么几个,非常适合快速上手。
1.2 硬件接线:四根线加两个上拉
STM32这边我选用了F103系列,SPI1挂载在APB2总线上。引脚分配是常规接法:PA5做SCK,PA6做MISO,PA7做MOSI,PA4做CS片选。
这里有个关键设计:片选CS没有接到SPI外设的硬件NSS脚上,而是用一个普通GPIO输出口控制。这样做的原因后面章节会详细讲,简单说就是Flash操作对片选时序要求很严格,用软件GPIO控制最直接可控。
| W25Q64JVSSIQ引脚 | 引脚名称 | 连接到STM32/电路 | 作用说明 |
|---|---|---|---|
| 1 | /CS | PA4 | 片选,低电平有效 |
| 2 | DO | PA6 (MISO) | 数据输出到MCU |
| 3 | /WP | 接10kΩ上拉到3.3V | 写保护,禁用 |
| 4 | GND | GND | 电源地 |
| 5 | DI | PA7 (MOSI) | MCU数据输入到Flash |
| 6 | CLK | PA5 (SCK) | SPI时钟 |
| 7 | /HOLD | 接10kΩ上拉到3.3V | 暂停通信,禁用 |
| 8 | VCC | 3.3V | 电源正极,并接0.1uF旁路电容 |
两个容易被忽视的引脚:WP和HOLD。WP是写保护脚,拉低会把状态寄存器锁住,导致写使能命令无效;HOLD是暂停脚,拉低会让Flash忽略时钟线上的后续信号,看起来就像芯片“死掉”了。这两个引脚芯片内部有弱上拉,但强烈建议外部再接10kΩ上拉到3.3V,防止上电瞬间或者信号干扰导致误触发。VCC旁边加0.1uF去耦电容,靠近芯片电源引脚放置。
接线完成之后不要急着上电,先用万用表量一下电源对地阻值,排除短路;再看一遍MOSI和MISO有没有接反。这两个引脚接反是新手最容易犯的错误,而且故障现象极其隐蔽——读ID返回数据全是0xFF,程序怎么调试都跑不通。
2. SPI通信基础与Flash指令体系
2.1 SPI的物理层与数据交换机制
SPI是同步串行通信协议,主机和从机之间通过时钟线同步数据。W25Q64JVSSIQ支持模式0(CPOL=0,CPHA=0)和模式3(CPOL=1,CPHA=1),我们这里用模式0,也就是最经典的SPI Mode 0:时钟空闲为低电平,数据在第一个上升沿采样。
SPI是“交换式”通信,主机向MOSI线上发出一字节数据的同时,从机会在MISO线上回传一字节数据。正因为这个特性,主机在读Flash数据时,需要不断向MOSI发送任意字节(通常发0xFF)来产生时钟,Flash才能把数据从MISO线上推出来。这个机制初学的时候容易绕晕,你以为是“读”数据,实际上全程都在“写”时钟。
这里必须提一下为什么推荐用STM32的硬件SPI而不是GPIO模拟SPI。硬件SPI有移位寄存器自动处理收发,你只需要检查标志位然后读写数据寄存器,CPU负载极低。而模拟SPI需要在每个bit上手动拉高拉低时钟、逐位移数据,一次8字节的读取要反复操作几十次,效率低不说,时序还容易受中断影响导致不稳。除非MCU的SPI外设刚好被占用,否则不建议模拟。
2.2 W25Q64指令集:读写之前先弄清这些命令
SPI NOR Flash都是“命令驱动”型的,MCU先发送命令字节,再发送地址,后面才是数据。W25Q64JVSSIQ的命令集不大,但每个命令都有严格的时序要求。我先整理项目中最常用的几条:
| 命令名称 | 命令字节 | 参数 | 说明 |
|---|---|---|---|
| Write Enable | 0x06 | 无 | 写使能,编程/擦除前必须执行 |
| Read Status Register 1 | 0x05 | 无,返回1字节 | 读状态寄存器,检查BUSY和写保护 |
| Read Data | 0x03 | 24位地址 | 从指定地址连续读取数据 |
| Page Program | 0x02 | 24位地址 + 数据 | 页编程,一次最多写256字节 |
| Sector Erase | 0x20 | 24位地址 | 4KB扇区擦除 |
| Block Erase | 0xD8 | 24位地址 | 64KB块擦除 |
| Chip Erase | 0xC7 | 无 | 整片擦除,非常耗时 |
| Read JEDEC ID | 0x9F | 无,返回3字节 | 读取厂商/类型/容量标识 |
每条指令的执行方式都一样:CS拉低,发送命令字节,发送24位地址(命令和地址都是高位在前),然后传输数据或接收数据,最后CS拉高。CS拉高的这个动作非常关键,它告诉Flash本次操作结束,Flash在CS拉高之后才会真正开始执行擦除或编程操作。
2.3 状态寄存器与操作约束
W25Q64有个状态寄存器1,其中最低两个位是编程时最关心的:bit0是BUSY位,1表示Flash内部正在执行操作;bit1是WEL写使能锁存位,1表示已经收到写使能命令。每次执行擦除或页编程之前,必须先发0x06写使能命令,把WEL位置1,否则Flash会直接忽略后续的编程/擦除命令。
还有一个重要的物理约束:NOR Flash只能把1写成0,把0改回1必须通过擦除操作。也就是说,你往某个地址写数据之前,要先确保该地址所在扇区已经被擦除过,否则会得到意想不到的结果。这个特性会直接影响上位机或者应用层的写入策略,后面写数据流程时会专门处理。
状态寄存器查询命令0x05的使用方式也是CS拉低发命令,然后持续读取字节直到BUSY位变0。整个过程CS保持低电平,不能中间拉高,否则状态读取就断了。这是Flash编程等待最基础的操作,几乎所有写操作都要用到它。
3. STM32 SPI外设初始化配置
3.1 引脚分配与复用配置
我用的是STM32F103系列的SPI1,引脚分配如下:PA4做片选CS,普通推挽输出;PA5做SCK,复用推挽输出;PA6做MISO,浮空输入;PA7做MOSI,复用推挽输出。
GPIO不仅要配置模式,还要注意速度。SPI通信频率相对较高,GPIO翻转速度建议配置到50MHz档位,避免信号边沿变缓导致时序劣化。MISO引脚配置为浮空输入,因为Flash的DO输出是推挽驱动,不需要内外上拉;MOSI和SCK配置为复用推挽输出。
对应代码如下:
void SPI1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // 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); // PA5 - SCK,复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_5; 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); // PA7 - MOSI,复用推挽输出 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); }CS使用普通GPIO输出而不是复用功能,这是个有意的设计。SPI外设的硬件NSS信号会在多主机模式下自动控制,但在单主机操作Flash时,硬件的自动管理反而不灵活。Flash操作命令和数据的边界完全靠CS掌控,软件控制GPIO可以随时拉低、拉高,配合时序要求做到精准。
3.2 SPI模式参数与时钟选择
SPI外设需要配置的参数包括工作模式、数据宽度、时钟极性、相位、波特率和帧格式。F103的SPI最大时钟是18MHz,而APB2总线是72MHz,所以至少需要4分频。我用的是8分频,也就是9MHz的SPI时钟,留足了余量。
关于SPI模式,我配置为模式0:CPOL=Low,CPHA=1Edge。W25Q64JVSSIQ手册上明确支持模式0和模式3,两个模式在数据传输上没有区别,只是时钟空闲电平和采样边沿不同。这里选模式0完全出于习惯,如果你配置成模式3也没问题,但主机和从机的CPOL、CPHA必须严格匹配,否则读出来的数据会错位。
配置代码:
void SPI1_Init(void) { SPI_InitTypeDef SPI_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_SPI1, ENABLE); 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_8; SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }NSS配置为软件模式,也就是完全不使用硬件片选信号,片选完全由GPIO控制。这种情况下我们只需要在初始化后使能SPI外设即可,CS引脚的上电默认状态保持高电平,防止Flash误选中。
3.3 底层读写字节函数
硬件SPI收发一字节的标准流程是:等发送缓冲区空,写入数据,等接收缓冲区非空,读出数据。整个流程封装成一个函数,后续所有Flash操作都基于它。
uint8_t SPI_Flash_ReadWriteByte(uint8_t data) { while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); return SPI_I2S_ReceiveData(SPI1); }这个函数是全双工的:传入data是发送的数据,返回值是同时接收到的数据。读Flash的时候传入0xFF,实际上就是产生时钟脉冲,把Flash输出的数据从MISO线上采回来。理解了这一点,后面读数据代码看起来就顺理成章了。
注意:如果工程里使用HAL库,对应的配置思路完全一致,只是把结构体初始化换成
SPI_InitHandle、HAL_SPI_Init,字节收发换成HAL_SPI_TransmitReceive。底层原理没有区别。
4. 读写代码实现:可以让Flash跑起来了
4.1 读ID与Flash连接验证
代码写完之后,首先要验证硬件连接和SPI通信是否正常,最快捷的方式是读取JEDEC ID。这个操作不涉及擦除和编程,也不依赖写使能,只要电路和时序没问题,就能读到固定值。
W25Q64JV系列的标准JEDEC ID是3字节:厂商ID 0xEF、内存类型0x40、容量0x17。如果这三字节能读出来,说明从GPIO配置到SPI外设再到Flash芯片整个链路都是通的。
uint32_t SPI_Flash_ReadID(void) { uint32_t id = 0; SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x9F); id |= (uint32_t)SPI_Flash_ReadWriteByte(0xFF) << 16; id |= (uint32_t)SPI_Flash_ReadWriteByte(0xFF) << 8; id |= (uint32_t)SPI_Flash_ReadWriteByte(0xFF); SPI_Flash_CS_HIGH(); return id; }CS操作函数非常简单:
#define SPI_Flash_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_4) #define SPI_Flash_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_4)读取结果如果是0xEF4017,硬件连接确认无误,可以直接进入后续读写功能开发了。如果返回全0xFF或全0x00,问题多半出在引脚配置或接线方向,这个我们在第五章单独展开排查。
4.2 擦除操作:Flash写入的前置条件
回到前面说的NOR Flash物理特性:只能把1写成0,想恢复成1必须擦除。所以一次完整的写入流程是:擦除目标扇区,检查BUSY等待擦除完成,然后执行页编程。
扇区擦除命令是4KB粒度,命令格式为0x20加上24位地址。地址只需要对齐到扇区起始地址即可,如果传入的地址不是4KB对齐,Flash也不会报错,但它会按地址所在的扇区擦除,这点要心里有数。
void SPI_Flash_EraseSector(uint32_t addr) { SPI_Flash_WriteEnable(); SPI_Flash_WaitBusy(); SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x20); SPI_Flash_ReadWriteByte((addr >> 16) & 0xFF); SPI_Flash_ReadWriteByte((addr >> 8) & 0xFF); SPI_Flash_ReadWriteByte(addr & 0xFF); SPI_Flash_CS_HIGH(); SPI_Flash_WaitBusy(); }写使能和等待函数是配套的:
void SPI_Flash_WriteEnable(void) { SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x06); SPI_Flash_CS_HIGH(); } void SPI_Flash_WaitBusy(void) { uint8_t status; SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x05); do { status = SPI_Flash_ReadWriteByte(0xFF); } while ((status & 0x01) != 0); SPI_Flash_CS_HIGH(); }擦除过程是芯片内部硬件行为,CS一旦拉高,Flash开始擦除,BUSY位置1。擦除4KB扇区的典型耗时在45ms左右,最大可能到400ms,期间不能进行其他写操作,只能查询状态。程序在擦除期间如果还需要做别的事,可以在等待BUSY期间切出去执行其他任务,等到需要访问Flash时再查询。
4.3 页编程与跨页写入处理
页编程是W25Q64最基本的写入方式,命令0x02加上24位地址,后面跟上最多256字节数据。页编程命令发出后,同样要等待BUSY清零才算完成。
这里有个最重要的坑:页编程不能跨页。如果写入的数据长度跨过了一个256字节页边界,数据会回卷到当前页的起始位置,后续内容就会覆盖本页前面已经写入的数据,造成整片数据错乱。这是W25Q64系列最经典的翻车点,几乎所有质量事故都和它有关。
先写页编程的基础函数:
void SPI_Flash_WritePage(uint8_t *buf, uint32_t addr, uint16_t len) { SPI_Flash_WriteEnable(); SPI_Flash_WaitBusy(); SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x02); SPI_Flash_ReadWriteByte((addr >> 16) & 0xFF); SPI_Flash_ReadWriteByte((addr >> 8) & 0xFF); SPI_Flash_ReadWriteByte(addr & 0xFF); while (len > 0) { SPI_Flash_ReadWriteByte(*buf++); len--; } SPI_Flash_CS_HIGH(); SPI_Flash_WaitBusy(); }这个函数只能处理长度不超过当前页剩余空间的写入。实际应用写入的数据长度通常都是任意的,可能从某个页的中间位置开始,长度也可能超过256字节。所以必须做一个跨页自动分包的处理:
void SPI_Flash_WriteData(uint8_t *buf, uint32_t addr, uint16_t len) { uint16_t remaining = len; uint16_t offset = addr & 0xFF; uint16_t chunk; // 第一页只能写当前页剩余空间 chunk = 256 - offset; if (chunk > remaining) chunk = remaining; while (remaining > 0) { SPI_Flash_WritePage(buf, addr, chunk); buf += chunk; addr += chunk; remaining -= chunk; // 后续每次都从页开头写,最多写满一页 chunk = (remaining > 256) ? 256 : remaining; } }这个函数先用addr & 0xFF取地址的低8位,算出现在已经写入到了当前页的哪个偏移位置;然后用256 - offset算出当前页还剩多少个有效字节空间,第一次写入不能超过这个值。之后每一轮都从新地址的页首开始,一次最多写256字节。这套逻辑在实际项目中可以直接抄作业。
写使能命令要放在每次页编程之前,不能在循环外只发一次。因为一次页编程完成后,WEL位会自动清零,下一次编程前必须重新使能。有些初学者图省事只在循环前执行一次写使能,结果就是只有第一页写成功,后面的页全部无效。
4.4 数据读取与校验
读取数据相比写入要简单得多,因为读操作不会改变Flash内部状态,不需要写使能,也不涉及擦除。只要CS拉低,发送0x03命令和24位地址,然后持续读字节直到长度满足要求。
void SPI_Flash_ReadData(uint8_t *buf, uint32_t addr, uint16_t len) { SPI_Flash_CS_LOW(); SPI_Flash_ReadWriteByte(0x03); SPI_Flash_ReadWriteByte((addr >> 16) & 0xFF); SPI_Flash_ReadWriteByte((addr >> 8) & 0xFF); SPI_Flash_ReadWriteByte(addr & 0xFF); while (len > 0) { *buf++ = SPI_Flash_ReadWriteByte(0xFF); len--; } SPI_Flash_CS_HIGH(); }读取命令0x03支持连续读取,也就是不需要每个字节都重复发送地址,芯片会自动递增地址,直到CS拉高为止。这个特性在大数据块读取时特别好用,读取整个8MB内容也不过是8MB字节流,速度取决于SPI时钟频率。
完整跑通读写之后,不要忘了做数据回读校验。工程上比较稳妥的做法是:先擦除一个扇区,写入一组已知模式数据(比如递增数),读回来逐字节比对,全部一致才算这个读写链路真正没问题。我通常还会专门测试跨页边界的情况,比如从地址0x0100开始写300字节,验证跨页分包是否正确。
5. 调试实录:那些年踩过的坑
5.1 读ID全FF问题排查
第一次上电调试,最常遇到的现象就是读ID返回0xFFFFFF。这背后至少有六种可能,得一条条排查。
网络表线序问题占了大头:MOSI和MISO接反、CS压根没接到PA4、SCK短路,这类错误万用表量一下就能排除。GPIO配置错误也很好查:PA5和PA7忘了配成复用推挽,输出不了时钟和数据;PA6配置成推挽输出而不是输入,MISO数据永远读不到0和1以外的东西。
SPI模式不匹配也经常发生,主机配置CPOL=0、CPHA=0,从机工作在模式3,时序完全对不上,Flash不会响应。时钟极性和相位这两项,务必和芯片手册确认清楚,W25Q64支持模式0和模式3,但我见过好几个项目两边各写各的,一个模式0一个模式3,结果读出来的数据全是0xFF。
软件流程问题同样要排查:发送0x9F之前没把CS拉低,或者命令发完后提前把CS拉高,都会导致ID读不出来。这种情况逻辑分析仪一抓波形,问题立刻清楚。
5.2 数据错乱背后的教训
数据回读时发现写入和读取不一致,首先要确认是不是没擦除就开始写。NOR Flash的特性决定了:如果一个地址之前被写入过0,那么在没有擦除的情况下再次写入是无法把它变成1的。向一个未擦除的扇区写入数据,读回来的内容会是你期望值和旧值的“与”运算结果,各种不重样的错乱。
还有一个隐蔽的坑:页编程跨页。很多开发者第一次写的页编程函数,直接按地址连续写,长度超过页边界后,数据会绕回到当前页首地址覆盖之前的数据。这种情况读出来的表现是前半段正确、后半段错乱,或者中间一段数据被莫名覆盖,完全看不出来是哪里写错了。用我们上面那个分包函数,跨页问题才真正从根上解决。
再一个容易犯的错是把0x06写使能命令当成一次性操作。写使能WEL位会在每次页编程、擦除完成后自动清零,所以每次写入操作前都要重新发送0x06。如果你在连续写入多个扇区时只在最开始发了一次写使能,会发现只有第一次写操作生效,后面所有擦除和编程都被静默忽略。
5.3 性能实测数据
实际测试中,SPI时钟设为9MHz,读取速度大约800KB/s左右。没有跑满理论值的950KB/s,主要损耗在每个字节收发时的标志位轮询和函数调用开销。如果项目有大数据量读取需求,可以考虑把SPI时钟提升到18MHz并启用DMA传输,速度可以再上一个台阶。
写入侧的性能瓶颈不在SPI时钟,而在Flash内部操作时间。页编程典型耗时0.7ms一页,1MB数据按纯页编程算大约需要3秒;再加上必须先做的扇区擦除,一个4KB扇区典型耗时45ms,擦除1MB需要256个扇区,也就是大约11.5秒。如果做全片擦除,W25Q64JV需要的时间在几十秒量级,所以整片擦除操作要慎用,最好只在产品初始化时执行一次。
| 操作 | 典型耗时 | 备注 |
|---|---|---|
| 读1MB数据 | 约1.2秒 | 9MHz SPI时钟,无DMA |
| 写1MB数据 | 约3秒 | 纯页编程,不含擦除 |
| 擦除1MB | 约11.5秒 | 按4KB扇区擦除,典型值 |
| 整片擦除 | 几十秒 | 按8MB整片擦除 |
5.4 常见问题速查表
| 故障现象 | 可能原因 | 排查方向 |
|---|---|---|
| 读ID返回0xFFFFFF | 接线/方向/SPI模式错 | 查线序、查GPIO复用、核对CPOL/CPHA |
| 读ID返回0x00开头 | MISO引脚没有信号 | 检查MISO引脚焊接、配置是否输入 |
| 写数据后读出全0xFF | 未擦除或写使能没生效 | 执行扇区擦除后再写,确认WEL为1 |
| 数据读到一半多出0xFF | 实际读的长度超预期 | 检查读取长度的uint16_t溢出或参数传递错误 |
| 偶发写入失败 | SPI时钟干扰或上电时序 | 降SPI时钟频率,检查电源纹波 |
| 芯片有时不响应 | HOLD或WP浮空导致误触发 | 外部接10kΩ上拉到3.3V |
| 跨地址写入数据错乱 | 页编程跨页 | 按页边界分包处理 |
HOLD脚的问题我额外提一句:如果芯片的HOLD引脚处于浮空状态,在电磁环境复杂的工业现场,一次静电放电或邻近信号干扰就可能把HOLD拉低,Flash立刻暂停响应。现象极其诡异,程序看起来一切正常,但芯片就是不干活。解决办法很简单,PCB布线时给HOLD接一个上拉电阻,这是我在实际项目里真金白银买来的教训。
6. 代码封装与工程集成建议
当读写Flash的基础代码调试稳定之后,建议再往前走一步,做一层简单的“存储管理封装”。直接在应用逻辑里到处调用页编程和扇区擦除,会让代码变得难以维护,尤其是涉及多扇区规划、坏块替换、数据备份这类需求时。
我个人的做法是在Flash驱动之上加一个简单的存储接口,把“擦哪个扇区、写哪个地址、读哪段数据”的细节全部封装起来。应用层只需要关心逻辑存储区域,比如配置区、日志区、数据区,每个区映射到Flash的哪一段地址空间由驱动层统一管理。这样即使换了更大容量的Flash,或者调整了地址分配,应用层代码几乎不用改。
一个值得推荐的小技巧是写入日志数据时使用“段内覆盖”策略:日志只追加不覆盖旧数据,写完一个扇区就切换到下一个扇区继续写。因为Flash的最小擦除单位是4KB,如果每次都从头覆盖写,磨损会非常不均匀。这个策略配合8MB容量,日志区可以工作很多年。
另外,产品量产时千万别忽略擦写寿命。W25Q64JVSSIQ的擦写寿命大概是10万次,看起来挺多,但如果你在代码里写了一个每100ms保存一次数据的逻辑,一天下来就是86万次写入,一个月就把芯片写穿了。所以写入频率必须有意识控制,数据相同则不写、批量变化才写、日志及时滚动删除,这些策略比换更贵的芯片要实在得多。
最后再分享一个调试习惯:调Flash相关的问题,不要光靠眼睛看代码,几十块钱的逻辑分析仪买一个,抓CS、CLK、MISO、MOSI四根线的波形,对着时序图看。芯片不响应、数据错位、命令没发出去,一眼就能定位。我每次新板子调试的第一件事就是抓一遍读ID的波形,确认时钟极性、整体时序、字节顺序完全符合预期,后面写应用代码心里特别踏实。这套流程跑通之后,W25Q64JVSSIQ在手就跟捏方便面一样简单。