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线在空闲时的状态。CPOL=0,空闲时为低电平;CPOL=1,空闲时为高电平。时钟相位(CPHA)则决定了数据在时钟的哪个边沿被采样。CPHA=0,在时钟的第一个边沿(对于CPOL=0是上升沿,对于CPOL=1是下降沿)采样数据;CPHA=1,在时钟的第二个边沿采样。SPI设备必须使用相同的CPOL和CPHA设置才能通信,这通常被称为SPI模式(Mode0到Mode3)。绝大多数SPI Flash芯片,如Winbond的W25Q系列,使用的都是Mode 0(CPOL=0, CPHA=0)或Mode 3(CPOL=1, CPHA=1)。在实际操作前,务必查阅你的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为例的完整读写流程
理论说再多,不如一行代码。我们以市面上最常见的128Mbit(16MB)SPI 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个(地址254,255)属于第N页,后8个属于第N+1页。你必须将这次写入拆分成两次独立的页编程操作。
// 单页编程函数 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(§orBuffer[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的单线数据传输成为性能瓶颈时,QSPI(Quad SPI)就登场了。它将数据线从1根(MOSI+MISO)扩展到了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, ®, 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主频是72MHz,SPI分频系数设为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。它本身不包含底层驱动,需要你实现read,program,erase和sync四个接口函数。这正是我们前面实现的FLASH_ReadData,FLASH_PageProgram(需要适配到扇区编程),FLASH_SectorErase(需要实现)和FLASH_WaitForBusy的用武之地。
将LittleFS移植到你的SPI Flash驱动上,大致步骤如下:
- 下载LittleFS源码。
- 实现
lfs_config结构体,并填充read,prog,erase,sync函数指针,指向你的驱动函数。 - 定义
block_size(擦除扇区大小,如4096),block_count(总扇区数),read_size,prog_size(页大小,如256)等参数。 - 调用
lfs_mount进行挂载,如果失败则尝试lfs_format格式化后再挂载。
一旦挂载成功,你就可以使用标准的lfs_file_open,lfs_file_write,lfs_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,从看懂时序图到写出稳定高效的驱动,再到集成文件系统支撑上层应用,这个过程是对嵌入式开发者硬件理解、协议掌握和软件架构能力的综合锻炼。它没有太多高深的算法,但每一个细节都关乎产品的稳定性和可靠性。我个人的体会是,最开始照着例程调通读写只是第一步,真正理解并处理好并发访问、异常掉电、寿命管理这些边界情况,才能算真正掌握了它。下次当你需要存储超过单片机内部空间的数据时,希望这篇内容能帮你省下大量查阅手册和调试的时间,直接聚焦于应用逻辑本身。