STM32H743 QSPI驱动W25Q64:HAL库内存映射与掉电保存实践
2026/9/16 12:16:32 网站建设 项目流程

简介:面向STM32H743IIT6单片机开发者,提供通过QSPI接口驱动W25Q64闪存进行直接读写的HAL库实验工程,解决如何在Cortex-M7平台上高效访问外部存储的问题,尤其适合学习STM32 HAL库和QSPI通信的初级、中级工程师参考。压缩包共95个文件,以55个.h头文件和28个.c源文件为主,涵盖HAL驱动库、CMSIS内核支持、用户业务代码,另含MDK-ARM工程文件与启动文件等,整体952KB,结构规整便于直接打开。已有514人学习下载。例程完整演示了QSPI引脚初始化、W25Q64命令配置、HAL_QSPI_Read/Write读写操作、以及错误查询与DMA中断扩展等关键环节,配以清晰的目录划分,可帮助开发者快速上手并作为后续RTOS或文件系统集成的基础。

1. 为什么 STM32H743IIT6 上要选 QSPI 读 W25Q64,而不是普通 SPI

把 W25Q64 挂到 STM32H743IIT6 的 QSPI 上,用 HAL 库直接读写,这组操作在 H743 上比普通 SPI 更值得优先选。QSPI 在同样的 GPIO 上把数据线扩到 4 根,读取带宽可以做到 SPI 的四倍;更关键的是,H743 的 QUADSPI 外设支持内存映射模式,CS 拉低后整个 8MB Flash 被映射到 0x90000000 开始的地址空间,CPU 用指针就能读数据,代码里不再是一长串读函数。这里说的“直接读写”就是这个意思:写阶段用 HAL_QSPI_Command 发页编程指令,读阶段可以切到映射地址直接取数。对需要掉电不丢失的参数存储、日志缓存和 bootloader 数据交换的场景,这套组合最简单直接,适合裸机、RTOS 和嵌入式内核方向工程师阅读调试。

2. QSPI 和 W25Q64 的协议细节:CubeMX 前先弄明白的三件事

2.1 W25Q64 的 JEDEC ID、QE 位和四线模式为什么决定成败

W25Q64 是 8MB NOR Flash,24 位地址宽度,JEDEC ID 通常是 0xEF 0x40 0x17,老批次可能返回 0x16。QSPI 四线读写依靠 IO2/IO3 复用实现,使用前必须确认状态寄存器 SR2 的 QE 位。HAL 库初始化不会自动处理 QE,我一般会在上电后先执行 0x05 读 SR1、0x35 读 SR2,如果 SR2 的 bit1 为 0,就用 0x01 写状态寄存器指令把 QE 置 1。这一步漏掉,后面发四线读指令 0xEB 时,大概率读回全 0xFF,而且现象和引脚接反非常像。

写状态寄存器时 W25Q64 要求先发 0x06 解除状态寄存器写保护,然后指令 0x01 携带数据 0x02,表示 SR2 写入值。构造 HAL_QSPI_CommandTypeDef 时,Instruction 填 0x01,DataMode 用 1-line,NbData 为 1。做完这步后再读 ID,返回值稳定了,总线初始化才算完成。

2.2 CubeMX 中 QSPI 的引脚、时钟和 Flash Size 参数怎么填

我习惯的引脚分配是 PB2 作为 QUADSPI_CLK,PB6 作为 BANK1_NCS,PD11、PD12、PD13、PE2 分别作为 IO0、IO1、IO3、IO2。CubeMX 里开启 QUADSPI 后选择 Single Flash,系统会自动把这组引脚锁到复用功能 AF10,实际画板时注意这些引脚不要和调试串口、以太网复用冲突。下面这组参数可以直接写入 .ioc,也可以对照 HAL 初始化代码核对:

配置项常用值说明
ModeSingle Flash只挂一片 W25Q64,不启用双闪存模式
Clock Prescaler9实际 QSPI 时钟 = HCLK / (9+1),H743 跑 480MHz 时得到 48MHz
Sample Shift1采样点移到时钟半周期,抬高频读余量
Flash Size222^(22+1) = 8MB,这是 W25Q64 的关键参数
Chip Select High Time4两条指令间 CS 拉高保持 4 个时钟周期
Fifo Threshold4FIFO 阈值,DMA 批量读写时按总线宽度调整

Flash Size 是最容易抄错的一项。W25Q128 是 16MB,对应 2^(FlashSize+1) = 16MB,所以要填 23;W25Q64 是 8MB,必须填 22。直接把 W25Q128 例程搬过来的话,内存映射区长度会多算一倍,明明只写了 8MB 数据却认为后面还有空间,读起来全是越界后的不确定内容。

SampleShift 和 ChipSelectHighTime 是例程里最常见的两个“能跑但不可靠”参数。SampleShift 默认 0 在低速下没问题,一旦把 Prescaler 降到 4 以上,读数据沿和时钟边沿贴在一起,容易收到不稳定电平;设成 1 后采样点偏离端点,兼容性最好。ChipSelectHighTime 太小时页编程末尾可能被状态寄存器轮询打断,造成最后几十个字节丢失。

2.3 HAL 库把 QSPI 操作拆成三类,先分清再写代码

HAL 库对 QUADSPI 的封装不是一两个函数,而是三条路径:间接模式、自动轮询、内存映射。间接模式由 HAL_QSPI_Command 发指令,再用 HAL_QSPI_Transmit 或 HAL_QSPI_Receive 传数据,所有写操作都走这条路。自动轮询用 HAL_QSPI_AutoPolling 等 W25Q64 的忙位翻转,替代代码里的 while 轮询。内存映射用 HAL_QSPI_MemoryMapped 把 Flash 映射到地址总线,之后的读操作连指令都不用发,CPU 直接走读总线。

这三类模式共享同一个 QUADSPI 外设,从一种模式切到另一种前,最好先调用 HAL_QSPI_Abort 清空外设状态机,否则 HAL 内部状态还停在之前的事务现场。理解这个前提后,“直接读写”的含义就清晰了:不是绕过 HAL,而是让指令、地址、数据组合尽量短。下面第 3 章的最小例程就是按照这条思路组织的。

3. 用 HAL 库直接读写 W25Q64:最小可跑的 ID、擦除、页编程

3.1 初始化序列先把 Flash Reset 干净

上电第一步是执行 0xFF、0x66、0x99 三组指令。0xFF 让芯片从深度掉电模式恢复,同时充当总线同步;0x66 是 Reset Enable,0x99 是真正复位。三者都只走指令线,不需要地址和数据。用 HAL 库实现时,命令结构体长这样:

static void qspi_reset_flash(void) { HAL_QSPI_CommandTypeDef cmd = {0}; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.DataMode = QSPI_DATA_NONE; cmd.Instruction = 0xFF; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { Error_Handler(); } cmd.Instruction = 0x66; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { Error_Handler(); } cmd.Instruction = 0x99; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { Error_Handler(); } HAL_Delay(2); }

这里的 AddressSize 虽然没有地址要发,也必须填 24 位,HAL 内部参数校验会检查这个字段。SIOOMode 选择每条命令都发送指令,这个设置最保守;只有四线快速读指令 0xEB 才有必要用 QSPI_SIOO_INST_ONCE_FIRST_CMD。复位后延迟 2ms 是让内部充电泵稳定,很多例程只延迟 1ms,量产时偶发第一次写失败。

3.2 读 JEDEC ID:唯一能证明接线和时钟都对的函数

读 ID 用 0x9F 指令,数据长度 3 字节。命令阶段 DataMode 用 1-line,因为读 ID 本身是传统 SPI 线序,和四线模式无关。代码骨架如下:

uint8_t qspi_read_jesd_id(uint8_t id[3]) { HAL_QSPI_CommandTypeDef cmd = {0}; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.Instruction = 0x9F; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; cmd.NbData = 3; if (HAL_QSPI_Command(&hqspi, &cmd, 1000) != HAL_OK) { return 0; } if (HAL_QSPI_Receive(&hqspi, id, 3, 1000) != HAL_OK) { return 0; } return (id[0] == 0xEF && (id[2] == 0x16 || id[2] == 0x17)) ? 1 : 0; }

返回 1 时,id[0] 是厂商 ID,id[1] 是类型 0x40,id[2] 是容量版本号。如果 id[2] 返回 0x15,那是 W25Q32 或者引脚接错;返回全 0xFF,优先检查 CS 上拉电阻、CLK 分频,以及 CubeMX 里有没有把这几个引脚复用成普通 GPIO。

3.3 扇区擦除和页编程的最小实现

写 Flash 前必须擦除。W25Q64 的扇区是 4KB,擦除指令 0x20,地址必须按 4KB 对齐。连续擦除多个扇区时,HAL 命令构造是一样的,只是把 Address 字段改掉,每段之间都要等忙位。页编程指令 0x02,页大小 256 字节,写入前也要对齐。下面代码做了页内边界裁剪,避免一次写跨越页尾:

static void qspi_write_enable(void) { HAL_QSPI_CommandTypeDef cmd = {0}; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.DataMode = QSPI_DATA_NONE; cmd.Instruction = 0x06; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; cmd.NbData = 0; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { Error_Handler(); } } uint8_t qspi_write_page(uint32_t addr, const uint8_t *buf, uint16_t len) { HAL_QSPI_CommandTypeDef cmd = {0}; if (len > 256) len = 256; if ((addr & 0xFF) + len > 0x100) { len = 0x100 - (addr & 0xFF); } qspi_write_enable(); cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.Instruction = 0x02; cmd.Address = addr; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; cmd.NbData = len; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { return 0; } if (HAL_QSPI_Transmit(&hqspi, (uint8_t *)buf, len, HAL_MAX_DELAY) != HAL_OK) { return 0; } return 1; }

HAL_QSPI_Transmit 与 HAL_QSPI_Command 之间 CS 不会被拉高,整条写命令是一个原子事务。跨页数据的处理方式应该是:先把本页剩余空间写满,再把剩余数据放到下一页,同时检查下一页是否跨扇区,跨扇区要先擦除。非对齐连续大块写入是 W25Q64 最常见的丢数据原因,不能用一句“我写之前先擦过”带过。

3.4 读回数据并打印,验证例程是否真的写进去了

用 0x03 指令读数据,命令阶段带上地址,再用 HAL_QSPI_Receive 接收:

uint8_t qspi_read(uint32_t addr, uint8_t *buf, uint32_t len) { HAL_QSPI_CommandTypeDef cmd = {0}; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.Instruction = 0x03; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; if (HAL_QSPI_Command(&hqspi, &cmd, HAL_MAX_DELAY) != HAL_OK) { return 0; } return (HAL_QSPI_Receive(&hqspi, buf, len, HAL_MAX_DELAY) == HAL_OK); }

调试时可以先写一页,再读同一地址,把两边数据用串口一起打印出来。普通读指令 0x03 在 48MHz 下没问题,产品里应换成快速读 0x0B,并给 DummyClockCycle 填 8,否则高时钟频率下读时序余量不够。这里顺便说一句:读回校验和写入不能在两个线程里同时跑,QSPI 外设不区分命令归属,两次 HAL 调用之间被其他任务插入一条读状态命令,读回的数据就会错位。

4. 内存映射模式:把 W25Q64 映射到 0x90000000 直接读

4.1 什么时候用映射读,什么时候继续用间接模式

间接模式适合写操作和数据量不大的读操作,每一次读取都要构造命令,总线开销不小。内存映射模式适合频繁读、随机读,例如字库、图标、校准曲线,以及把 W25Q64 当作一个小型只读存储池。H743 的 QUADSPI 映射地址是 0x90000000,映射长度由 FlashSize 参数决定,8MB 芯片映射区就是 8MB 连续空间,读操作不需要额外指令。映射区不支持写,写操作仍然要通过间接模式完成,这是“直接读写”里最需要理解清楚的边界。

4.2 先发一个 Fast Read 命令完成映射配置

配置内存映射本质上就是告诉 QSPI:以后每一次自动读都用哪条读指令。我一般先用 0x0B 跑通,再考虑换 0xEB:

void qspi_memory_mapped_enable(void) { HAL_QSPI_CommandTypeDef cmd = {0}; HAL_QSPI_MemoryMappedTypeDef mem_map = {0}; HAL_QSPI_Abort(&hqspi); cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_1_LINE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.Instruction = 0x0B; /* Fast Read 1-1-1 */ cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 8; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; mem_map.TimeOutActivation = QSPI_TIMEOUT_COUNTER_DISABLE; if (HAL_QSPI_MemoryMapped(&hqspi, &cmd, &mem_map, HAL_MAX_DELAY) != HAL_OK) { Error_Handler(); } }

0x0B 的 Dummy 周期在 W25Q64JV 上按 48MHz 时钟取 8 是保守值。如果把 Prescaler 调小到 60MHz 以上,就必须查数据手册的 dummy cycles 表格,不是越大越好:Dummy 太大只是浪费总线时间,Dummy 大小不够则读解码错位。改用 0xEB 四线快速读时,DataMode 要改成 QSPI_DATA_4_LINE,DummyClockCycle 按手册改成 4 或 6,同时确认 QE 已置位。

4.3 用指针读数据,但小心 D-Cache 返回旧值

映射完成后,读取变成两行:

const uint8_t *flash = (const uint8_t *)0x90000000; memcpy(tmp, flash + 0x1000, 64);

连续读没问题,最容易出问题的是:先用第 3 章的写入函数写一段数据,紧接着从 flash + addr 读取,发现内容还是上一次的。原因在 D-Cache。H743 是 Cortex-M7,D-Cache 打开后,CPU 读 0x90000000 时优先命中 cache,这个 Flash 地址被当成普通内存缓存了。写操作走的是 QSPI 外设,不经过 CPU 数据总线,cache 不知道数据已经变了。解决办法是读之前强制失效对应 cache 行:

SCB_InvalidateDCache_by_Addr((uint32_t *)(0x90000000 + addr), len); memcpy(tmp, flash + addr, len);

这里的地址和长度都要求 32 字节对齐,调用前把 addr 向下对齐到 32,len 向上对齐到 32。另一个更彻底的方案是配置 MPU,把 0x90000000 区域设为 Write-Through 或 Device 内存属性,但会损失读性能,用在数据区可以,用在 XIP 代码区不合适。例程里能跑通往往是因为没开 D-Cache,产品里一旦打开就会踩到这个坑。

5. 掉电保存前的最后一次校验:AutoPolling、双备份和 debug 窗口验证

5.1 用 HAL_QSPI_AutoPolling 把 while 忙循环收掉

HAL_QSPI_AutoPolling 可以在外设内部等待状态寄存器的特定位,CPU 不需要空转。等待 W25Q64 擦写完成的最小配置如下:

uint8_t qspi_wait_busy(uint32_t timeout) { HAL_QSPI_CommandTypeDef cmd = {0}; HAL_QSPI_AutoPollingTypeDef poll = {0}; cmd.InstructionMode = QSPI_INSTRUCTION_1_LINE; cmd.AddressMode = QSPI_ADDRESS_NONE; cmd.DataMode = QSPI_DATA_1_LINE; cmd.Instruction = 0x05; cmd.AddressSize = QSPI_ADDRESS_24_BITS; cmd.DummyClockCycle = 0; cmd.SIOOMode = QSPI_SIOO_INST_EVERY_CMD; cmd.NbData = 1; poll.MatchMode = QSPI_MATCH_MODE_AND; poll.Mask = 0x01; poll.Match = 0x00; poll.Interval = 1; poll.AutomaticStop = QSPI_AUTOMATIC_STOP_ENABLE; return (HAL_QSPI_AutoPolling(&hqspi, &cmd, &poll, timeout) == HAL_OK); }

MatchMode 用 AND,Mask 只保留 bit0,Match 目标是 0。状态寄存器返回后,硬件自动把 reg & 0x01 与 0 比较,相等就停止并返回 HAL_OK。这样比 while 轮询少占 CPU,也避免忘记加超时而卡死。调用时要留意:AutoPolling 会占用 QSPI 的接收通道,DMA 发送数据后不能立刻执行,必须等 Transmit 的 DMA 完成标志复位。

5.2 掉电保存别只写一份,用双扇区加 CRC 轮换

“掉电不丢失”并不是“写完就不掉电”那么简单。掉电瞬间,页编程可能只写了一部分,CRC 校验会发现损坏。常见做法是把两个 4KB 扇区做成 A/B 槽:头部放 4 字节魔数、4 字节版本号、4 字节 CRC32,后面才是业务数据。每次写入先擦 A 槽再写;下次写入擦 B 槽,版本号加 1。上电时两个槽都读一遍,先比版本号,再算 CRC,两个条件都满足的那个才是有效数据。如果版本号相同,说明是重复写同一槽,选 CRC 通过的那个。这个布局比“写之前关中断、写完读回比较”更抗电压跌落,因为任何时候都至少保留一个完整副本。

5.3 调试器里直接看 0x90000000 内存,对照故障现象定位

启用内存映射后,打开 Keil 的 Memory Window 输入 0x90000000,就能像看 RAM 一样看到 W25Q64 的内容。这个验证方式比串口打印更直接,能同时看到地址和十六进制数据。出现的典型故障可以按下面这个表排查:

现象优先检查方向
读 ID 全 0xFFClock Prescaler、QE 位、CS 上拉
映射区全是 0xFFFlashSize 是否填 22,0x0B 的 Dummy 是否设 8
写后读回旧数据是否漏了 SCB_InvalidateDCache_by_Addr
擦除后状态一直忙0x20 指令地址是否 4KB 对齐,跨扇区是否拆分
QSPI 初始化卡住有没有先 Abort 上一个模式,HAL 状态机是否停在内存映射

把这个表贴在工程头文件上方,排查时会省掉很多重复试错。

本文还有配套的精品资源,点击获取

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

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

立即咨询