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

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#片选低有效2SOMISOIO1数据输出/双向数据 13WP#IO2写保护/双向数据 24GNDGND地5SIMOSIIO0数据输入/双向数据 06SCLKSCLK时钟7HOLD#IO3暂停/双向数据 38VCCVCC电源 2.7~3.6V关键点在 3 脚和 7 脚。它们默认是 WP# 和 HOLD# 功能一个低电平就会让芯片进入硬件写保护另一个低电平会让芯片暂停通信。平时不接也可以但绝对不能悬空——悬空的 CMOS 输入脚是玄学之源周围一点噪声就可能让芯片莫名其妙进入暂停状态读出来全是 0xFF。我现在的习惯是这两个脚各挂一个 10k 上拉到 VCC板子面积够的话再加 100nF 到地。那怎么把它们切成 IO2/IO3 用答案是QE 位在状态寄存器 2SR2用 0x35 读取的 bit1。QE0 时仍是 WP#/HOLD# 功能QE1 才是四线数据脚。这意味着哪怕你的代码把 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 上升沿之间需要一段建立时间tSLCHCS# 拉高后到下一次拉低之间也需要 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 读状态寄存器 1RDSR1用来查两个位bit0 是 WIPWrite In Progressbit1 是 WELWrite Enable Latch。WIP1 表示芯片正在忙内部操作此时除了读状态寄存器之外几乎什么都不认。WEL1 表示写使能已生效可以执行一次写或擦除操作完成后芯片自动把它清 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 IDRDID是最适合拿来做芯片在不在自检的命令。时序是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_BITS、AlternateBytes填 0xA0同时把DummyCycles调成 6。如果你压根不设这个字段HAL 会按 0x6B 的时序去发那读出来的数据就是错的。2.3 编程与擦除命令的取舍4KB 还是 64KB写操作这边命令本身不复杂复杂的是策略选择。0x02 页编程是最基本的写入命令一次最多 256 字节且必须页对齐。它的完整时序是WREN → CS# 拉高 → CS# 拉低 → 发 0x02 24 位地址 数据 → CS# 拉高 → 芯片开始内部编程 → 轮询 WIP。注意最后那个 CS# 上升沿才是编程真正开始的信号在这之前数据只是被缓存在芯片的输入寄存器里。0x20 扇区擦除擦 4KB0xD8 块擦除擦 64KB0xC7 全片擦除擦整颗芯片。这三条命令都不带数据只有命令加地址全片擦除连地址都没有发完拉高 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/(31) 54MHz这是我最常用的配置GD25Q80E 在 0x0B 和 0x6B 命令下都轻松支持留了充足余量线长的情况下依然稳定。216/(11) 108MHz这个能跑芯片支持到 133MHz但时序余量很紧。108MHz 下每个时钟周期只有 9.26nsPCB 走线上稍微长一点或者多了几颗过孔数据采样相位就会偏。我用 108MHz 只在短走线、四层板、有完整地平面的场合试过而且必须配合半周期采样偏移才稳。216/(01) 216MHz这个基本别想STM32 自己的 QUADSPI 输出也到不了这么高而且 Flash 芯片也接不住。这里给个实用的自检方法先用 54MHz 把整套读写擦流程跑通然后用循环测 1MB 数据的读取时间。如果验证没问题再逐步提高频率每次都重新跑一遍完整的擦写读比对。一旦出现偶发错误就把频率降回来一档。别一次性调到最高再回头找问题那样的调试效率极低。3.2 FIFO 阈值、CS 高电平时间与采样偏移这几个参数不理解原理的话在配置工具里随便填也能跑但只要长了数据或者高了频率就会出问题。FIFO 阈值FifoThreshold决定什么时候触发中断和 DMA 请求。STM32 的 QUADSPI 内部有一个 32 字节的 FIFO如果你配成 4那么每攒够 4 字节就会申请一次 DMADMA 搬运频繁但延迟小如果配成 16DMA 请求次数减少但每次中断之间要等更多数据。大块读的时候我一般配 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 1FSIZE 应该是 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/(31) 54MHz */ hqspi.Init.FifoThreshold 4; hqspi.Init.SampleShifting QSPI_SAMPLE_SHIFTING_NONE; hqspi.Init.FlashSize 19; /* 2^(191) 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)单向模式下是 MOSIQUADSPI_BK1_IO1SO (pin2)单向模式下是 MISOQUADSPI_BK1_IO2WP# (pin3)四线模式下是 IO2需上拉QUADSPI_BK1_IO3HOLD# (pin7)四线模式下是 IO3需上拉布线的时候有两点值得注意。一是 CLK 和 IO 线要尽量等长虽然 54MHz 下没那么严格但如果要冲 108MHz长度差控制在 5mm 以内比较保险。二是在 Flash 的 VCC 脚旁边紧挨着放一颗 100nF 加一颗 1uFNOR Flash 在内部编程和擦除的瞬间会有电流尖峰退耦不好会出现擦除偶尔失败这种极难复现的问题。初始化顺序我固化成了这样GPIO 和时钟使能 → QSPI 外设初始化 → 10ms 上电等待 → 读 JEDEC ID 校验 → 读 SR2 检查 QE 位没置起来就置位 → 进入正式使用。这个顺序保证每一步都有明确的成败判据出问题能立刻知道卡在哪一步。4.2 读 ID、读状态、写使能的封装先把三个基础操作封装好后面所有高级功能都建立在它们之上。/* 读 JEDEC ID成功返回 0ID 通过指针带回 */ 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/s1MB 连续读0x6B 四线读 54MHz约 52ms约 20 MB/s4KB 扇区擦除0x2045~60ms-256B 页编程0x020.7~1.2ms-256KB 分区全写擦除编程约 3.8s-单线和四线的差距就是四倍加上命令开销的差别这个提升是实打实的。注意 20MB/s 是算上 FIFO 和 HAL 开销之后的数字理论值 27MB/s差的那部分主要是命令阶段8248 个时钟和软件搬运的成本。5. 内存映射模式让代码在 Flash 里取指运行5.1 进入与退出内存映射的正确姿势间接模式每次读都要发一遍命令和地址而且每次只能读 DLR 里设定的字节数读一大片数据要循环调用很多次每次都有几十个时钟的固定开销。内存映射模式解决的就是这个问题配置好之后整个 Flash 的内容被映射到 MCU 的一段地址区间你直接用指针访问就行外设会自动生成读命令和地址硬件层面完成没有软件循环的开销。进入内存映射之前必须先把读命令配置好因为映射模式下每次访问都用 CCR 里的配置。做法是先用HAL_QSPI_Command配一次读命令用 0x6B 或者 0xEB然后立刻调HAL_QSPI_MemoryMappedQSPI_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 之外还能干什么内存映射模式最直观的用途是 XIPExecute 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几乎可以确定是 MISOIO1这条路没通。要么引脚配错了要么 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而 QE0那 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-cacheable6.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 适合学习和验证真正上产品还是需要一层管理系统来兜住那些边界情况和故障恢复逻辑。