1. 从SPI到QSPI为什么外部Flash是嵌入式开发的“必修课”如果你玩过STM32大概率用过它的内部Flash来存点程序代码或者配置参数。但当你需要存一张图片、一段音频、或者一个稍微复杂点的文件系统时内部那点空间就显得捉襟见肘了。这时候外部Flash芯片就成了你的“外置硬盘”。而连接这颗“硬盘”的总线最常见的就是SPI串行外设接口以及它的增强版QSPI四线SPI。很多朋友一听到SPI协议就觉得头大HAL库的API又长又绕配置寄存器更是让人云里雾里。但说实话搞定外部Flash的读写是嵌入式开发从“玩具级”迈向“产品级”的一道关键门槛。它不仅仅是存储数据那么简单更涉及到总线时序的精确控制、DMA的高效运用、以及文件系统底层驱动的搭建。今天我们就抛开枯燥的协议手册用STM32的HAL库手把手把SPI和QSPI驱动外部Flash这件事彻底讲透让你不仅能读能写更能明白每一步背后的“所以然”。2. SPI协议核心三根线背后的握手艺术SPI协议常被称作“四线制”但这四根线里有一根片选CS是主设备用来选中小弟的真正负责数据传输的是另外三根SCK时钟、MOSI主出从入、MISO主入从出。它的工作模式像个严格的指挥家主设备STM32产生时钟脉冲数据在时钟的边沿被采样和移出。这里就引出了SPI第一个容易让人迷糊的点时钟极性和相位。时钟极性CPOL决定了SCK线在空闲时的状态。CPOL0空闲时为低电平CPOL1空闲时为高电平。时钟相位CPHA则决定了数据在时钟的哪个边沿被采样。CPHA0在时钟的第一个边沿对于CPOL0是上升沿对于CPOL1是下降沿采样数据CPHA1在时钟的第二个边沿采样。SPI设备必须使用相同的CPOL和CPHA设置才能通信这通常被称为SPI模式Mode0到Mode3。绝大多数SPI Flash芯片如Winbond的W25Q系列使用的都是Mode 0CPOL0 CPHA0或Mode 3CPOL1 CPHA1。在实际操作前务必查阅你的Flash芯片数据手册确认其支持的模式。注意配置错误是SPI通信失败的最常见原因。如果发现读取的数据全是0xFF或0x00第一件事就是检查CubeMX或代码中的SPI模式是否与Flash芯片要求的一致。SPI是全双工的意味着主设备在通过MOSI线发送命令字节的同时从设备Flash也在通过MISO线返回数据。但这里有个关键细节对于Flash的读操作主设备先发送命令比如读数据命令0x03再发送24位的地址然后主设备需要继续“假装”发送时钟通常发送0xFF或0x00这些虚拟字节才能从MISO线上接收到Flash返回的数据。这个“发送虚拟字节以产生时钟”的动作在HAL库中是通过HAL_SPI_TransmitReceive函数或者先Transmit再Receive来实现的理解这一点至关重要。3. HAL库SPI驱动实战以W25Q128JV为例的完整读写流程理论说再多不如一行代码。我们以市面上最常见的128Mbit16MBSPI Flash芯片W25Q128JV为例基于STM32 HAL库拆解一个完整的驱动实现。假设你已经在CubeMX中配置好了SPI1外设模式为全双工主模式数据大小8位首选分频器保证时钟频率在芯片允许的范围内通常低于50MHz模式设置为Mode 0。3.1 底层硬件抽象封装GPIO与SPI基础函数首先我们需要抽象出芯片的片选CS引脚控制。虽然HAL库的SPI传输函数里可以硬件管理NSS片选引脚但对于Flash这种需要复杂命令序列的设备手动控制CS引脚更为灵活和可靠。// flash_spi.h #define FLASH_SPI_HANDLE hspi1 // 你的SPI句柄 #define FLASH_CS_GPIO_PORT GPIOA #define FLASH_CS_GPIO_PIN GPIO_PIN_4 void FLASH_CS_LOW(void) { HAL_GPIO_WritePin(FLASH_CS_GPIO_PORT, FLASH_CS_GPIO_PIN, GPIO_PIN_RESET); } void FLASH_CS_HIGH(void) { HAL_GPIO_WritePin(FLASH_CS_GPIO_PORT, FLASH_CS_GPIO_PIN, GPIO_PIN_SET); }接着封装一个基础的SPI读写函数。这个函数将完成“拉低CS - 发送数据/同时接收数据 - 拉高CS”的完整过程。// flash_spi.c static void FLASH_SPI_TransmitReceive(uint8_t *pTxData, uint8_t *pRxData, uint16_t Size) { FLASH_CS_LOW(); HAL_SPI_TransmitReceive(FLASH_SPI_HANDLE, pTxData, pRxData, Size, HAL_MAX_DELAY); FLASH_CS_HIGH(); } // 简化版只发送命令 static void FLASH_SPI_Transmit(uint8_t *pData, uint16_t Size) { FLASH_CS_LOW(); HAL_SPI_Transmit(FLASH_SPI_HANDLE, pData, Size, HAL_MAX_DELAY); FLASH_CS_HIGH(); }3.2 核心命令解析读ID、写使能与状态寄存器在读写数据之前我们必须先和Flash“握手”确认它的身份和状态。每个SPI Flash都有一组制造商和设备ID。uint16_t FLASH_ReadID(void) { uint8_t txData[4] {0x90, 0x00, 0x00, 0x00}; // 读ID命令 3字节空地址 uint8_t rxData[4] {0}; FLASH_SPI_TransmitReceive(txData, rxData, 4); // W25Q128JV的ID制造商字节通常为0xEF设备ID高字节为0x40低字节为0x18 return ((uint16_t)rxData[2] 8) | rxData[3]; }Flash芯片在上电后默认处于写保护状态任何擦除或编程写操作前必须先发送“写使能”命令0x06。void FLASH_WriteEnable(void) { uint8_t cmd 0x06; FLASH_SPI_Transmit(cmd, 1); }更重要的是状态寄存器。它告诉我们芯片是否忙正在执行擦除或写操作以及写保护是否开启。读状态寄存器1的命令是0x05。uint8_t FLASH_ReadStatusReg1(void) { uint8_t txData[2] {0x05, 0xFF}; // 命令 一个虚拟字节用于接收 uint8_t rxData[2] {0}; FLASH_SPI_TransmitReceive(txData, rxData, 2); return rxData[1]; // 返回的状态字节 } // 等待Flash空闲BUSY位为0 void FLASH_WaitForBusy(void) { while (FLASH_ReadStatusReg1() 0x01) { // 检查BUSY位bit0 // 可以在这里加入超时机制防止死等 } }3.3 数据读取单次读、快速读与跨页处理最基本的读数据命令是0x03需要跟随24位地址。但为了提高速度更常用的是“快速读”命令0x0B它在地址字节后还跟了一个“哑元字节”Dummy Byte之后以更高的时钟频率输出数据。void FLASH_ReadData(uint32_t addr, uint8_t *pData, uint32_t size) { uint8_t cmdBuf[5]; cmdBuf[0] 0x0B; // 快速读命令 cmdBuf[1] (addr 16) 0xFF; // 地址字节2 cmdBuf[2] (addr 8) 0xFF; // 地址字节1 cmdBuf[3] addr 0xFF; // 地址字节0 cmdBuf[4] 0xFF; // 哑元字节 FLASH_CS_LOW(); // 发送命令地址哑元 HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmdBuf, 5, HAL_MAX_DELAY); // 开始接收数据 HAL_SPI_Receive(FLASH_SPI_HANDLE, pData, size, HAL_MAX_DELAY); FLASH_CS_HIGH(); }这里有一个非常重要的细节Flash的存储空间被划分为页Page通常256字节、扇区Sector通常4KB和块Block通常64KB。读操作可以跨页、跨扇区没有任何限制因为读操作不改变存储单元的状态。所以上面的函数可以一次性读取任意长度的数据只要地址和大小不超出芯片容量。3.4 数据写入页编程与擦除的“铁律”写操作在Flash术语中叫“页编程”则严格得多必须遵循“先擦后写”的原则。Flash存储单元只能从1变成0擦除操作或者从0变成1编程操作。擦除操作是以扇区或块为单位将整个区域置1。编程操作则是以页为单位将某些位从1变成0。页编程命令是0x02。关键限制在于一次页编程操作不能跨越物理页的边界。例如如果你的起始地址是254想要写入10个字节这10个字节中的前2个地址254255属于第N页后8个属于第N1页。你必须将这次写入拆分成两次独立的页编程操作。// 单页编程函数 static void FLASH_PageProgram(uint32_t addr, uint8_t *pData, uint16_t size) { // 确保size不超过一页且不跨页 if (size 256) size 256; if (((addr % 256) size) 256) { size 256 - (addr % 256); // 调整到页边界 } FLASH_WriteEnable(); // 使能写操作 uint8_t cmdBuf[4]; cmdBuf[0] 0x02; // 页编程命令 cmdBuf[1] (addr 16) 0xFF; cmdBuf[2] (addr 8) 0xFF; cmdBuf[3] addr 0xFF; FLASH_CS_LOW(); HAL_SPI_Transmit(FLASH_SPI_HANDLE, cmdBuf, 4, HAL_MAX_DELAY); HAL_SPI_Transmit(FLASH_SPI_HANDLE, pData, size, HAL_MAX_DELAY); FLASH_CS_HIGH(); FLASH_WaitForBusy(); // 等待编程完成 } // 通用的写数据函数内部处理跨页和擦除 int FLASH_WriteData(uint32_t addr, uint8_t *pData, uint32_t size) { uint32_t bytesWritten 0; uint32_t secAddr, secOffset; uint8_t sectorBuffer[4096]; // 假设扇区大小4KB while (bytesWritten size) { // 1. 计算当前地址所在的扇区 secAddr addr (~(0xFFF)); // 对齐到4KB扇区起始地址 secOffset addr 0xFFF; // 在扇区内的偏移 // 2. 如果要写入的地址所在的扇区不是全0xFF即未被擦除则需要先擦除整个扇区 // 这里简化处理为了安全任何写操作都先读取整个扇区检查是否需要擦除。 // 更高效的策略是维护一个“已擦除”的扇区映射表。 FLASH_ReadData(secAddr, sectorBuffer, 4096); int needErase 0; for (int i 0; i 4096; i) { if (sectorBuffer[i] ! 0xFF) { needErase 1; break; } } if (needErase) { // 执行扇区擦除(0x20)或块擦除(0xD8) FLASH_WriteEnable(); uint8_t eraseCmd[4] {0x20, (secAddr 16) 0xFF, (secAddr 8) 0xFF, secAddr 0xFF}; FLASH_SPI_Transmit(eraseCmd, 4); FLASH_WaitForBusy(); // 擦除后sectorBuffer在逻辑上应全为0xFF为了后续合并我们将其填充为0xFF memset(sectorBuffer, 0xFF, 4096); } // 3. 将新数据合并到扇区缓冲区 uint32_t bytesToCopy ((size - bytesWritten) (4096 - secOffset)) ? (size - bytesWritten) : (4096 - secOffset); memcpy(sectorBuffer[secOffset], pData[bytesWritten], bytesToCopy); // 4. 将整个扇区缓冲区写回Flash需要按页编程 for (int i 0; i 4096; i 256) { FLASH_PageProgram(secAddr i, §orBuffer[i], 256); } // 5. 更新地址和已写入字节数 addr bytesToCopy; bytesWritten bytesToCopy; } return 0; // 成功 }这个FLASH_WriteData函数实现了一个保守但可靠的写策略每次写操作都以保证扇区数据一致性为前提。在真实的产品开发中为了性能和寿命通常会引入更复杂的机制如写缓冲区、磨损均衡算法等但上述代码清晰地揭示了SPI Flash写入的核心约束和流程。提示Flash的擦除次数是有限的通常10万次左右。频繁擦写同一个扇区会使其提前损坏。在产品中一定要配合文件系统如LittleFS、SPIFFS或自己实现磨损均衡算法将写操作均匀分布到整个Flash空间。4. QSPI协议进阶四线并发的速度飞跃当SPI的单线数据传输成为性能瓶颈时QSPIQuad SPI就登场了。它将数据线从1根MOSIMISO扩展到了4根IO0~IO3并且这4根线在时钟的驱动下可以同时发送和接收数据理论带宽瞬间提升4倍。STM32的QSPI外设更加强大它支持“内存映射模式”。在此模式下外部Flash的存储空间会被映射到MCU的地址空间通常是0x90000000起始你可以像读取内部SRAM一样使用指针直接访问QSPI Flash中的数据无需调用任何传输函数这对运行代码XIP Execute In Place或快速读取大量数据如图片至关重要。4.1 QSPI的模式与命令格式QSPI通信比SPI复杂因为它有多种模式1-1-1模式命令、地址、数据都只用一根IO线1-1-4模式命令用1线地址用1线数据用4线1-4-4模式等。W25Q128JV支持QSPI模式其快速读命令在QSPI下变为0xEB配合哑元周期和4线数据输出。在HAL库中配置QSPI的关键在于初始化结构体QSPI_HandleTypeDef和命令结构体QSPI_CommandTypeDef。你需要正确设置指令模式、地址模式、数据模式、交替字节模式、哑元周期等。// 初始化QSPI外设基于CubeMX生成代码补充 void MX_QUADSPI_Init(void) { hqspi.Instance QUADSPI; hqspi.Init.ClockPrescaler 2; // 根据时钟和Flash速度设置 hqspi.Init.FifoThreshold 4; hqspi.Init.SampleShifting QSPI_SAMPLE_SHIFTING_HALFCYCLE; hqspi.Init.FlashSize 23; // 2^23 8MB地址线宽度-1对于16MB芯片设为24 hqspi.Init.ChipSelectHighTime QSPI_CS_HIGH_TIME_2_CYCLE; hqspi.Init.ClockMode QSPI_CLOCK_MODE_0; hqspi.Init.FlashID QSPI_FLASH_ID_1; hqspi.Init.DualFlash QSPI_DUALFLASH_DISABLE; HAL_QSPI_Init(hqspi); }4.2 配置内存映射模式实现XIP运行内存映射模式是QSPI的“杀手锏”。配置成功后你可以通过*(uint8_t*)0x90000000直接读取Flash起始地址的数据。// 进入内存映射模式 void QSPI_EnableMemoryMappedMode(void) { QSPI_CommandTypeDef s_command; QSPI_MemoryMappedTypeDef s_mem_mapped_cfg; // 配置QSPI命令使用0xEB命令1-1-4模式命令1线地址1线数据4线 s_command.InstructionMode QSPI_INSTRUCTION_1_LINE; s_command.Instruction 0xEB; // 快速读四线输出指令 s_command.AddressMode QSPI_ADDRESS_1_LINE; s_command.AddressSize QSPI_ADDRESS_24_BITS; s_command.DataMode QSPI_DATA_4_LINES; s_command.DummyCycles 6; // 哑元周期数参考Flash手册 s_command.SIOOMode QSPI_SIOO_INST_EVERY_CMD; // 配置内存映射参数 s_mem_mapped_cfg.TimeOutActivation QSPI_TIMEOUT_COUNTER_DISABLE; if (HAL_QSPI_MemoryMapped(hqspi, s_command, s_mem_mapped_cfg) ! HAL_OK) { Error_Handler(); } // 配置成功后从0x90000000开始的内存区域即映射到QSPI Flash }配置完成后读取数据就变得异常简单和快速uint32_t readAddr 0x000100; // Flash物理地址 uint8_t data *(__IO uint8_t *)(0x90000000 readAddr); // 直接指针访问重要心得内存映射模式虽然方便但访问速度受QSPI时钟和MCU缓存机制影响。对于需要极低延迟的代码段仍需拷贝到内部RAM中运行。此外在内存映射模式下你不能执行擦写操作需要先退出此模式。4.3 QSPI模式下的擦写操作在QSPI模式下进行擦写步骤和SPI类似但命令传输可能使用不同的IO模式。例如写使能0x06、页编程0x02、扇区擦除0x20等命令通常仍使用标准的1-1-1模式即普通的SPI模式发送因为Flash在接收这些命令时可能还未切换到四线模式。HAL库提供了HAL_QSPI_Command函数来发送各种指令。// QSPI模式下发送命令例如写使能 void QSPI_WriteEnable(void) { QSPI_CommandTypeDef s_command {0}; s_command.InstructionMode QSPI_INSTRUCTION_1_LINE; s_command.Instruction 0x06; // 写使能命令 s_command.AddressMode QSPI_ADDRESS_NONE; s_command.DataMode QSPI_DATA_NONE; HAL_QSPI_Command(hqspi, s_command, HAL_QSPI_TIMEOUT_DEFAULT); } // QSPI模式下读取状态寄存器 uint8_t QSPI_ReadStatusReg(void) { QSPI_CommandTypeDef s_command {0}; uint8_t reg; s_command.InstructionMode QSPI_INSTRUCTION_1_LINE; s_command.Instruction 0x05; s_command.DataMode QSPI_DATA_1_LINE; s_command.NbData 1; HAL_QSPI_Command(hqspi, s_command, HAL_QSPI_TIMEOUT_DEFAULT); HAL_QSPI_Receive(hqspi, reg, HAL_QSPI_TIMEOUT_DEFAULT); return reg; }5. 性能优化与实战避坑指南无论是SPI还是QSPI想要稳定高效地驱动外部Flash除了基本的读写还有一堆细节需要注意这些都是手册上不会明说但实际项目里一定会踩到的坑。5.1 SPI时钟分频与信号完整性的权衡在CubeMX里配置SPI时钟时很多人会盲目选择最高的分频系数以求最快速度。但这忽略了PCB布线质量、线长、Flash芯片本身最高时钟频率的限制。对于W25Q128JV在3.3V供电下标准SPI模式最高时钟频率是50MHz双线或四线模式可以到80MHz甚至104MHz。如果你的MCU主频是72MHzSPI分频系数设为2则SPI时钟为36MHz这在理论上是安全的。但如果你的开发板布线较差或者用了杜邦线连接高频下很容易出现数据错位。一个实用的建议是在项目初期先将时钟频率设低如PCLK/16确保基础通信稳定再逐步提高频率进行压力测试。用逻辑分析仪或示波器抓取SCK和MOSI/MISO的波形观察上升沿/下降沿是否陡峭有无明显的振铃或过冲。5.2 DMA传输解放CPU的利器当需要连续读取或写入大量数据时比如更新显示屏的帧缓冲区使用轮询Polling或中断Interrupt模式的SPI传输会大量占用CPU时间。此时DMA直接存储器访问是必须的。配置SPI的DMA传输可以让数据在内存和SPI数据寄存器之间自动搬运CPU只需在传输完成时被中断通知即可。在HAL库中使用HAL_SPI_Transmit_DMA和HAL_SPI_Receive_DMA函数。关键点在于缓存对齐确保发送和接收缓冲区在内存中正确对齐通常不是问题但需知晓。传输完成回调在HAL_SPI_TxCpltCallback和HAL_SPI_RxCpltCallback中断回调函数中处理后续逻辑。半传输中断对于双缓冲场景还可以利用HAL_SPI_TxHalfCpltCallback。DMA与CS引脚使用DMA时片选CS引脚通常仍需由软件控制。你需要在启动DMA传输前拉低CS在DMA传输完成回调函数中拉高CS。uint8_t txBuffer[1024], rxBuffer[1024]; // 填充txBuffer... FLASH_CS_LOW(); if (HAL_SPI_TransmitReceive_DMA(hspi1, txBuffer, rxBuffer, 1024) ! HAL_OK) { // 错误处理 FLASH_CS_HIGH(); } // ... 在传输完成回调函数中拉高CS void HAL_SPI_TxRxCpltCallback(SPI_HandleTypeDef *hspi) { if (hspi-Instance SPI1) { FLASH_CS_HIGH(); // 处理接收到的数据 rxBuffer } }5.3 中断与RTOS下的线程安全在RTOS如FreeRTOS环境中使用SPI Flash驱动必须考虑线程安全。SPI外设是一个共享资源如果多个任务同时调用SPI读写函数会导致数据混乱。最常用的保护方法是使用互斥信号量Mutex。SemaphoreHandle_t xSPIMutex; // 在初始化时创建 int ThreadSafe_FLASH_Read(uint32_t addr, uint8_t *pData, uint32_t size) { if (xSemaphoreTake(xSPIMutex, portMAX_DELAY) pdTRUE) { int result FLASH_ReadData(addr, pData, size); // 调用原始的读函数 xSemaphoreGive(xSPIMutex); return result; } return -1; // 获取信号量失败 }此外SPI传输本身可能耗时较长尤其是擦除操作需要几十毫秒。在中断服务程序ISR中调用HAL_SPI_Transmit等阻塞函数是绝对禁止的这会导致系统实时性急剧下降甚至死锁。所有Flash操作都应在任务线程中完成。5.4 电源管理与睡眠唤醒很多低功耗设备需要让MCU和外部Flash进入睡眠模式。对于SPI Flash通常有深度掉电Deep Power Down 命令0xB9和待机Standby模式。在进入这些模式前必须确保没有正在进行的擦写操作检查BUSY位。从深度掉电唤醒后芯片需要一段恢复时间tRES1 通常约3us才能接受命令直接发送命令会导致失败。正确的做法是唤醒后先拉高片选等待至少tRES1时间再开始通信。另一个常见问题是系统复位。MCU复位时GPIO可能处于不确定状态。如果SPI Flash的片选CS引脚在上电瞬间被短暂拉低可能会意外接收到噪声信号误触发某个命令。为了增加鲁棒性可以在MCU初始化函数的最开始先将Flash的CS引脚设置为高电平输出模式。void SystemInit(void) { // ... 其他初始化 // 尽早将Flash CS引脚置高防止误操作 GPIO_InitTypeDef GPIO_InitStruct {0}; __HAL_RCC_GPIOA_CLK_ENABLE(); GPIO_InitStruct.Pin FLASH_CS_GPIO_PIN; GPIO_InitStruct.Mode GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull GPIO_NOPULL; GPIO_InitStruct.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(FLASH_CS_GPIO_PORT, GPIO_InitStruct); HAL_GPIO_WritePin(FLASH_CS_GPIO_PORT, FLASH_CS_GPIO_PIN, GPIO_PIN_SET); // ... 继续其他初始化 }6. 从驱动到应用文件系统与高级功能初探当你实现了稳定的扇区擦除和页编程函数后就可以在此基础上构建更上层的应用了。最直接的应用就是挂载一个文件系统。6.1 集成LittleFS文件系统LittleFS是一个专为嵌入式Flash设计的抗掉电文件系统非常适合SPI Flash。它本身不包含底层驱动需要你实现readprogramerase和sync四个接口函数。这正是我们前面实现的FLASH_ReadDataFLASH_PageProgram需要适配到扇区编程FLASH_SectorErase需要实现和FLASH_WaitForBusy的用武之地。将LittleFS移植到你的SPI Flash驱动上大致步骤如下下载LittleFS源码。实现lfs_config结构体并填充readprogerasesync函数指针指向你的驱动函数。定义block_size擦除扇区大小如4096block_count总扇区数read_sizeprog_size页大小如256等参数。调用lfs_mount进行挂载如果失败则尝试lfs_format格式化后再挂载。一旦挂载成功你就可以使用标准的lfs_file_openlfs_file_writelfs_file_read等API来操作文件完全不用再操心擦写均衡和坏块管理LittleFS会帮你处理。6.2 实现OTA固件升级外部Flash的另一个关键应用是存储备份固件实现OTA空中升级功能。通常的方案是划分两个主要的固件存储区主程序区可能也在外部Flash或内部Flash和备份/下载区在外部Flash。当收到新固件包时将其写入备份区。校验通过后通过修改启动标志或直接跳转从备份区启动新固件。在这个过程中对Flash的读写可靠性和速度要求极高。你需要分块写入与校验将固件分包每写入一包立即读取回进行CRC校验确保数据无误。断电恢复在外部Flash中维护一个升级状态结构体记录当前已写入的包序号、CRC等信息。系统重启后能根据这个状态结构体判断是继续写入、回滚还是完成升级。高效的拷贝算法如果需要将备份区的固件拷贝到主程序区使用DMA进行SPI到内存或内存到SPI的大块数据传输能极大提升效率。6.3 磨损均衡与坏块管理对于需要频繁写入日志或配置数据的应用必须考虑Flash的寿命。即使使用了文件系统如果频繁更新同一个文件其对应的Flash扇区也会被快速写坏。一个简单的自制磨损均衡策略是将用于存储数据的Flash区域划分为多个逻辑“槽位”Slot。维护一个在Flash中保存的索引表记录哪个槽位是当前活跃的以及每个槽位的擦写次数。每次需要更新数据时将数据写入到一个新的、擦写次数最少的空闲槽位然后更新索引表将旧的槽位标记为无效。当空闲槽位不足时执行“垃圾回收”将有效数据合并并擦除无效的槽位。对于W25Q系列这类NOR Flash坏块并不常见但在NAND Flash中则是必须处理的。坏块管理通常包括在出厂或首次使用时进行全盘扫描标记坏块在读写过程中进行ECC校验和坏块替换。这些功能在成熟的文件系统如LittleFS SPIFFS或Flash管理中间件如FlashDB中都已集成通常不建议从零开始造轮子。驱动一颗外部SPI/QSPI Flash从看懂时序图到写出稳定高效的驱动再到集成文件系统支撑上层应用这个过程是对嵌入式开发者硬件理解、协议掌握和软件架构能力的综合锻炼。它没有太多高深的算法但每一个细节都关乎产品的稳定性和可靠性。我个人的体会是最开始照着例程调通读写只是第一步真正理解并处理好并发访问、异常掉电、寿命管理这些边界情况才能算真正掌握了它。下次当你需要存储超过单片机内部空间的数据时希望这篇内容能帮你省下大量查阅手册和调试的时间直接聚焦于应用逻辑本身。