尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

非易失存储器读写实战:从EEPROM到SPI Flash的嵌入式指南

非易失存储器读写实战:从EEPROM到SPI Flash的嵌入式指南 这块板子今天又没认出来拨码拨了半天最后发现是SPI Flash里的固件写坏了。干嵌入式这行的谁没在非易失存储器读写上栽过跟头非易失存储器这个东西说白了就是掉电不丢数据的存储器从AT24C02这种I2C接口的EEPROM到GD25Q128E这种SPI接口的NOR Flash再到板卡上的eMMC、固态硬盘里的NAND Flash全是这一大家族。读写它们几乎是所有嵌入式开发者的基本功也是项目里最容易出幺蛾子的环节写进去读出来是0xFF、校验对不上、掉电丢数据、擦写寿命耗尽、跨页写入被回卷覆盖……这篇内容就是围绕非易失存储器读写这个话题把原理、时序、实操和排查经验一次讲透。无论是刚接触单片机的学生还是转做FPGA的工程师或者是想搞懂文件系统底层逻辑的软件开发者都能从这里找到能直接抄走的方案。1. 先把“非易失存储器”这件小事想清楚1.1 一屋子存数据的工具你到底选哪把“非易失”对应的就是“易失”像SRAM、SDRAM、DDR这种一断电数据就烟消云散虽然读写快、寿命长但留不住东西。非易失存储器则相反靠的是浮栅晶体管里存电荷或者铁电材料的极化方向断电之后电荷也好、极化状态也好都还留在那里数据自然就保住了。但这个家族内部差别非常大我把常用的几类拉了个对照表类型典型型号接口存储单元擦写方式容量寿命典型场景EEPROMAT24C02、M24512I2C浮栅字节擦写2Kb~512Kb100万次参数存储、MAC地址、校准数据NOR FlashGD25Q128E、W25Q64SPI/QSPI/并口浮栅扇区擦除页编程1Mb~512Mb10万次固件存储、启动代码、字库NAND FlashK9F1G08并口/ONFI浮栅块擦除页编程1Gb~1Tb1万~10万次大容量文件存储、eMMC内部介质FRAMFM24C04、MB85RCI2C/SPI铁电晶体字节读写4Kb~8Mb100亿次实时写入频繁的计量数据MRAMMR25H40、EMxxxxSPI/并口磁隧道结字节/页1Mb~64Mb无限次军工、轨道交通、服务器日志从这张表能看出选型不是越贵越好而是看你的痛点在哪里。如果只是存几个配置参数1秒写一次用FRAM最合适因为它几乎没有写穿的问题省心如果存的是固件容量要求高、秒级写一次就够了那NOR Flash是主流如果想要512MB以上存储空间并且有文件系统需求直接上eMMC或NAND更划算。我见过很多新人在方案定型时随便挑芯片结果后期发现擦写寿命不够、掉电丢数据严重根本原因是没搞懂这类器件的底层行为。EEPROM能字节擦写看起来方便但大容量型号非常贵NOR Flash读起来像内存一样随意但擦除必须以扇区为单位最小4KB这就意味着你要改一个字节也得先擦掉4KB再写回来设计数据结构时就得避开这个问题。1.2 读和写在硬件层面到底发生了什么很多人写驱动写多了会自然把Flash和内存的读写等同起来这点非常危险。内存写一个字节地址过去就是过去数据写进去就完成了Flash/EEPROM写一个字节先要搬运电荷到浮栅这个过程实际上叫“编程”速度比读慢十倍百倍都不止。用个生活化的类比你可以把存储单元想象成一个水桶。桶底有个阀水就是电荷。普通存储器的水阀随时可以拧开随时可以倒掉非易失存储器的桶底是特制的平时阀门关死水根本漏不出去这就是“非易失”。写入时就是拿高压泵把水强行压进桶里这个过程叫“编程”擦除是把桶底阀门打开放水水位回到空这个过程叫“擦除”。所以Flash和EEPROM的物理特性就决定了写一个bit比读一个bit耗时多得多。NOR Flash页编程一页通常是0.7~3ms读同一块数据微秒级就完成了。擦除比编程更慢。GD25Q128E擦一个扇区要几十到几百毫秒擦全片甚至要几十秒。写前必须先擦而且只能把1变成0对绝大多数Flash而言。擦完是0xFF也就是所有bit都是1写操作只能把1改成0想把0改回1只能通过擦除。存储单元有磨损极限。EEPROM普遍号称100万次NOR Flash普遍10万次NAND Flash更低但NAND内部有均衡算法和坏块管理用户拿到的eMMC是经过封装处理后的表现。理解这几个物理事实后很多问题原因就浮出水面了。比如你擦除一个扇区之后假如没有写使能就直接写数据命令会被忽略读回来全是0xFF比如你在一个已经被多次擦写的扇区上继续写入发现数据时而正常时而异常大概率是寿命快到了再比如你在中断里或者掉电瞬间刚好踩到编程中点数据就处于“半写”状态一上电读出来的可能是垃圾数据。这些内容在后面常见问题章节还会细说。2. 读写协议的背后是时序时序背后是信任2.1 SPI、QSPI、I2C、并口别只看引脚多少选定存储颗粒后通信协议是绕不开的。不同接口各有脾气我做了个对比接口引脚需求速度典型值适合场景最容易踩的坑SPICS/SCK/MOSI/MISO 共4线1~50MHz中低容量Flash/EEPROM/FRAM时钟模式配错CPOL/CPHA搞反QSPI/OSPI6~8线40~200MHz高容量NOR Flash代码执行指令集不同模式切换要小心I2CSCL/SDA 共2线100kHz~1MHz小容量EEPROM、传感器参数ACK/NACK判断漏掉总线挂死并口8/16位数据总线100MHzNAND、SRAM接口时序紧张属于老工程师专属领域SPI是在嵌入式里用来读写Flash、EEPROM最常见的协议。它是一主多从的同步串行总线主设备拉低CS选中器件然后通过SCK时钟边沿把数据一位一位送进去同时从MISO引线上收数据。这里有两个问题新手一定问过为什么有些代码里MOSI和MISO并着用因为某些Flash支持半双工模式一条线双向复用但标准做法是全双工主机发数据的同时也在收数据收发互不搭界。QSPI是为了解决NOR Flash读速度瓶颈出现的。单线SPI一次只能送1个bitQSPI用4根数据线同时送4个bit速度直接翻四倍。GD25Q128E这类器件支持QPI模式全四线指令模式和Quad Output模式不过触发方式需要先发Enable QPI38h这样的专有命令中间状态维护比较麻烦。实测下来如果只是存固件、启动加载老老实实用标准SPI就够了要跑代码、要XIP再考虑QSPI。I2C则是另一种极端的协议只有两根线速度低但接线极简非常适合小容量EEPROM。I2C的数据线是漏极开路的所以必须外接上拉电阻这是很多人没意识到的问题。总线上所有设备共用两根线通过设备地址区分一旦某个从设备把SDA拉低不释放整个总线就堵死了表现就是主机一直收不到ACK。2.2 一条读取命令信号线上经历了什么以最常见的GD25Q128E为例W25Q系列同款我讲讲一条SPI读数据的完整过程。先把命令说清楚功能命令码地址/参数行为Read Data03h3字节地址从该地址开始连续读出直到CS拉高Fast Read0Bh3字节地址1个dummy字节同上但可工作在更高时钟频率Page Program02h3字节地址数据一次最多写256字节一页Sector Erase20h3字节地址擦除4KB扇区擦完全0xFFChip EraseC7h无全片擦除Write Enable06h无置位状态寄存器中的WEL位Read Status Register05h无读出状态寄存器判断WIP位主机拉低CS后依次发命令码0x03再发三个字节地址紧接着就发时钟。注意SPI是全双工的主机发命令和地址的同时MISO线上其实已经有数据在出来了只是前面几个字节是无效的。一般在发完地址后的第八个SCK边沿Flash才会把第一字节数据放到MISO线上。时序上如果时钟太快读出来的数据会错位所以高速工作时要用Fast Read多塞一个dummy字节来补偿Flash内部的延迟。写一页数据的流程则比读复杂得多。先发06h写使能这是第一个坑不写使能的话Flash直接忽略后续写命令再发02h命令地址数据最后CS拉高表示本次编程结束Flash内部进入忙状态。这时候必须轮询状态寄存器读05h检查bit0WIP如果是1就继续等变成0才表示这页写完了。这里要特别强调Page Program一次最多写256字节如果你给出的地址是0x0800然后一口气发300字节数据Flash不会自动跨页超出页边界的数据会被回卷到同一页的开头把你前面写好的数据覆盖掉。这个问题太经典了我后面专门讲。I2C EEPROM的时序细节更讲究。以AT24C02为例起始条件SCL为高时SDA由高变低停止条件SCL为高时SDA由低变高。主机先发起始再发设备地址0xA0高7位是1010000最低位是写标志1EEPROM应答ACK然后发1个字节的字地址再发数据字节最后停止。读数据操作则要先执行一个“伪写”——把字地址写进去但不发数据重新发出起始条件发0xA1读标志然后才能从总线收数据。I2C时序上有几个细节很容易翻车。SDA信号必须在SCL高电平时保持稳定只有SCL为低时才能变化这是协议最基础的规则主机读最后一个字节后要回NACK告诉从设备“不要再发了”然后发停止如果你一直回ACK从设备会一直吐数据总线停不下来写周期等待时间EEPROM内部的写操作要5ms左右期间不响应任何命令你必须在停止条件后Delay再继续下一次操作否则后面命令会被吞掉。3. 从零写一段SPI Flash读写程序STM32 GD25Q128E3.1 硬件准备与接线我用的平台是STM32F103C8T6配一颗GD25Q128E128Mbit也就是16MB存固件和配置都够用。接线很简单STM32引脚GD25Q128E引脚说明PA4CS片选低电平有效PA5SCKSPI时钟PA6MISO主入从出PA7MOSI主出从入3.3VVCC供电最好加100nF退耦电容GNDGND共地3.3VWP写保护必须上拉否则写命令全被拒3.3VHOLD保持必须上拉否则拉低后器件忽略所有信号WP和HOLD这两个脚特别容易忽略。WP拉低之后状态寄存器里的SRP位会被锁定写操作直接不允许HOLD拉低之后SCK和MOSI全被忽略表现就是主机的命令石沉大海。很多板子默认悬空结果写操作时好时坏。我的习惯是无论原理图怎么画程序初始化里把这俩引脚配置为推挽输出并置高或者硬件直接拉上拉电阻省心。3.2 基于HAL库的驱动代码开发环境是STM32CubeMX生成的工程SPI配置为主模式、8位数据、CPOL0、CPHA0也就是SPI Mode 0这是GD25Q128E/W25Q系列的默认模式。时钟分频选4分频对应9MHz远低于器件的最大时钟安全。先看读取芯片ID的代码这是第一个验证通讯是否正常的函数uint32_t SPI_Flash_ReadID(void) { uint8_t tx[4] {0x9F, 0x00, 0x00, 0x00}; uint8_t rx[4] {0}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_TransmitReceive(hspi1, tx, rx, 4, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); // GD25Q128E返回: Manufacturer0xC8, Memory Type0x40, Capacity0x18 return (rx[1] 16) | (rx[2] 8) | rx[3]; }读出来如果返回0xC84018说明SPI配置和接线都没问题。如果读出来全是0xFF先别怀疑代码多半是CS极性搞反或者器件供电没起来如果读出来是0x000000多半是MISO线没接好主机一直读到低电平。下面是核心的写使能、读状态、等待空闲三个函数void SPI_Flash_WriteEnable(void) { uint8_t cmd 0x06; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); } uint8_t SPI_Flash_ReadStatus(void) { uint8_t cmd 0x05; uint8_t status 0; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 1, 100); HAL_SPI_Receive(hspi1, status, 1, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); return status; } void SPI_Flash_WaitBusy(void) { while (SPI_Flash_ReadStatus() 0x01); // WIP位为1表示忙 }等待空闲这段如果你在RTOS环境里跑建议加系统延时或者信号量挂起不要傻等否则会在等待擦除期间把CPU时间白白吃掉。我一般是每轮询10次就丢一次5ms的延时兼顾实时性和不忙等。再看读数据函数和页编程函数void SPI_Flash_Read(uint32_t addr, uint8_t *buf, uint32_t len) { uint8_t cmd[4] {0x03, (addr 16) 0xFF, (addr 8) 0xFF, addr 0xFF}; HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Receive(hspi1, buf, len, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); } void SPI_Flash_PageProgram(uint32_t addr, uint8_t *data, uint16_t len) { uint8_t cmd[4] {0x02, (addr 16) 0xFF, (addr 8) 0xFF, addr 0xFF}; SPI_Flash_WriteEnable(); SPI_Flash_WaitBusy(); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_SPI_Transmit(hspi1, data, len, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); SPI_Flash_WaitBusy(); }调用这个PageProgram函数时有一个前提必须记住目标地址所在扇区必须已经是擦除状态也就是全0xFF。如果扇区里已经有数据写进去的结果是按位与原来为0的位根本改不回来。所以写数据之前必须先做扇区擦除调用void SPI_Flash_SectorErase(uint32_t addr) { uint8_t cmd[4] {0x20, (addr 16) 0xFF, (addr 8) 0xFF, addr 0xFF}; SPI_Flash_WriteEnable(); SPI_Flash_WaitBusy(); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_RESET); HAL_SPI_Transmit(hspi1, cmd, 4, 100); HAL_GPIO_WritePin(FLASH_CS_GPIO_Port, FLASH_CS_Pin, GPIO_PIN_SET); SPI_Flash_WaitBusy(); }擦除一个扇区就是一次完整的“先写使能—发命令—等忙闲”事务。擦除之后读回来整个4KB全是0xFF这时候再往里面写数据才可靠。真正项目里还要处理跨页写入。因为一页只有256字节如果你要写的len大于当前页剩余空间必须拆成多次PageProgram。我写了一个通用的跨页写函数void SPI_Flash_WriteBuffer(uint32_t addr, uint8_t *data, uint32_t len) { uint32_t page_offset addr 0xFF; // 当前地址在页内偏移 uint32_t page_remain 256 - page_offset; // 当前页剩余空间 uint32_t to_write 0; while (len 0) { to_write (len page_remain) ? len : page_remain; SPI_Flash_PageProgram(addr, data, to_write); addr to_write; data to_write; len - to_write; page_remain 256; // 下一页整页可用 } }这个函数已经成为我所有项目里SPI Flash操作的标准入口实测写几十KB日志、存配置参数都没有问题。核心逻辑就是先把当前页剩余空间算出来能写多少写多少写不满的留到下一次。3.3 外扩思路FPGA / Verilog时序控制要点如果你搞FPGA上面的MCU代码思路不能用但状态机的思想是相通的。FPGA读写GD25Q128E这类NOR Flash核心就是设计一个SPI主机状态机。我建议的状态机流程是IDLE → 发命令 → 发3字节地址 → 发数据/收数据 → 读状态等待WIP清零 → 返回IDLE。每个状态内部按bit计数8个bit完成一个字节3字节地址就是24个bit。时钟信号由FPGA内部计数器分频产生频率建议不超过25MHz这样对时序约束的要求低一些。QSPI外部Flash在FPGA里更常用因为单线SPI读代码太慢QSPI能上50~100MHz配合XIPExecute In Place可以直接在Flash里跑程序。但QSPI的麻烦在于命令分发读用03h或者0Bh或6BhFast Read Quad Output写使能还是06h页编程可以是02h或者32hQuad Input Page Program。模式选择要根据手册时序图精确匹配每个bit的采样边沿都要算清楚。我见过一个项目在QSPI模式切回标准SPI的时候状态机状态没复位Flash一直用QPI模式接收标准SPI命令结果所有操作全部失败。解决方法是加一个软件复位命令66h 99h或者掉电重启。DDR/SDRAM读写控制也是类似的状态机思路不过DDR是易失性存储器走的是行激活、读写、预充电、刷新这套流程。非易失存储器的重点是擦写状态管理易失存储器则是刷新时序两者本质不同。如果对DDR读写控制感兴趣可以先从SDRAM的读状态机起步再去看AXI接口的DDR控制器那是另一条进阶路线。4. 用I2C再搞一遍多字节EEPROM读写的那些坑4.1 AT24C系列地址与页边界I2C接口的EEPROM在线路板上的用量其实比SPI Flash还大存MAC地址、设备编号、校准参数一个板子可能挂两三颗。AT24C系列是最典型的代表它按容量区分地址结构AT24C02是256字节字地址1字节就够了AT24C04及以上字地址需要2字节。硬件上A0-A2引脚用于确定I2C设备地址比如AT24C02的固定地址是1010加上A2A1A0三位再拼上读写位写地址就是0xA0读地址就是0xA1。AT24C02还有页写入限制一页是8字节。写的时候如果跨过页边界比如从地址0x05开始写5字节会一直写到0x09但第0x08这个字节其实会被回卷到页首0x00数据就会写乱。AT24C512的页大小是128字节型号不同页尺寸不同用之前一定查手册。I2C总线上拉电阻也特别关键。标准模式100kHz建议4.7kΩ快速模式400kHz建议2.2kΩ。电阻过大会导致信号上升沿过慢通信时好时坏电阻过小则功耗偏高极端情况下波形振铃严重。示波器打一下SDA的上升沿如果边沿超过1us就先把上拉调小。4.2 I2C完整时序与代码示例用STM32的HAL库操作I2C其实很简单但正因为简单很多细节被隐藏了。我先把用HAL库写AT24C02多字节写入的代码贴出来#define AT24C02_ADDR_WRITE 0xA0 #define AT24C02_ADDR_READ 0xA1 #define AT24C02_PAGE_SIZE 8 HAL_StatusTypeDef AT24C02_WriteBytes(uint16_t mem_addr, uint8_t *data, uint16_t len) { HAL_StatusTypeDef status HAL_OK; uint16_t offset mem_addr % AT24C02_PAGE_SIZE; uint16_t chunk AT24C02_PAGE_SIZE - offset; while (len 0) { if (chunk len) chunk len; status HAL_I2C_Mem_Write(hi2c1, AT24C02_ADDR_WRITE, mem_addr, I2C_MEMADD_SIZE_8BIT, data, chunk, 100); if (status ! HAL_OK) return status; HAL_Delay(10); // 等待内部写周期完成至少5ms mem_addr chunk; data chunk; len - chunk; chunk AT24C02_PAGE_SIZE; } return HAL_OK; } HAL_StatusTypeDef AT24C02_ReadBytes(uint16_t mem_addr, uint8_t *buf, uint16_t len) { return HAL_I2C_Mem_Read(hi2c1, AT24C02_ADDR_READ, mem_addr, I2C_MEMADD_SIZE_8BIT, buf, len, 100); }HAL_I2C_Mem_Write这个接口封装的内部逻辑是主机发出起始条件、设备写地址、字地址、然后连续发数据、停止条件。但注意AT24C02的字地址是8位所以I2C_MEMADD_SIZE_8BIT没问题AT24C512这种容量大的型号要改成I2C_MEMADD_SIZE_16BIT否则地址错位数据写到莫名其妙的位置。HAL_Delay(10)这行很多初学者嫌慢想删掉。我劝你别删。AT24C02内部写周期典型值5ms最大值还要留余量。如果你不等待就发起下一次写命令EEPROM会不回ACKHAL库超时返回错误。用10ms迎合最坏情况换可靠性值得。如果你不用HAL库用GPIO模拟I2C那就要自己实现起始、停止、发送字节、接收字节、等待ACK这些函数。伪代码如下void I2C_Start(void) { SDA_HIGH(); SCL_HIGH(); Delay_us(5); SDA_LOW(); Delay_us(5); SCL_LOW(); } void I2C_Stop(void) { SDA_LOW(); SCL_HIGH(); Delay_us(5); SDA_HIGH(); } uint8_t I2C_WriteByte(uint8_t dat) { uint8_t i; for (i 0; i 8; i) { if (dat 0x80) SDA_HIGH(); else SDA_LOW(); dat 1; SCL_HIGH(); Delay_us(2); SCL_LOW(); } SDA_HIGH(); // 释放SDA准备接收ACK SCL_HIGH(); Delay_us(2); uint8_t ack (SDA_READ() 0) ? 0 : 1; // 低电平表示ACK SCL_LOW(); return ack; }这里有个坑是SDA方向的切换。如果用GPIO模拟I2C发送字节时GPIO是输出模式等ACK时要把GPIO切回输入模式否则读到的永远是自己的输出电平。如果用的是开漏输出配置可以不用切换但需要保证外部有上拉电阻。这个细节和I2C本身的电气特性有关。多字节读时序上还要注意如果要从任意地址开始连续读多个字节要分两步走先做“伪写”——发起始、写地址、字地址这个操作不真正写数据只是把内部地址寄存器设置好然后重新发起始发读地址然后连续读取。每读一个字节回ACK最后一个字节回NACK再停止这样从设备才知道“主机不要了”。HAL_I2C_Mem_Read封装了这套过程你不用手工做但如果自己写模拟I2C这些细节全得自己实现。5. 把读写放进系统文件系统、损耗均衡与掉电保护5.1 为什么不能直接“裸写”固件/数据很多人把Flash当作一个大数组用指针直接往里写这在小数据量场景下能跑但项目一复杂就崩。裸写有三个致命问题第一擦写次数不均匀。某个扇区放日志一天写几百次写几个月就寿命耗尽旁边用来存固件的扇区一直没动过到报废还没被擦写过一次。没有损耗均衡Flash死得特别快。第二没有掉电保护。你正在写一个结构体写到一半断电这个扇区里的数据新旧混杂上电后无法判断哪份是有效数据。第三没有坏块管理。NOR Flash的坏块不算严重但NAND Flash出厂就可能有坏块裸写没有替代机制坏一块数据就丢了。所以工程上要不就上文件系统要不就自己设计简单的数据管理机制。MCU轻量级文件系统目前最推荐LittleFS日志结构设计天然带掉电保护还自带磨损均衡。FATFS也可以用但它的掉电保护要靠上层应用配合裸奔场景不如LittleFS稳。SPIFFS相比之下功能弱一些只适合ESP8266老项目新项目不建议用。文件系统上手也很简单把底层读写接口对齐一下就行。LittleFS需要你实现read、prog、erase、sync四个函数本质就是调用前面写的SPI Flash驱动int lfs_flash_read(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, void *buffer, lfs_size_t size) { uint32_t addr block * c-block_size off; SPI_Flash_Read(addr, buffer, size); return LFS_ERR_OK; } int lfs_flash_prog(const struct lfs_config *c, lfs_block_t block, lfs_off_t off, const void *buffer, lfs_size_t size) { uint32_t addr block * c-block_size off; // 跨页拆分由内部处理 SPI_Flash_WriteBuffer(addr, (uint8_t *)buffer, size); return LFS_ERR_OK; } int lfs_flash_erase(const struct lfs_config *c, lfs_block_t block) { uint32_t addr block * c-block_size; SPI_Flash_SectorErase(addr); return LFS_ERR_OK; }上文件系统之后你不需要再操心扇区擦除、写使能这些琐事文件系统会帮你管理。但别以为文件系统是万能的LittleFS读快写慢因为每次写入都有可能触发元数据更新掉电保护也不是绝对的能保证的是文件系统本身不被破坏数据一致性仍需要上层处理好。所以关键数据我仍然建议在应用层做双备份和CRC校验。5.2 掉电保护与数据校验掉电保护的核心思想是“写旧留新”写入新数据之前保留一份旧数据新数据写完整且校验通过后才更新有效标记。最简单经典的做法是双块交替写入。比如用Flash的两个扇区存同一份配置数据每个扇区头放一串魔数和CRC尾部放写入序号。写入流程是先写备扇区写完后校验CRC再把有效标记切换到备扇区如果写到一半掉电主扇区还是旧数据上电后根据CRC和序号判断哪份有效自动恢复。CRC校验也是必备的。写入前计算出数据的CRC32连同数据一起写入读取后重新计算CRC不一致就判断数据损坏走恢复流程。CRC不仅在掉电场景有用平常也能拦截总线噪声导致的随机位翻转。我见过一个案子EEPROM数据偶尔被改一个字节分析半天发现是电源纹波太大导致写周期内数据线噪声干扰加了CRC校验才让系统在偶发干扰下不至于彻底挂掉。大容量存储设备比如eMMC、U盘内部已经有控制器在做磨损均衡和坏块管理但上层还是建议做CRC和备份。因为这些设备断电时的行为同样存在风险文件系统元数据可能被撕裂FAT表损坏会导致整个分区不可读。所以哪怕用eMMC关键分区也要有备份镜像或者使用具备掉电保护的日志型文件系统。6. 常见问题与排查技巧实录速查表6.1 故障速查表非易失存储器读写的问题通常呈现出很强的规律性。我把这些年遇到的典型问题整理成一张速查表方便你定位现象可能原因排查方法解决方法读出来全是0xFF未擦除、驱动未初始化、CS极性错误、WP拉低先读ID再看状态寄存器先擦除再写检查WP上拉和GPIO初始化读出来全是0x00MISO接地、焊接短路、器件未供电万用表量MISO电压示波器看波形检查焊接、更换器件写后校验失败未写使能、页跨写、扇区未擦除、硬件干扰单步执行写流程打印状态寄存器拆分为页内写入确保每次写完轮询WIP偶发性数据错乱电源纹波、时序太紧、上拉偏弱、SPI时钟过快示波器抓SCK与数据上升沿降低SPI时钟加电源去耦电容调整上拉电阻写入后断电丢数据没有等待写周期完成检查代码是否等待WIP/ChipBusy严格等待必要时增加延迟I2C总线卡死某设备拉低SDA不释放逐设备断开排查示波器看SCL/SDA电平给总线设备上电复位软件复位从机EEPROM写入总是超时上电时序未就绪、从机地址错误确认设备地址停止后延时足够上电后等待100ms再初始化核对地址位Flash识别ID为零WP/HOLD未上拉导致命令无效测量WP/HOLD引脚电压硬件上拉或软件推挽输出置高这条表看着简单但每一行都是我实际调板时遇到过的不是从手册抄来的。比如“读出来全是0xFF”最容易出现在你刚把程序烧进去没做任何初始化就去读Flash的场景因为芯片出厂就是0xFF状态。还有“写后校验失败”新手最容易栽在跨页写上写函数发了一大串数据结果后半段全部回卷覆盖校验死活不过。6.2 我这些年踩过的读写坑第一个坑WP和HOLD引脚悬空。我记得有一块双层板布板时少走了两根上拉Flash识别ID时好时坏。用手按压芯片附近功能竟然恢复松手又故障。查了一晚上最后发现是HOLD引脚悬空手指触摸引入的耦合电压恰好让它工作松手就回到低电平。从此之后我每块板子上的Flash WP和HOLD全都强制上拉到VCC再也不悬空设计。第二个坑跨页写入回卷覆盖。那时我写一个日志功能倒腾数据缓冲区时没算页边界一次写入长度300字节结果第256字节之后的数据覆盖了页首。日志内容一段正常一段乱码排查了很久才发现是Flash不会自动换页。后来我把跨页拆分逻辑写成了工具函数每页写入前都检查剩余空间这个问题就再也没有出现过。第三个坑I2C时序中ACK的判断。我用GPIO模拟I2C时因为GPIO模式没有从输出切换成输入读到的ACK永远是0代码以为每个字节都写入成功。实际上EEPROM写满一页后对后续数据不响应数据全部丢失。后来改成开漏输出外部上拉才正确读到电平。这个问题如果不用示波器看波形真的很难发现。第四个坑掉电瞬间写Flash。设备上电后先读配置根据配置执行业务逻辑断电时做掉电保存。有一次客户反馈设备用一段时间后配置恢复出厂值查到最后是掉电时电压跌落过程中Flash写使能仍然有效恰好触发了一次错误写入把配置区域写成了0xFF。后来方案里加了电压监测芯片电压低于阈值时禁止任何Flash写操作才彻底解决。7. 快速上手的选型与动手建议如果你正准备在项目里加入非易失存储器读写我的建议是先做最小验证板不要一上来就画完整PCB。买一块STM32F103的开发板外接一颗AT24C02或者GD25Q128E的模块把上面的代码跑通用示波器把波形看明白然后再往自己的板子上移植。硬件调试顺序建议是先量供电再量WP/HOLD电平然后读ID最后才做读写测试。ID能正常读出来说明SPI/I2C物理链路和基本协议都通了后面就是数据布局和校验的问题。写驱动的过程里一定要有打印日志的习惯。每执行完写操作就调用读回校验把状态寄存器的值、写入的地址和数据长度都打印出来。嵌入式Debug看不到Printf的地方就用串口输出或者写一个临时变量用调试器监视。宁可多打几行不要凭感觉猜。常见的开发环境配置我也简单提一下。STM32CubeMX里SPI引脚要选Alternate Function模式GPIO速度选HighCS引脚要自己配置为普通GPIO输出并默认拉高。I2C引脚要配置为开漏输出并复用CubeMX通常会自动处理但如果你手工初始化别忘记开启上拉。时钟配置上Flash通信时钟一般不要超过25MHzEEPROM I2C时钟不要超过400kHz稳妥是第一位的。代码结构上建议把Flash/EEPROM驱动封装成独立文件对外只暴露几个接口Init、Read、Write、Erase、Sync。上层业务不用关注底层协议细节这样换芯片时改动量最小。我现在的项目里Flash驱动和EEPROM驱动可以无缝替换甚至连文件系统的底层适配层都不用改。7.1 从零到一的项目模板这里给一个我常用的项目目录结构project/ ├── drv/ │ ├── spi_flash.c # GD25Q128E驱动 │ ├── spi_flash.h │ ├── eeprom_at24c02.c # AT24C02驱动 │ ├── eeprom_at24c02.h │ └── lfs_port.c # LittleFS底层适配 ├── app/ │ ├── storage_manager.c # 数据管理调度 │ └── storage_manager.h ├── fs/ │ ├── lfs.c │ └── lfs.h └── main.c每次接新项目我都是把这个目录拷贝一份修改引脚配置和芯片型号参数把上下位机联调一跑存储子系统就算就绪了。这套模板帮我省了很多时间。7.2 存储子系统设计时别忘了这些细节设计存储方案时有几个容易被忽略的细节专门提醒一下Flash擦除次数统计在量产阶段特别有用。每写一次扇区就把计数加一存到另一个扇区里可以评估整个设备的预期寿命。多核/中断环境下Flash操作要加互斥锁否则中断里写了一个数主循环又写一个数两个写并发会互相干扰。通信总线速度不是越快越好高速模式对PCB布线、上拉电阻、引脚电容要求更高。低速稳定才是嵌入式设备的核心竞争力。如果系统支持OTA升级固件区域和数据区域要分开升级时只擦固件区数据区通过备份机制保持不变。很多人升级完固件发现配置全丢就是因为整片Flash都擦成0xFF了。8. 最后再分享一个实用技巧非易失存储器的读写说到底考验的是对物理器件特性的理解而不是代码量大不大。只要记住三件事写前先擦、写前先使能、等写完再断电——就能避开九成的问题。我再分享一个我惯用的小技巧做一套“存储自检”功能放到出厂测试程序里。设备下线前自动对Flash和EEPROM执行一轮全地址写读校验写入固定数据和随机数混合读出来比对CRC失败的直接判定不合格。这套测试能把早期失效和焊接问题在生产阶段拦截掉而不是等设备跑到客户现场才出问题。测试程序和我量产固件的驱动代码是同源的只是多跑了一段校验逻辑维护成本很低。如果后面你想再进一步可以试着给Flash驱动加上简易损耗均衡或者移植LittleFS然后把配置项放到文件里管理。到那一步你已经不是在“读写存储”而是在设计一个存储子系统了。希望这篇关于非易失存储器读写的整理能帮上你的忙少踩几个我踩过的坑。
返回列表