☰
STM32F103驱动W25Q64 SPI Flash读写全流程详解与避坑指南
2026/10/14 1:25:51 网站建设 项目流程

做过 STM32F103 项目的朋友大多有过这种经历:系统里总有些小数据要存,比如设备序列号、校准参数、运行日志。用片内 Flash 也不是不行,但容量小,频繁擦写怕磨损;外挂一颗 I2C EEPROM 又觉得容量和速度都差点意思。我的选择比较常规但很稳:给主控接一片 SPI NOR Flash,型号 W25Q64。64Mbit 也就是 8MB,接线只有 4 根,SPI 时钟能跑到几十兆,写之前只要把“先擦后写”这个逻辑理顺,驱动其实就是一个几十行的 C 文件。

这篇文章就是基于 STM32F103 的 SPI 外设,把 W25Q64 的读写全流程拆开讲透。从芯片内部结构、引脚接线、SPI 模式选择,到初始化代码、指令封装、跨页写入、回读校验,最后附上我实际调试中踩过的坑。适合刚接触 SPI 设备的读者快速跑通驱动,也适合准备给项目增加外部存储的开发者做方案参考。

1. 被忽略的硬件基础:W25Q64 的内部结构决定了你的代码写法

1.1 8MB 存储空间是怎么分层的

W25Q64 这颗芯片虽然容量不大,但内部结构有三层粒度:页(Page)、扇区(Sector)、块(Block)。

简单换算:64Mbit 除以 8 就是 8MByte。在这 8MB 里,每一页是 256 字节,每 16 页组成一个 4KB 的扇区,每 16 个扇区组成一个 64KB 的块。于是整个芯片一共 4096 个扇区、128 个块。

这个分层结构不是随便定的,它直接对应了指令集的粒度。后面你会看到,页编程一次最多写 256 字节,扇区擦除一次最少擦 4KB,块擦除一次是 64KB。如果你代码里没有这个概念,早晚会写出“跨页丢数据”的 bug。

1.2 为什么 Flash 的“写”这么麻烦

用个生活化的比喻:NOR Flash 就像一块可反复擦写的黑板墙。你不能只擦掉黑板上某一个粉笔字,要擦就得把一整块区域全部擦白。擦白之后你才能往上写字,而且写字这个动作只能把空白处填上内容。

物理层面的真相是:Flash 的存储单元只能把数据位从 1 变成 0,反过来把 0 恢复成 1,必须依赖“擦除”操作给整个擦除单元充电。所以每次写入之前必须保证目标区域处于 0xFF 的空白状态。否则你写进去的字节会和旧值做“按位与”操作,最后得到一堆不是你预期的东西。

上面那句“先擦后写”不是规范建议,是铁律。

1.3 页编程那个容易忽略的“回卷”行为

页编程指令 0x02 最多一次写入 256 字节,而且它内部是锁定在某个页面内连续写的。假设你要从地址 0x0001FF 开始写 4 个字节,这个地址已经是页尾了。芯片不会报错,而是会从页首回卷,把没有擦除的页首几个字节覆盖掉。我曾经在这上面吃过大亏,批量写入时出了一片随机错误,找了半天才发现是跨页边界没处理。

所以正规驱动里,页编程的上层必须封装一个自动分段的写入函数:算页内剩余空间,能写多少就先写多少,分多次命令完成整段写入。这个函数在后面的代码里会给你。

1.4 和 EEPROM 对比,什么时候选它

对比项普通 I2C EEPROM(如 24Cxx 系列)SPI NOR Flash(W25Q64)
典型容量Kbit 级到 Mbit 级64Mbit 起步,选择空间大
写前处理可以直接按字节改写必须先擦除目标区域
写入粒度字节级页编程一次 256 字节
擦除粒度不需要扇区 4KB / 块 64KB
接口速度I2C 一般在 400K 以内SPI 几十MHz,差距明显
适合场景少量参数、频繁小改写文件系统、固件升级、字库、日志

如果项目只需要存几个配置项并且经常改写,老老实实上 EEPROM 省心得多。但如果需求变成“放一个中文字库”“保存几段波形数据”“挂一个文件系统”,W25Q64 这种 SPI NOR Flash 才是正解。

2. 接线与初始化:STM32F103 的 SPI 外设和引脚分配

2.1 用 SPI1 还是 SPI2

STM32F103 一般有 SPI1 和 SPI2 两个外设。我的示例用 SPI1,因为它的引脚更顺手。

标准外设库模式下默认映射是这样:

信号SPI1SPI2
SCKPA5PB13
MISOPA6PB14
MOSIPA7PB15
NSS / CSPA4PB12

注意一个关键点:PA4 是 SPI1 的硬件 NSS 引脚,但绝大多数 SPI 从机设备并不会配合 STM32 的硬件 NSS 自动片选逻辑。我在工程里都是把这个引脚当成普通 GPIO,手动拉低拉高来控制 CS。

这叫软件 NSS,好处有两点:一是逻辑直观,什么时候开始一次指令、什么时候结束,代码自己说了算;二是以后想挂多片 SPI 设备,每个设备分配一个独立 GPIO 做片选就行,可扩展性比硬件 NSS 好很多。

2.2 硬件接线清单

  • VCC → 3.3V 电源
  • GND → 公共地
  • SCK → PA5
  • MISO → PA6
  • MOSI → PA7
  • CS → PA4

就这么简单。如果用的是现成模块,上面一般自带滤波电容;如果是自己画板,至少注意两点:Flash 芯片的 VCC 引脚附近放一个 0.1uF 去耦电容;CS 引脚接一个 10k 左右的上拉电阻到 3.3V,防止主控上电瞬间 GPIO 未初始化时,Flash 被误选中进到奇怪的指令状态。

2.3 SPI 时钟频率怎么选

STM32F103 的 SPI1 挂在 APB2 总线上,系统主频 72MHz 时,APB2 最大时钟是 36MHz。SPI 外设内部有分频器,可选 2、4、8、16、32、64、128、256 分频。

W25Q64 的标称 SPI 时钟很高(常见型号可以到 100MHz 以上),瓶颈其实在主控。我把预分频设为 4,也就是 SPI 时钟 = 36 / 4 = 9MHz。这个速度对稳定性和波形抓取都很友好。

想提升读写速度可以用分频 2,得到 18MHz,也不会有什么压力。除非你的 PCB 走线特别长或特别差,否则 18MHz 一样跑得稳。这里建议新手先跑 9MHz 把功能调通,再提频,不要一上来就去挑战极限。

3. 编程模型:指令集和时序是最需要记牢的部分

3.1 SPI Mode 0 还是 Mode 3

SPI 通信里有一个最容易被忽略的细节:CPOL 和 CPHA。

CPOL 是时钟极性的缩写,决定空闲时 SCK 是高电平还是低电平;CPHA 是时钟相位,决定数据在第一个边沿还是第二个边沿采样。两者组合起来就是 4 种 SPI Mode。

W25Q64 手册明确支持 Mode 0(CPOL=0、CPHA=0,空闲为低、第一个边沿采样)和 Mode 3(CPOL=1、CPHA=1,空闲为高、第二个边沿采样)。工程上我默认选 Mode 0,因为这是大多数 SPI 从机兼容度最高的配置。

在 STM32 里的对应参数就是 SPI_CPOL_Low 和 SPI_CPHA_1Edge。如果你配成了 CPOL=0、CPHA=1,也就是 Mode 1,通信往往也能“跑”,但读回来的数据可能错位、乱跳、全 F,这类问题在调试时极难定位。

3.2 核心指令你真的用不了那么多

W25Q64 数据手册指令一大片,但正常读写场景真正高频使用的只有下面几条:

指令码名称作用注意点
0x9FJEDEC ID读取厂商与设备 ID返回多个字节,先看首位
0x06Write Enable写使能擦除、写之前必须发送
0x05Read Status读状态寄存器 1bit0 是 BUSY 忙标志
0x03Read Data读数据可以连续跨页读
0x02Page Program页编程一次最多 256 字节
0x20Sector Erase扇区擦除擦除一个 4KB 扇区
0xD8Block Erase块擦除擦除一个 64KB 块
0xC7Chip Erase全片擦除整片擦除,耗时太长慎用

每条指令的收发格式都一样:拉低 CS、发指令字节、按指令要求发地址或数据、拉高 CS。CS 的拉高代表一次完整指令的结束,芯片内部会以这个边沿作为指令边界来解析。

3.3 写使能到底在防什么

Flash 内部有一个写保护锁存器,叫 WEL。每次上电默认是锁住的。执行擦除、页编程、写状态寄存器之前,必须通过 0x06 指令把 WEL 置 1,芯片才会接受后续命令。

如果你漏发写使能,芯片会静默忽略写和擦除指令,不报错、不中断。你回读数据,发现还是旧值或者全 0xFF,非常容易让人怀疑是硬件坏了。

一个经验是:写使能 0x06 发完之后,不需要等 BUSY,但要在同一次 CS 事务后立即继续后续指令,中间不要插入别的 SPI 操作。驱动里我就是固定这样写:一个 W25Q64_WriteEnable 函数,擦除和页编程内部先调用它。

3.4 怎么判断芯片忙不忙

Flash 做擦除或写入时内部是有实打实动作的,这需要时间。页编程大约几百微秒,扇区擦除可能到几十毫秒甚至几百毫秒。期间你不能发新的写类指令。

判断忙的方法就是反复发 0x05 指令读状态寄存器,看 bit0 是否为 1。这个位叫 BUSY,1 表示忙,0 表示空闲。等待忙函数是所有写流程之后的标配,少了它,你下一个动作就可能和内部操作冲突。

4. 代码实现:从 SPI 初始化到跨页写完整封装

4.1 软件分层思路

写 W25Q64 驱动不要一个大函数到处复制粘贴。我习惯分三层:

  • 底层:SPI 外设初始化、单字节收发
  • 中间层:W25Q64 指令封装,比如读 ID、写使能、等待忙、擦除、页编程
  • 应用层:跨页写缓冲、连续读、校验逻辑

这样做的最大好处是问题好定位。读回数据不对,先怀疑中间层;改成别的 SPI 芯片或者换主控,只需要替换底层。

4.2 GPIO 与 SPI 初始化代码

下面的代码基于 STM32 标准外设库,工程里直接把这几个函数放到 spi_flash.c 里就行。

void SPI1_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_SPI1, ENABLE); // SCK PA5、MOSI PA7:复用推挽输出 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); // MISO PA6:浮空输入 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_6; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING; GPIO_Init(GPIOA, &GPIO_InitStructure); // CS PA4:普通推挽输出 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 默认高电平 }

注意 GPIO 复用模式:SCK 和 MOSI 一定得配成复用推挽,不能配成普通推挽。否则上面带不动线上电平,SPI 波形会畸变。

然后是 SPI 外设配置:

void SPI1_Init(void) { 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_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; SPI_Init(SPI1, &SPI_InitStructure); SPI_Cmd(SPI1, ENABLE); }

SPI_BaudRatePrescaler_4 在 36MHz APB2 时钟下就是 9MHz。如果你把 APB2 配到了 72MHz,那这个值是 18MHz,也能用。

4.3 单字节收发:整个驱动的基石

SPI 是全双工,主控发一个字节的同时会接收一个字节。要读从机数据,就持续发送空字节把时钟拉起来,数据自然从 MISO 上送回来。

uint8_t SPI1_ReadWriteByte(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); }

这段代码是后续所有指令的基础。读 ID、读状态、读数据、写数据,全部由它堆出来。

4.4 读 ID:硬件链路的第一道验证

上电后我最先做的一件事就是读 JEDEC ID。这一步能同时确认供电、接线、SPI 通信、芯片型号全没问题。

void W25Q64_ReadJEDEC(uint8_t *mid, uint8_t *type, uint8_t *cap) { W25Q64_CS_Low(); SPI1_ReadWriteByte(0x9F); *mid = SPI1_ReadWriteByte(0x00); *type = SPI1_ReadWriteByte(0x00); *cap = SPI1_ReadWriteByte(0x00); W25Q64_CS_High(); }

W25Q64 一般会返回 0xEF、0x40、0x17 三个字节。如果串口打印出来是这个组合,说明硬件链路没问题,后面写驱动就是纯软件工作了。

4.5 写使能与等待忙

void W25Q64_WriteEnable(void) { W25Q64_CS_Low(); SPI1_ReadWriteByte(0x06); W25Q64_CS_High(); } void W25Q64_WaitBusy(void) { uint8_t status; do { W25Q64_CS_Low(); SPI1_ReadWriteByte(0x05); status = SPI1_ReadWriteByte(0x00); W25Q64_CS_High(); } while (status & 0x01); }

等待忙这里要注意:0x05 读状态寄存器之后的那个空字节就是状态值。读完之后必须把 CS 拉高,再开始下一次轮询。如果在 CS 低电平期间反复发指令,芯片会认为这是一条超长指令,行为就不可控了。

4.6 页编程与扇区擦除

void W25Q64_PageProgram(uint32_t addr, uint8_t *buf, uint16_t len) { W25Q64_WriteEnable(); 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(buf[i]); } W25Q64_CS_High(); W25Q64_WaitBusy(); } void W25Q64_SectorErase(uint32_t addr) { W25Q64_WriteEnable(); 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(); }

地址都是 24 位。我习惯把地址按 16、8、0 位移位拆成三个字节发送。这一写法和后面连续读保持统一,就不用记两种地址顺序了。

4.7 跨页写:最容易出问题的封装

假设要在地址 0x0000FF 写入 300 字节。0x0000FF 是某一页的最后一个字节,页内剩余空间只有 1 字节。正确的做法是分三段:

第一段,写入 1 个字节;第二段,从下页页首开始写 256 字节;第三段,继续在下一页写剩下的 43 字节。

通用封装函数长这样:

void W25Q64_WriteBuffer(uint32_t addr, uint8_t *buf, uint32_t len) { while (len > 0) { uint16_t in_page = addr & 0xFF; uint16_t page_left = 256 - in_page; uint16_t chunk = (len < page_left) ? (uint16_t)len : page_left; W25Q64_PageProgram(addr, buf, chunk); addr += chunk; buf += chunk; len -= chunk; } }

这里的核心就是 addr & 0xFF 取页内偏移,用 256 减去它得到当前页还能写多少。然后取剩余长度和页剩余空间的最小值作为本次写入长度。W25Q64_WriteBuffer 内部循环调用页编程,每一次页编程之后都有等待忙,所以不用担心连续命令冲突。

4.8 连续读取

void W25Q64_Read(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(0x00); } W25Q64_CS_High(); }

读数据指令可以连续跨页,只要 CS 不拉高就能一直读下去,不像页编程有 256 字节边界限制。这是读和写在驱动难度上最大的区别。

5. 完整测试流程与数据校验

5.1 最小验证不跳步

拿到一颗新板子,我不会直接跑读写大数组。测试顺序非常重要,每一步都验证上一层的正确性:

  1. 上电读 JEDEC ID,确认 SPI 物理链路正常。
  2. 擦除 0x000000 起始的扇区,读回 4096 字节确认全 0xFF。
  3. 写入已知递增序列,比如 0x00、0x01、0x02。
  4. 读回数据,逐字节比对。
  5. 做跨页测试:从 0x0000F0 写 300 字节,专门验证芯片不会回卷覆盖。
  6. 断电再上电,重新读回,确认非易失性。

每一步都是前一步的断言,任何一步不对都可以立刻知道问题出在硬件、初始化还是指令封装。

5.2 串口打印观察手段

调试时我用串口把结果打出来。打印 ID 是最直接的:

uint8_t mid, type, cap; W25Q64_ReadJEDEC(&mid, &type, &cap); printf("W25Q64 ID: 0x%02X 0x%02X 0x%02X\r\n", mid, type, cap);

如果是模块化代码,建议把校验结果也打印成摘要,不要傻乎乎地把 8MB 数据全打出来。比如“写入 300 字节,比对错误 0 个”这样一句话就够了,串口大数据刷屏既慢又伤眼睛。

5.3 校验不只是“看着对”

简单场景里回读逐字节比对就够用。但如果你的数据要长期保存,我更建议在写入端加一个 CRC32 或累加和,写入时把校验值存到固定位置,读回后重新计算再比较。这样做的意义在于,即使回读的字节没全错,但个别位翻转了,你能立刻发现。

只用回读比对的话,比较代码本身就是“人眼找不同”,数据量一多就不现实。加个累加和或 CRC32,测试代码会干净很多。

uint32_t checksum = 0; for (uint32_t i = 0; i < len; i++) { checksum += buf[i]; }

这种简单校验足够应付大多数写入正确性测试。

6. 我踩过的坑和排查技巧

6.1 问题速查表

故障现象常见原因解决思路
读 ID 全 0xFFCS 没拉低、MISO 接线错、SPI 未初始化先查硬件接线,再查 GPIO 是否复用配置
读 ID 随机乱值SPI 相位配错、信号线接触不良检查 CPOL/CPHA,尝试 Mode 3
擦除后读回不全 FF地址发错、擦除指令没完整发出断点检查地址拆分字节
写入后读回旧值忘记写使能 0x06,或目标扇区未擦除写流程先调用 W25Q64_WriteEnable
部分数据写入失败跨页边界没处理,页回卷覆盖改用跨页写函数 W25Q64_WriteBuffer
卡死在等待忙SPI 频率过高或 CS 拉高太快降低分频为 8 或 16,检查 CS 时序
数据随机跳变SPI 线太长、外部干扰降速、加长 CS 保持时间、查线序

6.2 用逻辑分析仪抓 SPI 波形

遇到疑难数据错位,我最推荐的方法不是猜代码,而是用逻辑分析仪直接抓波形。接四根线:CLK、MOSI、MISO、CS,采样率至少设到 SPI 时钟的 4 倍以上,我一般直接上 50M 采样率。

抓下来之后,对照波形看几个关键点:CS 拉低之后第一个字节是不是 0x9F;随后三个字节是不是对应 ID;每个字节的 bit 顺序是不是先高位后低位。如果波形里 CS 和 CLK 的时序是对的,但数据对不上,问题几乎一定在 CPOL/CPHA 设置上。

6.3 几个值得长期记住的经验

先跑通读 ID 再谈后面。读 ID 是 Flash 驱动最简单的功能,但它覆盖了引脚配置、SPI 通信、芯片选通全过程。这个没弄好,写功能再花哨也是空中楼阁。

不要把所有逻辑塞在一个函数。我早期调试时写过一个大而全的“擦除加写入加校验”,结果出问题完全不知道是擦的错还是写的错。拆开之后,每一步都能单独验证。

遇到怪异问题先降速。SPI 时钟从 9MHz 降到 4.5MHz 或 2.25MHz,能排除掉一大半信号质量类问题。绝大多数应用根本不在乎那几兆的读写速度差异,稳定压倒一切。

怀疑芯片坏了就换一颗。Flash 芯片质量整体稳定,但不代表不会买到二手片、翻新片、批次不良片。一次完整流程都跑不通,换一颗全新对照测试不丢人。

结语

我在实际操作里比较保守:SPI 时钟固定 9MHz,片选用普通 GPIO,每个扇区做固定用途规划。踩过数据被回卷覆盖、写前忘擦除、SPI 配置成 Mode 1 这些坑之后,最大的心得就是不要靠猜。逻辑分析仪一抓波形,指令边界、相位、数据位对不对立刻清楚;读 ID、擦除、写读回这三步依次过了,整个驱动基本就稳了。

后续我会把这个工程移植成 HAL 库版本,再考虑把磨损均衡和掉电保护加进去,实际验证之后再写一篇新的分享。如果你也在调 SPI Flash,希望这篇文章能帮你少走几段弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询