
简介本资源是一套面向嵌入式物联网设备开发者的STM32G071平台FLASH存储优化实践方案聚焦单片机片内FLASH模拟EEPROM的高可靠性实现解决传统FLASH频繁擦写导致寿命衰减的核心痛点。方案采用轮询式地址索引管理与智能页擦除策略将有效擦写寿命提升至百万次级别适用于需长期断电保存参数、日志或配置的低功耗IoT终端场景。压缩包共1236个文件含664个C源码含HAL驱动、数学库调用及FLASH管理核心逻辑、261个头文件定义接口与数据结构、104个汇编启动文件及74个说明文档整体体积20.96MB内容预览显示已集成ARM CMSIS-DSP数学库、STM32G0xx HAL外设驱动及IAR/Keil双环境工程配置.uvprojx/.icf等具备即编即用特性。已有32人下载学习提供完整可运行工程、分层模块化代码结构与关键算法注释便于开发者快速理解地址映射机制、磨损均衡逻辑与异常掉电保护设计。1. 项目概述与核心痛点最近在做一个物联网传感器节点的项目主控用的是STM32G071一个性价比很高的Cortex-M0内核芯片。项目里需要保存一些校准参数、设备ID和运行状态标志位这些数据量不大但要求掉电不丢失并且能频繁更新。一开始我理所当然地想到了外挂一颗EEPROM芯片比如AT24C02几毛钱成本用I2C读写简单省事。但老板掐着BOM成本要求能省则省最好一颗MCU搞定所有功能。这就引出了嵌入式开发里一个经典问题如何在没有硬件EEPROM的单片机上实现类似EEPROM的非易失性数据存储STM32G071内部只有Flash没有独立的EEPROM存储区。直接对Flash进行“写”操作是行不通的因为Flash的特性是“写0容易写1难”必须先擦除整块或整页变为全1才能写入。频繁擦写固定区域Flash很快就会达到寿命终点通常10万次擦写。我的目标就是设计一套算法在有限的Flash空间里通过巧妙的地址管理和擦除策略把等效的“写入”寿命提升到百万次级别满足物联网设备长期、频繁更新参数的需求。2. 方案选型与设计思路拆解面对这个需求业内通常有几种思路。最简单粗暴的是“单地址直写”每次更新数据都擦除再写同一个地址这显然会迅速耗尽Flash寿命不可取。另一种是“双备份”或“多备份”轮换写入这是模拟EEPROM的常见方案。我最终选择了基于“页轮询”和“地址索引”的混合策略核心思想可以概括为将Flash空间虚拟化通过一个动态的“写指针”和状态标记将多次数据更新分散到多个物理地址上从而大幅减少对单一物理单元的擦除次数。2.1 为什么选择页擦除与轮询策略STM32G071的Flash以2KB为一页。擦除操作的最小单位就是一页。如果我每次只更新几个字节的数据就擦除一整页那效率极低寿命消耗也极快。因此我的策略是预留多页作为模拟EEPROM的存储区例如分配4页Flash8KB作为一个“虚拟EEPROM池”。以“记录”为单位进行写入每次数据更新我不直接覆盖旧值而是将“数据值”连同其“虚拟地址”和“序列号”打包成一个完整的记录写入到当前写指针指向的物理地址。写指针在池内顺序移动当一页写满后写指针跳转到下一页的起始位置继续写入。惰性页擦除只有当所有页都即将被写满时才去擦除最早被写满、且其中所有记录都已过时被新记录覆盖的那一页。擦除后该页变为空白页加入写入队列的尾部形成循环。这样一来一次数据更新比如修改一个参数只消耗一个“记录”的写入寿命而一个物理页2KB可以存放很多个记录。只有当一个页中的所有记录都失效后它才会被擦除一次。假设每个记录16字节那么一页可以存放128个记录。即使我们频繁更新数据也需要更新128次才会触发一次对该页的擦除。等效地该页上每个物理地址的擦除频率降低了128倍从而将整体寿命从10万次提升到了千万次量级理论值。2.2 关键数据结构设计记录与状态管理要实现上述逻辑需要在Flash中存储一些元数据来管理整个存储池。我设计了两种关键的数据结构记录结构体Record这是实际存储用户数据的单元。typedef struct { uint16_t virtual_addr; // 虚拟地址代表用户数据的ID如0x0001代表温度校准值 uint16_t seq_num; // 序列号用于区分同一虚拟地址的新旧记录 uint32_t data; // 用户数据这里以32位为例可根据需要调整 uint16_t crc16; // 校验和确保记录完整性 } flash_record_t;virtual_addr相当于EEPROM的地址。用户通过这个地址来存取数据。seq_num这是实现“覆盖”逻辑的关键。每次更新同一virtual_addr的数据时seq_num递增。在查找最新数据时我们遍历所有记录找到同一virtual_addr下seq_num最大的那条它就是有效数据。crc16Flash可能发生位翻转增加CRC校验可以保证数据的可靠性在读取时进行验证。页头信息Page Header写在每一页的起始几个字节用于标识页的状态。// 通常直接写在Flash固定偏移处例如页起始地址 #define PAGE_STATUS_EMPTY 0xFFFFAAAA // 空白页 #define PAGE_STATUS_ACTIVE 0xFFFFBBBB // 正在写入的页 #define PAGE_STATUS_FULL 0xFFFFCCCC // 已写满的页 #define PAGE_STATUS_OBSOLETE 0xFFFFDDDD // 已过时待擦除的页页状态机驱动着整个轮询流程。系统初始化时会扫描所有预留页找到状态为ACTIVE的页当前写入页。如果没有ACTIVE页但有EMPTY页则将其置为ACTIVE。如果连EMPTY页都没有了说明存储池已满需要启动垃圾回收回收OBSOLETE页。注意这些状态标记字必须选择在Flash擦除后全0xFF不会自然出现的值。0xFFFFAAAA这类值在擦除后的Flash里是0xFFFFFFFF写入0xFFFFAAAA意味着将某些位从1变为0是可行的操作无需先擦除。3. 核心算法实现与代码解析整个系统的核心是三个函数初始化、读数据、写数据。下面我结合代码和流程图来详细说明。3.1 系统初始化与状态恢复系统上电后第一步就是扫描整个Flash存储池重建内存中的管理信息。这个过程决定了系统的鲁棒性。void FLASH_EEPROM_Init(void) { uint32_t page_addr; uint32_t found_active_page 0; flash_page_header_t *p_header; // 1. 遍历所有预留页 for (int i 0; i FLASH_EEPROM_PAGE_NUM; i) { page_addr FLASH_EEPROM_START_ADDR (i * FLASH_PAGE_SIZE); p_header (flash_page_header_t *)page_addr; // 2. 识别页状态 switch (p_header-status) { case PAGE_STATUS_ACTIVE: // 找到当前写入页记录其地址和当前写偏移 g_active_page_addr page_addr; g_write_offset sizeof(flash_page_header_t); // 从头之后开始 // 需要遍历该页已有记录找到最后一个有效记录的位置更新g_write_offset find_last_record_offset(page_addr, g_write_offset); found_active_page 1; break; case PAGE_STATUS_EMPTY: // 记录空白页地址以备后用 add_empty_page_to_list(page_addr); break; case PAGE_STATUS_FULL: // 记录满页地址 add_full_page_to_list(page_addr); break; case PAGE_STATUS_OBSOLETE: // 记录过时页地址它们是垃圾回收的首选目标 add_obsolete_page_to_list(page_addr); break; default: // 异常状态可能该页从未被初始化将其格式化为EMPTY format_page_as_empty(page_addr); break; } } // 3. 如果没有找到ACTIVE页则需要从EMPTY页中激活一页 if (!found_active_page) { if (get_empty_page_count() 0) { g_active_page_addr get_next_empty_page(); format_page_as_active(g_active_page_addr); g_write_offset sizeof(flash_page_header_t); } else { // 没有空白页存储池已满必须立即触发垃圾回收 perform_garbage_collection(); } } // 4. 在内存中构建虚拟地址到最新记录的映射表可选加速读取 rebuild_virtual_addr_map(); }初始化过程的心得断电恢复必须考虑任何一步操作中突然断电的情况。例如正在写记录时断电可能导致记录不完整CRC校验失败。初始化时遇到CRC错误的记录应直接跳过视为无效。状态一致性页头状态和页内实际内容必须一致。我的做法是只有在成功写完一个记录后才去更新内存中的写指针。修改页状态如从ACTIVE改为FULL是一个关键操作必须在确保页内数据都稳定后再进行。3.2 数据写入流程追加记录与页切换写入数据是核心操作它不仅仅是“写Flash”更包含了状态判断和空间管理。bool FLASH_EEPROM_Write(uint16_t virtual_addr, uint32_t data) { flash_record_t new_record; uint32_t record_size sizeof(flash_record_t); uint32_t next_seq_num; // 1. 为该虚拟地址分配下一个序列号 next_seq_num get_next_sequence_number(virtual_addr); // 2. 封装新记录 new_record.virtual_addr virtual_addr; new_record.seq_num next_seq_num; new_record.data data; new_record.crc16 calculate_crc16((uint8_t*)new_record, record_size - 2); // 3. 检查当前ACTIVE页剩余空间是否足够 if (g_write_offset record_size FLASH_PAGE_SIZE) { // 当前页已满需要切换页 if (!switch_to_next_page()) { return false; // 切换失败如无可用页且回收失败 } } // 4. 执行Flash写入 HAL_FLASH_Unlock(); // 解锁Flash写保护 if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, g_active_page_addr g_write_offset, *(uint64_t*)new_record) ! HAL_OK) { HAL_FLASH_Lock(); return false; } HAL_FLASH_Lock(); // 5. 更新内存中的管理信息 g_write_offset record_size; update_virtual_addr_map(virtual_addr, next_seq_num, g_active_page_addr g_write_offset - record_size); return true; }页切换函数switch_to_next_page的要点将当前ACTIVE页的状态标记为FULL。检查是否有EMPTY页。如果有将其状态标记为ACTIVE并更新g_active_page_addr和g_write_offset。如果没有EMPTY页则触发垃圾回收Garbage Collection。3.3 垃圾回收机制提升寿命的关键垃圾回收是算法中最精妙的部分目标是回收那些已经“过时”里面所有记录都有更新版本在其他页的页将其擦除变为EMPTY页。static bool perform_garbage_collection(void) { uint32_t page_to_erase; // 1. 选择要回收的页。策略选择最早被标记为FULL或OBSOLETE的页。 page_to_erase select_page_for_erasure(); if (page_to_erase 0) { return false; // 没有合适的页可回收理论上不应该发生除非所有数据都是最新的 } // 2. 关键步骤搬迁有效数据。 // 遍历待擦除页中的所有记录对于每个记录检查其是否为该虚拟地址的最新记录。 // 如果不是则忽略因为它已过时。如果是则必须将其写入到当前的ACTIVE页 // 这一步确保了有效数据不会丢失。 if (!relocate_valid_records(page_to_erase)) { return false; } // 3. 数据搬迁完成后将该页标记为OBSOLETE可选也可直接擦除 mark_page_as_obsolete(page_to_erase); // 4. 执行页擦除 HAL_FLASH_Unlock(); if (HAL_FLASHEx_Erase(pEraseInit, PageError) ! HAL_OK) { HAL_FLASH_Lock(); return false; } HAL_FLASH_Lock(); // 5. 将擦除后的页状态标记为EMPTY并加入空白页列表 format_page_as_empty(page_to_erase); return true; }垃圾回收的注意事项搬迁风暴在最坏情况下一个页里可能所有记录都是最新的比如设备刚初始化后第一次写满一页。此时垃圾回收需要搬迁大量数据会阻塞系统较长时间。对于实时性要求高的系统需要将搬迁操作分步进行或者选择有效数据最少的页进行回收。断电保护在垃圾回收的“标记-搬迁-擦除”过程中任何一步断电都可能导致数据不一致。一种增强策略是引入“事务日志”或使用两套页状态标记但会增大复杂性和Flash开销。对于百万次寿命的场景发生断电时正好在进行垃圾回收的概率极低通常可以接受基础方案的风险。3.4 数据读取流程查找最新记录读取操作相对简单但效率需要考虑。最直接的方法是每次读取都遍历所有页的所有记录找到对应virtual_addr且seq_num最大的记录。这在记录数不多时可以接受。bool FLASH_EEPROM_Read(uint16_t virtual_addr, uint32_t *data) { flash_record_t *p_record; uint16_t max_seq_num 0; uint32_t found_addr 0; bool found false; // 遍历所有页可以从后向前遍历因为新数据通常在更后面的地址 for (int page_idx 0; page_idx FLASH_EEPROM_PAGE_NUM; page_idx) { uint32_t page_start FLASH_EEPROM_START_ADDR page_idx * FLASH_PAGE_SIZE; uint32_t offset sizeof(flash_page_header_t); while (offset FLASH_PAGE_SIZE) { p_record (flash_record_t *)(page_start offset); // 检查记录头魔数或CRC判断是否为一个有效记录 if (!is_valid_record(p_record)) { offset sizeof(flash_record_t); continue; } if (p_record-virtual_addr virtual_addr) { if (p_record-seq_num max_seq_num) { max_seq_num p_record-seq_num; *data p_record-data; found true; } } offset sizeof(flash_record_t); } } return found; }读取性能优化 在初始化时rebuild_virtual_addr_map()就是在内存RAM中建立一个哈希表或数组将virtual_addr映射到其最新记录的物理地址和seq_num。这样读取操作就变成了O(1)复杂度的内存查找速度极快。代价是消耗一些RAM并在每次写入后需要更新这个映射表。对于STM32G07116KB RAM和几十个参数的情况这点开销完全可以接受。4. 关键参数配置与寿命估算设计完成后我们需要定量评估这个方案是否能达到“百万次”级别的寿命。这取决于几个关键参数Flash物理擦写次数Endurance以STM32G071为例其数据手册标称的Flash擦写寿命通常是10万次100k cycles。这是每个物理扇区/页的保证值。模拟EEPROM总大小Total_Size我们分配的Flash总字节数。页大小Page_SizeSTM32G071是2KB。记录大小Record_Size包含头信息和数据的完整记录长度假设为16字节。用户数据更新频率假设最频繁更新的一个参数每秒更新1次。寿命估算过程每页可存储记录数Records_Per_Page Page_Size / Record_Size 2048 / 16 128个。写放大系数Write Amplification由于垃圾回收时需要搬迁有效数据实际物理写入量会大于逻辑写入量。在最坏情况下每次垃圾回收都搬迁整页数据写放大系数接近2。在平均情况下假设为1.5。等效擦除一次页面对应的逻辑写入次数一次页擦除对应着该页被逻辑写满。因此Logical_Writes_Per_Erase Records_Per_Page 128次。系统总逻辑写入次数Total_Logical_Writes Flash_Endurance * Logical_Writes_Per_Erase * Number_Of_Pages / Write_Amplification。假设我们使用4页8KBTotal_Logical_Writes 100,000 * 128 * 4 / 1.5 ≈ 34,133,333次。对单一地址的写入寿命系统总逻辑写入次数需要分配到各个虚拟地址上。如果只有1个地址频繁写入那么它就能独占这约3400万次写入。如果频繁写入的地址有10个那么每个地址的寿命约为340万次。如果频繁写入的地址有100个每个地址的寿命约为34万次。结论通过分配4页Flash在最坏写放大1.5倍的情况下即使有10个参数需要每秒更新1次也能连续工作约340万秒 ≈ 39天才耗尽寿命。而实际物联网设备参数更新频率远低于此可能几分钟甚至几小时一次因此实现“百万次级别”的寿命绰绰有余甚至可以达到千万次。重要提示上述估算是理论值。实际寿命受以下因素影响温度高温会显著降低Flash寿命。数据模式全0xFF擦除后写入特定模式其磨损程度可能不同。均衡磨损上述基础算法并未主动实现全池的均衡磨损。select_page_for_erasure()函数如果总是选择最早的满页会导致前面几页磨损更快。更高级的算法可以记录每页的擦除计数优先选择擦除次数最少的页进行回收。5. 实战调试与问题排查实录在STM32G071上实现并调试这套系统时我遇到了几个典型问题这里分享出来供大家避坑。5.1 Flash编程对齐与数据类型问题问题现象调用HAL_FLASH_Program写入数据后读取出来的值不正确或者直接导致HardFault。排查过程检查地址对齐。STM32G071的Flash编程要求64位双字对齐。我的记录结构体是16字节本来是自然对齐的但在计算写入地址g_active_page_addr g_write_offset时必须确保这个地址是8的倍数。检查解锁序列。必须在编程前调用HAL_FLASH_Unlock()编程后调用HAL_FLASH_Lock()。且不能嵌套。检查编程函数的数据类型。HAL_FLASH_Program的第一个参数是编程类型FLASH_TYPEPROGRAM_DOUBLEWORD第二个参数是地址第三个参数是uint64_t类型的数据。我需要将我的flash_record_t指针强制转换为uint64_t*并一次写入8个字节。由于记录是16字节需要分两次调用HAL_FLASH_Program。解决方案// 正确的写入方式 uint64_t *p_data (uint64_t*)new_record; HAL_StatusTypeDef status; status HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, target_addr, p_data[0]); if (status HAL_OK) { status HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, target_addr8, p_data[1]); } if (status ! HAL_OK) { // 错误处理 }同时在定义存储池起始地址FLASH_EEPROM_START_ADDR时必须确保其是8字节对齐的通常可以指定到某个页的起始地址这本身就是对齐的。5.2 中断与擦写时序冲突问题现象在频繁写入Flash时系统偶尔会死机或数据错乱。排查过程Flash擦写操作期间CPU会暂停执行指令因为Flash总线被占用。如果此时发生了中断而中断向量表也存放在Flash中CPU就无法取指响应中断导致程序跑飞。解决方案在执行Flash擦写操作HAL_FLASHEx_Erase和编程操作HAL_FLASH_Program的关键段内必须关闭全局中断。HAL库的擦写函数内部可能已经处理了部分中断屏蔽但为了绝对安全我通常在调用前后加上__disable_irq(); // ... Flash操作 ... __enable_irq();注意关闭中断的时间应尽可能短。一次双字编程很快但一次页擦除可能需要几十毫秒。长时间关闭中断会影响系统实时性。因此垃圾回收这类耗时操作最好放在低优先级任务或主循环的空闲时段进行。5.3 电源跌落导致数据损坏问题现象设备意外断电重启后发现某些参数恢复了旧值或者整个模拟EEPROM区域无法初始化。排查过程这通常发生在写入记录或更改页状态的过程中断电。例如刚写完记录数据还没来得及更新页头的状态指针或者正在搬迁数据时断电。解决方案增强记录完整性校验除了CRC16可以在记录头添加一个固定的“魔数”Magic Number如0x55AA。初始化时只有同时具备正确魔数和CRC的记录才被认可。状态变更原子化页状态的变更应尽可能一步完成。例如将页状态从ACTIVE改为FULL只需要写入一个双字64位。STM32的Flash编程可以保证这个64位写入是原子的要么全成功要么全失败。引入预写日志WAL对于更苛刻的应用可以在另一个Flash页开辟一个小区域作为日志。在修改主要数据区之前先把要做的操作如“将地址0x0001的值改为123”写到日志页。操作完成后再清除日志。上电恢复时检查日志页完成未完成的操作。这会增加复杂性和写放大但安全性最高。5.4 常见问题速查表问题现象可能原因排查步骤与解决方案写入后读回数据错误1. 地址未8字节对齐2. 未正确解锁Flash3. 数据长度非8字节倍数尾部处理错误1. 检查并确保写入地址(addr 0x7) 02. 确认调用了HAL_FLASH_Unlock()且返回成功3. 对于非8倍数字节的数据填充0xFF并分次写入系统在擦写Flash时死机1. 擦写期间未关闭中断2. 擦写函数超时1. 在擦写操作前后调用__disable_irq()和__enable_irq()2. 检查擦除参数是否正确页地址是否有效初始化时卡住或进入错误状态1. Flash内容被意外修改状态字异常2. CRC校验大量失败遍历逻辑陷入死循环1. 在初始化代码中增加默认状态处理遇到非法状态时格式化该页2. 在遍历记录循环中增加超时或最大记录数限制频繁写参数后参数丢失1. 垃圾回收失败导致无空白页可用2. 序列号溢出处理不当1. 检查垃圾回收函数perform_garbage_collection的返回值并增加日志2. 为seq_num设置合理的位宽如16位溢出后从0开始但需确保能区分新旧记录结合页新旧判断读取速度慢每次读取都全盘遍历在RAM中建立“虚拟地址-最新记录物理地址”的查找表在初始化rebuild_virtual_addr_map和每次写入后更新该表6. 进阶优化与扩展思路实现基础功能后还可以从以下几个方向进行优化以适应更复杂或更极端的场景。6.1 磨损均衡算法优化基础算法中页的回收顺序是FIFO先写满的先回收这可能导致前面几页的磨损远高于后面几页。为了实现更均匀的磨损可以引入“擦除计数”。在每页的页头信息中增加一个erase_count字段。每次页被擦除后将该计数加1注意写入erase_count本身也是一次Flash操作需要小心设计以避免无限递归。在select_page_for_erasure()函数中优先选择erase_count最小的满页进行回收。这样擦除次数少的页会被优先使用从而让所有页的磨损程度趋于一致最大化整体Flash寿命。6.2 掉电保护增强策略对于数据安全性要求极高的场景可以采用“双状态位”或“预写日志”策略。双状态位每个页的状态用两个不相关的Flash字来表示例如status_a和status_b。只有在两个状态字都成功写入新值后状态变更才被认为生效。上电恢复时如果两个状态字不一致则通过预定义的规则如选择序号大的来确定最终状态或者执行恢复操作。预写日志如前所述在另一个小区域记录操作日志。这相当于一个简易的文件系统日志安全性最高但实现也最复杂。6.3 适配不同容量与频率需求这套算法框架是通用的可以通过宏定义轻松适配不同需求小数据量、低频更新可以减少预留页数如2页减小记录头大小比如只用8位序列号以节省Flash空间。大数据量、高频更新可以增加预留页数如8页或更多减少垃圾回收的频率。同时可以考虑将记录头与数据分离将频繁更新的元数据如序列号放在RAM中仅定期备份到Flash但这会牺牲掉电保存性。多数据类型当前的data字段是uint32_t。可以将其定义为一个联合体union以支持存储浮点数、数组或结构体。typedef union { uint32_t u32; float f32; uint8_t bytes[4]; } data_field_t;在记录结构体中可以再增加一个data_type字段来标识数据类型。6.4 与文件系统或键值数据库结合对于更复杂的应用需要管理大量且结构多样的非易失性数据可以在此模拟EEPROM的基础上构建一个轻量级的键值存储Key-Value Store。将virtual_addr扩展为更灵活的“键”Key可以是一个字符串哈希值。值Value可以是任意长度的二进制数据通过多个连续的记录来存储。实现基本的put(key, value)、get(key, value)、delete(key)接口。这相当于实现了一个极简的、针对Flash特性优化的嵌入式数据库非常适合物联网设备的配置管理。经过在STM32G071上的实际测试这套轮询式Flash模拟EEPROM的方案完全满足项目需求。在预留4页Flash8KB、每秒写入1次最频繁参数的极限测试下连续运行了两个月未出现任何数据错误或Flash失效。对于一般的物联网设备参数存储几分钟到几小时更新一次其寿命足以支撑设备全生命周期的使用。整个方案的核心价值在于它用软件算法的复杂性换取了硬件成本的节约和系统可靠性的提升这种权衡在成本敏感的嵌入式产品中是非常经典的思路。本文还有配套的精品资源点击获取