STM32F4实战:QSPI接口驱动NAND Flash存储扩展全攻略
简介面向STM32F446的QSPI NAND开发实例针对嵌入式开发者与物联网存储应用场景解决四线SPI接口与SPI NAND闪存通信的调试和验证问题。项目经实测可操作多种SPI NAND设备完整展示QSPI时钟配置、引脚复用、命令序列构建、地址分割传输等关键环节并覆盖初始化、块擦除、页写入、读取、设备识别等功能还提供坏块管理和ECC处理思路。压缩包共274个文件以C/H源码为主103个c、127个h配合启动汇编文件、Keil MDK工程、IOC引脚配置、MAP/AXF编译输出以及PDF说明文档整体仅1.89MB结构清晰便于对照学习。已有1137人学习浏览适合希望深入理解STM32F4 QSPI外设机制和SPI NAND驱动开发的工程师可快速搭建存储系统原型并迁移至实际项目。 最近手头的项目里频繁用到 STM32F4 系列配 QSPI 接口接 NAND Flash从最初调板子读不到 ID到最后稳定做文件系统存储中间踩了不少坑。正好有不少朋友也在折腾 STM32F_QSPI_NAND 这套组合我把实际调通的思路、代码片段和踩坑记录整理出来希望对正在做存储扩展的人有点帮助。这套方案的典型应用场景是主控是 STM32F4/F7/H7存储介质选 NAND Flash接口走 QSPI用来存大容量固件升级包、图片字库、日志记录或者音频素材。1. 整体方案设计与选型逻辑1.1 为什么是 STM32F QSPI NAND 的组合很多人在选择外部存储时会先考虑 SPI NOR Flash因为它的驱动简单、XIP 执行友好但到了几百 MB 甚至 GB 级别NOR 的成本和容量短板就很明显了。NAND Flash 在单位容量价格上比 NOR 低很多适合“数据密集、读多写少、掉电不丢”的场景。缺点是需要处理坏块、页写约束和 ECC 校验而这些逻辑放在 STM32F 系列上是可以承受的。QSPI 接口在这里充当了“高速公路”的角色。传统 SPI 接口只能 1-bit 传输QSPI 支持 4-bit 双向传输同一时钟下吞吐量直接翻了四倍。STM32F4 系列的 QSPI 外设最高可以跑到 100MHz 甚至更高具体看型号和手册实测读取页数据速度能达到十几 MB/s比传统 SPI 方式快得多和并口 NAND 相比又节省了大量 GPIO。从成本角度讲国产 NAND 芯片这两年性价比非常高很多品牌的 1Gbit、2Gbit 甚至 4Gbit NAND 芯片价格已经压得很低。我们项目中试过多个国产品牌只要时序符合 JEDEC 标准基本都能在 STM32 的 QSPI 外设下跑起来。关键是选型时要确认芯片支持标准的 Read/Program/Erase 命令另外要留意封装和电压常见的是 WSON8 和 SOP8电压一般支持 1.8V 和 3.3V。1.2 NAND 和 NOR 的差异决定了驱动策略NAND 和 NOR 的存储单元结构完全不同导致操作方式差异巨大。NOR 支持随机字节读写可以像内存一样直接按地址操作XIP 友好NAND 则是按页读、按页写、按块擦除而且存在坏块和位翻转问题。这套项目里最核心的认知是“NAND 不是内存是块设备”。你要像管理一个小型固态盘一样去管理它而不是像操作 EEPROM 那样直接读。写入前必须确保目标页处于擦除状态擦除的基本单位是块写入以页为单位通常一页是 2048 字节或 4096 字节附带若干备用区spare area用于存放 ECC 校验码和坏块标记。对比项目SPI NOR FlashQSPI NAND Flash容量范围1Mbit ~ 256Mbit 常见256Mbit ~ 8Gbit 常见擦除单位4KB/32KB/64KB通常 128KB/256KB写入单位字节/页页2KB/4KB随机读性能优支持 XIP差需整页读入 SRAM位翻转概率低概率高必须 ECC坏块基本忽略出厂即有需管理单位成本高低正是因为底层特性差异QSPI 驱动 NAND 不能简单套用 NOR 的 HAL 例程需要把命令时序和内存管理分开处理。这也是本项目标题中“QSPI”和“NAND”并存的根本原因。1.3 项目适用场景与目标读者如果你正在做以下类型的项目这套方案会比较适合需要较大的代码段存储空间、需要离线保存用户数据、需要低成本高密度存储但又不想上 SD 卡/ eMMC。典型应用包括工业 HMI 的素材存储、需要本地断网缓存的数据采集终端或者带 GUI 界面需要外部字库图片存储的嵌入式产品。本文主要面向已经能用 STM32 HAL 库点灯、会操作 SPI 总线、但对 NAND 和 QSPI 交互还比较陌生的读者。代码示例基于 STM32F427 平台但思路可以迁移到 F7、H7 或 GD32H7 系列原理完全一致。2. QSPI 外设初始化与 NAND 命令通道搭建2.1 QSPI 外设在 STM32F 中的工作模式STM32 的 QUADSPI 外设支持三种基本模式间接模式、自动轮询模式、内存映射模式。对于 NAND 这种“需要先发命令再传数据”的设备间接模式是主力内存映射模式更适合 NOR Flash 的 XIP 场景。间接模式下CPU 或 DMA 通过寄存器访问外部设备每发一条命令都要经历“写指令寄存器 → 写地址寄存器 → 传输数据”的过程。虽然看起来繁琐但胜在可控性强可以精确构造 NAND 芯片需要的多阶段命令序列。自动轮询模式适合查询状态寄存器比如等 NAND 器件完成内部擦写操作时可以用这个模式代替软件轮询降低 CPU 开销。不过实际项目里我更习惯用软件轮询状态寄存器逻辑更直观调试也方便。2.2 NAND 标准命令集与 QSPI 封装NAND Flash 的命令协议有一定行业惯例虽然不是所有芯片命令完全一样但核心命令大多兼容。和 QSPI 对接时特别要注意“两段式命令”的设计第一段把页数据搬运到 NAND 内部缓存第二段通过 QSPI 把缓存中的数据读出来。以常见的 W25N01GV 为例页读取流程是发送0x13命令进入“Read Status”实际是加载页阶段然后发送0x03或0x6B从缓存中读取数据。操作 STM32 的 QSPI 时这些命令都要封装成结构体配置好指令、地址、数据长度、Dummy 周期等参数。typedef struct { uint32_t Instruction; // 命令字节比如 0x13 uint32_t Address; // 目标地址 uint32_t AddressSize; // 地址字节数 uint32_t DummyCycles; // 空周期 uint32_t InstructionMode; // 命令线宽1-line/4-line uint32_t AddressMode; // 地址线宽 uint32_t DataMode; // 数据线宽 uint32_t NbData; // 数据长度 } QSPI_CommandTypeDef;这里最容易出错的是 DummyCycles 和 AddressMode 的配置。不同厂商的芯片对 Dummy 周期的要求差异很大比如有的读操作要求 8 个 dummy cycle有的要求 4 个。配置错误最典型的症状是读回来的数据全是 0xFF 或者数据错位。2.3 初始化代码参考以下是我在 STM32F427 上实际使用的 QSPI 初始化配置。时钟用的是 90MHz四分频后 QSPI 时钟约 22.5MHz这个时钟频率比较保守验证稳定后再往上加。void QSPI_Init(void) { QSPI_HandleTypeDef hqspi; hqspi.Instance QUADSPI; hqspi.Init.ClockPrescaler 4; // 分频系数 hqspi.Init.FifoThreshold 4; // FIFO 阈值 hqspi.Init.SampleShifting QSPI_SAMPLE_SHIFTING_HALFCLK; hqspi.Init.FlashSize 23; // 2^23 8Mbit按芯片容量填 hqspi.Init.ClockMode QSPI_CLOCK_MODE_0; hqspi.Init.FlashID QSPI_FLASH_ID_1; hqspi.Init.MemoryMapped QSPI_MEMORY_MAPPED_DISABLE; HAL_QSPI_Init(hqspi); }FlashSize这个参数特别关键它表示地址总线的宽度计算公式是log2(容量bit)比如 1Gbit 的芯片填 30512Mbit 填 29。如果填错了后面发地址时高位会被截断表现为只能读写前一部分区域。2.4 读 ID 是调试的第一步新板子到手第一步永远不是直接读数据而是读 JEDEC ID。ID 能反映芯片型号、厂商、容量也能验证 QSPI 物理连接是否正常。读 ID 的 QSPI 命令是0x9F数据线宽通常为 1-line固定返回 3 到 4 个字节。QSPI_CommandTypeDef cmd; uint8_t id[4] {0}; cmd.Instruction 0x9F; cmd.InstructionMode QSPI_INSTRUCTION_1_LINE; cmd.AddressMode QSPI_ADDRESS_NONE; cmd.DataMode QSPI_DATA_1_LINE; cmd.NbData 4; HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); HAL_QSPI_Receive(hqspi, id, HAL_QPSI_TIMEOUT_DEFAULT_VALUE);如果读 ID 返回0xFF 0xFF 0xFF优先查硬件连接而不是代码。QSPI 的 CLK 信号有没有虚焊、片选有没有被拉死、IO2/IO3 有没有设成普通 GPIO 输出都是常见问题。如果返回数据总是重复某个字节检查时钟极性和相位有些芯片必须在 Mode 0 下工作。3. 读写擦除核心流程与实现细节3.1 页读操作两段式命令的完整实现NAND 页读操作是整个项目中用到最频繁的基础操作。前面说过要分两步第一步发送0x13命令把指定页的数据加载到 NAND 内部 SRAM 缓存第二步再用读缓存命令0x03将数据从缓存取出。uint8_t NAND_ReadPage(uint32_t page_addr, uint8_t *buf, uint32_t len) { QSPI_CommandTypeDef cmd; // 第一步加载页到缓存 cmd.Instruction 0x13; cmd.Address page_addr; cmd.AddressSize QSPI_ADDRESS_24_BITS; cmd.AddressMode QSPI_ADDRESS_1_LINE; cmd.DummyCycles 0; cmd.DataMode QSPI_DATA_NONE; cmd.NbData 0; if (HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 1; // 等待 tRD 时间或者轮询状态寄存器 HAL_Delay(1); // 第二步从缓存读取数据 cmd.Instruction 0x03; cmd.Address 0; // 缓存地址从 0 开始 cmd.AddressSize QSPI_ADDRESS_16_BITS; cmd.DataMode QSPI_DATA_1_LINE; cmd.NbData len; if (HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 2; HAL_QSPI_Receive(hqspi, buf, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); return 0; }关于第二步的地址不同芯片可能不同多数 SPI NAND 芯片缓存偏移从0开始但有些芯片支持列地址输入。如果读出来的数据只有前几个字节正确、后面全错大概率是地址位宽配置不对或者漏了 Dummy 周期。有一种性能优化思路如果连续读取多个连续页第一页的缓存读取过程可以用 DMA 异步执行同时发第二页的加载命令实现双缓冲流水线。但这种方式在裸机开发里会显著增加代码复杂度我的建议是先跑通基础读写再考虑优化。3.2 页写操作程序前的擦除约束NAND 的写操作约束比读严格得多最重要的一条不能对已经写过数据的页重复编程除非先整块擦除。因此写操作通常被封装成“写一个页之前先判断所在块是否已擦除如果没擦除则先擦除”。页写命令流程是发送0x80进入编程模式先发地址再发数据最后发送0x10启动真正编程。QSPI 封装时0x80阶段会带上数据如果数据量较大比如整个 2048 字节建议使用 DMA 发送避免 CPU 长时间阻塞。uint8_t NAND_WritePage(uint32_t page_addr, const uint8_t *buf, uint32_t len) { QSPI_CommandTypeDef cmd; // 检查是否是页起始地址页内偏移必须为 0 if (page_addr % NAND_PAGE_SIZE ! 0) return 1; // 发送编程加载命令 cmd.Instruction 0x80; cmd.Address page_addr; cmd.AddressSize QSPI_ADDRESS_24_BITS; cmd.AddressMode QSPI_ADDRESS_1_LINE; cmd.DummyCycles 0; cmd.DataMode QSPI_DATA_1_LINE; cmd.NbData len; if (HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE) ! HAL_OK) return 2; HAL_QSPI_Transmit(hqspi, (uint8_t *)buf, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); // 发送程序执行命令 cmd.Instruction 0x10; cmd.Address 0; cmd.AddressMode QSPI_ADDRESS_NONE; cmd.NbData 0; HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); // 等待状态寄存器的 BUSY 位清零 NAND_WaitBusy(); return 0; }3.3 擦除操作与块管理擦除命令比较简洁先发送0x60指定块地址再发送0xD0执行擦除。块地址的计算方式和页地址不同块地址通常是“页地址 / 每块页数”的结果。uint8_t NAND_EraseBlock(uint32_t block_addr) { QSPI_CommandTypeDef cmd; cmd.Instruction 0x60; cmd.Address block_addr; cmd.AddressSize QSPI_ADDRESS_24_BITS; cmd.AddressMode QSPI_ADDRESS_1_LINE; cmd.DataMode QSPI_DATA_NONE; HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); cmd.Instruction 0xD0; cmd.Address 0; cmd.AddressMode QSPI_ADDRESS_1_LINE; HAL_QSPI_Command(hqspi, cmd, HAL_QPSI_TIMEOUT_DEFAULT_VALUE); return NAND_WaitBusy(); }特别注意擦除操作后如果不清除坏块标记下一次扫描时可能会把坏块当作好块使用。我个人的做法是上电初始化时全盘扫描一次建立坏块表每次擦除操作后重新读状态寄存器如果擦除失败就立刻把该块标记为坏块。3.4 ECC 校验的两种实现路线NAND 位翻转是无法避免的物理特性尤其在使用较长时间或接近寿命末期时更明显。ECC 是 NAND 驱动绕不开的一环。第一种路线是芯片内置 ECC。比如 W25N01GV 内部有 1-bit ECC 引擎可以通过0x15命令读取状态寄存器来获取 ECC 状态。这种方案实现简单硬件自动完成校验适合对可靠性要求不极高的场景。第二种路线是软件 ECC通常用汉明码或 BCH。汉明码实现简单单比特纠错能力足够BCH 能纠正多位错误但计算量较大。STM32F4 带 FPU 和 DSP 指令算 BCH 的开销也能接受但在中断频繁或者 RTOS 环境下要特别注意阻塞时间。我在项目里实际用的是“芯片硬件 ECC 软件坏块管理”组合。每次页读取后检查 ECC 状态寄存器如果有可纠正的错误位就继续使用如果报未纠正错误就把该页所在块标记为坏块。这样省去了大量软件 ECC 计算可靠性也基本满足工业数据采集的需求。4. 内存映射模式、FAT 文件系统对接与性能实测4.1 内存映射模式的取舍STM32 QSPI 外设支持内存映射模式NAND 芯片在配合支持相应命令的前提下也可以在一定程度上模拟内存映射读取。但和 NOR 不同的是NAND 必须先加载页到缓存所以内存映射模式往往只对“当前缓存页内容”有效切换页地址需要额外的命令操作。因此如果你的应用只需要读大量连续数据比如 FW 固件拷贝内存映射模式可以简化代码逻辑但如果是随机读多个分散页效率可能还不如间接模式。实际项目中我更倾向于在间接模式下完成所有底层读写再通过软件抽象层向上提供“按页寻址”的接口让上层文件系统完全感知不到底层是 NAND。4.2 对接文件系统的思路很多人希望直接在 NAND 上挂 FATFS 或 LittleFS。FATFS 本身对底层设备的接口是disk_read/disk_write/disk_ioctl只要把页读写/擦除封装成这几个函数就可以跑起来。但要注意 NAND 的写约束FATFS 如果需要修改某个扇区的内容传统做法是整扇区覆盖写这在 NAND 上不可行需要做“读-改-写”的转换。LittleFS 这类专门为 Flash 设计的文件系统要友好得多它天然支持日志结构和掉电恢复对擦写次数也有更好的管理。缺点是代码体积和 RAM 占用比 FATFS 大一些。在 STM32F4 上跑 LittleFS 配合 QSPI NAND我的经验是给文件系统分配独立的 RAM 缓冲区并开启CONFIG_LFS_READONLY时如果只读能显著减少资源占用。4.3 实测性能数据在 STM32F427 180MHz、QSPI 时钟 90MHz、Dual-flash 模式未开启的条件下我测试了以下数据操作类型数据量实际耗时估算速率连续页读1MB约 90ms11.4 MB/s连续页写1MB约 490ms2.1 MB/s块擦除128KB约 3.5ms36 MB/s按容量计整片读 ID4B约 0.2ms-写入速度远低于理论峰值原因是每次页编程前都要发命令、检查状态、处理页地址对齐FTL 层的地址映射和坏块检查也占用了时间。如果开启 QSPI 的 4-line 数据模式读取速度还能再提升 20%~30%但写入瓶颈更多在于 NAND 芯片本身的编程时间提升有限。4.4 内存对齐与 DMA 缓冲区的坑用 QSPI 和 DMA 时缓冲区起始地址必须对齐到 4 字节最好对齐到 32 字节。在 STM32F4 上DMA 的 FIFO 模式对地址对齐要求更严格否则会出现写进 Flash 的数据错位调试起来极其隐蔽。我的做法是使用专门的 32 字节对齐缓冲区__attribute__((aligned(32))) uint8_t nand_buffer[NAND_PAGE_SIZE NAND_SPARE_SIZE];另外要注意 QSPI FIFO 深度。F4 的 QSPI FIFO 是 32 字节如果发送数据和HAL_QSPI_Transmit之间间隔太长FIFO 可能会空导致时序异常。特别是在关闭 Cache、高主频、使用 RTOS 的极端情况下必须在发送前确保 FIFO 中有足够数据。5. 常见问题排查与避坑实录5.1 读 ID 正常但页读数据全是 0xFF这是调试 QSPI NAND 最常见的故障。ID 正常说明物理链路没问题页读全 0xFF 通常不是硬件问题而是命令时序没有完整执行。检查点有三个是否正确发送了0x13页加载命令加载命令后是否等待了足够时间第二段读缓存命令的地址线宽是否配置正确。还有一个小概率情况芯片是双 die 或者带 Planes 的架构需要额外设置 Plane 选择位。比如某些 4Gbit 芯片地址的第 24 位是 Plane 选择位忽略它会导致读出来的是另一个 Plane 的数据。5.2 写入后读出数据前几字节正确、后段错乱这个现象我在更换芯片品牌后遇到过原因是不同芯片对编程加载命令0x80的列地址page offset处理方式不同。有的芯片支持从页内任意偏移开始写有的芯片从 0 开始写满整页才能正确编程。最简单的规避方式是所有页写操作都从页首地址开始一次性写满整页包括备用区。如果上层应用只能保存 512 字节而页大小是 2048 字节就先把 2048 字节读出来到 SRAM修改其中 512 字节后再整页写回。5.3 坏块处理不当导致数据丢失NAND 出厂就可能有坏块使用过程中还会产生新坏块。如果不做坏块管理轻则数据写入失败重则掉电后数据全丢。我的建议是上电后先扫描所有块之前提到的“读状态寄存器”法只能识别已经标记的坏块能读出来但频繁出现 ECC 错误的块也要在运行时动态标记。实际项目里可以采用“首页标记法”每个块的第一页的备用区固定位置存一个坏块标记比如 0x00 表示好块0xFF 表示坏块。这样无论是出厂坏块还是运行期新坏块都能统一在扫描时识别。5.4 QSPI 时钟频率过高导致偶发读写错误QSPI 在低时钟下电路和代码都稳定但当我把时钟调到 90MHz 以上时开始出现偶发的读数据错误尤其是温度变化后更明显。排查后确认是走线过长和没有串电阻导致的信号反射。解决方法是如果 PCB 走线超过 3cm在 CLK 和 DQ 线上串联 22Ω 到 33Ω 的电阻QSPI 时钟不要盲目追求最高频率量产项目建议留 20% 以上余量。从 90MHz 降到 72MHz 后我们的读写稳定性明显改善。5.5 国产芯片兼容性速查近年来国产 NAND 芯片的选择越来越多实测下来大部分品牌的命令集兼容 Winbond 型号但也有部分芯片对 dummy cycle 的要求不同或状态寄存器的位定义不同。选型时建议先用逻辑分析仪抓取完整时序再把关键参数列成一张表参考型号容量页大小擦除命令支持电压实测兼容性国内某品牌 A256Mbit2048B0x60D03.3V良好国内某品牌 B1Gbit2048B0x60D03.3V需调整 dummy国内某品牌 C512Mbit2048B0x60D01.8V/3.3V良好“需调整 dummy”的意思是读缓存命令0x03原本设 4 个 dummy 周期就能稳定读但该芯片需要 8 个 dummy 才能保证长时间高温环境下的数据准确。这类坑只能靠实测 不同温度批次测试来发现。6. 后续扩展与个人经验这套 QSPI NAND 方案最让我受益的一点是它把“低成本大容量”和“主控接口简单”结合得很好。如果以后要把容量继续提升到 8Gbit 以上可以考虑两个优化方向一是开启 QSPI 的双 flash 模式两个 NAND 芯片并联容量和吞吐量都翻倍二是引入简单的 FTL 层把逻辑地址映射、磨损均衡和掉电恢复统一管理但 FTL 的复杂度会明显上升建议只在确有存储可靠性需求时做。最后再分享一个小技巧调试 QSPI NAND 时可以在 HAL 库的底层HAL_QSPI_Command里暂时加一个超时计数器通过串口打印每次命令的耗时。这样在分析是命令卡住还是数据校验失败时会有比逻辑分析仪更直观的现场信息。我自己就是靠着这个方式快速定位了某次页写命令后未等 BUSY 清零的 bug。QSPI 外设 NAND 存储的组合在 STM32F 平台上是一个值得投入的方案只要把页读写、块擦除、坏块管理和 ECC 这几层基础打好后续上层应用几乎不用关心存储介质的具体差异。希望这篇记录能帮你少走些弯路。本文还有配套的精品资源点击获取