
《STM32H750xx【QSPI】轮询方式读写GD25Q64E》完整博文最近一直在折腾STM32H750这颗芯片代码量稍微一涨内部那128KB Flash就报警没办法外挂一颗QSPI Flash成了最常规的解法。GD25Q64E这颗8MB的NOR Flash价格便宜、货源稳定和ST的QSPI外设配合起来非常顺手基本是玩H750的标配外设之一了。我这次把整个过程整理成一篇实测笔记用的是最基础的轮询方式不涉及中断和DMA调通了再去折腾更复杂的高性能模式思路会清晰很多。这篇内容适合刚接触STM32H750、对QSPI外设不太熟、或者是想快速跑通一颗SPI NOR Flash读写流程的开发者。如果你已经会MSP430或者普通SPI Flash的操作想迁移到H750的QSPI上来这篇也能帮你少踩不少坑。文章中会穿插一些我实际调试时遇到的问题以及排查思路尽量做到你能直接照着操作就点亮。1. 整体设计思路为什么选QSPIGD25Q64E轮询方式在做任何外设驱动之前先想清楚几个问题为什么选这颗Flash为什么用QSPI而不是普通SPI为什么轮询而不是中断或DMA。想明白了后面写代码就是顺着逻辑走的事情。1.1 STM32H750的“先天不足”与外挂Flash的必然性STM32H750VBT6这颗芯片非常特殊它虽然跑着480MHz主频的Cortex-M7核心性能在STM32家族里属于第一梯队但内部Flash只有128KB。你随便编译个带RTOS、带LVGL、带文件系统的工程固件轻轻松松就超过128KB了。更别说还想在里面存字库、图片资源、录音数据这容量连塞牙缝都不够。所以玩H750的工程师基本达成共识外挂Flash是必选项。常见的选择有通过FMC总线外挂NOR Flash或SDRAM也有通过QSPI外挂一颗串行NOR Flash。前者速度快但占引脚多、布线麻烦后者引脚占用少、容量弹性大、价格便宜更适合大多数产品场景。GD25Q64E作为兆易创新推出的64Mbit即8MB串行NOR Flash脚位兼容Winbond的W25Q64系列指令集几乎一模一样。在国产替代的大背景下它不仅供货稳定价格也很有竞争力实测下来性能和W25Q64基本持平是QSPI入门非常典型的选择。1.2 QSPI外设相比普通SPI的优势在哪里很多刚从标准SPI转过来的人会有疑问我直接拉四根线MOSI、MISO、SCK、CS读这颗Flash不行吗当然行标准SPI模式完全可以操作GD25Q64E但问题是速度差太远了。标准SPI是单线双向传输一个时钟周期只能传1bitQSPI支持四线模式同一个时钟周期能传4bit理论速度直接翻四倍。STM32H750的QSPI外设最高可以跑到133MHz配合Quad模式理论带宽能到66MB/s左右这已经能跟一些并口NOR Flash掰手腕了。而标准SPI在同样时钟下只有16MB/s出头差距非常明显。更关键的还在于QSPI支持内存映射模式。你可以把QSPI Flash映射到CPU的地址空间直接像访问内部Flash一样执行代码或者读数据不需要手动发送读写命令。这个特性对H750尤其重要因为内部Flash太小很多人会把代码放到外部QSPI Flash里执行也就是常说的XIPExecute In Place。要实现XIP就必须走QSPI普通SPI外设没有这个能力。对于本文要讲的轮询方式虽然用不上内存映射模式但QSPI外设的间接模式也提供了比SPI更灵活的命令封装机制发送各种Flash操作指令都非常顺手。1.3 为什么先用轮询方式而不是DMA或中断调试外设驱动的时候我强烈建议先用轮询方式把整个链路的逻辑打通再去优化到中断或者DMA。原因很简单轮询方式代码执行路径是线性的每一步发生了什么都是可预期的出问题的时候好定位。比如你发了一条读状态寄存器的指令轮询模式就是死等Flash返回BUSY位清零这个过程阻塞CPU但逻辑一目了然。如果用DMA你还需要关心DMA配置、传输完成中断、半传输中断、错误中断任何一个环节不对现象都是“读到一堆乱码”排查范围反而更大。等轮询版本调通之后你已经确认了硬件连接没问题、QSPI外设配置没问题、Flash的指令时序没问题。这时候再切DMA你只需要关注数据搬运层面的正确性可以放心地进行性能优化。我自己做项目时都是这个套路先把基础打牢固再上速度。2. 硬件连接与准备工作写代码之前先把硬件准备妥当。别小看这一步很多读不出来数据的问题根源都在杜邦线松动、引脚复用配错、电源纹波太大这些“低级”问题上。2.1 GD25Q64E引脚功能与典型接线GD25Q64E的封装以SOP-8为主一共8个引脚功能比较简单引脚编号名称功能说明连接到STM32H7501/CS片选信号低电平有效QSPI_CS如PB62DO数据输出标准SPI时为MISOQSPI_BK1_IO13/WP写保护低电平有效接3.3V4GND电源地GND5DI数据输入标准SPI时为MOSIQSPI_BK1_IO06CLK时钟信号QSPI_CLK如PB27/HOLD暂停传输低电平有效接3.3V8VCC电源正极3.3V这个接线表对应的是四线QSPI模式中IO0和IO1两根线的接法。如果你后续要使用双线或四线模式IO0~IO3别忘了把IO2和IO3也连接到MCU的对应引脚上。[外链图片转存中...(img-eXopOkD7-1734241232190)]我刚接触QSPI时犯过的错误是以为四线模式的IO0~IO3都要接上才能开始调试。其实GD25Q64E默认上电后工作在标准SPI模式你完全可以用只有IO0和IO1两根数据线的方式先跑通读写然后再根据需求扩展。这样既降低了第一次调试的复杂度又不会影响后续升级。2.2 开启QSPI的时钟与引脚复用配置STM32H750的QSPI外设在硬件上属于AHB总线使用时需要确保两个东西被正确配置一是外设时钟使能二是引脚复用功能。推荐的时钟配置路径是QSPI外设时钟来源于AHB一般建议不要直接使用480MHz主频来驱动Flash因为GD25Q64E最高支持120MHz左右的时钟频率不同批次可能有差异保守一点用60~80MHz更稳。我通常的做法是给QSPI的时钟分频系数配置为3~5分频让实际QSPI时钟在80MHz上下既有性能又留有余量。如果使用STM32CubeMX只需要在“Connectivity”中找到QUADSPI勾选开启然后在GPIO设置里把对应的引脚复用模式设置为AF10QSPI即可。CubeMX会自动帮你生成时钟使能和GPIO初始化代码。需要注意一点STM32H750的QSPI引脚在不同的封装上位置不完全一样比如VBT6和VGT6的引脚映射就略有差异。自己画板或者做飞线实验的时候一定要对着芯片的数据手册的引脚定义图核对一遍不要想当然地按网上的接线方式直接连。2.3 搭建最小软件工程STM32CubeMX HAL库我常用的软件工程组合是STM32CubeMX生成初始化代码 HAL库进行业务逻辑开发。虽然直接操作寄存器更“硬核”但在调试QSPI这种本身寄存器就比较多的外设时HAL库封装已经足够成熟生成的代码可读性也还可以能省下很多查阅寄存器手册的时间。新建工程时有几个关键配置点芯片型号选择STM32H750VBTx或者你实际使用的具体型号RCC选择外部晶振HSE时钟树按最大主频480MHz配置QUADSPI开启模式选择“Indirect mode”Flash大小根据GD25Q64E配置调试接口选择Serial Wire保留SWD下载能力避免程序卡死时无法重新烧录这里特别提醒下调试QSPI外设期间SWD调试口一定要保留。你在调试过程中大概率会遇到CPU进入HardFault或者卡死的场景如果SWD被禁用了只能用串口ISP来擦除芯片那就非常被动了。Generated code完成后先编译一下确认基本工程没问题再开始写QSPI的业务代码。这样做的好处是后续如果出问题至少能排除工程配置层面的错误。3. QSPI轮询读写的核心机制在敲代码之前先把GD25Q64E的几个核心工作机制讲透。很多人代码写不出来不是不会调用HAL库函数而是不理解Flash内部到底是怎么工作的。3.1 NOR Flash的基础特性先擦后写NOR Flash在写入数据时有一个硬性规则只能把1写成0不能把0写成1。比如某个地址当前存储的值是0xFF二进制11111111你可以把它写成0xFE11111110因为只是把一个bit从1变成了0但如果你想把它从0xFE恢复成0xFF那就写不进去了必须先把该区域擦除让所有bit回到1的状态。这就是所谓的“先擦后写”原则。GD25Q64E支持多种擦除粒度4KB扇区擦除、32KB块擦除、64KB块擦除、以及整片擦除。实际使用中4KB扇区擦除是最灵活、最常用的操作因为粒度小不会误伤其他数据。[外链图片转存中...(img-YPqtPLc2-1734241232190)]擦除操作是需要时间的。 GD25Q64E擦除一个4KB扇区典型耗时在45ms左右整片擦除可能耗时40~60秒。标准SPI方式下让CPU死等这么长时间不现实所以才需要“轮询状态寄存器”这个机制来等待擦除完成。这也是本文核心点之一。3.2 GD25Q64E的指令集需要重点掌握的几条基本指令GD25Q64E的指令集兼容JEDEC标准内部定义了几十条指令但对于基础的读写应用你只需要掌握以下几条指令名称指令码HEX功能说明Write Enable0x06写使能每次写操作前必须先发Read Status Register-10x05读取状态寄存器1用于查询BUSY位和写使能状态Read Data0x03普通读取速度较慢但兼容性好Fast Read0x0B快速读取带Dummy周期速度更快Page Program0x02页编程一次最多写入256字节Sector Erase0x20扇区擦除擦除4KB区域Block Erase0xD8块擦除擦除64KB区域Chip Erase0xC7整片擦除耗时最长Read ID0x9F读取芯片的厂商ID和器件ID验证通信从指令表就能看出来QSPI外设底层做的事本质上跟普通SPI一样往Flash发指令码后面跟地址和数据。QSPI的优势在于数据线可以扩展为2根或者4根传输速率大幅提升。但指令的编码格式、状态寄存器的语义这些都是Flash芯片定义的跟MCU无关换芯片时要重新查数据手册。3.3 轮询方式到底“轮询”的是什么说回本文的主题轮询方式。这里的“轮询”核心是轮询Flash的状态寄存器中的BUSY位bit0。当该位为1时表示Flash内部正在执行写操作、擦除操作等耗时任务当该位为0时表示Flash空闲可以接收新的指令。代码层面的实现逻辑很直接while (flash_is_busy()) ;不过这里有一个需要注意的细节在“写使能成功”这个阶段状态寄存器的bit1WEL位写使能锁存也需要被检查。如果写使能指令没有正确生效后续所有写操作都会被Flash拒绝而Flash不会主动报错只会静默地不执行很容易让人误以为是硬件故障。我一般会把状态寄存器的两个位都打印出来BUSY位和WEL位一起看排查问题的时候能省很多时间。3.4 Flash状态寄存器含金量很高细节要看清GD25Q64E有3个状态寄存器前面提到的读状态寄存器10x05指令是最常用的它的bit分布大概是bit0BUSY1表示Flash忙bit1WEL写使能锁存标志bit2BP0~bit4BP3块写保护位bit5TB顶部/底部写保护选择bit6SEC扇区/块保护选择bit7SRP看到这些写保护位你是不是想到了什么没错如果状态寄存器里BP位被设置为1Flash的部分区域会被硬件写保护这时候你执行写操作或擦除操作都会失败。新买的Flash芯片默认BP位是0但如果之前被其他代码写入过保护配置你的读写操作就会变得非常“诡异”。所以初始化时我建议对状态寄存器做一次完整检查必要时直接把写保护位清零// 读取状态寄存器1 uint8_t status read_status_register(); if (status 0x0C) // 检查BP0~BP3中是否有保护位被置位 { // 发送写使能然后写入0x00清除所有写保护 }这里写入状态寄存器的指令是0x01后面的数据就是你要写入的状态寄存器值。注意写状态寄存器前也必须先发写使能0x06而且写状态寄存器和写数据用的写使能机制是一样的很容易被忽略。4. 核心代码实现QSPI轮询方式读写GD25Q64E下面进入重头戏完整代码实现。我基于STM32CubeMX自动生成的HAL库代码添加了QSPI Flash驱动部分版本基于STM32CubeH7的1.11.0HAL库版本不同API可能有一点差异但核心思路是通用的。4.1 QSPI参数初始化配置全解析CubeMX生成的QSPI初始化结构体里有几个关键的成员值得仔细说明/* QUADSPI init function */ void MX_QUADSPI_Init(void) { hqspi.Instance QUADSPI; hqspi.Init.ClockPrescaler 3; // QSPI时钟 AHB时钟 / (31) hqspi.Init.FifoThreshold 1; // FIFO阈值 hqspi.Init.SampleShifting QSPI_SAMPLE_SHIFTING_HALFCLK; // 数据采样时点 hqspi.Init.FlashSize 23; // Flash大小2^(231) 16MB这里对应64Mbit hqspi.Init.ChipSelectHighTime QSPI_CS_HIGH_TIME_5_CYCLE; // CS拉高持续时间 hqspi.Init.ClockMode QSPI_CLOCK_MODE_0; // 模式0CPOL0CPHA0 hqspi.Init.FlashID QSPI_FLASH_ID_1; // 使用Flash Bank 1 hqspi.Init.DualFlash QSPI_DUALFLASH_DISABLE; // 不使能双Flash模式 if (HAL_QSPI_Init(hqspi) ! HAL_OK) { Error_Handler(); } }逐个解释一下关键参数的含义。ClockPrescaler是分频系数实际QSPI时钟 外设时钟 / (ClockPrescaler 1)。我的工程里QSPI外设时钟源为AHB默认480MHz设置这个值为3得到实际时钟120MHz。但为了稳妥我是建议保守一点设置为4或者5分频降频使用。Flash芯片标称最高时钟并不代表在所有PCB布线条件下都能稳定跑到实际产品中还要考虑信号完整性因素。FlashSize这个参数特别容易搞错它的计算公式是FlashSize log2(字节容量) - 1。GD25Q64E容量为8MB 2^23字节所以FlashSize 23。如果你误填了一个偏小的值读取操作时地址会超出Flash实际地址范围表现出来就是读出来的数据不是预期的内容。ChipSelectHighTime表示每次传输结束后CS信号保持高电平的时长GD25Q64E要求两次指令之间CS高电平时间至少持续ns级别配置为5个时钟周期的时长足够满足要求。4.2 QSPI间接模式下的指令收发通用框架HAL库为QSPI提供了几个关键的函数。我们通常使用HAL_QSPI_Command()发送指令然后配合HAL_QSPI_Transmit()或HAL_QSPI_Receive()来传输数据。理解这几个函数后面的代码基本就是组合填空。来看一个发送命令的通用封装/** * brief 发送QSPI命令并携带地址和数据 * param instruction: 指令码 * param address: 地址 * param address_size: 地址宽度 * param data_mode: 数据线模式单/双/四线 * param dummy_cycles: 哑元周期数 * param nb_data: 数据字节数 */ static void QSPI_SendCommand(uint8_t instruction, uint32_t address, uint32_t address_size, uint32_t data_mode, uint32_t dummy_cycles, uint32_t nb_data) { QSPI_CommandTypeDef cmd; cmd.Instruction instruction; cmd.InstructionMode QSPI_INSTRUCTION_1_LINE; // 指令用单线发送 cmd.Address address; cmd.AddressMode (address_size 0) ? QSPI_ADDRESS_1_LINE : QSPI_ADDRESS_NONE; cmd.AddressSize (address_size 0) ? QSPI_ADDRESS_24_BITS : QSPI_ADDRESS_NONE; cmd.DataMode (nb_data 0) ? data_mode : QSPI_DATA_NONE; cmd.DummyCycles dummy_cycles; cmd.NbData nb_data; cmd.SIOOMode QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(hqspi, cmd, HAL_MAX_DELAY) ! HAL_OK) { Error_Handler(); } }这个封装能覆盖绝大多数Flash操作场景。由于GD25Q64E支持的最大地址宽度是24位在默认的Contiguous寻址模式下用24位地址就够了。如果以后换成更大的Flash需要改为32位地址模式注意同步调整。4.3 读取芯片IDQSPI通信验证第一步拿到开发板的第一件事不是急着写Flash而是先读ID。能正确读到ID说明物理连接、QSPI外设初始化都是正常的。GD25Q64E的JEDEC ID是三个字节厂商ID 存储类型ID 容量IDGD25Q64E具体为0xC8 0x40 0x17。/** * brief 读取Flash的JEDEC ID * retval 读取到的ID三字节合并为一个uint32_t高8位为厂商ID */ uint32_t QSPI_ReadID(void) { uint8_t id[3] {0}; QSPI_SendCommand(0x9F, 0, 0, QSPI_DATA_1_LINE, 0, 3); HAL_QSPI_Receive(hqspi, id, HAL_MAX_DELAY); printf(Manufacturer ID: 0x%02X\r\n, id[0]); printf(Memory Type ID: 0x%02X\r\n, id[1]); printf(Memory Capacity ID: 0x%02X\r\n, id[2]); return (id[0] 16) | (id[1] 8) | id[2]; }如果读出来的全是0xFF或者全是0x00大概率是硬件连接问题如果读出来的ID在0xC8 0x40 0x18附近说明你手里的是升级版GD25Q128E16MB配置参数需要相应调整。注意发送0x9F指令时数据模式使用的是单线模式QSPI_DATA_1_LINE。因为读取ID是芯片上电后的基本信息交流用标准SPI方式最可靠。JEDEC标准规定ID读取工作在单线模式下进行。4.4 擦除操作轮询等待的关键场景擦除操作是Flash所有操作中最耗时的一类GD25Q64E的扇区擦除典型时间是45ms整片擦除可达40秒以上。在这个时间段内必须不断轮询BUSY位直到Flash完成内部操作。/** * brief 扇区擦除4KB * param sector_address: 扇区起始地址必须按4KB对齐 */ void QSPI_EraseSector(uint32_t sector_address) { // 1. 发送写使能命令 QSPI_SendCommand(0x06, 0, 0, QSPI_DATA_NONE, 0, 0); // 2. 发送扇区擦除指令 QSPI_SendCommand(0x20, sector_address, 24, QSPI_DATA_NONE, 0, 0); // 3. 轮询等待擦除完成 QSPI_WaitBusy(); }[外链图片转存中...(img-zsf5wxqx-1734241232190)]QSPI_WaitBusy()的实现在后面专门说明。这里要特别强调的是第2步的地址对齐GD25Q64E要求扇区擦除地址必须是4KB对齐的即低12位为0。如果不满足对齐条件Flash不一定报错但擦除行为是未定义的可能擦了错误的扇区也可能什么都没擦。实际项目中我都是先用宏定义封装好扇区起始地址的计算公式。另外有些更早期的驱动会先擦除整个块再重新写入这种方式虽然能保证数据最终是正确的但牺牲了Flash寿命和写入速度不建议采用。4.5 写入操作页编程的边界问题GD25Q64E支持页编程理论上一次最多写入256字节。但有个隐含陷阱数据不能跨页写入边界。也就是说如果你的数据从页内的第250字节开始那么一次最多只能写入6个字节到该页的末尾再往下必须等下一页重新发一次Page Program指令。[外链图片转存中...(img-Tj6kNMYk-1734241232190)]这个陷阱在操作Flash时非常经典尤其当你要写入的数据长度不固定时特别容易踩中。看下面的实现/** * brief 页编程写入 * param write_addr: 写入地址建议页内偏移为0 * param pData: 待写入数据指针 * param data_len: 待写入数据长度256且不跨页 */ void QSPI_WritePage(uint32_t write_addr, uint8_t *pData, uint32_t data_len) { // 1. 写使能 QSPI_SendCommand(0x06, 0, 0, QSPI_DATA_NONE, 0, 0); // 2. 页编程指令 QSPI_SendCommand(0x02, write_addr, 24, QSPI_DATA_1_LINE, 0, data_len); // 3. 发送数据 HAL_QSPI_Transmit(hqspi, pData, HAL_MAX_DELAY); // 4. 轮询等待写入完成 QSPI_WaitBusy(); } /** * brief 任意长度数据写入自动处理跨页边界 * param write_addr: 写入起始地址目标扇区必须先擦除 * param pData: 数据指针 * param data_len: 数据长度 */ void QSPI_WriteData(uint32_t write_addr, uint8_t *pData, uint32_t data_len) { uint32_t page_offset write_addr 0xFF; // 当前页内偏移 uint32_t remain_len data_len; uint32_t cur_addr write_addr; uint8_t *cur_ptr pData; while (remain_len 0) { // 计算本页可写入的最大字节数最多到页边界 uint32_t write_len 256 - page_offset; if (write_len remain_len) { write_len remain_len; } QSPI_WritePage(cur_addr, cur_ptr, write_len); cur_addr write_len; cur_ptr write_len; remain_len - write_len; page_offset 0; // 后续操作都是从页首开始 } }写操作完成后还有一件事值得做专门验证写入的数据。QSPI_WaitBusy()只保证Flash内部写操作完成但不保证数据一定写对了。正规做法是写完后回读比对很多降低可靠性的问题都是因为忽略了这一步。4.6 轮询等待BUSY位清零的实现细节这是整个“轮询方式”的核心所在单独拿出来详细说明。QSPI读状态寄存器的流程比较特殊它需要发送0x05指令读取一个字节但这里有个细节是读取状态寄存器的数据和常规Flash数据读取有所不同它不携带地址。在QSPI命令结构中指令模式设置为1线指令本身用单线发送数据模式设置为单线读取的状态寄存器字节也用单线返回但地址模式为无地址DummyCycles为0/** * brief 读取状态寄存器 * retval 状态寄存器1的值 */ uint8_t QSPI_ReadStatusRegister(void) { uint8_t status 0; QSPI_SendCommand(0x05, 0, 0, QSPI_DATA_1_LINE, 0, 1); HAL_QSPI_Receive(hqspi, status, HAL_MAX_DELAY); return status; } /** * brief 轮询等待Flash空闲BUSY位为0 */ void QSPI_WaitBusy(void) { uint32_t timeout 0; const uint32_t max_timeout 100000; // 超时保护防止死循环 while (QSPI_ReadStatusRegister() 0x01) // bit0: BUSY标志 { if (timeout max_timeout) { printf(QSPI wait busy timeout!\r\n); break; } } }注意这里的超时保护。虽然Flash操作时间有典型值但万一芯片损坏、接线松动、或者某个指令写飞了程序会永远卡在这里。加一个超时计数至少能让程序不至于完全死掉也方便打印调试信息判断问题位置。实际项目中超时时间应根据具体场景合理设置比如擦除整片需要40秒那么超时设定至少要大于这个时间。4.7 读取操作最直接的验证手段读取操作相比于写入和擦除要简单很多不需要写使能也不需要等待只要发送读取指令和地址就能立即得到数据。GD25Q64E支持普通读取0x03和快速读取0x0B。两者区别在于快速读取在地址后额外加了8个Dummy周期用于让Flash内部有足够时间切换输出模式在更高时钟频率下更稳定。普通读取在低频下也能正常读出数据但面对高速时钟时可能会出现读错的情况因为Flash内部的数据输出建立是需要时间的。实际项目中我的默认选择是快速读取/** * brief 读取数据 * param read_addr: 读取起始地址 * param pData: 数据缓冲区 * param data_len: 读取长度 * note 使用快速读指令0x0B带8个Dummy周期 */ void QSPI_ReadData(uint32_t read_addr, uint8_t *pData, uint32_t data_len) { QSPI_SendCommand(0x0B, read_addr, 24, QSPI_DATA_1_LINE, 8, data_len); for (uint32_t i 0; i data_len; i) { HAL_QSPI_Receive(hqspi, pData[i], HAL_MAX_DELAY); } }等等这段代码我思考了一下最好不要在循环里逐个字节调用HAL_QSPI_Receive因为QSPI每个字节读取都涉及到命令传输流程效率非常低。更合理的做法是如果QSPI支持FIFO批量读取应该用一块连续内存来接收数据一次性收完如果不支持批量传输才退而求其次。实测发现HAL_QSPI_Receive这个函数内部已经做了轮询等待和FIFO搬运工作配合一个足够大的缓冲区进行批量读取速度比单字节循环快几十倍。实际代码优化为void QSPI_ReadData(uint32_t read_addr, uint8_t *pData, uint32_t data_len) { QSPI_SendCommand(0x0B, read_addr, 24, QSPI_DATA_1_LINE, 8, data_len); HAL_QSPI_Receive(hqspi, pData, HAL_MAX_DELAY); }HAL_QSPI_Receive内部会从QSPI FIFO中读取NbData字节的数据放到pData缓冲区中。一个小提示如果数据量较大确保pData指向的内存空间足够PC端调试时可以使用大数组。4.8 一整套完整的主函数调用流程把前面几节的核心函数串起来一个完整的读写流程就是#include main.h #include qspi_flash.h int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_QUADSPI_Init(); MX_USART2_UART_Init(); // 用于打印日志 // 1. 验证通信 uint32_t id QSPI_ReadID(); if (id ! 0xC84017) { printf(Flash ID error: 0x%06X\r\n, id); while (1); } printf(Flash ID OK: 0x%06X\r\n, id); // 2. 擦除扇区地址0 printf(Erasing sector 0...\r\n); QSPI_EraseSector(0x000000); // 3. 验证擦除结果擦除后应该全是0xFF uint8_t buf[64]; QSPI_ReadData(0x000000, buf, 64); int erased_ok 1; for (int i 0; i 64; i) { if (buf[i] ! 0xFF) { erased_ok 0; break; } } printf(Erase verify: %s\r\n, erased_ok ? PASS : FAIL); // 4. 写入测试数据 uint8_t write_buf[64]; for (int i 0; i 64; i) { write_buf[i] (uint8_t)i; } printf(Writing 64 bytes...\r\n); QSPI_WriteData(0x000000, write_buf, 64); // 5. 回读验证 uint8_t read_buf[64]; QSPI_ReadData(0x000000, read_buf, 64); int write_ok 1; for (int i 0; i 64; i) { if (read_buf[i] ! write_buf[i]) { write_ok 0; printf(Data mismatch at offset %d: expect 0x%02X, read 0x%02X\r\n, i, write_buf[i], read_buf[i]); break; } } printf(Write verify: %s\r\n, write_ok ? PASS : FAIL); while (1) { // 主循环 } }这个流程覆盖了“验证通信 - 擦除 - 验证擦除 - 写入 - 回读验证”的完整链条。每一环都有验证步骤任何一步失败都能快速定位问题。一把过的情况很少大部分人第一次跑这个流程都会遇到这样或那样的问题。我把实际调试中遇到的高频故障整理了一下供你参照排查。5. 常见问题与排查技巧实录这一部分是我最想分享的因为网上资料大多只讲“怎么配”很少讲“出错了怎么办”。我把两年里遇到过的问题整理成表格和实录希望能帮你少踩坑。5.1 读取ID失败全是0xFF或全是0x00这个问题出现的概率最高。从经验来看硬件层面的问题占80%软件配置占20%。针对硬件按顺序检查首先确认Flash的VCC确实是3.3V且纹波不大用万用表实测别只看电源指示灯然后确认CS、CLK、IO0、IO1四根线没有接反尤其是IO0和IO1接反了就会导致读出来全0xFF最后确认WP引脚和HOLD引脚有没有正确处理这两个引脚如果不接必须上拉至VCC悬空时Flash可能处于写保护或暂停传输状态通信时序会异常。软件配置层面重点检查QSPI初始化参数里的ClockMode配置GD25Q64E支持SPI Mode 0和Mode 3我使用的STM32 QSPI外设配置的ClockMode QSPI_CLOCK_MODE_0恰好是Mode 0。如果配置成了Mode 1或Mode 2时钟极性和相位不匹配通信一样会失败。5.2 能读ID但写入后回读数据不对能读ID说明底层通信链路是通的问题大概率出在写入流程上。最常见的原因是目标扇区没有先擦除。我们来回忆下NOR Flash的特性只能把1写成0不能把0写成1。如果你往一个未擦除的扇区写入数据原本是0的位置无法变成1回读数据自然和你预期不符。这一点我在第一次上手时吃过亏后来专门在写入函数前加了一个自动擦除扇区的逻辑根据写入地址计算所在扇区从此再也没出过这类问题。第二个常见原因是写使能没有及时发送。GD25Q64E要求每一次写命令之前都必须先发送0x06指令否则写命令不生效。注意我刚才讲的“每一次”不是写一次使能后就能一直写。当Flash正常完成一次写操作后WEL位会被硬件自动清零你必须重新发送写使能才能执行下一次写操作。第三个原因是跨页边界问题。上一节已经详细说过一次页编程最多256字节且不能跨页。如果你用固定长度的数据块往Flash里写比如一次写入512字节却只调用了一次Page Program那么超出页边界的数据是写不进去的。最简单的验证方式是检查你的写入函数是否根据页边界做了拆分处理。5.3 擦除超时或者擦除后数据没变擦除超时基本可以排除硬件问题因为能成功完成通信只是Flash操作耗时长而已。先检查你的超时设置是否合理。扇区擦除的典型时间是45ms但在极端情况下可能到400ms。整片擦除最坏需要40秒以上。如果超时设置为几毫秒或者几百毫秒当然会报超时。从我经验看单次擦除操作超时最好不要低于1秒整片擦除不要低于60秒。擦除后数据没变大概率是写保护位BP位被置位了。此时需要先读取状态寄存器确认把保护位清零后再尝试擦除。另外还要检查地址对齐问题GD25Q64E的扇区擦除地址必须按4KB对齐块擦除按64KB对齐。如果传入的地址不对齐操作结果是未定义的数据可能完全不变也可能擦除了错误的区域。5.4 QSPI时钟频率偏高导致偶发读错数据这类问题最有迷惑性因为不是必现的可能连续读10000次都没问题但第10001次突然读出一个错误字节。主频拉得太高、PCB走线过长、杜邦线飞线、供电纹波偏大都可能导致时序裕量不足。排查方法很直接降低QSPI时钟频率比如把ClockPrescaler从3改成7即从120MHz降到60MHz。如果问题消失说明就是时序裕量问题。我在做产品化设计时会把量产时钟频率定在80MHz甚至更低这是综合考虑了Flash芯片批次差异、PCB生产公差和环境温度变化后的保守选择。做开发板实验可以用高速但产品要稳宁可降频保可靠。5.5 一个容易踩的坑初始化顺序最后说一个很多人忽略的细节QSPI外设的初始化顺序是有讲究的。不要在主函数里直接先调QSPI_ReadID()再调MX_QUADSPI_Init()虽然这看起来是“理所当然”的顺序但QSPI外设的时钟和引脚复用没有配置好之前发出的命令都是无效的。正确的初始化顺序一定是时钟 - GPIO - 外设CubeMX生成的顺序就是对的然后才轮到QSPI操作函数。如果你用的不是CubeMX生成工程而是裸寄存器方式尤其要注意先使能AHB时钟、再配置GPIO复用、最后初始化QSPI控制寄存器顺序反了轻则通信失败重则引脚状态异常。6. 排错工具箱从底层往上逐步排查调试过程的思路和方法也很重要它决定了你面对一个看似无解的问题时是从哪里下手还是干瞪眼。6.1 用逻辑分析仪抓时序一锤定音如果你手头有逻辑分析仪抓取QSPI通信波形是排查时序问题最直观的手段。把探针接在CS、CLK、IO0、IO1上抓取读ID的一段波形然后用协议解析功能分析。正常的读ID波形特征是CS拉低后CLK出现至少8个周期的指令字节0x9F然后紧跟着24个周期的数据返回C8 40 17。如果波形显示指令发出后没有数据返回大概率是Flash没响应如果数据位是乱的可能是时钟极性问题也可能是数据线接线问题。不用怀疑逻辑分析仪的必要性第一次抓波形看到数据在示波器上跳动的感觉比你对着寄存器猜半天要靠谱得多。6.2 用串口打印关键变量缩小问题范围没有逻辑分析仪的话用串口打印关键变量的值也一样能定位问题。QSPI驱动调试期间我会把以下几个信息打印出来每次读ID返回的原始三字节数据每次擦除前和擦除后的状态寄存器值每次写入前和写入后的回读数据抽验前几个字节通过比对“理论应该是什么”和“实际读出来是什么”能快速把问题缩小到某个具体环节。比如状态寄存器显示WEL位一直是0那就是写使能命令没有正确生效回读数据全是0xFF那就是根本没写进去。6.3 用一个小工具函数做周期性的读写自检调试稳定之后我还习惯写一个简单的自检函数对几个固定的地址反复执行“擦除-写入-回读-比对”操作每次循环都打印一次结果。让程序跑几个小时后观察是否有偶发错误。这个工具在上线前非常有用能提前暴露时钟频率过高或电源不稳定等隐性风险。void QSPI_SelfTest(void) { uint8_t tx_buf[16] {0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}; uint8_t rx_buf[16] {0}; uint32_t test_addr 0x001000; // 使用第二个扇区测试保留第一个扇区 while (1) { QSPI_EraseSector(test_addr); QSPI_WriteData(test_addr, tx_buf, sizeof(tx_buf)); QSPI_ReadData(test_addr, rx_buf, sizeof(rx_buf)); if (memcmp(tx_buf, rx_buf, sizeof(tx_buf)) ! 0) { printf(SelfTest FAIL at addr 0x%08X\r\n, test_addr); } else { printf(SelfTest PASS\r\n); } HAL_Delay(1000); } }这个自检函数运行几天几夜都不出一次FAIL才说明你的QSPI读写链路是基本可靠的。7. 从轮询到高性能后续的优化方向与扩展轮询方式调通之后你其实已经掌握了QSPI Flash操作的核心逻辑。但实际产品里通常不会让CPU长时间死等擦除也不满足于单线程的读写性能。性能优化一般顺着下面这几个方向走。7.1 中断方式擦除期间CPU干别的活轮询方式最明显的短板就是擦除期间CPU被完全占用。比如扇区擦除要45ms整片擦除要40秒这段时间里主循环什么都干不了。如果系统里还有其他任务要处理就必须改用中断方式。QSPI外设支持在操作完成时产生中断。你可以在发出擦除指令后注册一个回调函数等Flash操作完成的中断触发后再接着执行后续逻辑。HAL库里对应的机制是HAL_QSPI_ErrorCallback和HAL_QSPI_AbortCpltCallback等回调函数。这种模式其实是一个异步状态机逻辑比轮询方式要复杂一些但CPU利用率和系统响应能力会好很多。7.2 DMA方式读数据不再占用CPU读取大块数据时如果用轮询方式HAL_QSPI_Receive会阻塞CPU直到数据全部搬完。DMA方式则是把数据搬运工作交给DMA控制器CPU可以在数据传输过程中执行其他代码。STM32H750的QSPI支持通过AHB总线接入DMA配置相对简单。核心流程是发送读取命令然后启动DMA传输数据到达后触发DMA传输完成中断。实测下来读取上百KB数据时DMA方式的CPU占用率几乎为0而轮询方式则要打满CPU。如果你的系统有高频率任务、图形刷新、通信协议栈等DMA几乎是个必需品轮询方式扛不住。7.3 内存映射模式代码直接在Flash里执行前面提到的XIPExecute In Place是H750用户最期待的功能。配置好QSPI内存映射模式后QSPI Flash会被映射到0x90000000起始的地址空间CPU可以直接从这个区域取指令执行。这对H750意义太大了因为内部Flash只有128KB如果代码量超过这个容量传统做法是把代码拷贝到外部RAM或SDRAM再执行但初始化过程很繁琐也占用RAM空间。有了XIP你可以直接把大部分代码编译到0x90000000地址上电后从内部Bootloader引导跳转过去执行代码近乎无缝运行在外部Flash上省时省力。不过内存映射模式有个技术门槛它要求QSPI Flash以连续地址空间方式映射但Flash内部的写操作仍然要通过间接模式完成。也就是说你需要在使用时做切换正常执行代码时工作在内存映射模式需要擦除或写入时切回间接模式。这个过程容易出错建议先将本文的基础读写逻辑研究透再尝试这个高级功能。8. 写在最后的实操体会GD25Q64E这颗Flash配合STM32H750的QSPI外设是我个人认为嵌入式存储方案里非常实用的一套组合。8MB容量、四线高速传输、稳定的国产货源再加上H750这颗性能亮眼的MCU无论是做开机画面存储、日志记录、参数保存还是音频数据缓冲都有很大的发挥空间。轮询方式调通不过是整个QSPI应用的第一步但它帮你建立了一个非常扎实的技术地基。后续无论是切DMA、上中断还是玩内存映射XIP这套代码框架都能平滑迁移。我自己在实际项目中基本是把它当作一个标准块设备来使用上层跑小文件系统比如LittleFS下层就是这套驱动逻辑。最后分享一个心得调试这类底层驱动最忌讳的就是代码一跑不通就开始怀疑芯片有问题。先检查硬件连接再检查初始化参数最后才怀疑代码逻辑按部就班排查绝大部分问题都能在一两个小时内解决。踩过几次坑之后你就会发现QSPI其实是一个相当“温顺”的外设一旦建立通信后面的一切都是水到渠成的事。