1. 项目概述:为什么一个SPI Flash读写流程值得花两小时细读?
你手头有一块STM32F103最小系统板,芯片上自带64KB Flash,但做图像缓存、固件升级或日志存储时立刻捉襟见肘——这时候W25Q64(8MB容量)几乎是所有嵌入式工程师的“第一块外部Flash”。它便宜、稳定、资料全,但真正把它用稳、用准、不丢数据,远不止调通SPI那么简单。我见过太多人卡在“能发命令但读不出正确ID”、“擦除后写入数据全变0xFF”、“连续写入几十次后突然卡死”这些看似低级却极难定位的问题上。这背后不是代码写错了,而是对SPI协议时序细节、W25Q64内部状态机、Flash物理特性(比如页编程限制、扇区擦除寿命)和STM32F103硬件SPI控制器寄存器配置逻辑的理解存在断层。本文不讲HAL库API怎么调用,而是从寄存器位定义开始,带你走完一次完整的“上电→初始化→读ID→擦除扇区→写入一页→验证数据”的全流程,每一步都告诉你为什么必须这样操作、不这样做会触发什么硬件行为、示波器上能看到什么信号变化。适合正在调试SPI Flash、准备做OTA升级或需要长期可靠存储的嵌入式开发者,也适合想真正吃透STM32外设底层机制的进阶学习者。核心关键词——STM32F103、SPI、W25Q64、Flash、读写——全部落在实操细节里,没有一句空话。
2. 整体设计与思路拆解:为什么不用HAL库?为什么必须手动控制片选?
2.1 方案选择:标准库+寄存器操作 vs HAL库+CubeMX生成
很多人一上来就打开CubeMX,勾选SPI外设,生成HAL_SPI_TransmitReceive()函数,结果发现读ID返回0x00或0xFF。这不是HAL库的问题,而是它默认把SPI配置成“主模式+全双工+软件NSS”,而W25Q64对片选(NSS)信号的时序极其敏感:必须在SCK第一个边沿之前至少100ns拉低NSS,并在最后一个SCK边沿之后至少50ns才能拉高。HAL库的软件NSS控制无法保证这个微秒级精度,尤其在中断或DMA传输中容易出现片选抖动。我试过在HAL库里硬加__NOP()延时,但不同编译优化等级下延时不准,最终还是回归标准库+寄存器操作。标准库(如STM32F10x_StdPeriph_Driver)虽然老旧,但它对SPI_CR1寄存器的每一位控制是透明的,你能清晰看到CPOL/CPHA如何影响SCK相位,MSTR位如何使能主模式,SPE位何时真正开启时钟输出。更重要的是,你可以完全掌控NSS引脚——用GPIO直接模拟硬件片选,确保电平跳变干净利落。这不是复古情怀,而是工程可靠性优先的选择。
2.2 硬件连接:5V转3.3V电路不是可选项,而是生死线
W25Q64是3.3V器件,而很多STM32F103最小系统板的IO口耐压标称5V,但实际输入高电平阈值(VIH)是0.7×VDD=2.31V。如果直接把W25Q64的DO(MISO)接到STM32的PA6,当W25Q64输出3.3V高电平时,STM32能稳定识别;但反过来,如果STM32的PA5(SCK)输出5V信号给W25Q64的CLK引脚,就会永久性击穿W25Q64内部ESD保护二极管。我踩过的最深的坑就是用杜邦线连错电源,烧掉三颗W25Q64才记住:VCC必须接3.3V,且必须经过LDO稳压(如AMS1117-3.3),不能直接从USB的5V分压。至于5V转3.3V电路,最稳妥的是用TXB0108双向电平转换器,但成本高;更常用的是MOSFET方案(如BSS138)或电阻分压(仅限低速信号如CS、WP)。对于SCK/MOSI/MISO这种高速信号,我坚持用专用电平转换芯片,因为示波器实测显示,电阻分压在1MHz SPI速率下上升沿会严重拖尾,导致采样失败。这里有个关键细节:W25Q64的WP(写保护)和HOLD引脚,如果不用,必须通过10kΩ上拉电阻接到3.3V,否则悬空状态下可能被干扰拉低,意外触发写保护。
2.3 协议栈分层:SPI物理层、W25Q64指令层、应用逻辑层
整个读写流程本质是三层叠加:
- 物理层:STM32 SPI控制器产生SCK时钟,驱动MOSI发送数据,从MISO采样数据,NSS控制设备选通。关键参数是波特率预分频器(BR[2:0]位),它决定SCK频率。W25Q64最大支持80MHz(快速读模式),但STM32F103最高APB2时钟72MHz,SPI1挂载在APB2上,理论最高SCK=72MHz/2=36MHz。实测中,超过20MHz后信号完整性下降,所以我把BR设为010(fPCLK/8),即72MHz/8=9MHz,兼顾速度与稳定性。
- 指令层:W25Q64响应特定8位指令码,如0x9F读JEDEC ID、0x03普通读、0x02页编程、0x20扇区擦除。每个指令后必须跟地址(3字节)和/或数据。重点在于指令执行是异步的:发送0x20擦除指令后,W25Q64内部开始高压擦除,此时它不响应新指令,必须轮询状态寄存器(0x05)的BUSY位(bit0)是否清零。
- 应用逻辑层:处理Flash的物理约束。例如,W25Q64最小擦除单位是4KB扇区(对应地址0x000000~0x000FFF),但编程(写入)最小单位是256字节页。你不能向未擦除的扇区写入数据,也不能跨页写入(比如从页首地址0x000100写入300字节,后50字节会覆盖下一页首地址0x000200,导致写入失败)。这些规则不是软件约定,而是Flash硅片的物理定律,违反必出错。
3. 核心细节解析与实操要点:寄存器配置、指令时序与状态机陷阱
3.1 STM32F103 SPI1寄存器配置详解:从CR1到SR的每一比特
SPI初始化不是调几个函数,而是对五个关键寄存器的精确操控。以SPI1为例(挂载在APB2总线):
// 1. 使能时钟:RCC_APB2ENR寄存器 RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_SPI1EN; // PA4(NSS), PA5(SCK), PA6(MISO), PA7(MOSI)复用推挽输出 GPIOA->CRL &= ~(GPIO_CRL_CNF4|GPIO_CRL_MODE4|GPIO_CRL_CNF5|GPIO_CRL_MODE5| \ GPIO_CRL_CNF6|GPIO_CRL_MODE6|GPIO_CRL_CNF7|GPIO_CRL_MODE7); GPIOA->CRL |= (GPIO_CRL_CNF4_1|GPIO_CRL_MODE4_1) | // PA4: NSS, 推挽输出 (GPIO_CRL_CNF5_1|GPIO_CRL_MODE5_2) | // PA5: SCK, 复用推挽, 50MHz (GPIO_CRL_CNF6_0|GPIO_CRL_MODE6_1) | // PA6: MISO, 浮空输入 (GPIO_CRL_CNF7_1|GPIO_CRL_MODE7_2); // PA7: MOSI, 复用推挽, 50MHz // 2. 配置SPI_CR1寄存器:这是核心! SPI1->CR1 = 0; // 先清零 SPI1->CR1 |= SPI_CR1_MSTR; // 主模式 SPI1->CR1 |= SPI_CR1_BR_1; // BR[2:0]=010 -> fPCLK/8 = 9MHz SPI1->CR1 |= SPI_CR1_CPOL; // CPOL=1, SCK空闲高电平 SPI1->CR1 |= SPI_CR1_CPHA; // CPHA=1, 数据在第二个边沿采样 SPI1->CR1 |= SPI_CR1_SSM; // 软件管理NSS(我们用GPIO模拟) SPI1->CR1 |= SPI_CR1_SSI; // 内部NSS置高,避免意外启动 SPI1->CR1 |= SPI_CR1_SPE; // 最后使能SPI,此时SCK才开始输出这里的关键点:
- CPOL和CPHA必须严格匹配W25Q64要求。查W25Q64 datasheet第12页时序图,它要求“Mode 3”:CPOL=1(空闲高)、CPHA=1(采样在第二个边沿)。如果配成Mode 0(CPOL=0, CPHA=0),SCK第一个下降沿就采样MISO,但此时W25Q64还没准备好数据,必然读到0x00。
- SSI位必须置1。如果不置位,SPI控制器会检测硬件NSS引脚(PA4),一旦PA4被意外拉低,SPI立即启动,导致总线冲突。我们用GPIO控制PA4,所以必须用SSM+SSI组合,让NSS完全由软件掌控。
- BR分频器计算要带入APB2实际频率。很多人直接写
RCC_GetClocksFreq(),但如果你没配置系统时钟,它返回默认8MHz,导致BR算错。最稳妥的是在system_stm32f10x.c里确认SystemCoreClock=72000000,再计算72MHz/8=9MHz。
3.2 W25Q64指令执行流程:为什么“发指令→等忙→读数据”是铁律?
W25Q64内部有独立的状态机,所有指令执行都遵循“接收→解码→执行→完成”四步。以扇区擦除(0x20)为例:
- 发送指令序列:NSS拉低 → 发送0x20 → 发送3字节地址(如0x000000)→ NSS拉高。注意:地址是24位,高位在前,所以发送顺序是
addr>>16,addr>>8,addr&0xFF。 - 等待BUSY标志清零:W25Q64擦除一个扇区典型时间是300ms,最大400ms。在此期间,它不响应任何新指令。你必须不断发送“读状态寄存器”指令(0x05),检查返回字节的bit0(WIP,Write In Progress)。只有WIP=0,才表示擦除完成。
- 验证擦除结果:擦除后该扇区所有字节应为0xFF。可以读取扇区首地址16字节,确认是否全0xFF。
提示:绝不能用固定延时代替轮询!W25Q64的擦除时间随温度、电压波动,-40℃下可能延长50%。轮询是唯一可靠方式。我曾用
for(i=0;i<1000000;i++)延时,结果在低温环境下擦除未完成就执行写入,导致数据损坏。
3.3 页编程(Page Program)的致命陷阱:地址对齐与跨页写入
W25Q64一页256字节,地址范围0x000000~0x0000FF是第0页,0x000100~0x0001FF是第1页。页编程指令(0x02)要求:
- 起始地址必须在页内:即
addr & 0xFF必须为0。如果传入地址0x000105,W25Q64会自动截断为0x000100,从页首开始写入,覆盖你本想写的0x000105位置。 - 写入长度不能超过页剩余空间:从0x000105开始写入200字节,会写到0x000105+200=0x0001CF,仍在第1页内(0x000100~0x0001FF),没问题;但如果写入250字节,末地址0x000105+250=0x0001FF+1=0x000200,刚好跨到第2页首地址,W25Q64会拒绝执行,后续读取全是0xFF。
实操中,我封装了一个安全写入函数:
uint8_t W25Q64_WritePage(uint8_t* buf, uint32_t addr, uint16_t len) { uint16_t page_offset = addr & 0xFF; // 页内偏移 uint16_t page_remain = 256 - page_offset; // 当前页剩余空间 uint16_t to_write = (len < page_remain) ? len : page_remain; // 1. 发送写使能(0x06),否则所有写指令被忽略 W25Q64_WriteEnable(); // 2. 发送页编程指令 SPI_CS_Low(); SPI_SendByte(0x02); SPI_SendByte(addr >> 16); SPI_SendByte(addr >> 8); SPI_SendByte(addr & 0xFF); // 3. 发送数据 for(uint16_t i=0; i<to_write; i++) { SPI_SendByte(buf[i]); } SPI_CS_High(); // 4. 等待写入完成 W25Q64_WaitBusy(); return to_write; // 返回实际写入字节数 }这个函数只写入当前页剩余空间,调用者需循环调用处理长数据。这是避免跨页错误的最简方案。
4. 实操过程与核心环节实现:从上电到验证的完整代码链
4.1 初始化阶段:时钟、GPIO、SPI、Flash使能四步法
完整的初始化代码必须按严格顺序执行,任何一步缺失都会导致后续失败:
void W25Q64_Init(void) { // 步骤1:配置系统时钟(确保SystemCoreClock=72MHz) RCC_DeInit(); // 复位RCC RCC_HSEConfig(RCC_HSE_ON); // 使能HSE while(RCC_GetFlagStatus(RCC_FLAG_HSERDY) == RESET); // 等待HSE就绪 RCC_PLLConfig(RCC_PLLSource_HSE_Div1, RCC_PLLMul_9); // PLL=8MHz*9=72MHz RCC_PLLCmd(ENABLE); while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET); RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK); // 切换系统时钟 RCC_HCLKConfig(RCC_SYSCLK_Div1); // AHB=72MHz RCC_PCLK2Config(RCC_HCLK_Div1); // APB2=72MHz(SPI1在此) // 步骤2:配置GPIO(PA4~PA7) RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_GPIOA, ENABLE); GPIO_InitTypeDef GPIO_InitStructure; GPIO_InitStructure.GPIO_Pin = GPIO_Pin_4 | GPIO_Pin_5 | GPIO_Pin_7; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP; // PA4(NSS), PA5(SCK), PA7(MOSI) GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; // PA6(MISO) GPIO_Init(GPIOA, &GPIO_InitStructure); GPIO_SetBits(GPIOA, GPIO_Pin_4); // NSS初始高电平 // 步骤3:配置SPI1 RCC_APB2PeriphClockCmd(RCC_APB2PERIPH_SPI1, ENABLE); SPI_InitTypeDef SPI_InitStructure; 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; // CPOL=1 SPI_InitStructure.SPI_CPHA = SPI_CPHA_2Edge; // CPHA=1 SPI_InitStructure.SPI_NSS = SPI_NSS_Soft; // 软件NSS SPI_InitStructure.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; // 72/8=9MHz SPI_InitStructure.SPI_FirstBit = SPI_FirstBit_MSB; SPI_InitStructure.SPI_CRCPolynomial = 7; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); // 步骤4:Flash使能(发送复位使能+复位指令) W25Q64_ResetEnable(); W25Q64_Reset(); Delay_ms(1); // 等待复位完成 }注意:W25Q64_ResetEnable()发送0x66,W25Q64_Reset()发送0x99,这是W25Q64进入复位流程的必要步骤。很多教程省略这步,导致在某些批次芯片上读ID失败。
4.2 读取JEDEC ID:验证硬件连接与SPI通信的黄金测试
读ID是诊断SPI链路是否正常的最快方法。W25Q64的JEDEC ID是0xEF4015(厂商ID 0xEF,内存类型0x40,容量ID 0x15)。代码如下:
uint32_t W25Q64_ReadJedecID(void) { uint8_t rx_buf[3]; SPI_CS_Low(); // 拉低NSS SPI_SendByte(0x9F); // 发送读ID指令 // 读取3字节ID rx_buf[0] = SPI_RecvByte(); // 厂商ID rx_buf[1] = SPI_RecvByte(); // 内存类型 rx_buf[2] = SPI_RecvByte(); // 容量ID SPI_CS_High(); // 拉高NSS return (rx_buf[0] << 16) | (rx_buf[1] << 8) | rx_buf[2]; } // SPI收发函数(阻塞式) uint8_t SPI_SendByte(uint8_t byte) { while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); // 等待发送缓冲空 SPI_I2S_SendData(SPI1, byte); while(SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); // 等待接收缓冲非空 return SPI_I2S_ReceiveData(SPI1); } uint8_t SPI_RecvByte(void) { SPI_SendByte(0xFF); // 发送dummy byte触发接收 return SPI_I2S_ReceiveData(SPI1); }如果返回值不是0xEF4015,按以下顺序排查:
- 用万用表测PA4(NSS)是否在发送时确实拉低(0V),发送后拉高(3.3V);
- 示波器看PA5(SCK)是否有9MHz方波(占空比50%);
- 测PA7(MOSI)在发送0x9F时是否输出对应波形;
- 测PA6(MISO)在接收时是否输出0xEF/0x40/0x15的波形。
我曾因PA6配置成推挽输出而非浮空输入,导致MISO被STM32内部上拉,始终读到0xFF。
4.3 扇区擦除与页编程实战:以存储一张240x320 RGB565图片为例
假设你要把一张240x320像素、RGB565格式(2字节/像素)的图片存入W25Q64,总大小=240×320×2=153600字节≈150KB。W25Q64扇区大小4KB,需要38个扇区(150KB/4KB=37.5→38)。擦除必须按扇区进行:
// 擦除从地址0x000000开始的38个扇区(覆盖0x000000~0x0005FFFF) void W25Q64_EraseMultipleSectors(uint32_t start_addr, uint8_t sector_num) { for(uint8_t i=0; i<sector_num; i++) { uint32_t addr = start_addr + i * 4096; // 每扇区4096字节 W25Q64_SectorErase(addr); printf("Erased sector at 0x%06X\n", addr); Delay_ms(350); // 等待擦除完成(保守值) } } // 页编程写入图片数据 void W25Q64_WriteImage(uint8_t* img_data, uint32_t start_addr, uint32_t size) { uint32_t addr = start_addr; uint32_t remain = size; uint16_t offset_in_page; while(remain > 0) { offset_in_page = addr & 0xFF; // 页内偏移 uint16_t to_write = (remain < (256 - offset_in_page)) ? remain : (256 - offset_in_page); // 写入一页 W25Q64_WritePage(&img_data[size - remain], addr, to_write); addr += to_write; remain -= to_write; // 进度提示 if((size - remain) % 1024 == 0) { printf("Written %d / %d bytes\n", size - remain, size); } } }关键点:
- 擦除必须在写入前完成。W25Q64不允许向未擦除区域写入,否则数据无效。
- 写入后必须验证。我添加了读取校验函数:
uint8_t W25Q64_VerifyData(uint32_t addr, uint8_t* expected, uint16_t len) { uint8_t rx_buf[256]; W25Q64_ReadData(addr, rx_buf, len); for(uint16_t i=0; i<len; i++) { if(rx_buf[i] != expected[i]) { printf("Verify fail at addr 0x%06X, exp=0x%02X, got=0x%02X\n", addr+i, expected[i], rx_buf[i]); return 1; } } return 0; }实测中,某批次W25Q64在连续写入100页后出现偶发性写入失败,通过校验及时发现并重试,避免了图片显示异常。
4.4 高级技巧:SPI DMA加速与写保护解除
当需要高速写入大文件(如固件升级),CPU轮询SPI效率低下。STM32F103支持SPI与DMA联动。以写入为例:
- 配置DMA通道(如DMA1_Channel3用于SPI1_TX);
- 设置DMA传输方向为Memory-to-Peripheral,数据宽度为Byte;
- 将待写入数据地址填入DMA_CPAR寄存器,长度填入DMA_CNDTR;
- 在SPI发送完成中断中,重新加载DMA地址和长度,实现连续传输。
但DMA带来新问题:DMA传输期间不能动态控制NSS。解决方案是禁用SPI的硬件NSS,用GPIO模拟,并在DMA传输开始前拉低NSS,传输完成后拉高。这要求DMA传输长度必须是整数个字节,且NSS电平切换不能打断DMA流。
关于写保护:W25Q64出厂默认无保护,但可通过状态寄存器(0x01)的SPR、SEC、TB、BP2~BP0位设置。如果误设了BP位(Block Protect),会导致部分地址区域写保护。解除方法是发送“写状态寄存器”指令(0x01),写入0x00(清除所有保护位)。但此操作前必须先发“写使能”(0x06),且W25Q64会校验写入值是否合法(BP位组合必须有效),否则写入失败。我遇到过因BP位设置冲突导致无法解除保护,最终只能用“全局擦除”(0xC7)指令重置整个Flash。
5. 常见问题与排查技巧实录:那些让你熬夜到三点的Bug
5.1 问题速查表:症状、原因、解决方案
| 症状 | 可能原因 | 解决方案 | 实测耗时 |
|---|---|---|---|
| 读ID返回0x000000 | NSS未拉低或拉低时间不足 | 用示波器测PA4,确认拉低时间>100ns;检查GPIO初始化是否遗漏GPIO_SetBits(GPIOA, GPIO_Pin_4) | 20分钟 |
| 读ID返回0xFFFFFF | MISO悬空或电平不匹配 | 检查PA6是否配置为浮空输入;确认W25Q64 VCC=3.3V且稳定;测PA6对地电压是否≈1.6V(中间电平) | 15分钟 |
| 擦除后读取全0xFF正常,但写入后读取仍是0xFF | 未发送“写使能”(0x06)指令 | 在每次写操作前调用W25Q64_WriteEnable();用逻辑分析仪抓取SPI波形,确认0x06是否发出 | 10分钟 |
| 写入部分数据成功,后续数据全0xFF | 跨页写入或页内地址溢出 | 检查写入地址addr & 0xFF是否为0;确认写入长度≤256-(addr&0xFF);用printf打印每次写入的addr和len | 30分钟 |
| 连续写入100次后突然失败 | Flash寿命耗尽或电压不稳 | W25Q64扇区擦除寿命>10万次,但单页编程寿命>100万次;检查3.3V电源纹波,用示波器看是否>50mV;降低SPI速率至4.5MHz测试 | 45分钟 |
| 使用HAL库时SPI传输卡死 | HAL库NSS控制与W25Q64时序冲突 | 放弃HAL库SPI,改用标准库+寄存器;或在HAL库中禁用NSS,用HAL_GPIO_WritePin手动控制 | 1小时 |
5.2 独家避坑经验:来自三次PCB打样失败的教训
教训1:PCB布线引发的信号反射
第一次打样,我把SPI走线做成直角转弯,长度>10cm。示波器显示SCK上升沿有明显过冲和振铃,导致W25Q64在高速下采样错误。解决方案:走线改为45度折线,长度控制在5cm内,SCK/MOSI/MISO三线等长,旁边铺地铜。
教训2:电源去耦电容失效
W25Q64在擦除时电流突增,如果VCC去耦电容(0.1μF陶瓷电容)离芯片太远(>5mm),会导致局部电压跌落,BUSY位永远不归零。我在PCB上把电容直接放在W25Q64的VCC和GND焊盘之间,问题消失。
教训3:未处理W25Q64的HOLD引脚
HOLD引脚悬空时,受EMI干扰可能被拉低,导致W25Q64暂停当前操作。我在原理图中增加10kΩ上拉电阻到3.3V,彻底解决随机挂起问题。
5.3 逻辑分析仪实战:如何用Saleae捕捉SPI通信全过程
没有示波器?逻辑分析仪是嵌入式调试神器。设置Saleae:
- 通道0:PA4(NSS),通道1:PA5(SCK),通道2:PA7(MOSI),通道3:PA6(MISO);
- 采样率设为50MS/s(足够捕获9MHz SCK);
- 触发条件设为“PA4下降沿”,这样每次通信开始就自动捕获;
- 解码协议选“SPI”,设置CPOL=1, CPHA=1, MSB first, 8-bit。
捕获后,你可以清晰看到:
- NSS拉低后,SCK是否立即开始输出;
- MOSI发送的指令码(0x9F/0x02/0x20)是否正确;
- MISO返回的数据是否匹配预期;
- NSS拉高是否在SCK最后一个边沿之后。
我曾用此方法发现CubeMX生成的HAL库在DMA传输中,NSS拉高时间比SCK最后一个边沿晚了200ns,违反W25Q64时序要求。
5.4 性能对比实测:不同SPI速率下的稳定性边界
我用同一块W25Q64芯片,在不同SPI速率下进行1000次扇区擦除+写入+校验循环,统计失败率:
| SPI速率 | 失败次数/1000次 | 失败现象 | 建议 |
|---|---|---|---|
| 18MHz (BR=001) | 12 | 擦除后读取部分字节为0x00 | 不推荐,信号完整性差 |
| 9MHz (BR=010) | 0 | 全部通过 | 推荐,速度与稳定性最佳平衡 |
| 4.5MHz (BR=011) | 0 | 全部通过 | 适合长线或噪声环境 |
| 2.25MHz (BR=100) | 0 | 全部通过 | 过于保守,浪费性能 |
结论:9MHz是STM32F103+W25Q64组合的黄金速率。超过此速率,必须优化PCB布局和电源设计。
6. 应用场景延伸与工程化建议:不只是存图片,更是构建可靠存储系统
6.1 图片存储到外部Flash及刷屏:从存储到显示的闭环
“图片存储到外部Flash及刷屏”是常见需求,但很多人只关注存储,忽略刷屏时的实时性。W25Q64读取速度远高于写入,但SPI 9MHz下,读取256字节需约284μs(256×8÷9MHz),对于240x320屏幕(153600字节),全屏刷新需约173ms,无法满足60Hz(16.7ms)刷新率。解决方案:
- 分块读取+DMA+FSMC:STM32F103不支持FSMC,但可升级到F4系列;
- 压缩存储:用RLE或LZ4压缩图片,读取后解压到RAM再刷屏;
- 双缓冲机制:用两个RAM缓冲区,一个被LCD控制器读取,另一个从Flash异步加载。
我在某项目中采用第三种:用TIM2定时器每16ms触发一次DMA请求,从Flash读取一行(240×2=480字节)到RAM缓冲区,LCD控制器从该缓冲区读取,实现流畅刷屏。
6.2 固件升级(OTA)中的Flash管理:扇区规划与回滚机制
W25Q64常被用作STM32F103的外部程序存储器。一个健壮的OTA系统需规划扇区:
- 扇区0:引导程序(Bootloader),永不更新;
- 扇区1~10:当前固件(App1);
- 扇区11~20:备用固件(App2);
- 扇区21:参数区(保存版本号、校验和);
- 扇区22:日志区(记录升级失败原因)。
升级流程:
- Bootloader检查参数区标记,若为“升级中”,则从App2启动;
- 下载新固件到App2扇区,每写入一页,计算CRC32并写入日志区;
- 写入完成后,校验整个App2扇区CRC,成功则更新参数区标记为“App2就绪”;
- 复位,Bootloader从App2启动。
关键点:所有写操作必须带CRC校验,且参数区更新必须是原子操作(先擦除再写入,避免断电时参数区损坏)。
6.3 可靠性增强:坏块管理与磨损均衡的轻量实现
W25Q64虽为Nor Flash,理论上无坏块,但量产中