MCU 内部 Flash 模拟 EEPROM 的磨损均衡方案:写入次数优化与掉电保护的全链路实现

📅 2026/7/16 19:20:04
MCU 内部 Flash 模拟 EEPROM 的磨损均衡方案:写入次数优化与掉电保护的全链路实现
MCU 内部 Flash 模拟 EEPROM 的磨损均衡方案写入次数优化与掉电保护的全链路实现一、内部 Flash 写入寿命的数学约束每天 100 次配置保存的寿命推演在工业传感器、电表、汽车 ECU 等嵌入式设备中运行参数校准系数、累计计数、配置信息需要在掉电后保持。当 MCU 没有内置 EEPROM 时工程上的标准做法是在内部 Flash 的某个扇区模拟 EEPROM 的行为。这个方案的致命短板在于 Flash 的擦写寿命。以 STM32F103 为例其内部 Flash 标称擦写寿命为 1 万次典型值。假设设备每小时保存一次运行参数24 次/天一年共计 8760 次写入寿命仅 1.14 年。如果每 5 分钟保存一次寿命仅剩 34 天。这还仅仅是写满一页就擦除一次的最坏情况。如果每次写 4 字节直接擦除整个 1KB 页擦写放大因子为 256 倍实际寿命将被缩短到几天。磨损均衡Wear Leveling策略的目标是将写入操作均匀分布到所有可用的 Flash 页上使整体擦写寿命乘以页数。一个 10 页的 EEPROM 模拟区域如果均衡策略工作良好有效寿命可以从 1 万次提升到接近 10 万次。但是在 MCU 内部 Flash 上实施磨损均衡面临两个独特约束第一Flash 的擦除操作必须按整页进行通常是 1KB2KB不能按字节擦除第二擦除操作耗时较长典型值 2040ms期间需要保持供电稳定且不能被中断打断。二、基于页面状态机 数据头的磨损均衡协议内部 Flash 模拟 EEPROM 的通用实现架构由三个核心组件构成虚拟地址映射层将逻辑 EEPROM 地址映射到 Flash 物理位置、页面状态机管理每页的 ERASED → RECEIVING → ACTIVE → FULL → ERASING 生命周期以及数据头标记系统每条记录携带有效性标记用于垃圾回收时判断。每条数据记录的头部格式如下/* EEPROM 模拟数据记录头格式 — 每条记录 8 字节开销 */ typedef struct __attribute__((packed)) { uint16_t virt_addr; /* 虚拟 EEPROM 地址 (用户视角的逻辑地址) */ uint16_t data_len; /* 数据部分长度 (不含头) */ uint32_t crc32; /* 数据部分的 CRC32 校验值 */ /* uint8_t data[data_len]; — 实际数据紧跟在头之后 */ } eeprom_record_header_t; /* 页状态标记 — 存放在每页的最后 4 字节 (保留区域) */ #define PAGE_STATE_ERASED 0xFFFFFFFF /* 擦除态 */ #define PAGE_STATE_ACTIVE 0xAAAA5555 /* 活跃页当前写入目标 */ #define PAGE_STATE_FULL 0x00000000 /* 已满页 */ #define PAGE_STATE_INVALID 0xDEADBEEF /* 标记为可回收 */三、带磨损均衡的写入与垃圾回收实现下面的代码实现了一个完整的内部 Flash EEPROM 模拟系统支持最多 16 个逻辑变量、2KB 页大小和自动垃圾回收/* flash_eeprom.c — 内部 Flash 模拟 EEPROM 的磨损均衡实现 */ #define FLASH_PAGE_SIZE 2048 /* 内部 Flash 页大小 2KB */ #define FLASH_EEPROM_BASE 0x0807F800 /* 最后 2KB 保留用于 EEPROM */ #define EEPROM_PAGE_COUNT 8 /* 使用 8 页做磨损均衡 */ #define RECORD_OVERHEAD sizeof(eeprom_record_header_t) #define MAX_VARIABLES 16 /* 最多管理 16 个逻辑变量 */ /* 全局状态 — 跟踪每页的使用状态和当前活跃页 */ typedef struct { uint32_t page_addrs[EEPROM_PAGE_COUNT]; /* 每页的基地址 */ uint32_t active_page; /* 当前写入页索引 */ uint32_t active_offset; /* 当前页内写入偏移 */ uint32_t write_count; /* 总写入次数用于均衡 */ eeprom_record_header_t *cache[MAX_VARIABLES]; /* 变量最新值缓存 */ } eeprom_ctx_t; static eeprom_ctx_t g_ctx; /* 初始化扫描所有页面重建变量缓存和当前写入位置 */ int eeprom_init(void) { /* 1. 初始化页面地址表 */ for (uint32_t i 0; i EEPROM_PAGE_COUNT; i) { g_ctx.page_addrs[i] FLASH_EEPROM_BASE i * FLASH_PAGE_SIZE; } /* 2. 扫描所有页面找到 ACTIVE 页和所有有效记录 */ uint32_t max_erase_count 0; uint32_t most_worn_page 0; for (uint32_t p 0; p EEPROM_PAGE_COUNT; p) { uint32_t page_state *(volatile uint32_t *) (g_ctx.page_addrs[p] FLASH_PAGE_SIZE - 4); if (page_state PAGE_STATE_ACTIVE) { g_ctx.active_page p; /* 扫描此页找到最后写入位置 */ g_ctx.active_offset eeprom_scan_page(g_ctx.page_addrs[p]); } else if (page_state PAGE_STATE_ERASED) { /* 如果还没找到 ACTIVE 页使用此页 */ if (g_ctx.active_page 0xFFFFFFFF) { g_ctx.active_page p; g_ctx.active_offset 0; } } } /* 3. 遍历所有页重建变量缓存 — 取每个变量的最新记录 */ for (uint32_t p 0; p EEPROM_PAGE_COUNT; p) { eeprom_rebuild_cache_from_page(g_ctx.page_addrs[p]); } return 0; } /* 扫描单页返回下一个可写偏移跳过所有已写入记录 */ static uint32_t eeprom_scan_page(uint32_t page_addr) { uint32_t offset 0; while (offset FLASH_PAGE_SIZE - RECORD_OVERHEAD - 4) { eeprom_record_header_t *hdr (eeprom_record_header_t *)(page_addr offset); /* Flash 擦除后为 0xFF如果 virt_addr 全是 0xFF 说明后面没数据了 */ if (hdr-virt_addr 0xFFFF) { break; } /* 校验记录长度合法性 */ if (hdr-data_len 0 || offset RECORD_OVERHEAD hdr-data_len FLASH_PAGE_SIZE) { break; } offset RECORD_OVERHEAD hdr-data_len; } return offset; } /* 写入一个变量 — 追加写入当前活跃页页满时触发 GC */ int eeprom_write(uint16_t virt_addr, const uint8_t *data, uint16_t len) { if (virt_addr MAX_VARIABLES || len 0 || len 256) { return -EINVAL; } /* 1. 检查当前活跃页剩余空间 */ uint32_t record_size RECORD_OVERHEAD len; if (g_ctx.active_offset record_size FLASH_PAGE_SIZE - 4) { /* 空间不足触发垃圾回收 */ int ret eeprom_garbage_collection(); if (ret 0) { return ret; /* GC 失败无法写入 */ } } /* 2. 构造记录头 — 先在 RAM 中准备好 */ eeprom_record_header_t hdr; hdr.virt_addr virt_addr; hdr.data_len len; hdr.crc32 eeprom_crc32(data, len); /* 3. 分步写入 Flash — 先写数据后写头保证原子性 */ uint32_t data_addr g_ctx.page_addrs[g_ctx.active_page] g_ctx.active_offset RECORD_OVERHEAD; /* Flash 编程必须按半字16 位进行 */ HAL_FLASH_Unlock(); /* 3a. 先写入数据部分 */ for (uint16_t i 0; i len; i 2) { uint16_t half_word data[i] | ((i 1 len) ? (data[i1] 8) : 0xFF00); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, data_addr i, half_word) ! HAL_OK) { HAL_FLASH_Lock(); return -EIO; } } /* 3b. 写入记录头 — 分两个半字写入 */ uint32_t header_addr g_ctx.page_addrs[g_ctx.active_page] g_ctx.active_offset; if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, header_addr, *(uint16_t *)hdr) ! HAL_OK) { HAL_FLASH_Lock(); return -EIO; } if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, header_addr 2, *((uint16_t *)hdr 1)) ! HAL_OK) { HAL_FLASH_Lock(); return -EIO; } HAL_FLASH_Lock(); /* 4. 更新偏移量和缓存 */ g_ctx.active_offset record_size; g_ctx.cache[virt_addr] (eeprom_record_header_t *) (g_ctx.page_addrs[g_ctx.active_page] g_ctx.active_offset); g_ctx.write_count; return 0; } /* 垃圾回收将有效数据迁移到新页擦除旧页 */ static int eeprom_garbage_collection(void) { uint32_t new_page (g_ctx.active_page 1) % EEPROM_PAGE_COUNT; uint32_t new_offset 0; /* 1. 如果新页不是擦除态先擦除 */ uint32_t page_state *(volatile uint32_t *) (g_ctx.page_addrs[new_page] FLASH_PAGE_SIZE - 4); if (page_state ! PAGE_STATE_ERASED) { if (eeprom_erase_page(new_page) 0) { return -EIO; /* 擦除失败GC 终止 */ } } /* 2. 标记旧活跃页为 FULL */ eeprom_mark_page_state(g_ctx.active_page, PAGE_STATE_FULL); /* 3. 将所有有效变量的最新值迁移到新页 */ for (uint16_t v 0; v MAX_VARIABLES; v) { if (g_ctx.cache[v] ! NULL) { /* 从缓存中读取最新值并写入新页 */ uint8_t *data (uint8_t *)(g_ctx.cache[v]) RECORD_OVERHEAD; uint16_t len g_ctx.cache[v]-data_len; /* 构造新记录并编程到新页 */ eeprom_record_header_t new_hdr; new_hdr.virt_addr v; new_hdr.data_len len; new_hdr.crc32 eeprom_crc32(data, len); /* 写入新页此处简化实际需要 HAL Flash 编程 */ uint32_t dst g_ctx.page_addrs[new_page] new_offset; eeprom_program_record(dst, new_hdr, data, len); new_offset RECORD_OVERHEAD len; } } /* 4. 标记新页为 ACTIVE */ eeprom_mark_page_state(new_page, PAGE_STATE_ACTIVE); /* 5. 擦除旧页使其可复用 */ eeprom_erase_page(g_ctx.active_page); eeprom_mark_page_state(g_ctx.active_page, PAGE_STATE_ERASED); /* 6. 切换活跃页 */ g_ctx.active_page new_page; g_ctx.active_offset new_offset; return 0; } /* 掉电中断处理 — 必须在 BOR 触发前完成当前写入保护 */ /* BOR (Brown-Out Reset) 下降沿提供约 2ms 窗口完成原子保护 */ void HAL_PVD_IRQHandler(void) { if (__HAL_PWR_GET_FLAG(PWR_FLAG_PVDO)) { /* 电压低于 PVD 阈值 — 立即将活跃页标记为 FULL */ __HAL_PWR_CLEAR_FLAG(PWR_FLAG_PVDO); /* 如果 gc 正在迁移过程中不标记旧页 — * 重启后通过扫描恢复 */ uint32_t page_state *(volatile uint32_t *) (g_ctx.page_addrs[g_ctx.active_page] FLASH_PAGE_SIZE - 4); if (page_state ! PAGE_STATE_ACTIVE) { return; /* 已经在 GC 中不干扰 */ } /* 原子地将 ACTIVE 标记为 FULL — 这是一个半字编程操作 */ HAL_FLASH_Unlock(); HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, g_ctx.page_addrs[g_ctx.active_page] FLASH_PAGE_SIZE - 4, 0x0000); /* 写 FULL 标记的前半字 */ HAL_FLASH_Lock(); } }四、擦写放大、掉电鲁棒性与 Flash 编程时序的三重张力磨损均衡方案最根本的 trade-off 是擦写放大因子与空间开销的权衡。当使用 N 页进行均衡时每次 GC 需要将有效数据从旧页复制到新页这个复制代价是每页填充度的函数。在最优情况下页面刚好写满时触发 GC擦写放大因子接近 1在最坏情况下只有少量有效数据时需要 GC擦写放大因子可达 N 倍。掉电鲁棒性是另一个致命边界。Flash 的页擦除操作通常需要 20~40ms如果在此期间掉电页可能处于部分擦除状态——有些字节已变为 0xFF有些仍是旧值。这种非确定性状态在下一次上电时需要特殊处理通过 CRC 校验每条记录的完整性跳过损坏的记录。GC 过程中如果掉电新旧两页中的数据可能都是部分有效状态需要额外的commit标记来指示 GC 是否成功完成。此外Flash 编程操作需要稳定的供电电压通常 ≥2.7V。在电池供电设备中大电流的 Flash 擦除操作可能导致电压瞬时跌落触发了 BOR 复位形成擦除 → 电压跌落 → 复位 → 重新擦除的死循环。解决方案是在擦除前检查电池电压低于阈值时暂缓 GC仅保持追加写入。五、总结内部 Flash 模拟 EEPROM 的磨损均衡系统是 MCU 掉电存储设计中的核心组件。关键实现要点包括数据头标记 追加写入是实现原子性的基础先写数据后写头确保任何时刻中断只产生完整或完全不存在两种状态页状态机管理 Flash 页的生命周期通过状态标记区分擦除态、活跃态和已满态GC 的迁移-擦除两阶段操作必须考虑掉电场景通过 CRC 校验和 commit 标记实现故障恢复擦写放大因子取决于页面填充度和 GC 触发时机需要在空间利用率和 Flash 寿命之间取得平衡。