1. 搞懂 SPI NOR Flash 到底在系统里扮演什么角色
1.1 为什么嵌入式项目绕不开 SPI NOR Flash
做过 STM32 项目的人大概率都碰过这样一个场景:板子上的 MCU 内部 Flash 只有 512KB 甚至 128KB,跑个裸机程序绰绰有余,可一旦要存字库、图片、音频、日志、OTA 升级包,内部空间立刻捉襟见肘。这时候最省事、最便宜、最通用的扩容方案,就是外挂一颗 SPI NOR Flash。
SPI NOR Flash 在嵌入式系统里的定位非常清晰:它是非易失性存储,掉电不丢数据,读写接口简单,封装小,价格低,容量从 1Mbit 到 512Mbit 都有。像 GD25Q80E 这颗 8Mbit(也就是 1MB)的芯片,几乎成了很多开发板的标配,正点原子、野火、安富莱的板子上都能见到它的身影。
它解决的问题其实就三类:
- 代码存储:把一部分不常改的代码或资源放到外部 Flash,MCU 通过 XIP 或者搬运到 RAM 执行。
- 数据存储:存配置参数、字库、图片、音频、日志等。
- OTA 升级:新固件先写到外部 Flash,校验通过后再搬运到内部 Flash 或者直接在外部执行。
适合读这篇文章的人,我大致分三类:一是刚学完 STM32 基础外设,想进阶搞存储的;二是项目里要用外部 Flash 但被时序和命令搞得一头雾水的;三是想从普通 SPI 升级到 QSPI 四线模式提升读写速度的。这三类人看下去都会有收获。
1.2 GD25Q80E 这颗芯片的基本盘
GD25Q80E 是兆易创新(GigaDevice)出的 8Mbit SPI NOR Flash,标准 SPI 接口,支持单线、双线、四线模式。它的关键参数如下:
| 参数项 | 数值 | 说明 |
|---|---|---|
| 容量 | 8Mbit / 1MB | 128 个 4KB 扇区,或 16 个 64KB 块 |
| 电压 | 2.7V ~ 3.6V | 典型 3.3V 系统 |
| 接口 | SPI / Dual SPI / Quad SPI | 支持模式 0 和模式 3 |
| 最高时钟 | 单线 120MHz,四线 120MHz | 实际受 MCU 和布线限制 |
| 页大小 | 256 字节 | 一次最多写 256 字节 |
| 扇区大小 | 4KB | 最小擦除单位 |
| 块大小 | 64KB | 大块擦除单位 |
| 擦写寿命 | 10 万次 | 每个扇区 |
| 数据保持 | 20 年 | 常温 |
这里有几个点必须提前说清楚,不然实操一定踩坑:
注意:NOR Flash 的写入规则是“先擦后写”,而且擦除的最小单位是 4KB 扇区,不是你想改哪个字节就改哪个字节。写之前对应区域必须是 0xFF 状态,否则写进去的数据是错的。
注意:一次写操作不能跨页。页大小 256 字节,如果你从地址 0x0000FF 开始写 10 个字节,会写到 0x000100 去,但地址 0x000100 属于下一页,实际会回卷到本页开头覆盖数据。这是新手最容易翻车的地方。
1.3 单线、双线、四线的本质区别
很多人搞不清 SPI、Dual SPI、Quad SPI 到底差在哪。我用一句话概括:区别只在数据线的数量,命令和地址的传输方式也相应变化,但底层还是 SPI 协议那一套。
- 标准 SPI(单线):MOSI 发数据,MISO 收数据,一个时钟周期传 1 位。
- Dual SPI(双线):把 MOSI 和 MISO 都当成双向数据线用,一个时钟周期传 2 位。
- Quad SPI(四线):再加上 WP# 和 HOLD# 两个引脚当数据线,一个时钟周期传 4 位。
理论上四线比单线快 4 倍,但实际提升没那么夸张,因为命令阶段和地址阶段可能还是单线,只有数据阶段是四线。真正全四线的模式叫 QPI,命令、地址、数据全走四线,那才是速度拉满的玩法。
STM32 的 QSPI 外设就是专门为这种场景设计的,它硬件上支持命令阶段、地址阶段、数据阶段、交替字节阶段、空周期阶段分别配置线宽,非常灵活。后面我会详细讲怎么配。
2. GD25Q80E 命令时序拆解:别只会发 0x03
2.1 常用命令一览与使用场景
GD25Q80E 的命令集和大多数 SPI NOR Flash 兼容,我整理了一张常用命令表,这些是实操中真正会用到的:
| 命令 | 十六进制 | 功能 | 常用场景 |
|---|---|---|---|
| Write Enable | 0x06 | 写使能 | 每次擦写前必须发 |
| Write Disable | 0x04 | 写禁止 | 可选 |
| Read Status Register 1 | 0x05 | 读状态寄存器 | 轮询 BUSY 位 |
| Read Data | 0x03 | 普通读 | 低速读,单线 |
| Fast Read | 0x0B | 快速读 | 带空周期,支持高速 |
| Fast Read Dual | 0x3B | 双线快速读 | 双线模式 |
| Fast Read Quad | 0x6B | 四线快速读 | QSPI 常用 |
| Page Program | 0x02 | 页编程 | 写数据,最多 256 字节 |
| Quad Page Program | 0x32 | 四线页编程 | 四线写 |
| Sector Erase | 0x20 | 4KB 扇区擦除 | 小范围擦除 |
| Block Erase 32KB | 0x52 | 32KB 块擦除 | 中等范围 |
| Block Erase 64KB | 0xD8 | 64KB 块擦除 | 大范围擦除 |
| Chip Erase | 0xC7/0x60 | 全片擦除 | 整片清空 |
| Read ID | 0x9F | 读 JEDEC ID | 识别芯片 |
| Enable QPI | 0x38 | 进入 QPI 模式 | 全四线 |
| Exit QPI | 0xFF | 退出 QPI | 恢复单线 |
| Enable Reset | 0x66 | 复位使能 | 软复位前 |
| Reset | 0x99 | 复位 | 软复位 |
这张表建议直接收藏,调试的时候对着看,比翻数据手册快得多。
2.2 读操作时序:0x03 和 0x0B 差在哪
先看最简单的读命令 0x03。它的时序是:
- CS 拉低
- 发送 0x03(8 个时钟)
- 发送 24 位地址(3 字节)
- 连续读取数据,每个时钟一位
- CS 拉高结束
这个命令的优点是简单,缺点是速度上不去。因为数据在时钟下降沿输出,MCU 需要在上升沿采样,留给信号建立的时间很短,一般时钟不能超过 50MHz 左右。
0x0B(Fast Read)多了一个东西:空周期(Dummy Cycles)。时序变成:
- CS 拉低
- 发送 0x0B
- 发送 24 位地址
- 发送 8 个空周期(时钟照跑,数据线不管)
- 连续读取数据
- CS 拉高
这 8 个空周期是给 Flash 内部准备数据用的,有了它,时钟可以拉到 100MHz 以上。你可以这样理解:0x03 是“你问了我立刻答”,0x0B 是“你问了我喘口气再答”,喘这口气的时间让芯片能跑得更快。
实操心得:如果你用普通 SPI 读,时钟低于 30MHz,用 0x03 就行,简单可靠。一旦超过 50MHz,必须换 0x0B,否则读出来的数据会随机出错,而且这种错误很难查,因为低速测试时完全正常。
2.3 写操作时序:Write Enable 是绕不过去的门槛
NOR Flash 的写操作有个硬性规定:每次擦除或编程之前,必须先发 Write Enable(0x06)。这是芯片的安全机制,防止误写。
完整的写流程是这样的:
- 发 0x06(Write Enable)
- 读状态寄存器(0x05),确认 WEL 位(bit 1)为 1
- 发擦除或编程命令
- 轮询状态寄存器,等 BUSY 位(bit 0)变 0
- 完成
这里有个细节很多人忽略:Write Enable 是“一次性”的。你发一次 0x06,只能执行一次擦除或编程操作。如果你要连续写多个页,每一页之前都要重新发 0x06。我见过有人写了一个循环,只发一次 0x06,结果只有第一页写成功,后面全是 0xFF,查了半天以为是硬件问题。
状态寄存器的 BUSY 位轮询也有讲究。擦除一个 4KB 扇区典型时间是 45ms,最大 400ms;写一页 256 字节典型 0.7ms,最大 3ms。你不能发完命令立刻读数据,必须等 BUSY 变 0。
// 等待 Flash 空闲的典型实现 void Flash_WaitBusy(void) { uint8_t status; do { Flash_CS_Low(); SPI_TransferByte(0x05); // 读状态寄存器1 status = SPI_TransferByte(0xFF); Flash_CS_High(); } while (status & 0x01); // bit0 是 BUSY 位 }这段代码看起来简单,但有个坑:轮询频率不能太高。如果你在 100MHz SPI 下疯狂轮询,CS 频繁翻转,反而会影响 Flash 内部操作。实际项目中我一般加个 1ms 延时再轮询,既不影响速度,又稳定。
2.4 擦除操作时序:4KB、32KB、64KB 怎么选
擦除命令有三个粒度:
- 0x20 扇区擦除:4KB,典型 45ms
- 0x52 块擦除:32KB,典型 120ms
- 0xD8 块擦除:64KB,典型 150ms
- 0xC7 全片擦除:整片,典型 10s(8Mbit)
选择原则很简单:擦除范围越小,时间越短,但命令开销占比越高;范围越大,单字节擦除成本越低,但灵活性差。
举个例子,你要存 10KB 的数据,分布在 3 个 4KB 扇区里。用扇区擦除,发 3 次命令,总时间约 135ms。用 64KB 块擦除,发 1 次命令,时间 150ms,但会把块内其他数据也擦掉。所以如果你只是更新一小块数据,千万别图省事用大块擦除,会把旁边的数据一起干掉。
踩过的坑:有一次做 OTA,新固件 60KB,我图快用了 64KB 块擦除,结果把紧挨着的配置参数区也擦了。配置区在 64KB 边界之后,我以为没事,实际上块擦除是按地址对齐的,0x10000 开始的 64KB 块覆盖 0x10000~0x1FFFF,配置区在 0x1F000,正好在里面。这个教训告诉我,擦除前一定要算清楚地址范围。
2.5 状态寄存器:BUSY 和 WEL 两位必须盯紧
状态寄存器 1 的位定义如下:
| 位 | 名称 | 含义 |
|---|---|---|
| bit 0 | BUSY | 1 表示芯片忙,0 表示空闲 |
| bit 1 | WEL | 1 表示写使能已置位 |
| bit 2 | BP0 | 块保护位 0 |
| bit 3 | BP1 | 块保护位 1 |
| bit 4 | BP2 | 块保护位 2 |
| bit 5 | TB | 顶部/底部保护选择 |
| bit 6 | SEC | 扇区/块保护选择 |
| bit 7 | SRP0 | 状态寄存器保护 |
BP0~BP2 和 TB、SEC 组合起来可以保护部分区域不被写入,这个功能在量产时很有用,可以防止程序跑飞误擦 bootloader 区。但调试阶段建议全部清零,不然你会遇到“明明发了写命令却写不进去”的诡异问题。
实操心得:如果你发现写操作一直失败,先读状态寄存器,看看 BP 位是不是被置位了。有些 Flash 出厂默认保护一部分区域,或者上一次程序设置了保护没清除,都会导致写失败。发 0x01 写状态寄存器可以把 BP 位清零。
3. STM32 QSPI 外设配置实战
3.1 为什么用 QSPI 而不是普通 SPI
普通 SPI 外设也能驱动 NOR Flash,但有几个硬伤:
- 速度上限低:STM32 的 SPI 最高时钟一般 50MHz 左右(F4 系列),F7/H7 能到 100MHz,但单线模式下实际有效带宽有限。
- CPU 占用高:每次读写都要 CPU 参与搬运数据,大块数据传输时 CPU 基本被占满。
- 不支持内存映射:没法像访问内存一样直接读 Flash,XIP 执行代码很麻烦。
QSPI 外设解决了这些问题:
- 四线模式:理论带宽是单线的 4 倍。
- DMA 支持:大块数据传输不占 CPU。
- 内存映射模式:可以把外部 Flash 映射到地址空间,像读内存一样读 Flash,直接 XIP 执行代码。
- 硬件自动处理命令阶段:命令、地址、空周期、数据阶段的线宽和长度都能配,不用软件一位一位拼。
以 STM32F767 为例,QSPI 时钟最高可以到 108MHz(AHB 216MHz 的二分频),四线模式下理论带宽 54MB/s,实际能跑到 30MB/s 以上,比普通 SPI 快一个数量级。
3.2 硬件连接:引脚映射和注意事项
GD25Q80E 和 STM32 QSPI 的典型连接如下(以 STM32F767 的 QUADSPI 为例):
| GD25Q80E 引脚 | STM32 引脚 | 功能 |
|---|---|---|
| CS# | PB6 | 片选 |
| CLK | PB2 | 时钟 |
| DI (MOSI) | PD11 | 数据线 0 |
| DO (MISO) | PD12 | 数据线 1 |
| WP# | PD13 | 数据线 2 |
| HOLD#/RESET# | PB10 | 数据线 3 |
| VCC | 3.3V | 电源 |
| GND | GND | 地 |
几个硬件上的注意点:
注意:WP# 和 HOLD# 在四线模式下变成数据线 2 和数据线 3,所以这两个引脚不能再做其他用途。如果你用的是普通 SPI 模式,WP# 和 HOLD# 要上拉到 VCC,否则芯片可能进入保护或保持状态。
注意:CLK 走线尽量短,最好等长处理。四线模式下 100MHz 时钟,走线长了信号质量会明显下降,读数据出错率飙升。我见过一块板子 CLK 走了 8cm,四线模式死活跑不起来,降到 50MHz 才稳定。
注意:CS# 上拉一个 10K 电阻到 VCC,防止 MCU 复位期间引脚浮空导致 Flash 误动作。
3.3 CubeMX 配置:参数怎么填
用 CubeMX 配置 QSPI 相对省事,但有几个参数必须理解清楚,不然生成的代码跑不起来。
Clock Prescaler:分频系数,QSPI 时钟 = AHB 时钟 / (Prescaler + 1)。比如 AHB 216MHz,Prescaler 设 1,QSPI 时钟 108MHz。调试阶段建议先设大一点,比如 8 分频,跑通了再往上加。
FIFO Threshold:FIFO 阈值,一般设 4 或 8。这个影响 DMA 触发时机,设太小会频繁中断,设太大可能溢出。
Sample Shifting:采样偏移,可选 None 或 Half Cycle。高速时钟下建议选 Half Cycle,给数据信号更多建立时间。
Flash Size:Flash 容量,GD25Q80E 是 8Mbit,这里填 0x14(表示 2^20 = 1MB,即 8Mbit)。这个值影响内存映射模式的地址范围。
CSHT(Chip Select High Time):片选高电平保持时间,可选 1~8 个时钟周期。一般设 1 或 2 就行,太短可能导致命令之间间隔不够。
Clock Mode:CPOL 和 CPHA。GD25Q80E 支持模式 0(CPOL=0, CPHA=0)和模式 3(CPOL=1, CPHA=1)。CubeMX 里选 Mode 0 即可。
3.4 命令配置结构体:每个字段都要搞明白
STM32 HAL 库的 QSPI 命令配置结构体QSPI_CommandTypeDef是核心,字段比较多,我逐个解释:
QSPI_CommandTypeDef sCommand = {0}; sCommand.Instruction = 0x6B; // 命令码,四线快速读 sCommand.InstructionMode = QSPI_INSTRUCTION_1_LINE; // 命令阶段单线 sCommand.Address = 0x000000; // 读起始地址 sCommand.AddressMode = QSPI_ADDRESS_1_LINE; // 地址阶段单线 sCommand.AddressSize = QSPI_ADDRESS_24_BITS; // 24位地址 sCommand.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; // 无交替字节 sCommand.AlternateBytes = 0; sCommand.AlternateBytesSize = QSPI_ALTERNATE_BYTES_8_BITS; sCommand.DummyCycles = 8; // 8个空周期 sCommand.DataMode = QSPI_DATA_4_LINES; // 数据阶段四线 sCommand.NbData = 4096; // 读4096字节 sCommand.DdrMode = QSPI_DDR_MODE_DISABLE; // 关闭DDR sCommand.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY; sCommand.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; // 每次命令都发指令这里最容易搞错的是DummyCycles。GD25Q80E 在四线快速读(0x6B)时,数据手册规定是 8 个空周期。但如果你用的是 0xEB(Quad I/O Fast Read),空周期可能是 6 个或 10 个,具体看模式配置。填错了读出来全是 0xFF 或者乱码。
实操心得:DummyCycles 不对是 QSPI 调试最常见的坑。我的经验是,先用单线 0x03 读 ID,确认硬件连接没问题;再切到 0x0B 单线快速读,确认空周期配置;最后才上 0x6B 四线。一步一步来,比一上来就四线好查问题。
3.5 内存映射模式:像读内存一样读 Flash
QSPI 最爽的功能是内存映射模式。配置好之后,外部 Flash 的内容会映射到 STM32 的地址空间(通常是 0x90000000 开始),你可以直接用指针读:
// 配置内存映射模式 QSPI_CommandTypeDef sCommand = {0}; sCommand.Instruction = 0x6B; sCommand.InstructionMode = QSPI_INSTRUCTION_1_LINE; sCommand.AddressMode = QSPI_ADDRESS_1_LINE; sCommand.AddressSize = QSPI_ADDRESS_24_BITS; sCommand.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; sCommand.DummyCycles = 8; sCommand.DataMode = QSPI_DATA_4_LINES; sCommand.NbData = 0; // 内存映射模式必须为0 QSPI_MemoryMappedTypeDef sMemMappedCfg = {0}; sMemMappedCfg.TimeOutActivation = QSPI_TIMEOUT_COUNTER_DISABLE; sMemMappedCfg.TimeOutPeriod = 0; HAL_QSPI_MemoryMapped(&hqspi, &sCommand, &sMemMappedCfg); // 之后就可以直接读 uint8_t *flash_ptr = (uint8_t *)0x90000000; uint8_t data = flash_ptr[0x1000]; // 读地址0x1000的数据内存映射模式下,读操作完全由硬件完成,CPU 只管取数据,速度非常快。如果你要把代码放到外部 Flash 执行(XIP),就必须用这个模式。
但内存映射模式有个限制:只能读,不能写。写操作还是要退出映射模式,用间接模式发命令。所以典型流程是:上电配好映射模式,运行时代码直接读;需要写的时候切回间接模式,写完再切回映射模式。
4. 完整驱动实现与常见问题排查
4.1 从零写一个可用的驱动框架
我把驱动分成三层:底层 SPI 收发、中间命令封装、上层应用接口。这样分层的好处是换 MCU 或换 Flash 型号时,只需要改底层或中间层。
底层:负责 CS 控制、字节收发、延时。如果用 QSPI 外设,这层基本由 HAL 库代劳。
中间层:封装读 ID、读状态、写使能、擦除、页写、读数据等函数。
上层:提供Flash_Read、Flash_Write、Flash_Erase这种通用接口,内部处理跨页、擦除对齐等逻辑。
先看读 ID 函数,这是验证硬件连接的第一步:
uint32_t Flash_ReadID(void) { uint8_t id[3]; QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0x9F; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; cmd.DummyCycles = 0; cmd.DataMode = QSPI_DATA_1_LINE; cmd.NbData = 3; HAL_QSPI_Command(&hqspi, &cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); HAL_QSPI_Receive(&hqspi, id, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); return (id[0] << 16) | (id[1] << 8) | id[2]; }GD25Q80E 的 JEDEC ID 是 0xC84014。如果你读出来是 0xFFFFFF 或 0x000000,说明硬件连接有问题;如果是其他值,可能是兼容型号或者命令模式不对。
再看页写函数,这里要处理跨页问题:
void Flash_PageWrite(uint32_t addr, uint8_t *buf, uint16_t len) { // 计算本页剩余空间 uint16_t page_remain = 256 - (addr % 256); if (len > page_remain) { len = page_remain; // 截断到本页末尾 } Flash_WriteEnable(); QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0x02; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.Address = addr; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; cmd.DummyCycles = 0; cmd.DataMode = QSPI_DATA_1_LINE; cmd.NbData = len; HAL_QSPI_Command(&hqspi, &cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); HAL_QSPI_Transmit(&hqspi, buf, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); Flash_WaitBusy(); }上层写函数负责拆分跨页和擦除:
void Flash_Write(uint32_t addr, uint8_t *buf, uint32_t len) { uint32_t sector_start = addr & ~(0xFFF); // 4KB对齐 uint32_t sector_end = (addr + len - 1) & ~(0xFFF); // 先擦除涉及的扇区 for (uint32_t s = sector_start; s <= sector_end; s += 4096) { Flash_SectorErase(s); } // 再按页写入 while (len > 0) { uint16_t page_len = 256 - (addr % 256); if (page_len > len) page_len = len; Flash_PageWrite(addr, buf, page_len); addr += page_len; buf += page_len; len -= page_len; } }这个框架虽然简单,但覆盖了 90% 的使用场景。实际项目中你可能还需要加缓存、加磨损均衡、加掉电保护,那就是另一个话题了。
4.2 常见问题速查表
调试 SPI NOR Flash 时遇到的问题,八成集中在下面这张表里:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 读 ID 全是 0xFF | CS 没拉低、时钟没输出、电源没接 | 示波器看 CS 和 CLK |
| 读 ID 全是 0x00 | MISO 没接、引脚配置错 | 检查引脚复用和上拉 |
| 读 ID 正确但读数据错 | 空周期配置不对、时钟太快 | 降时钟、调 DummyCycles |
| 写不进去 | 没发 Write Enable、区域被保护 | 读状态寄存器看 WEL 和 BP |
| 写进去读出来是 0xFF | 没擦除、擦除没等完成 | 确认擦除后 BUSY 变 0 |
| 只有第一页写成功 | Write Enable 只发了一次 | 每页写前都发 0x06 |
| 数据错位 | 跨页写没处理 | 检查页边界 |
| 四线模式读乱码 | DummyCycles 不对、线序错 | 先用单线验证 |
| 内存映射读崩溃 | Flash Size 配置错、地址越界 | 检查 CubeMX 配置 |
| 高速下随机出错 | 信号完整性差、采样点不对 | 降时钟、改 Sample Shifting |
4.3 几个真实踩坑记录
坑一:CS 高电平时间太短导致命令丢失。有一次用 QSPI 连续发命令,两条命令之间 CS 只拉高了 1 个时钟周期,结果第二条命令偶尔不执行。后来把 CSHT 改成 4 个时钟周期,问题消失。原因是 Flash 内部需要时间识别 CS 上升沿,太快了它反应不过来。
坑二:DMA 传输长度不是 4 的倍数导致卡死。STM32 的 QSPI DMA 在某些系列上要求传输长度是 4 的倍数,我传了 4095 字节,结果 DMA 卡住不动。改成 4096 或者分两次传就好了。这个坑在参考手册里写得很隐蔽,不看勘误表根本发现不了。
坑三:内存映射模式下写操作没退出映射。我一开始想省事,在映射模式下直接发写命令,结果写命令被当成读数据处理,完全没效果。后来老老实实先HAL_QSPI_Abort退出映射,写完再重新配置映射模式。
坑四:擦除和写入之间没等 BUSY。擦除命令发完立刻发写命令,写命令被忽略,因为芯片还在忙。这个错误在低速下偶尔能成功,高速下必失败,非常具有迷惑性。
4.4 性能优化:从 1MB/s 到 30MB/s
如果你只是存点配置参数,单线 10MHz 足够了。但如果你要跑 XIP 或者传大文件,性能优化就很重要。我实测下来,优化路径是这样的:
第一步,从 0x03 换成 0x0B,时钟从 30MHz 提到 80MHz,读速度从 3MB/s 提到 8MB/s。
第二步,从单线换成四线 0x6B,时钟保持 80MHz,读速度提到 25MB/s。
第三步,开 DMA,CPU 占用从 100% 降到 5% 以下,读速度稳定在 28MB/s。
第四步,如果支持 DDR 模式,还能再翻倍,但 GD25Q80E 不支持 DDR,所以到 28MB/s 就是极限了。
写速度方面,页写 256 字节典型 0.7ms,算下来约 0.36MB/s,这是 Flash 物理特性决定的,优化空间不大。擦除 4KB 典型 45ms,约 0.09MB/s。所以写密集场景要考虑磨损均衡和缓存策略。
实操心得:如果你发现四线模式速度没比单线快多少,检查一下命令阶段和地址阶段是不是还在单线。真正的四线加速需要命令、地址、数据全四线(QPI 模式),但 QPI 模式下不能再用普通 SPI 命令,需要专门的进入和退出命令。这个取舍要看你的具体场景。
5. 进阶玩法与项目落地建议
5.1 OTA 升级中的 Flash 分区设计
外部 Flash 做 OTA 是很常见的需求,分区设计直接影响升级可靠性和回滚能力。我一般这样分:
| 区域 | 大小 | 用途 |
|---|---|---|
| Bootloader | 64KB | 内部 Flash,负责搬运和校验 |
| App A | 512KB | 外部 Flash,当前运行固件 |
| App B | 512KB | 外部 Flash,新固件暂存 |
| Config | 4KB | 配置参数 |
| Log | 剩余空间 | 运行日志 |
升级流程是:新固件写到 App B,校验 CRC 通过后写一个标志位到 Config,重启后 Bootloader 根据标志位决定搬运哪个 App。这样即使升级失败,也能回滚到旧版本。
这里的关键点是校验。我一般用 CRC32,对整个固件算一遍,和固件头里的 CRC 比对。不要用简单的累加和,碰撞概率太高。
5.2 磨损均衡:什么时候需要,怎么做
NOR Flash 每个扇区擦写寿命 10 万次。如果你每秒写一次配置参数,同一个扇区一年就写废了。所以写频繁的场景必须做磨损均衡。
最简单的做法是双扇区交替:准备两个 4KB 扇区,轮流写,写之前先擦另一个。这样寿命翻倍。复杂一点的是日志式存储:把扇区当成环形缓冲区,每次写新记录,写满了擦一次。这样寿命能提高几十倍。
但说实话,大部分嵌入式项目根本写不到 10 万次。我做过一个工业设备,每分钟写一次日志,一年也就 50 万次,但分布在 128 个扇区上,每个扇区才 4000 次,远没到寿命。所以磨损均衡要不要做,先算一下实际写入频率再说。
5.3 选型建议:GD25Q80E 之外还有什么选择
GD25Q80E 是 8Mbit 的入门款,如果你的项目需要更大容量或更高速度,可以考虑:
- GD25Q16E / GD25Q32E:16Mbit / 32Mbit,引脚兼容,代码基本不用改。
- GD25Q64E / GD25Q128E:64Mbit / 128Mbit,适合存大量资源。
- W25Q 系列:华邦的,兼容性好,资料多。
- IS25LP 系列:ISSI 的,低功耗做得好。
选型时重点看三个参数:容量、电压、最高时钟。容量按实际需求留 30% 余量;电压要和 MCU 系统匹配;最高时钟要满足你的速度要求。另外注意封装,SOP8 和 WSON8 的焊接难度不一样,手焊建议选 SOP8。
5.4 调试工具与技巧
调试 SPI NOR Flash,光靠 printf 效率太低。我常用的工具组合是:
- 逻辑分析仪:抓 SPI 波形,看命令、地址、数据是否符合预期。Saleae 或者国产的 DSLogic 都行,采样率至少 100MHz。
- 示波器:看信号完整性,特别是高速下的过冲和振铃。
- Flash 编程器:单独把 Flash 拆下来读写,验证芯片本身是否正常。
- STM32CubeProgrammer:支持外部 Flash 烧录,可以配合 QSPI 直接下载固件。
逻辑分析仪抓波形时,重点看几个地方:CS 拉低到第一个时钟的间隔、命令码是否正确、地址是否对齐、空周期数量是否匹配、数据阶段线宽是否正确。这几个点对了,基本就没问题。
实操心得:我习惯在驱动里加一个
Flash_Dump函数,把 Flash 前 256 字节打印出来。每次调试新板子,先读 ID,再 Dump,确认基本读写正常,再跑业务逻辑。这个习惯帮我省了大量时间,因为很多问题在 Dump 阶段就能发现。
5.5 一个完整的读写测试用例
最后给一个可以直接用的测试用例,覆盖擦除、写入、读取、校验全流程:
#define TEST_ADDR 0x000000 #define TEST_SIZE 4096 void Flash_Test(void) { uint8_t write_buf[TEST_SIZE]; uint8_t read_buf[TEST_SIZE]; // 准备测试数据 for (int i = 0; i < TEST_SIZE; i++) { write_buf[i] = i & 0xFF; } // 擦除 printf("Erasing sector...\n"); Flash_SectorErase(TEST_ADDR); // 写入 printf("Writing data...\n"); Flash_Write(TEST_ADDR, write_buf, TEST_SIZE); // 读取 printf("Reading data...\n"); Flash_Read(TEST_ADDR, read_buf, TEST_SIZE); // 校验 if (memcmp(write_buf, read_buf, TEST_SIZE) == 0) { printf("Test PASS!\n"); } else { printf("Test FAIL!\n"); for (int i = 0; i < TEST_SIZE; i++) { if (write_buf[i] != read_buf[i]) { printf("Mismatch at %d: write=0x%02X read=0x%02X\n", i, write_buf[i], read_buf[i]); break; } } } }这个用例虽然简单,但能覆盖 90% 的硬件和驱动问题。每次换新板子或新 Flash 型号,先跑这个用例,通过了再上业务代码。
我个人在实际操作中的体会是,SPI NOR Flash 这东西入门不难,但细节特别多,每一个细节都可能让你卡半天。最好的学习方式就是拿一块板子,从读 ID 开始,一步一步把命令都试一遍,用逻辑分析仪看波形,把数据手册上的时序图和实际波形对上。这个过程走一遍,以后遇到任何 SPI Flash 都能快速上手。