news 2026/9/18 10:08:30

STM32 QSPI 驱动 GD25Q80E:命令时序、四线读与内存映射实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 QSPI 驱动 GD25Q80E:命令时序、四线读与内存映射实战

第一次把 GD25Q80E 焊到板子上,我盯着示波器上那四根 IO 线看了半天,心里就一个疑问:这么一颗 SOP-8 的小芯片,真能装下 1M 字节?后来在STM32 QSPI接口上把时钟拉到 54MHz,走内存映射模式,代码直接从SPI NOR Flash里取指执行,读出 JEDEC ID 是 0xC8 40 14,那一刻才算真正把GD25Q80E玩明白了。这篇东西就是把这中间踩过的坑、算过的参数、量过的波形整理出来。

内容会从这颗芯片的物理结构讲起,把命令时序一层层拆开——为什么读命令有三代、dummy 周期到底怎么数、擦除粒度为什么只能按 4KB 走;接着落到 STM32 的 QUADSPI 外设上,讲时钟分频怎么算、采样点怎么调、间接模式和内存映射模式分别适合什么场景。中间穿插可直接抄的 HAL 代码、寄存器配置和实测数据。

适合谁看?如果你手上正有一块带 GD25Q80E 或者同系列 GD25Q 的板子,想用 STM32 把它当数据存储用;或者你在做 OTA 升级、字库图片存储、参数掉电保存,需要一个靠谱的片外非易失存储方案;又或者你已经会写 SPI 但一碰到四线模式和 dummy 周期就懵——这篇应该都能接上。不需要你先精通时序图,但至少要能看懂 C 语言和基本的 SPI 概念,剩下的我在文中补齐。

1. 先把 GD25Q80E 这颗片子摸清楚

1.1 容量、地址与页/扇区/块的三级粒度

GD25Q80E 的命名规则里,8 代表 8Mbit,换算成字节就是 1MByte,地址空间从 0x000000 到 0x0FFFFF,三字节地址刚好够用,不需要 4 字节地址模式。这个容量听着不大,但对你理解 Flash 的组织方式已经足够了,因为它的内部结构和几百兆的大容量 NOR 是完全一样的,学一颗就等于学一类。

真正要记牢的是它内部的三级划分,这三级的粒度和用途完全不同,搞混了就会出各种玄学问题。

层级容量数量用途单次操作典型耗时
页 Page256 Byte4096 页一次连续写入的最大单位0.7ms(典型)/ 5ms(最大)
扇区 Sector4 KB256 个最常用的擦除单位45ms(典型)/ 400ms(最大)
块 Block64 KB16 个大范围擦除,提高效率150ms(典型)/ 2s(最大)

这里有个必须刻进肌肉记忆的规则:NOR Flash 的写入只能把 bit 从 1 变成 0,不能从 0 变回 1。想让某一位重新变回 1,只能擦除。而擦除的最小单位是 4KB 扇区,不是字节,也不是 256 字节的页。所以任何一次"改数据"操作,实际流程都是:读出整个扇区到 RAM → 在 RAM 里改 → 擦掉扇区 → 按页写回去。这个流程是 NOR 与 EEPROM 最大的区别,也是新手最容易低估工作量的地方。

再强调一次那个经典坑:页编程不能跨页。假设你想往地址 0x0000F0 开始写 32 字节,实际写到 0x000100 时,芯片内部地址会回卷到 0x000000,把开头的 16 字节覆盖掉。芯片不会报错,状态寄存器也不会告诉你出了事,你只会在后来读数据时发现前半段莫名其妙变成了后半段的内容。我第一次遇到时排查了整整一个下午,还以为是自己 SPI 时序配错了。

1.2 引脚定义与四线模式的前置条件

GD25Q80E 是标准 SOP-8 封装,八个脚,但功能是复用的,这点必须提前搞清楚:

引脚单线模式功能四线模式功能说明
1CS#CS#片选,低有效
2SO(MISO)IO1数据输出/双向数据 1
3WP#IO2写保护/双向数据 2
4GNDGND
5SI(MOSI)IO0数据输入/双向数据 0
6SCLKSCLK时钟
7HOLD#IO3暂停/双向数据 3
8VCCVCC电源 2.7~3.6V

关键点在 3 脚和 7 脚。它们默认是 WP# 和 HOLD# 功能,一个低电平就会让芯片进入硬件写保护,另一个低电平会让芯片暂停通信。平时不接也可以,但绝对不能悬空——悬空的 CMOS 输入脚是玄学之源,周围一点噪声就可能让芯片莫名其妙进入暂停状态,读出来全是 0xFF。我现在的习惯是这两个脚各挂一个 10k 上拉到 VCC,板子面积够的话再加 100nF 到地。

那怎么把它们切成 IO2/IO3 用?答案是QE 位,在状态寄存器 2(SR2,用 0x35 读取)的 bit1。QE=0 时仍是 WP#/HOLD# 功能,QE=1 才是四线数据脚。这意味着哪怕你的代码把 QSPI 外设配成了四线模式,只要 QE 没置起来,IO2/IO3 上根本不会有数据,你会看到读到一半数据错位、或者高四位全是 1。这条是四线模式跑不通的头号原因,比 dummy 周期算错还常见。

置位 QE 的流程是:发 0x06 写使能 → 发 0x31 命令写 SR2 一个字节 0x02 → 等待 WIP 清零 → 发 0x35 读回 SR2 确认 bit1 是 1。注意整个流程都必须用单线模式完成,因为这时候四线还没使能。有些朋友一上来就把 QSPI 配上四线,然后在四线模式下想去写 SR2,那是写不进去的。

1.3 上电时序与电气上必须守住的底线

芯片不是上电就能立刻干活的。VCC 从 0 爬升到最低工作电压 2.7V 之后,芯片内部还需要一段时间完成上电复位,数据手册里给的 tPUW 典型值是 1ms 量级,最坏情况下会更大。如果你在 MCU 初始化完立刻冲上去发命令,可能前几条命令直接丢掉,表现就是"偶尔上电读不到 ID,复位一次又好了"。

我在项目里的做法是在 QSPI 初始化后加一个 10ms 的延时,虽然理论上过头了,但能彻底消除这个偶发问题,代价可以忽略。另外在掉电方向上也要注意,VCC 掉到 2.7V 以下时芯片可能正在执行擦写操作,这时候断电会造成该扇区数据处于不确定状态。如果你的系统有掉电检测,务必在检测到电压跌落时确认 WIP 为 0 再关机,或者干脆加个几百微法的储能电容争取几百毫秒的完成时间。

还有一点是关于 CS# 的时序。CS# 从低电平到第一个 SCLK 上升沿之间需要一段建立时间(tSLCH),CS# 拉高后到下一次拉低之间也需要 tSHSL,通常在几十纳秒量级。STM32 的 QUADSPI 外设有个 ChipSelectHighTime 参数就是管后者的,如果你调得太小、或者用 GPIO 模拟 SPI 时切换过快,就会出现"命令偶尔失效"的现象。频率低的时候看不出来,一旦上到 80MHz 以上就会频繁翻车。

2. 命令时序拆解:每条命令的时钟到底怎么走的

2.1 状态机命令:WREN、RDSR、RDID 的时序细节

Flash 内部有一个很简单的状态机,绝大多数操作都要靠这几条命令去驱动它。理解它们,后面所有复杂命令都是这个模式的组合。

0x06 写使能(WREN)是所有写操作的前置动作。时序很短:CS# 拉低,发 8 个时钟把 0x06 送出去,然后 CS# 必须拉高。这里有个细节很多人忽略——WREN 是靠CS# 上升沿才把 WEL 位置进状态寄存器的,如果你 CS# 一直保持低电平紧接着发下一条命令(有些模拟 SPI 会这么写),WEL 位根本不会生效,后面的写入自然失败。所以 WREN 必须是一条独立的、以 CS# 拉高结束的事务。

0x05 读状态寄存器 1(RDSR1)用来查两个位:bit0 是 WIP(Write In Progress),bit1 是 WEL(Write Enable Latch)。WIP=1 表示芯片正在忙内部操作,此时除了读状态寄存器之外几乎什么都不认。WEL=1 表示写使能已生效,可以执行一次写或擦除,操作完成后芯片自动把它清 0。

轮询等待的代码看起来简单,但要注意超时保护:

/* 等待 WIP 清零,timeout_ms 必须按最坏情况给足 */ static int flash_wait_ready(uint32_t timeout_ms) { uint8_t sr = 0; uint32_t start = get_tick_ms(); do { if (flash_read_sr1(&sr) != 0) return -1; /* 读状态本身失败 */ if ((sr & 0x01) == 0) return 0; /* WIP = 0,空闲 */ if ((get_tick_ms() - start) > timeout_ms) return -2; } while (1); }

扇区擦除的超时我一般给 500ms,块擦除给 3s,整片擦除给 20s。这些数字看着离谱,但数据手册上的最大值就是这么写的,按典型值给超时的项目在低温或者老化芯片上迟早出问题。

0x9F 读 JEDEC ID(RDID)是最适合拿来做"芯片在不在"自检的命令。时序是:CS# 拉低,发 0x9F,然后连续读 3 个字节。GD25Q80E 返回 0xC8、0x40、0x14,第一个字节是厂商号,第二个是存储类型,第三个是容量码——0x14 表示 2 的 20 次方,也就是 1M 字节。这个自检的意义在于:它完全绕过了数据区的任何问题,只要这三个字节对上,就说明电源、时钟、片选、MISO 这几条最基本的路是通的。我习惯把这三个字节做成上电自检项,对不上就直接把系统停在错误状态,比后面调试数据错乱要容易定位得多。

2.2 读命令的三代演进与 dummy 周期计算

读命令是最能体现 SPI NOR 演进脉络的部分,一共三代,速度一代比一代快,代价是时序越来越讲究。

第一代是0x03 普通读。8 个时钟发命令,24 个时钟发地址,之后每一个时钟出一个 bit 数据。它不需要任何 dummy 周期,时序最简单,但速度上不去,GD25Q80E 上一般限制在 50MHz 以内。好处是它跟任何 SPI 控制器都能兼容,你的 MCU 如果只有普通 SPI 外设没有 QSPI,那就只能用这个。

第二代是0x0B 快速读。它在地址之后插入了 8 个 dummy 时钟,然后才开始输出数据。为什么要插这 8 个周期?因为芯片从接收到地址到内部阵列真正把数据准备好,需要一段访问时间(tACC),高频下这段时间换算成的周期数不能忽略。dummy 周期就是给芯片留的这段缓冲,MCU 在这段时间里不发数据也不收数据,只是照常打时钟。GD25Q80E 用 0x0B 最高能到 133MHz,比 0x03 高出一倍多。

第三代是0x6B / 0xEB 四线读。0x6B 是四线输出快速读:命令和地址还是用单线(IO0 发、IO1 收),但数据阶段 IO0~IO3 四条线同时工作,每个时钟传 4 个 bit,理论吞吐直接翻四倍。它在地址之后需要 8 个 dummy 周期,然后开始出数据。

0xEB 比 0x6B 更进一步,连地址阶段都用四条线,速度最快,但它多了一个"模式字节"(Mode Bit)的概念。GD25Q80E 的 0xEB 时序是:命令 8 个时钟(单线)+ 地址 24 个时钟(四线)+ 模式位 8 个时钟(四线)+ 6 个 dummy 时钟 + 数据。模式位通常是 0xA0,作用是告诉芯片下一轮继续沿用四线连续读模式,省掉重复发命令地址的开销。

这里就是最容易翻车的地方:模式字节和 dummy 周期的数量必须和芯片的实际配置对上,多一个少一个都会导致整个数据流移位。而且不同型号、甚至同一型号不同批次,默认的 dummy 数量都可能不同。

我的建议是分两步走。第一步,先用0x6B + 8 个 dummy跑通,它没有模式字节,时序最干净,适合建立信心和验证硬件。第二步,如果确实需要 0xEB 的极限速度,再去做两件事:一是查你手上这批芯片的数据手册,确认 SR3 里 DC1:DC0 的默认值;二是用示波器抓一次实际波形,把时钟沿对齐到数据稳定的位置,数清楚从地址结束到第一个有效数据位之间到底有几个时钟。别靠猜,猜一次的调试成本比抓波形高十倍。

另外提一句,STM32 的 HAL 里有个AlternateByteMode字段,很多人不知道它就是给模式字节用的。如果你要用 0xEB 带模式位,就得把它设成QSPI_ALTERNATE_BYTES_8_BITSAlternateBytes填 0xA0,同时把DummyCycles调成 6。如果你压根不设这个字段,HAL 会按 0x6B 的时序去发,那读出来的数据就是错的。

2.3 编程与擦除命令的取舍:4KB 还是 64KB

写操作这边,命令本身不复杂,复杂的是策略选择。

0x02 页编程是最基本的写入命令,一次最多 256 字节,且必须页对齐。它的完整时序是:WREN → CS# 拉高 → CS# 拉低 → 发 0x02 + 24 位地址 + 数据 → CS# 拉高 → 芯片开始内部编程 → 轮询 WIP。注意最后那个 CS# 上升沿才是编程真正开始的信号,在这之前数据只是被缓存在芯片的输入寄存器里。

0x20 扇区擦除擦 4KB,0xD8 块擦除擦 64KB,0xC7 全片擦除擦整颗芯片。这三条命令都不带数据,只有命令加地址(全片擦除连地址都没有),发完拉高 CS# 就开始工作,同样靠 WIP 轮询判断完成。

那什么时候用 4KB,什么时候用 64KB?不能简单地"越大越快"就完事,因为擦除的粒度直接影响你的写入放大倍数。

举个具体例子。假设你要写一个 1KB 的配置文件,正常逻辑下你会这样想:读一个扇区(4096 字节)到 RAM,改掉其中 1KB,擦掉这个扇区,再写回 4096 字节。这里假设每个扇区的寿命是 10 万次,按这个策略,每次保存配置都消耗一次扇区擦写,10 万次之后这个扇区就废了。但如果你的配置文件是每 10 秒保存一次,一天 8640 次,不到 12 天就把这个扇区写死了。

所以我一般的做法是:在扇区粒度之上再包一层日志式写入。把若干个连续扇区当成一个环形日志区,每次保存都追加到下一个位置,而不是原地覆盖。写满一圈后再统一擦除一次。这样擦除次数被摊薄到 1/N,寿命直接翻 N 倍。代价是需要一个 GC(垃圾回收)逻辑和一个索引结构,但对参数存储这种场景完全值得。

至于 64KB 块擦除,它适合在大批量更新固件分区的时候用。比如你有一个 256KB 的固件区要整块替换,用块擦除就是 4 次操作,用扇区擦除要 64 次,光擦除耗时就从 600ms 变成 2.9s。但块擦除的代价是你在擦之前必须确保这 64KB 里的数据全都不需要保留,一旦擦错,整个块都没了。所以我的经验是:只有在对某个区域具有完全所有权(比如整块固件分区)的时候才用块擦除,通用的参数区一律用 4KB 扇区,安全和效率之间我选安全。

3. STM32 QSPI 外设配置:从时钟分频到采样点

3.1 QSPI 时钟来源与分频参数怎么算

STM32 的 QUADSPI 挂在 AHB 总线上,时钟直接来自 AHB。这里说的 QSPI 指的是 Quad SPI 外设,不是队列式的 Queue SPI,名字容易让人误解。以 STM32F7 系列为例,假设 AHB 跑到 216MHz,那么 QUADSPI 模块的输入时钟就是 216MHz。

分频公式是:

f_QSPI_CLK = f_AHB / (ClockPrescaler + 1)

ClockPrescaler 的取值范围是 0 到 255。要让 QSPI 时钟尽量快,但不能超过两个限制:一个是 Flash 芯片本身能承受的最大频率,另一个是 STM32 端 QUADSPI 的输出频率上限。

216/(3+1) = 54MHz,这是我最常用的配置,GD25Q80E 在 0x0B 和 0x6B 命令下都轻松支持,留了充足余量,线长的情况下依然稳定。

216/(1+1) = 108MHz,这个能跑,芯片支持到 133MHz,但时序余量很紧。108MHz 下每个时钟周期只有 9.26ns,PCB 走线上稍微长一点,或者多了几颗过孔,数据采样相位就会偏。我用 108MHz 只在短走线、四层板、有完整地平面的场合试过,而且必须配合半周期采样偏移才稳。

216/(0+1) = 216MHz,这个基本别想,STM32 自己的 QUADSPI 输出也到不了这么高,而且 Flash 芯片也接不住。

这里给个实用的自检方法:先用 54MHz 把整套读写擦流程跑通,然后用循环测 1MB 数据的读取时间。如果验证没问题,再逐步提高频率,每次都重新跑一遍完整的擦写读比对。一旦出现偶发错误就把频率降回来一档。别一次性调到最高再回头找问题,那样的调试效率极低。

3.2 FIFO 阈值、CS 高电平时间与采样偏移

这几个参数不理解原理的话,在配置工具里随便填也能跑,但只要长了数据或者高了频率就会出问题。

FIFO 阈值(FifoThreshold)决定什么时候触发中断和 DMA 请求。STM32 的 QUADSPI 内部有一个 32 字节的 FIFO,如果你配成 4,那么每攒够 4 字节就会申请一次 DMA,DMA 搬运频繁但延迟小;如果配成 16,DMA 请求次数减少,但每次中断之间要等更多数据。大块读的时候我一般配 4 到 8 之间,让 DMA 保持连续搬运。配太大在数据快结束时容易剩下零头没搬完,你就得手动处理剩余字节,麻烦。

ChipSelectHighTime是 CS# 拉高后维持的时间,单位是时钟周期数。范围一般从 1 个周期到 16 个周期可选。前面提过 Flash 有个 tSHSL 参数要求 CS# 拉高至少几十纳秒,如果频率是 108MHz,一个周期是 9.26ns,那至少要 4 个周期才能满足 37ns 的要求。所以高频下这个值不能小,我一般直接给 4 或者更高。低频的时候这个问题不明显,但代码要写得能适配高频,免得以后提频时又踩一次。

SampleShifting是采样相位偏移,这是高频下的救命参数。SPI 是全双工同步总线,主机在上升沿发数据,从机在下降沿(或者按自己的相位)出数据,主机应该在什么时候采样?理论上应该采在数据眼的中间。但实际走线上有传播延迟,加上 Flash 内部输出延迟,数据到达 MCU 的时刻可能已经偏了。这时候如果还按理论位置采样,就会采到跳变沿上,读出一堆随机值。

STM32 提供了半周期偏移选项,相当于把采样点往后挪半个时钟周期,正好落在数据中心。我的做法是:54MHz 及以下用无偏移,108MHz 用半周期偏移,实际验证下来这样最稳。注意半周期偏移会引入固定的相移,必须实际测过才能确定是否适合你的板子,别照抄。

3.3 芯片容量寄存器和内存映射地址

QUADSPI 的 DCR 寄存器里有个 FSIZE 字段,用来声明外部 Flash 的地址范围。计算公式是:

Flash 容量字节数 = 2^(FSIZE + 1)

1M 字节等于 2 的 20 次方,所以 20 = FSIZE + 1,FSIZE 应该是 19。HAL 里直接填hqspi.Init.FlashSize = 19;

这个值如果填大了会怎样?表面上可能看不出来,因为间接模式下每次读写都是显式给地址的,跟 FSIZE 没关系。但一旦进入内存映射模式,FSIZE 就决定了地址解码范围。填成 23 意味着声明有 16MB 空间,你读到 0x200000 时外设不会报错,而是照常发地址出去,芯片那边因为只接了 20 根地址线,高位被丢弃,实际上回绕到了 0x000000。你会发现在映射区读着读着又读回开头的内容了,这种现象特别容易让人误以为是缓存问题。

填小了会怎样?比如填 18 表示 512KB,那你访问 0x80000 之后的地址时外设会认为越界,行为不确定。所以这个值必须严格等于你的芯片容量。

初始化代码大致长这样:

static QSPI_HandleTypeDef hqspi; void bsp_qspi_init(void) { hqspi.Instance = QUADSPI; hqspi.Init.ClockPrescaler = 3; /* 216/(3+1) = 54MHz */ hqspi.Init.FifoThreshold = 4; hqspi.Init.SampleShifting = QSPI_SAMPLE_SHIFTING_NONE; hqspi.Init.FlashSize = 19; /* 2^(19+1) = 1MB */ hqspi.Init.ChipSelectHighTime = QSPI_CS_HIGH_TIME_4_CYCLE; hqspi.Init.ClockMode = QSPI_CLOCK_MODE_0; hqspi.Init.FlashID = QSPI_FLASH_ID_1; hqspi.Init.DualFlash = QSPI_DUALFLASH_DISABLE; if (HAL_QSPI_Init(&hqspi) != HAL_OK) { error_handler(__LINE__); } }

ClockMode 选 0 还是 3,取决于你的 Flash 芯片支持哪种。GD25Q 全系列都支持 Mode 0(空闲时钟低电平,上升沿采样)和 Mode 3。选 0 是最通用的,也是大部分例程的默认值。这两个模式对 Flash 来说都能工作,但对时序余量的影响不同,如果 Mode 0 下高频不稳,可以试试切到 Mode 3,有时会有意外效果。

4. 完整实操:从裸机读到跑通擦写读回环

4.1 引脚规划与初始化顺序

先把硬件的路走通。以常见的 STM32F7 为例,QUADSPI 的引脚一般是固定映射的:CLK、NCS、BK1_IO0 到 BK1_IO3,再加上 BK2 组用于双 Flash 场景。单颗 GD25Q80E 只用到 BK1 组。

连线对应关系是:

STM32 QUADSPIGD25Q80E备注
QUADSPI_CLKSCLK (pin6)尽量短,避免走线分叉
QUADSPI_NCSCS# (pin1)需要 10k 上拉
QUADSPI_BK1_IO0SI (pin5)单向模式下是 MOSI
QUADSPI_BK1_IO1SO (pin2)单向模式下是 MISO
QUADSPI_BK1_IO2WP# (pin3)四线模式下是 IO2,需上拉
QUADSPI_BK1_IO3HOLD# (pin7)四线模式下是 IO3,需上拉

布线的时候有两点值得注意。一是 CLK 和 IO 线要尽量等长,虽然 54MHz 下没那么严格,但如果要冲 108MHz,长度差控制在 5mm 以内比较保险。二是在 Flash 的 VCC 脚旁边紧挨着放一颗 100nF 加一颗 1uF,NOR Flash 在内部编程和擦除的瞬间会有电流尖峰,退耦不好会出现"擦除偶尔失败"这种极难复现的问题。

初始化顺序我固化成了这样:GPIO 和时钟使能 → QSPI 外设初始化 → 10ms 上电等待 → 读 JEDEC ID 校验 → 读 SR2 检查 QE 位,没置起来就置位 → 进入正式使用。这个顺序保证每一步都有明确的成败判据,出问题能立刻知道卡在哪一步。

4.2 读 ID、读状态、写使能的封装

先把三个基础操作封装好,后面所有高级功能都建立在它们之上。

/* 读 JEDEC ID,成功返回 0,ID 通过指针带回 */ int flash_read_id(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.DataMode = QSPI_DATA_1_LINE; cmd.DummyCycles = 0; cmd.NbData = 3; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, 100) != HAL_OK) return -1; if (HAL_QSPI_Receive(&hqspi, id, 100) != HAL_OK) return -1; return 0; }

注意AddressMode要设成QSPI_ADDRESS_NONE,因为 0x9F 命令后面没有地址。如果误设成 24 位地址,HAL 会多发 24 个时钟出去,Flash 那边就会把这 24 位当成命令参数吃掉,返回的 ID 自然对不上。这个错误我见过好几次,症状是返回0xC8 0x00 0x00之类的奇怪值。

写使能和等待的封装:

int flash_write_enable(void) { QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0x06; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; cmd.DataMode = QSPI_DATA_NONE; cmd.DummyCycles = 0; cmd.NbData = 0; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle= QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; return (HAL_QSPI_Command(&hqspi, &cmd, 100) == HAL_OK) ? 0 : -1; }

这里DataMode设成QSPI_DATA_NONE很重要,表示这条命令没有数据阶段。HAL 会在发完 8 个时钟的命令后自动拉高 CS#,形成完整的 WREN 事务。如果你多此一举地给 DataMode 配了个模式,那 CS# 的拉高时机就变了,WEL 可能置不上。

4.3 页编程、扇区擦除与性能实测

页编程的封装要注意自动分页:

int flash_page_program(uint32_t addr, const uint8_t *buf, uint16_t len) { QSPI_CommandTypeDef cmd = {0}; if (len == 0 || len > 256) return -1; /* 跨页检查:本页剩余空间不足就拒绝,让上层自己拆 */ if ((addr & 0xFF) + len > 256) return -2; if (flash_write_enable() != 0) return -3; cmd.Instruction = 0x02; cmd.Address = addr; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.AlternateByteMode= QSPI_ALTERNATE_BYTES_NONE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.DummyCycles = 0; cmd.NbData = len; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, 100) != HAL_OK) return -4; if (HAL_QSPI_Transmit(&hqspi, (uint8_t *)buf, 1000) != HAL_OK) return -4; return flash_wait_ready(10); /* 单页编程最坏 5ms,给 10ms 余量 */ }

那个跨页检查我建议做成硬性拒绝而不是自动拆分。自动拆分看起来贴心,但会掩盖上层逻辑的地址计算错误,等到出问题时你根本不知道是哪里算错了。硬拒绝加上明确的错误码,调试时一眼就能看出问题。

扇区擦除更简单:

int flash_sector_erase(uint32_t addr) { QSPI_CommandTypeDef cmd = {0}; addr &= 0xFFFFF000; /* 强行对齐到 4KB 边界 */ if (flash_write_enable() != 0) return -1; cmd.Instruction = 0x20; cmd.Address = addr; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.AlternateByteMode = QSPI_ALTERNATE_BYTES_NONE; cmd.DataMode = QSPI_DATA_NONE; cmd.DummyCycles = 0; cmd.NbData = 0; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle= QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, 100) != HAL_OK) return -2; return flash_wait_ready(500); /* 4KB 擦除最坏 400ms */ }

读函数我用 0x6B 四线模式,先跑通再说:

int flash_read_quad(uint32_t addr, uint8_t *buf, uint32_t len) { QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0x6B; cmd.Address = addr; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.AlternateByteMode= QSPI_ALTERNATE_BYTES_NONE; cmd.DataMode = QSPI_DATA_4_LINES; cmd.DummyCycles = 8; cmd.NbData = len; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, 100) != HAL_OK) return -1; if (HAL_QSPI_Receive(&hqspi, buf, 5000) != HAL_OK) return -1; return 0; }

用这套代码做性能实测,环境是 216MHz 主频、54MHz QSPI 时钟、四线模式,用定时器打点测 1MB 连续读取的耗时:

操作配置实测耗时折算吞吐
1MB 连续读0x0B 单线读 54MHz约 680ms1.5 MB/s
1MB 连续读0x6B 四线读 54MHz约 52ms约 20 MB/s
4KB 扇区擦除0x2045~60ms-
256B 页编程0x020.7~1.2ms-
256KB 分区全写擦除+编程约 3.8s-

单线和四线的差距就是四倍加上命令开销的差别,这个提升是实打实的。注意 20MB/s 是算上 FIFO 和 HAL 开销之后的数字,理论值 27MB/s,差的那部分主要是命令阶段(8+24+8 个时钟)和软件搬运的成本。

5. 内存映射模式:让代码在 Flash 里取指运行

5.1 进入与退出内存映射的正确姿势

间接模式每次读都要发一遍命令和地址,而且每次只能读 DLR 里设定的字节数,读一大片数据要循环调用很多次,每次都有几十个时钟的固定开销。内存映射模式解决的就是这个问题:配置好之后,整个 Flash 的内容被映射到 MCU 的一段地址区间,你直接用指针访问就行,外设会自动生成读命令和地址,硬件层面完成,没有软件循环的开销。

进入内存映射之前,必须先把读命令配置好,因为映射模式下每次访问都用 CCR 里的配置。做法是先用HAL_QSPI_Command配一次读命令(用 0x6B 或者 0xEB),然后立刻调HAL_QSPI_MemoryMapped

QSPI_MemoryMappedTypeDef mm_cfg = {0}; /* 先配置 CCR,把读命令、dummy 等参数写进去 */ QSPI_CommandTypeDef cmd = {0}; cmd.Instruction = 0x6B; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.AlternateByteMode= QSPI_ALTERNATE_BYTES_NONE; cmd.DataMode = QSPI_DATA_4_LINES; cmd.DummyCycles = 8; cmd.DdrMode = QSPI_DDR_MODE_DISABLE; cmd.DdrHoldHalfCycle = QSPI_DDR_HHC_ANALOG_DELAY; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; cmd.NbData = 0; HAL_QSPI_Command(&hqspi, &cmd, 100); mm_cfg.TimeOutActivation = QSPI_TIMEOUT_COUNTER_ENABLE; mm_cfg.TimeOutPeriod = 0xFF; if (HAL_QSPI_MemoryMapped(&hqspi, &mm_cfg) != HAL_OK) { error_handler(__LINE__); }

不同版本的 HAL 在这里行为有差异,有的版本HAL_QSPI_Command会顺带发起一次空传输,看起来不太优雅但无伤大雅。如果你发现映射模式进去之后读出来不对,第一件事就是打印QUADSPI->CCR的值,看看 dummy 位数和指令有没有被改掉。必要时直接操作寄存器写 CCR 和 DCR,比绕 HAL 更直接。

映射区的基地址由 QUADSPI 的地址映射决定,一般外设所在区域会映射到像 0x90000000 这样的固定地址。进入映射模式后,直接:

const uint8_t *p = (const uint8_t *)0x90000000; uint8_t magic = p[0];

退出映射模式要调HAL_QSPI_Abort(&hqspi),这个函数会让 QUADSPI 的 ABORT 位置位,终止所有正在进行的传输,然后再用HAL_QSPI_Command配置新的命令。我建议在任何要写 Flash 的操作之前都先退出映射模式,因为映射模式下外设只认读命令,你发 WREN 它可能理都不理。

5.2 Cache 与 MPU 配置的坑

这一节是我踩过最深的坑,值得单独拿出来说。

如果你的 MCU 是 Cortex-M7 内核,比如 STM32F7 或 H7,那么 D-Cache 和 I-Cache 默认可能是开启的。当你用内存映射模式读一个地址,数据会被缓存进 D-Cache。这时候如果你退出映射模式、擦掉了那个扇区、写回新数据、再重新进入映射模式,然后读同一个地址——你读到的可能还是缓存里的老数据。因为 Cache 不知道 Flash 内容被外部改写了。

症状表现非常迷惑:单步调试的时候数据是对的,全速跑的时候数据是错的;或者擦写完立刻读是对的,过一会儿再读就变成旧值了。我第一次遇到时怀疑了半天时序和电源,最后才反应过来是 Cache 的问题。

解决办法有两个。一是在修改 Flash 内容之后手动清一次相关地址范围的 Cache,用SCB_CleanInvalidateDCache_by_Addr()之类的函数,注意地址要按 Cache Line(一般是 32 字节)对齐。二是干脆把 Flash 映射区配置成 non-cacheable,用 MPU 的MPU_ACCESS_NOT_CACHEABLE属性,虽然会牺牲一点读取速度,但彻底消除了一致性问题。对参数存储这种低频访问的场景,我强烈建议用第二种。

对 I-Cache 也类似。如果你把代码放在外部 Flash 里执行(XIP),然后做 OTA 升级写入了新固件,必须 invalidate 一次 I-Cache 才能执行到新代码,否则会跑在老指令上。而且执行中的代码和我们正在擦写的地址如果是同一块区域,那根本没法操作,必须先跳转到 RAM 里执行擦写函数。

5.3 XIP 之外还能干什么

内存映射模式最直观的用途是 XIP(Execute In Place),但它的价值不止于此。

第一是快速读取大块常量数据。字库、图片、音频采样,这些数据放进外部 Flash,通过映射区指针直接读,省掉了间接模式的命令开销。我在一个带 LCD 的项目里把图片资源都放在 Flash 里,刷屏时直接用 memcpy 从映射区搬到显存,比之前一条条发读命令快了不止一个数量级。

第二是给算法提供大缓冲区。有些滤波、FFT 或者神经网络推理的场景需要比较大的查表空间,内部 RAM 装不下,映射区的 Flash 读起来是只读的,正好适合放那些不变的系数表。

第三是做双分区 OTA。把 Flash 分成 A、B 两个固件区和一个参数区,当前运行 A 区的时候通过映射模式读 A 区代码,同时把新固件写到 B 区,写完校验通过后改一个标志位重启,Bootloader 去 B 区启动。整个过程用同一颗芯片完成,不需要额外的存储器件。

6. 常见问题速查与排查手法实录

6.1 读 ID 就对不上:先看硬件再看频率

读 ID 失败是所有问题里最好定位的,因为它只依赖最基础的几根线。

如果读回来是0x00 0x00 0x00,几乎可以确定是 MISO(IO1)这条路没通。要么引脚配错了,要么 Flash 的 SO 脚没焊上,要么 CS# 一直没被拉低导致芯片根本没响应。用万用表量一下引脚通断是最快的。

如果读回来是0xFF 0xFF 0xFF,说明 MISO 一直被拉高,典型原因是 Flash 的电源没到,或者是 WP#/HOLD# 被拉低导致芯片处于保护或暂停状态。另外如果 MCU 的 MISO 引脚配置成了输出模式(推挽),会跟 Flash 的输出打架,也可能读到恒定电平。

如果读回来的前两个字节对、第三个字节不对,那基本是时钟或者采样点的问题。0xC8 和 0x40 能对说明通信链路基本正常,只有容量码错可能是某个位的采样位置刚好落在跳变沿上。这种情况下降低频率试试,如果降频后正确,就是时序余量不够的问题;降频还错,就要检查采样相位配置。

还有一种情况是读回来是0xC8 0x00 0x00,这种很有规律的值通常是命令阶段多了或少了几位时钟,导致后续数据整体移位。检查一下AddressMode是不是误设成了 24 位(0x9F 是不带地址的)。

6.2 写不进、写错位、读回不一致

写入环节的问题比读取环节复杂得多,因为涉及到状态机的配合。

第一类问题:写完了读回来还是 0xFF。可能原因排序如下。

最常见的是漏了 WREN。每条写命令之前都必须单独发一次 WREN,而且 WREN 和写命令之间不能有其他操作。有些芯片在 WREN 之后如果收到读命令会把 WEL 清掉,所以顺序不能乱。

其次是写保护没解除。GD25Q80E 的状态寄存器里有 BP0~BP2 三个位,加上 TB 和 SEC,可以保护一部分或全部地址空间。这些位的默认值一般是 0,也就是不保护,但如果你之前跑过某个例程写进去过保护位,就会留下隐患。排查时先读 SR1,看高几位是不是 0。

再其次是 QE 位没置起来。如果你用四线写命令(0x32)而 QE=0,那 IO2/IO3 不工作,写进去的数据只有两位有效,读回来自然不对。这种情况的特点是读出来的数据呈现规律性的错误,比如每 4 个 bit 只有低两位是对的。

第二类问题:写进去了但位置不对。这基本就是跨页了。前面分析过,跨页会回卷覆盖。排查时算一下起始地址除以 256 的余数加上长度有没有超过 256,超过就必须拆开。

第三类问题:读回数据的部分字节正确、部分字节错误,且错误位置每次不一样。这种偶发错误基本锁定时钟和信号完整性。检查方向包括:降低 QSPI 时钟、调整采样偏移、检查退耦电容、缩短走线。在 54MHz 以下出现这种问题的概率很低,一旦出现在 108MHz 上,十有八九是相位问题。

6.3 常见问题速查表

现象最可能原因快速验证方法解决方向
ID 全 0x00MISO 不通、CS 没拉低万用表量通断、示波器看 CS检查引脚与焊接
ID 全 0xFF电源未上、WP#/HOLD# 被拉低量 VCC、量 pin3/pin7 电平补上拉、查供电
ID 前两字节对、第三字节错采样相位偏、频率过高降到 27MHz 再读调采样偏移或降频
读出全 0xFF进入暂停态、时钟没输出示波器看 SCLK 有无波形查时钟配置与 QE 位
四线读数据错位QE 位没置、dummy 数错读 SR2 看 bit1置 QE、抓波形数 dummy
写后读回仍为 0xFF漏 WREN、写保护写前读 SR1 看 WEL补 WREN、清 BP 位
写入后数据回卷覆盖跨页检查 addr%256 + len拆分成多页写
擦除超时电压不足、保护位锁死量 VCC 纹波、读 SR1换电源、清保护位
映射模式读到旧数据D-Cache 未失效关 Cache 再试清 Cache 或配 MPU
擦完立刻读对、过一会错Cache 一致性问题加内存屏障后再读配置 non-cacheable

6.4 几条我踩过坑之后固化下来的工程习惯

最后分享几条成本很低但收益很大的习惯,都是从实际事故里总结出来的。

第一条,任何写操作之后立刻回读比对。写完一个扇区,马上把内容读出来跟 RAM 里的源数据 memcmp 一遍。多花几十毫秒,但能在问题扩散之前就抓住它。我在一个产品里就是因为省了这一步,导致一批设备出厂后参数偶尔丢失,返修成本远超当时的节省。

第二条,给每个存储区域加一个版本号和 CRC。参数区的头部我一般放一个魔数、一个版本号和一个 CRC32。上电时先校验魔数和 CRC,验证不通过就加载默认值并触发一次重写。这样即使 Flash 某个区域被损坏,也能降级运行而不是整机死掉。

第三条,擦除和编程的超时值按数据手册最大值给,不要按典型值给。典型值只是统计平均,芯片在不同的温度和老化状态下,最坏情况能达到典型值的 5 到 8 倍。按典型值设超时,等于把产品可靠性押在运气上。

第四条,保留一个只读的测试分区。在量产时把一批已知数据写进固定的扇区,开机自检的时候读出来比对。这相当于给 Flash 做了一次在线体检,能提前发现介质退化、焊接虚焊等隐患。配合前面的 JEDEC ID 自检,基本上电就能判断外部存储链路是否健康。

第五条关于寿命管理:如果你的应用需要频繁写参数,务必做磨损均衡。哪怕是最简单的"两个扇区轮流写、写满再擦"的双缓冲方案,也能把寿命提高一倍。我在一个小型记录仪项目里用了 16 个扇区的环形日志,擦除次数从每天 8000 次降到每天 500 次,按 10 万次的寿命算,设备能用十几年。

关于调试工具,我个人比较依赖示波器和逻辑分析仪。QSPI 的问题里,有一半以上靠抓波形就能直接看出答案——CS 有没有按预期拉高、命令发完到数据出来之间有几个时钟、IO2/IO3 在四线阶段有没有活动,这些东西在波形上是一目了然的,比在代码里反复猜快得多。如果你在用逻辑分析仪,记得选采样率足够高的型号,54MHz 的 QSPI 至少需要 200MHz 以上的采样率才能还原波形细节。

如果后面要在这个基础上继续扩展,我建议的方向是先把手写的擦写逻辑抽象成一个带缓冲的文件系统(哪怕是最简单的 littlefs 也行),再基于它往上做参数管理、日志记录和 OTA。裸机直接操作 Flash 适合学习和验证,真正上产品还是需要一层管理系统来兜住那些边界情况和故障恢复逻辑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 10:08:08

Figma MCP 实战:自动读取设计稿生成开发文档

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 10:08:02

Claude 读 Google Home 状态,TaoToken Key 放在哪里

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 10:08:01

不只看 81.5 分:TaoToken 视角下 GPT-Live-1 的 Token 单耗

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/18 10:07:39

苹果秋季发布会怎么选?从硬件到系统的换机决策框架

凌晨的直播看完,群里已经吵成一锅粥:有人在算以旧换新的差价,有人在问手表要不要一起换,还有人直接甩过来一句"到底值不值得升"。每年苹果秋季新品发布会结束后的这几个小时,我基本上都在干同一件事——把发…

作者头像 李华
网站建设 2026/9/18 10:06:56

智能健康监护系统软件设计:分层架构、告警引擎与可靠传输

简介:这是一篇聚焦智能健康监护系统软件设计的PDF论文,面向物联网、嵌入式系统以及医疗健康信息化方向的技术人员与研究者,内容围绕社区家庭老人健康监测场景,完整呈现了感知层、网络层、应用层三层的软件架构方案。资源共1个PDF文…

作者头像 李华
网站建设 2026/9/18 10:06:38

预算耗尽前,TaoToken 该触发哪类 Agent 压缩

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华