ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

GD32F303内部Flash模拟EEPROM:磨损均衡与掉电保护实战

GD32F303内部Flash模拟EEPROM:磨损均衡与掉电保护实战 1. 项目缘起为什么要在GD32F303上用内部Flash替代EEPROM做嵌入式开发的朋友大概率都遇到过这个场景板子上需要保存几个关键参数比如设备序列号、校准系数、用户配置项掉电之后不能丢。第一反应往往是外挂一颗EEPROM比如AT24C02、AT24C16这类I2C接口的小芯片。但真到了量产阶段问题就来了——BOM成本增加、PCB面积被占、采购周期不稳定、焊接良率多一个风险点。尤其是那种成本敏感、空间紧凑的小型工控板或者消费类设备多一颗芯片就是多一份麻烦。GD32F303这颗MCU我自己用得很顺手Cortex-M4内核主频能跑到120MHz外设资源也够丰富。它内部有最高512KB的Flash实际项目里代码往往用不到一半剩下的空间完全可以拿来当数据存储区。用内部Flash模拟EEPROM省掉外挂芯片硬件成本直接降下来而且读写速度比I2C的EEPROM快得多——I2C那点速率写一个字节要等好几个毫秒内部Flash虽然也要等擦除但整体吞吐量完全不是一个量级。不过这里有个坑必须提前说清楚Flash的擦写寿命和EEPROM没法比。EEPROM按字节擦写典型寿命100万次GD32F303的内部Flash擦写寿命官方手册标的是10万次而且是按扇区擦除。如果你频繁写同一个地址比如每秒存一次数据那这个扇区很快就废了。所以这个项目的核心不只是“怎么把数据写进Flash”更重要的是“怎么让Flash活得久一点”也就是磨损均衡。这篇文章适合谁看如果你正在用GD32F303做项目需要掉电保存功能又不想加外挂EEPROM那这篇内容可以直接抄作业。我会从Flash的物理特性讲起把扇区划分、读写接口、磨损均衡算法、掉电保护策略全部拆开最后给出一套可以直接移植的代码框架。即使你用的是GD32其他系列或者STM32思路也是通用的。2. 先搞懂GD32F303内部Flash的脾气2.1 Flash和EEPROM的本质区别很多人把Flash和EEPROM混着叫其实两者在物理结构上差别很大。EEPROM是电可擦除可编程只读存储器它可以在字节级别直接修改数据写之前不需要擦除硬件内部自动处理。Flash则是按块组织的写之前必须先擦除整个扇区擦除后所有位变成1写入操作只能把1变成0不能把0变回1。这个特性决定了Flash的写入逻辑和EEPROM完全不同。GD32F303的Flash控制器支持页擦除和整片擦除页大小根据型号不同有2KB和4KB两种。以GD32F303ZE为例512KB的Flash分成若干页每页2KB。擦除一页的时间大概是20到40毫秒写入一个字32位大概几十微秒。这个时间在掉电保存场景下是可以接受的因为掉电保存通常只在检测到掉电信号或者主动保存时触发不是每时每刻都在写。注意Flash擦除期间CPU如果从同一块Flash取指令会出现总线阻塞。所以擦写操作最好放在RAM中执行或者确保擦写期间不执行同块Flash的代码。GD32的固件库已经帮我们处理了这个问题但如果你自己写底层驱动这一点必须留意。2.2 扇区划分与地址映射GD32F303的Flash起始地址是0x08000000这是代码区的起点。假设你的程序编译出来占用了前128KB那从0x08020000开始就可以拿来做数据存储。我一般习惯把最后几个扇区留给数据区这样代码区扩容的时候不会冲突。具体划分方案要看你的数据量和磨损均衡策略。如果只是存几十个字节的配置参数用一个2KB的扇区就够了。但为了磨损均衡通常需要至少两个扇区交替使用。我的建议是划出4个扇区每个2KB总共8KB对于大多数应用来说绰绰有余。地址计算很简单扇区n的起始地址 0x08000000 n × 2048。比如第100个扇区起始地址就是0x08000000 100 × 2048 0x08032000。在代码里用宏定义把这些地址管理起来后面改起来方便。2.3 擦写寿命与磨损均衡的必要性前面提到GD32F303的Flash擦写寿命是10万次。这个数字看起来不小但你要这么想如果一个扇区每天擦写100次1000天就用完了不到三年。如果是工业设备设计寿命动辄十年那肯定不够。而且这10万次是理想条件下的标称值实际使用中温度、电压波动都会影响寿命。磨损均衡的核心思想就是不要让擦写操作集中在同一个物理地址上。最简单的做法是准备两个扇区轮流写。写满一个扇区后把有效数据搬到另一个扇区然后擦除旧扇区。这样每个扇区的擦写次数就减半了。如果再加入地址映射表把逻辑地址动态映射到物理地址磨损就能更均匀地分布到整个数据区。我实测过一个方案用4个扇区做环形缓冲每个扇区分成若干条记录每条记录包含数据、校验和、写入序号。写的时候顺序往后追加写满一个扇区就跳到下一个。擦除操作只在扇区写满后才发生。这样在数据量不大的情况下擦除频率极低Flash寿命轻松超过设备本身的设计寿命。3. 整体方案设计从需求到架构3.1 需求拆解与设计目标在动手写代码之前先把需求理清楚。掉电保存功能的核心需求有这么几条第一数据写入后能可靠保存掉电不丢第二写入次数要尽可能少延长Flash寿命第三掉电瞬间要有保护机制不能写到一半断电导致数据损坏第四读取速度要快不能影响系统实时性第五代码要可移植方便换到其他项目。基于这些需求我设计的方案包含三个层次底层是Flash驱动层负责扇区擦除、数据写入、数据读取中间是存储管理层负责地址映射、磨损均衡、掉电保护上层是应用接口层提供类似EEPROM的按地址读写接口。这样分层的好处是底层驱动可以复用GD32的固件库存储管理逻辑可以独立测试应用层调用简单。3.2 为什么选择双扇区交替加日志式写入磨损均衡算法有很多种最简单的就是双扇区交替两个扇区一个当前使用一个备用。写满当前扇区后把有效数据复制到备用扇区擦除当前扇区然后交换角色。这个方案实现简单可靠性高但磨损还是集中在两个扇区上。更进一步的是日志式写入也叫追加写入。每个扇区被分成多个记录槽每次写入数据时不是覆盖旧数据而是在扇区末尾追加一条新记录。记录里包含逻辑地址、数据长度、数据和校验值。读取的时候从后往前扫描找到每个逻辑地址最新的记录。当扇区写满后把每个逻辑地址的最新记录整理出来写到下一个扇区然后擦除旧扇区。这个方案的好处很明显擦除操作只在扇区写满后发生写入操作只是简单的追加不需要擦除。假设每个记录占16字节一个2KB的扇区能存128条记录。如果你每天写10次那12.8天才需要擦除一次。10万次擦写寿命算下来能用3500多年。当然这是理论值实际还要考虑其他因素但足以说明问题。我最终选择的是日志式写入加多扇区环形管理。用4个扇区组成一个环形队列写满一个就切到下一个同时触发垃圾回收。这样磨损分布到4个扇区上寿命再翻4倍。3.3 掉电保护策略设计掉电保护是这个项目的难点。你想想如果正在写Flash的时候突然断电会是什么后果如果只是追加写入最坏情况是最后一条记录写了一半校验不过读取的时候跳过这条就行之前的数据不受影响。但如果是擦除过程中断电那整个扇区的数据就全没了。所以掉电保护的核心原则是永远不要擦除唯一一份有效数据。我的做法是在擦除一个扇区之前确保下一个扇区的数据已经完整写入并校验通过。具体流程是当前扇区写满后先把所有有效数据整理写入下一个扇区校验通过后再擦除当前扇区。这样即使擦除过程中断电下一个扇区还有完整数据系统重启后能正常恢复。另外在写入每条记录时先写数据再写校验和最后写状态标志。状态标志用一个特定的魔数表示“这条记录有效”。读取的时候先检查状态标志再校验数据。如果状态标志不对或者校验失败就认为这条记录无效跳过。这样即使写到一半断电也不会影响已有数据。提示GD32F303有内置的电压监测功能可以配置低电压检测中断。在检测到电压低于阈值时立即触发紧急保存把关键数据写入Flash。这个功能配合掉电保护逻辑能大幅提高可靠性。4. 核心细节解析Flash读写与磨损均衡实现4.1 Flash解锁与擦除操作GD32F303的Flash控制器默认是锁定的防止误操作。要写Flash先要解锁。解锁流程是调用fmc_unlock()函数这个函数会依次写入两个特定的密钥到FMC_KEY寄存器。解锁之后才能操作FMC_CTL寄存器进行擦除和编程。擦除一个页的代码大概长这样fmc_state_enum flash_erase_page(uint32_t page_addr) { fmc_state_enum state; fmc_unlock(); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); state fmc_page_erase(page_addr); fmc_lock(); return state; }这里有几个细节要注意。第一擦除之前要清除所有标志位否则可能会误判操作结果。第二擦除完成后要重新锁定Flash防止其他代码意外修改。第三fmc_page_erase的返回值要检查只有返回FMC_READY才表示擦除成功。擦除时间大概20到40毫秒这段时间CPU会暂停。如果你的系统有实时性要求最好在擦除前关掉中断擦除完成后再打开。或者把擦除操作放在系统空闲时执行。4.2 数据写入与校验Flash写入不像EEPROM那样可以按字节写GD32F303支持按字32位、半字16位写入。我一般用按字写入效率高。写入函数如下fmc_state_enum flash_write_word(uint32_t addr, uint32_t data) { fmc_state_enum state; fmc_unlock(); fmc_flag_clear(FMC_FLAG_END | FMC_FLAG_WPERR | FMC_FLAG_PGERR); state fmc_word_program(addr, data); fmc_lock(); return state; }写入之前要确保目标地址已经被擦除也就是内容是0xFFFFFFFF。如果没擦除就写写入的数据会是旧数据和新数据的按位与结果肯定不对。校验是保证数据可靠性的关键。我通常用CRC32或者简单的累加和。CRC32计算量大一点但检错能力强。累加和实现简单对于短数据够用。校验值要跟数据一起写入Flash读取的时候重新计算并比对。4.3 日志式记录结构设计每条记录的结构我设计成固定16字节方便地址计算字段长度说明状态标志4字节0x5A5A5A5A表示有效逻辑地址2字节应用层看到的地址数据长度2字节实际数据字节数数据4字节存储的数据校验和4字节前面所有字段的累加和这个结构支持每次写4字节数据对于大多数配置参数够用了。如果要存更长的数据可以扩展数据字段或者用多条记录拼起来。写入流程是先找到当前扇区的下一个空槽计算地址然后依次写入状态标志、逻辑地址、数据长度、数据、校验和。注意写入顺序状态标志最后写这样即使前面写完了但状态标志没写读取的时候也会认为这条记录无效。4.4 磨损均衡算法实现磨损均衡的核心逻辑在写入和垃圾回收两个环节。写入的时候不是直接覆盖旧数据而是追加新记录。读取的时候从扇区末尾往前扫描找到每个逻辑地址最新的有效记录。垃圾回收的触发条件是当前扇区剩余空间不足。回收流程是遍历当前扇区所有有效记录把每个逻辑地址的最新值整理出来写入下一个扇区。写入完成后校验下一个扇区的数据确认无误后擦除当前扇区把当前扇区标记为空闲。这里有个细节整理数据的时候同一个逻辑地址可能有多条记录只需要保留最新的那条。怎么判断最新记录里可以加一个写入序号或者利用记录在扇区中的位置——越靠后的记录越新。我用的是位置判断因为追加写入天然保证了顺序。扇区状态用一个单独的标志位来管理。每个扇区的第一个字用来存状态0xFFFFFFFF表示空闲0x00000000表示正在使用0xAAAAAAAA表示已满待回收。系统启动时扫描所有扇区找到正在使用的那个然后从里面恢复数据。5. 实操过程从零搭建断电保存模块5.1 工程配置与Flash空间规划先在Keil或者IAR里新建一个GD32F303的工程配置好时钟和基本外设。然后打开链接脚本或者分散加载文件把数据区预留出来。以Keil为例在Target选项卡里修改ROM的起始地址和大小把最后8KB排除掉。比如512KB的FlashROM配置成0x08000000开始大小0x7E000这样最后8KB就不会被代码占用。接着定义数据区的宏#define DATA_FLASH_START 0x0807E000 #define DATA_FLASH_END 0x08080000 #define DATA_SECTOR_SIZE 2048 #define DATA_SECTOR_COUNT 4 #define DATA_SECTOR_ADDR(n) (DATA_FLASH_START (n) * DATA_SECTOR_SIZE)这些宏后面会频繁用到定义清楚能避免很多地址计算错误。5.2 底层驱动编写与测试底层驱动包含三个函数擦除扇区、写入数据、读取数据。擦除和写入直接调用GD32固件库的fmc_page_erase和fmc_word_program。读取更简单直接用指针访问uint32_t flash_read_word(uint32_t addr) { return *(volatile uint32_t *)addr; }写完驱动后先做个简单测试擦除一个扇区写入几个字然后读出来比对。再擦除再写入反复几次确认稳定。这个测试能帮你排除大部分硬件和配置问题。注意调试的时候如果开了Flash断点可能会影响擦写操作。建议在RAM里调试或者把断点设在擦写之后。5.3 存储管理层实现存储管理层的核心是一个结构体记录当前扇区号、当前写入偏移、以及一个内存缓存。内存缓存用来加速读取避免每次都去扫描Flash。typedef struct { uint8_t sector_index; uint16_t write_offset; uint8_t cache[256]; uint8_t cache_valid; } storage_ctx_t;初始化的时候扫描所有扇区找到正在使用的扇区然后把有效数据加载到缓存。加载过程就是从扇区开头往后扫描遇到有效记录就更新缓存中对应逻辑地址的值。写入的时候先更新缓存然后在当前扇区追加一条记录。如果扇区剩余空间不足触发垃圾回收。垃圾回收就是把缓存中的数据完整写入下一个扇区然后擦除当前扇区。5.4 掉电检测与紧急保存GD32F303的电源管理单元支持低电压检测。配置好之后当电压低于设定阈值时会触发中断。在中断服务函数里立即调用存储层的保存函数把缓存中的数据写入Flash。这里要注意中断服务函数里不能做太耗时的操作。如果数据量不大直接写一条记录就行。如果数据量大可以只保存最关键的几个参数其他参数等下次正常保存。另外掉电检测的阈值要设置合理。太低的话还没来得及写完就没电了太高的话正常电压波动就会误触发。一般设置在2.7V到2.9V之间比较合适具体要看你的电源设计。6. 常见问题与排查技巧实录6.1 写入失败或数据错乱最常见的问题是写入地址没有对齐。GD32F303的Flash编程要求地址按字对齐也就是地址必须是4的倍数。如果你传了一个非对齐的地址写入会失败或者写入错误的数据。解决办法是在写入函数里加一个地址对齐检查不对齐就返回错误。另一个常见问题是擦除不彻底。有时候擦除操作返回成功但实际内容没完全变成0xFF。这通常是因为擦除期间有中断打断了操作。解决办法是在擦除前关中断擦除后开中断。6.2 读取数据全FF或全00读取全FF说明扇区是空的没有有效记录。检查一下初始化流程是不是没有正确扫描扇区。读取全00说明扇区被擦除了但状态标志没更新或者读取地址算错了。用调试器直接查看Flash内容确认地址和数据是否正确。还有一种可能是编译器优化导致的。volatile关键字一定要加否则编译器可能把读取操作优化掉读到的是缓存里的旧值。6.3 磨损均衡不生效如果发现擦除操作还是集中在同一个扇区检查垃圾回收的触发条件。可能是扇区剩余空间判断有误或者扇区切换逻辑没写对。在垃圾回收函数里加打印观察每次回收时扇区号的变化。另外如果数据量很小但写入频率很高比如每秒写一次那即使有磨损均衡擦除频率还是很高。这种情况下可以考虑在RAM里做缓存积累一定数量的修改后再统一写入Flash。6.4 掉电后数据丢失掉电丢失通常是因为保存时机不对。如果你是在检测到掉电后才开始写那留给你的时间窗口很短。电容上的储能有限可能只够写几条记录。解决办法是提前保存比如在数据修改后就立即写入而不是等掉电再写。还有一种可能是掉电检测阈值设置不当。用示波器观察电源电压和掉电中断的触发时刻调整阈值电阻分压比确保有足够的写入时间。6.5 常见问题速查表现象可能原因排查方法写入后读出来不对地址未对齐检查地址是否为4的倍数擦除后仍有旧数据擦除被中断打断擦除前关中断读取全FF扇区为空或未初始化检查初始化扫描逻辑读取全00状态标志未更新检查状态标志写入顺序擦除集中在同一扇区垃圾回收未触发检查剩余空间判断掉电后数据丢失保存时机太晚提前保存或调整检测阈值系统运行变慢擦除阻塞CPU把擦除放在空闲时执行7. 进阶优化让方案更稳更耐用7.1 增加ECC校验与坏块管理如果对可靠性要求极高可以在每条记录里加ECC校验。简单的汉明码能纠正一位错误检测两位错误。实现起来不复杂但能大幅提高数据可靠性。坏块管理则是记录哪些扇区已经擦写次数过多提前标记为不可用避免写到坏块导致数据丢失。7.2 多级缓存减少Flash写入在RAM里做一个多级缓存把频繁修改的数据暂存在缓存里积累到一定量或者定时触发时才写入Flash。这样能大幅减少Flash写入次数。比如一个计数器每秒加一如果每次都写Flash一天就是86400次如果每分钟写一次一天只有1440次寿命延长60倍。7.3 数据压缩与去重如果存储的数据有很多重复或者规律性可以在写入前做压缩。简单的RLE压缩或者差分编码就能减少数据量从而减少占用的记录槽间接延长Flash寿命。去重则是把相同的数据合并只保留一份。7.4 移植到其他GD32系列这套方案的思路是通用的移植到GD32F103、GD32F407等其他系列只需要改几个地方Flash起始地址、扇区大小、固件库函数名。存储管理层和应用接口层完全不用动。我试过从F303移植到F407半天就搞定了。8. 个人实操心得与踩坑记录这个方案我在三个项目里实际用过最长的已经稳定运行两年多没有出现过数据丢失。踩过的坑也不少说几个印象深刻的。第一个坑是扇区状态标志的设计。一开始我用0xFFFFFFFF表示空闲0x00000000表示使用中结果发现擦除后扇区内容就是0xFFFFFFFF跟空闲状态冲突了。后来改成用不同的魔数区分比如0xA5A5A5A5表示使用中0x5A5A5A5A表示已满0xFFFFFFFF表示空闲问题就解决了。第二个坑是垃圾回收时的数据一致性。有一次回收过程中断电重启后发现数据丢了。原因是回收时先擦除了旧扇区但新扇区的数据还没写完。后来改成先写新扇区校验通过后再擦旧扇区就再没出过问题。第三个坑是掉电检测的响应时间。一开始阈值设得太低掉电中断触发时电压已经降到2.5V以下Flash写入不稳定。后来把阈值调到2.9V并且加大了电源滤波电容给写入留出了足够的时间。最后分享一个小技巧在Flash数据区的开头放一个版本号每次修改存储结构时递增。系统启动时检查版本号如果不匹配就执行数据迁移或者恢复默认值。这样后续升级固件时不会因为存储结构变化导致数据错乱。这套方案的核心思想其实就一句话用追加写入代替覆盖写入用空间换寿命。理解了这一点具体的代码实现都是水到渠成的事。
返回列表