ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

GD32F303内部Flash替代EEPROM:磨损均衡与掉电保护实战 EEPROM涨价那阵子我手上一个基于GD32F303的项目正好卡在BOM成本上。原本板上挂了一颗I2C的EEPROM存配置参数结果采购说交期拉到十几周价格翻了三倍。当时第一反应是换型号但硬件已经打样了改板来不及。后来翻GD32F303的手册发现它内部有512KB的Flash擦写寿命标称10万次拿来存配置参数绰绰有余——前提是你得自己处理擦写粒度、地址管理和寿命均衡的问题。这篇文章就把我当时从选型论证到代码落地、再到磨损均衡方案设计的完整过程拆开讲一遍包括中间踩过的几个坑。如果你也在用GD32F303或者类似的Cortex-M4国产MCU想省掉一颗外部EEPROM这篇可以直接抄作业。1. 为什么内部Flash能顶替EEPROM以及它顶不了的部分1.1 先搞清楚两者的物理差异很多人一上来就问“Flash能不能当EEPROM用”这个问题本身问得不够精确。正确的问法是你的数据写入模式和Flash的擦写特性之间差距有多大EEPROM的卖点是字节级可擦写你可以直接往地址0x10写一个字节不影响旁边的0x11。而GD32F303的内部Flash是页擦除、字/半字编程的结构。具体到GD32F303它的主Flash按页组织不同容量型号页大小不一样我用的这款是2KB一页具体以你手上的数据手册为准GD32F303系列有1KB和2KB两种页规格。这意味着你想改一个字节理论上得把整页2KB擦掉再写回去。这个差异带来的直接后果是擦除次数是稀缺资源。手册标称的10万次擦写寿命指的是单页的擦除次数。如果你每次保存参数都擦一次页一天存100次两年就把一页写废了。所以“能不能替代”的答案取决于你能不能把“频繁的小数据写入”转换成“低频的页擦除”。1.2 什么场景适合什么场景趁早放弃我总结了一个简单的判断标准你可以直接对照自己的项目数据特征适合内部Flash建议外挂EEPROM写入频率每天几次到几十次每秒都在写单次数据量几十字节到几百字节任意掉电保存要求有但可容忍最后一次丢失必须零丢失数据变化模式配置参数、校准值、累计计数实时日志、高频采样寿命要求产品生命周期内擦写5万次需要百万次以上我那个项目存的是设备ID、通信参数、运行小时数、几个校准系数加起来不到200字节每天最多写十几次。这种场景用内部Flash完全没问题。但如果你要做数据记录仪每秒往Flash里写一条采样值那还是老老实实加EEPROM或者用带FRAM的方案别跟Flash的寿命较劲。1.3 一个容易被忽略的坑Flash写入期间的CPU行为GD32F303的Flash在擦除或编程时从Flash取指会暂停。也就是说如果你把擦写代码放在Flash里执行擦写期间CPU会stall直到操作完成。页擦除一次大概几十毫秒这期间你的中断响应会被延迟。我第一次测试时没注意这点擦除期间正好来了个串口中断结果数据丢了。解决办法有两个一是把擦写函数放到RAM里执行用__attribute__((section(.ramfunc)))或者直接拷贝到RAM二是擦写前关掉全局中断擦完再开。我选的是第二种因为我的擦写操作不频繁关中断几十毫秒对系统没影响。但如果你有硬实时要求就得用RAM执行的方式。2. GD32F303 Flash的寄存器级操作别急着抄HAL2.1 解锁、擦除、编程的三步曲GD32的Flash操作和STM32类似但不完全一样寄存器名字有区别。核心流程是解锁FMCFlash Memory Controller→ 擦除页 → 编程数据 → 上锁。下面是我实际用的寄存器级代码不依赖任何库#include gd32f30x.h #define FLASH_PAGE_SIZE 2048U #define FLASH_START_ADDR 0x08000000U #define FLASH_END_ADDR 0x0807FFFFU // 512KB型号 #define PARAM_PAGE_ADDR 0x0807F000U // 用最后一页存参数 static void fmc_unlock(void) { fmc_unlock(); // GD32的库函数叫fmc_unlock()寄存器操作是 // FMC_KEY 0x45670123; FMC_KEY 0xCDEF89AB; } static void fmc_lock(void) { fmc_lock(); } // 擦除一页 static int32_t flash_erase_page(uint32_t page_addr) { fmc_state_enum state; fmc_unlock(); state fmc_page_erase(page_addr); fmc_lock(); return (state FMC_READY) ? 0 : -1; } // 编程一个字32位 static int32_t flash_program_word(uint32_t addr, uint32_t data) { fmc_state_enum state; fmc_unlock(); state fmc_word_program(addr, data); fmc_lock(); return (state FMC_READY) ? 0 : -1; }如果你用的是寄存器直接操作关键寄存器是FMC_CTL控制寄存器、FMC_ADDR地址、FMC_DATA数据。擦除时先置FMC_CTL的PER位然后写FMC_ADDR再置START位轮询FMC_STAT的BUSY位直到清零。编程时置PG位写地址和数据同样等BUSY清零。注意GD32F303的Flash编程必须按32位字对齐写入。如果你想写一个字节得先读出整字改掉对应字节再写回去。这也是为什么后面设计数据结构时要考虑对齐。2.2 擦除前必须做的检查直接擦除是有风险的。如果目标页里还有你没备份的数据擦了就没了。我在代码里加了两层保护第一层是地址范围校验确保不会误擦到程序区static int is_param_page(uint32_t addr) { return (addr PARAM_PAGE_ADDR addr PARAM_PAGE_ADDR FLASH_PAGE_SIZE); }第二层是擦除前的数据有效性判断。如果这一页已经是空的全0xFF就没必要擦。这个判断能省掉很多无谓的擦除次数static int is_page_blank(uint32_t page_addr) { uint32_t *p (uint32_t *)page_addr; for (uint32_t i 0; i FLASH_PAGE_SIZE / 4; i) { if (p[i] ! 0xFFFFFFFF) return 0; } return 1; }实测下来这个简单的判断在我那个项目里减少了大约30%的擦除操作因为参数不是每次都变有时候连续几次保存的数据是一样的。2.3 读取为什么不需要解锁Flash读取不需要解锁也不需要特殊时序直接指针访问就行。这是Flash相比EEPROM的一个优势——读操作零开销不像I2C EEPROM每次读都要走一遍起始条件、设备地址、寄存器地址的流程。我实测过从内部Flash读200字节参数大概几微秒而从I2C EEPROM读同样数据要几百微秒。如果你的参数在启动时需要频繁读取内部Flash的读取速度优势很明显。3. 参数存储的数据结构设计让擦除次数降下来3.1 双页交替最朴素的磨损均衡磨损均衡这个词听起来高级核心思想其实很简单别盯着一页反复擦轮着来。我一开始的方案是单页存储每次保存都擦那一页。后来算了一下寿命每天写20次一年7300次10万次寿命大概能撑13年。看起来够用但这是理想情况实际Flash寿命受温度、电压影响会打折扣。而且如果某个bug导致频繁写入可能几个月就废了。改成双页交替后擦除次数直接减半。逻辑是这样的用两个页A和B当前有效数据在A要写入新数据时擦B、写B然后把B标记为有效A标记为无效。下次写入时反过来。这样每页的擦除频率降低一半。typedef struct { uint32_t magic; // 魔数标识数据有效 uint32_t seq; // 序列号判断哪页更新 uint8_t data[PARAM_SIZE]; uint32_t crc; // 校验 } param_block_t; #define MAGIC_VALID 0x50415241 // PARA #define PAGE_A_ADDR (PARAM_PAGE_ADDR) #define PAGE_B_ADDR (PARAM_PAGE_ADDR FLASH_PAGE_SIZE)读取时比较两页的seq取大的那个写入时写到seq小的那一页写入前先擦除。3.2 序列号回绕的处理seq是32位的理论上写到40亿次才会回绕实际产品根本到不了。但代码里还是要处理否则回绕时逻辑会出错。我的做法是用一个简单的比较函数static int seq_is_newer(uint32_t a, uint32_t b) { return ((int32_t)(a - b) 0); }这个写法利用了有符号整数的溢出特性即使回绕也能正确判断新旧。这是嵌入式里判断序列号新旧的经典技巧比直接比大小靠谱。3.3 数据对齐与CRC校验前面提到Flash必须按32位写所以param_block_t的大小要是4的倍数。我用了__attribute__((aligned(4)))来强制对齐。CRC校验用的是软件CRC32每次写入前算好读取时验证。如果CRC不对说明数据损坏自动切换到另一页。这里有个细节CRC的计算范围要包含magic和seq否则如果seq被写坏CRC还是对的就会读到错误的数据。我一开始就犯了这个错只对data算CRC结果有一次掉电导致seq写了一半读出来的seq是乱的但CRC通过系统用了错误的参数。后来改成全字段校验就没再出过问题。4. 磨损均衡的进阶方案从双页到多页轮转4.1 双页方案的局限双页交替在大多数场景够用但有个问题如果数据量小比如只有几十字节用两个2KB的页来存空间浪费严重。而且擦除次数虽然减半但本质上还是每次写入都擦一页。如果你的写入频率更高比如每天几百次双页方案可能撑不了太久。这时候可以考虑多页轮转用N个页组成一个环形缓冲区每次写入写到下一个空页只有当所有页都写满时才擦除最老的一页。这样擦除频率降低到1/N。4.2 多页轮转的实现要点多页轮转的核心是维护一个写指针每次写入后指针前移。读取时从最新的页读。判断“最新”的方法还是靠seq每写一次seq加一。#define ROTATE_PAGES 4 #define ROTATE_BASE (PARAM_PAGE_ADDR) static uint32_t find_latest_page(void) { uint32_t latest_seq 0; uint32_t latest_addr ROTATE_BASE; for (int i 0; i ROTATE_PAGES; i) { param_block_t *blk (param_block_t *)(ROTATE_BASE i * FLASH_PAGE_SIZE); if (blk-magic MAGIC_VALID seq_is_newer(blk-seq, latest_seq)) { latest_seq blk-seq; latest_addr ROTATE_BASE i * FLASH_PAGE_SIZE; } } return latest_addr; }写入时找到最新的页写到它的下一页如果下一页不是空的先擦。这个方案下每页的擦除间隔变成了N次写入寿命直接乘以N。4.3 什么时候该上多页轮转不是所有项目都需要多页轮转。我的建议是写入频率 每天50次双页交替足够写入频率 每天50~500次考虑4页轮转写入频率 每天500次要么上多页轮转减小页占用要么老实加外部存储另外多页轮转的代价是启动时要扫描所有页找最新数据启动时间会增加。4页扫描大概多花几十微秒可以接受。但如果页数太多启动扫描时间会线性增长需要权衡。5. 掉电保护比磨损均衡更容易翻车的地方5.1 掉电发生在擦除过程中会怎样这是最危险的情况。Flash擦除一页需要几十毫秒如果在这期间掉电这一页的数据就处于不确定状态——可能部分位被擦成1部分还是0。如果这页正好是当前有效数据页数据就丢了。双页交替方案天然能应对这个问题因为写入时操作的是“旧页”当前有效页没动。即使写入过程中掉电旧页的数据还在系统下次启动时读到旧页最多丢失最后一次更新。这就是为什么我坚持用双页而不是单页——单页方案在擦除时掉电数据全丢。5.2 写入顺序的设计写入一页时的顺序也有讲究。正确的顺序是先写data和crc再写seq最后写magic为什么magic最后写因为magic是“这页数据有效”的标志。如果先写magic然后写data时掉电这页会被误认为是有效数据但data是残缺的。把magic放最后只有所有数据都写完了这页才被标记为有效。static int save_params(param_block_t *new_blk) { uint32_t target_addr get_inactive_page_addr(); // 1. 擦除目标页 if (flash_erase_page(target_addr) ! 0) return -1; // 2. 写data和crcmagic先不写 new_blk-magic 0xFFFFFFFF; // 暂时无效 // ... 写入data、seq、crc ... // 3. 最后写magic flash_program_word(target_addr offsetof(param_block_t, magic), MAGIC_VALID); return 0; }这个顺序看起来简单但我是踩过坑才这么写的。最早的时候我是按结构体顺序从头写到尾magic在最前面结果一次掉电测试中magic写进去了但data没写完系统启动后读到一个magic有效但data乱码的块直接跑飞了。5.3 启动时的数据恢复逻辑启动时读取参数的流程要健壮不能假设数据一定有效。我的做法是int load_params(param_block_t *out) { param_block_t *a (param_block_t *)PAGE_A_ADDR; param_block_t *b (param_block_t *)PAGE_B_ADDR; int a_valid (a-magic MAGIC_VALID) (crc32(a) a-crc); int b_valid (b-magic MAGIC_VALID) (crc32(b) b-crc); if (a_valid b_valid) { // 两页都有效取seq大的 *out seq_is_newer(a-seq, b-seq) ? *a : *b; return 0; } else if (a_valid) { *out *a; return 0; } else if (b_valid) { *out *b; return 0; } else { // 两页都无效加载默认参数 load_default_params(out); return -1; } }这个逻辑覆盖了所有情况两页都好、只有一页好、两页都坏。两页都坏的情况虽然罕见但必须有兜底否则系统启动就挂了。6. 实测数据与几个反直觉的发现6.1 擦除时间实测我用示波器抓了GPIO翻转来测擦除时间GD32F303在120MHz主频下2KB页擦除大约25~35ms和手册标称的20~40ms吻合。编程一个字大约几十微秒。这个时间在保存参数时是可以接受的但如果你在擦除期间有通信任务一定要考虑这个延迟。6.2 擦除次数对擦除时间的影响我做了个简单的寿命测试对同一页反复擦写。前1万次擦除时间基本稳定在30ms左右到5万次以后开始有轻微上升到8万次时部分擦除操作会超过40ms。这说明Flash老化后擦除时间会变长如果你的系统对擦除时间有硬性要求要留足余量。6.3 温度的影响在低温-20°C下测试时擦除时间比常温长了大约20%。高温85°C下擦除时间反而略短但数据保持能力会下降。如果你的产品要在宽温范围工作建议在高温下做数据保持测试低温下做擦写时间测试。6.4 一个反直觉的发现不是所有位都能擦成1我一直以为Flash擦除后所有位都是1但实测发现如果一页擦除次数接近寿命上限有些位会“擦不干净”读出来还是0。这就是所谓的“擦除残留”。解决办法是在擦除后加一个校验如果发现非0xFF的位再擦一次。如果连续擦三次还有残留就标记这页为坏页换用其他页。static int flash_erase_page_verified(uint32_t page_addr) { for (int retry 0; retry 3; retry) { if (flash_erase_page(page_addr) ! 0) continue; if (is_page_blank(page_addr)) return 0; } return -1; // 坏页 }这个校验逻辑在双页方案里可能用不上但在多页轮转里很重要因为页数多了坏一页的概率也大了。7. 移植到其他GD32型号时要注意的差异GD32F303的Flash操作代码不能直接搬到所有GD32型号上。我后来在GD32F103上试过发现几个差异第一页大小不同。F103的页大小是1KBF303是2KB。如果你的代码里硬编码了页大小移植时会出错。建议用宏定义根据型号切换。第二FMC寄存器的位定义有细微差别。比如F303的FMC_STAT里有个PGERR位F103里叫PGERR但位置不同。直接操作寄存器的话要查对应型号的手册。第三主频不同导致擦除时间不同。F103最高72MHz擦除时间比F303长。如果你的超时判断是按F303写的在F103上可能会误判超时。我的做法是把Flash操作封装成一层抽象上层的数据结构和磨损均衡逻辑不变底层根据型号适配。这样换型号时只需要改底层驱动。8. 完整代码框架与集成建议8.1 文件组织我把整个模块分成三个文件flash_drv.c/h底层Flash擦写驱动寄存器操作param_store.c/h参数存储逻辑双页交替、CRC校验param_api.c/h上层接口提供param_save()和param_load()这样分层的好处是底层驱动可以复用到其他项目参数存储逻辑可以独立测试。8.2 集成时的注意事项第一链接脚本要预留参数页。如果你用的是默认链接脚本程序可能会占用到最后一页。需要在链接脚本里把参数页排除或者把参数页放在程序区之后。我是在链接脚本里加了一段MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 510K PARAM (rw) : ORIGIN 0x0807F800, LENGTH 2K }第二初始化顺序。参数加载要在系统初始化早期完成因为后面的模块可能依赖参数。但Flash驱动本身需要时钟使能所以顺序是系统时钟初始化 → FMC时钟使能 → 参数加载 → 其他模块初始化。第三写入时的中断处理。前面提到擦写期间CPU会stall如果擦写函数在Flash里执行中断响应会延迟。我的做法是在param_save()里关中断擦写完再开。关中断的时间大约30ms对大多数应用可以接受。8.3 测试建议上线前至少做这几项测试掉电测试在擦除和写入的不同阶段断电验证数据不丢寿命测试对同一页连续擦写观察擦除时间变化宽温测试在温度极限下验证读写正确性异常测试人为破坏数据改magic、改crc验证恢复逻辑我那个项目做完这些测试花了大概两周但省掉了一颗EEPROM的成本和交期风险算下来还是值得的。最后分享一个我在调试时用的小技巧在参数区旁边留一个“调试页”专门用来记录擦写次数和最后一次擦写的时间戳。这个页不参与磨损均衡只用于诊断。产品出厂时可以擦掉但在开发和测试阶段它能帮你快速定位问题。比如有一次客户反馈参数丢失我让他们读回调试页发现擦写次数异常高最后定位到一个定时器配置错误导致参数被频繁保存。没有这个调试页这种问题很难查。
返回列表