ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32G0 Flash存储优化:一次擦除多次写入的分块方案

STM32G0 Flash存储优化:一次擦除多次写入的分块方案 STM32G070xx这颗料我用了两三年印象最深的反而不是性能而是它的Flash存储策略。之前做的一个采集设备需要把校准系数、累计值这类参数频繁保存下来设备一天要掉电或复位好几次每次复位前都得写一遍参数。最开始我图省事直接定义了一个固定的Flash地址每次更新就往里写。结果没多久就发现问题不对一次更新就要擦掉整个页再写擦写次数涨得飞快按规格书的寿命算下来这设备跑一年都撑不到。后来我把方案改成了“固定数据块分块存储”也就是标题里说的“一次擦除、多次写入”这个问题才算根治。这篇文章就把这个方案的完整思路、代码实现和踩过的坑展开聊聊适合正在做STM32G0系列参数存储、日志记录或者类似需要频繁擦写Flash的朋友参考。1. NOR Flash的物理脾性为什么“直接覆盖写”行不通1.1 擦和写完全是两回事STM32G070xx内部集成的Flash属于NOR Flash类型它有两个底层特性直接决定了我们不能像操作RAM一样操作它写入只能把1变成0不能把0变成1。任何一次写入操作本质上是在Flash阵列中“抹掉某些位”把原本的1清成0。反过来想把0恢复成1只有擦除这一条路。擦除的最小单位是页不是字节。G070的Flash页大小通常是2KB具体型号以数据手册为准也就是说哪怕你只想改一个字节也得先把整个2KB的页擦成全0xFF然后再把需要写的数据写进去。这两个特性叠加在一起就形成了嵌入式开发里最常见的Flash操作模型读可以按字节随时读写可以按字32位或半字执行但擦除必须整页来。很多朋友第一次接触STM32的HAL库会下意识地以为HAL_FLASH_Program就能“改”Flash里的旧数据。实际上如果你写入的地址上已经有非0xFF的数据写入结果大概率不符合预期因为旧值中的0无法再变回1。唯一的办法就是先擦除整页再写新值。1.2 直接整页擦写的两个真实代价假设我们要在Flash里保存一份64字节的设备参数按照最原始的做法每次更新都擦除指定页然后从头写入。这样做有两个代价第一Flash磨损速度极快。STM32G0系列内部的Flash擦写寿命数据手册上给的典型量级在1万次左右。每天保存50次参数一年大约需要擦写18000多次也就是说设备可能不到一年Flash就磨穿了。一旦某个页的擦写次数超限这个页就可能出现位翻转、编程失败等问题导致数据损坏。第二整页擦除期间掉电可能导致所有参数全部丢失。擦除是逐页进行的时序上需要毫秒级。在这段时间里如果系统突然断电整页数据都变成全0xFF之前保存的所有历史版本和当前有效参数一起没了。这在很多需要“掉电保存关键参数”的场景里是无法接受的。1.3 内部Flash和外部SPI Flash的差异项目里如果用到外部SPI NOR Flash很多人会疑惑为什么那些Flash经常可以按扇区擦写而且还能做磨损均衡这里需要区分的点是STM32G070内部Flash通过系统总线直接映射到地址空间CPU可以像读内存一样直接取指和数据读取这带来极快的读性能和代码执行能力。但它在写擦时序、磨损寿命上和外部SPI NOR Flash并没有本质差别同样不能按字节改写。外部SPI NOR Flash可以通过文件系统和磨损均衡算法来延长整体寿命而内部Flash通常没有这层软件保护需要我们在应用层自己设计存储策略。所以“分块存储”本质上就是把外部Flash常见的磨损均衡思路搬到MCU内部Flash上来做。这也是为什么这个方案值得仔细设计的原因。2. 固定数据块方案把“每写必擦”改成“写满才擦”2.1 核心思路一句话与其每次更新数据都擦一次整页不如把一个页划分成多个固定大小的数据块每次更新只往下一个空闲块里写入新数据当一个页的所有块都写满之后才统一擦除一次整页。这样擦除频率就从“每写1次擦1次”变成了“每写N次才擦1次”。对于2KB的页如果每个数据块是128字节一页可以分成16个块。也就是说一次擦除可以支撑16次数据更新Flash的理论寿命直接放大16倍。如果再搭配双页轮转磨损还可以进一步摊平到两个页上寿命还能往上走。2.2 块内格式设计固定数据块的大小需要仔细斟酌。块太大一页能放的块数就少磨损均衡效果差块太小块头元数据占比高浪费空间。以2KB页为例我最终选择了128字节作为块大小这样每页16块结构上比较规整。每个数据块内部按如下格式组织偏移长度字段说明04字节magic块提交标记未提交为0xFFFFFFFF提交后为0xA5A5A5A542字节data_len有效数据长度62字节seq写入序号用于判断哪个块最新8100字节data应用数据区1084字节crc32整个块内容的CRC32校验值11216字节保留留空补齐到128字节这里有两个设计细节值得展开说。第一个是magic字段放在块最前面且最后写入。写入一个数据块时先把其他字段包括CRC都写好最后才把magic从0xFFFFFFFF写成0xA5A5A5A5。这一步很关键它相当于一个事务提交标记只要magic不是有效值就说明这个块写入未完成读取时直接跳过。这样即使写入过程中掉电也不会把一个写了一半的“脏块”当成有效数据。第二个是seq序号字段。由于每次更新都会追加写入新块同一个参数会存在多个历史版本怎么知道哪一个是最新的靠的是seq单调递增。每次写入新块seq就加1读取时扫描所有有效块magic有效且CRC通过seq最大的那个就是最新版本。seq只需要在压缩时保持连续即可uint32_t的容量足够用到产品报废。2.3 一页内块的生命周期我们可以把一页看作一个小型日志区。块的状态有三种空闲块整块内容全为0xFF可以写入。已提交块magic有效、CRC校验通过属于某个历史版本。未提交块部分内容已被写入但magic仍是0xFFFFFFFF说明上次写入中断了。这种块不能复用只能跳过等下次整页擦除时自然回收。写入流程就是从当前页的写指针位置开始找到第一个空闲块写入数据提交magic然后把写指针移动到下一个块。当一页16个块全部写满后触发页压缩擦除整页再继续使用。3. 双页轮转写满之后的回收与掉电保护3.1 单页方案为什么不够如果只有一个页那么当16个块全写满之后还是要擦除这个页。擦除完成后所有历史版本都没了只能写入新数据。这个方案相比“每写必擦”寿命提升了16倍但存在两个问题压缩擦除时刻如果掉电整页数据全丢。没有“当前正在写哪个页”的稳定记录如果掉电后页内块状态复杂恢复逻辑会很麻烦。所以实际项目里我采用了双页轮转A页和B页交替使用始终保持至少一个页处于“可写”状态另一个页作为压缩时的安全缓冲区。3.2 压缩流程假设当前活动页是A页写指针已指向页末尾B页是全空闲的备用页。此时来了第17次写入流程如下从A页中读取最新有效块seq最大的块。把这个最新块原样写入B页的第0块位置。擦除A页整页。将当前活动页切换为A页写指针指向A页第0块。正常追加写入新数据到A页第0块。这次压缩之后B页保留了压缩之前的最新数据A页被清空并重新作为日志区。下一次写入继续往A页追加直到A页再次写满再往B页方向压缩。如此反复两个页交替承担“日志页”和“备份页”的角色。整个过程可以用一个表格描述场景A页状态B页状态下一步写入行为正常运行有冗余块空闲/备份继续写A页A页满、B页空闲满空闲最新块写入B页第0块擦除A页继续写A页B页满、A页空闲空闲/备份满最新块写入A页第0块擦除B页继续写B页两页都有有效数据有有选择seq最大的页继续写另一页触发压缩3.3 掉电窗口分析双页方案更强的点在于掉电恢复。压缩过程中有几次可能掉电的窗口我们来逐一分析窗口一准备压缩尚未写入B页时掉电。A页满、B页空闲数据全在A页下次上电扫描发现A页满而B页全空再次触发压缩即可数据无损。窗口二最新块已写入B页第0块但A页尚未擦除时掉电。此时两页都有有效数据上电扫描时通过比较seq就能判断哪个块最新最新的在B页A页属于过期数据。恢复逻辑只需要擦除A页然后以B页中的新块为准继续工作。窗口三A页擦除完成、尚未切换活动页时掉电。A页全空、B页有有效数据扫描后把活动页切到B页即可数据无损。这个设计的关键在于任意时刻都存在至少一个完整的有效数据副本。它不会像单页方案那样在擦除窗口掉电就全军覆没。4. STM32G070上的具体实现4.1 定义存储参数和数据结构下面这一段是完整的存储模块代码我稍微整理成适合直接移植的样子。首先定义参数#include stm32g0xx_hal.h #include string.h /* 两个物理页分配这里以G070RB的128KB Flash为例最后两页通常留给参数区。 如果你自己的程序占了这些位置需要根据链接脚本的实际情况调整页号。 */ #define STORAGE_PAGE_A_INDEX 62 #define STORAGE_PAGE_B_INDEX 63 #define FLASH_PAGE_SIZE 2048 #define FLASH_BLOCK_SIZE 128 #define FLASH_BLOCK_COUNT (FLASH_PAGE_SIZE / FLASH_BLOCK_SIZE) /* 16 */ #define BLOCK_MAGIC 0xA5A5A5A5UL #define BLOCK_UNCOMMITTED 0xFFFFFFFFUL #define DATA_MAX_LEN 100 /* 数据块结构 */ typedef struct { uint32_t magic; uint16_t data_len; uint16_t seq; uint8_t data[DATA_MAX_LEN]; uint32_t crc; uint8_t reserved[16]; /* 补到128字节 */ } flash_block_t; _Static_assert(sizeof(flash_block_t) FLASH_BLOCK_SIZE, block size error); static uint8_t active_page; static uint16_t next_seq;这里把reserved[16]补进去之后结构体要正好128字节。用_Static_assert在编译期强制检查防止后续加上其他字段导致块大小失配。4.2 RAM执行Flash操作的准备STM32G070是Cortex-M0内核Flash是单Bank结构没有读写并行RWW能力。也就是说CPU在执行擦除/编程Flash操作时如果指令本身还放在同一片Flash里会形成取指冲突轻则卡死重则HardFault。所以必须把涉及Flash擦写的函数放到RAM中执行。CubeIDE环境下可以通过GCC的section属性把函数放进自定义段然后在链接脚本中把该段放到RAM并在启动阶段从Flash拷贝到RAM。函数定义方式如下#define RAM_FUNC __attribute__((section(.ramfunc), noinline))链接脚本里需要增加类似这样的段定义.ramfunc : { . ALIGN(4); *(.ramfunc) . ALIGN(4); } RAM AT FLASH _sramfunc LOADADDR(.ramfunc); _eramfunc LOADADDR(.ramfunc) SIZEOF(.ramfunc); _sramfunc_ram ADDR(.ramfunc); _eramfunc_ram ADDR(.ramfunc) SIZEOF(.ramfunc);然后在启动代码Reset_Handler里把_sramfunc到_eramfunc的数据从Flash拷贝到_sramfunc_ram。很多STM32CubeMX生成的工程模板已经包含了.ramfunc段的拷贝逻辑如果发现自己的模板没有就手动补一下或者在main()开头调用一个拷贝函数。如果你用的是IAR对应写法是__ramfunc关键字效果一样。我习惯用一个宏来屏蔽编译器差异方便换工具链。4.3 Flash底层驱动封装接下来封装底层擦写函数。这里有个重要注意点HAL库的HAL_FLASH_Program只能按32位字、64位字或双字编程G070的Flash编程粒度是32位。所以即使我们想写一个字节进去也必须凑成4字节对齐的32位数据再写而且写入地址必须4字节对齐。RAM_FUNC int storage_flash_program_word(uint32_t addr, uint32_t data) { HAL_FLASH_Unlock(); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, data) ! HAL_OK) { HAL_FLASH_Lock(); return -1; } HAL_FLASH_Lock(); return 0; } RAM_FUNC int storage_flash_erase_page(uint32_t page_addr) { FLASH_EraseInitTypeDef erase_cfg; uint32_t page_error 0; erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress page_addr; erase_cfg.NbPages 1; HAL_FLASH_Unlock(); if (HAL_FLASHEx_Erase(erase_cfg, page_error) ! HAL_OK) { HAL_FLASH_Lock(); return -1; } HAL_FLASH_Lock(); return 0; }擦除和编程期间我建议把中断处理策略想清楚。如果在RTOS环境下Flash操作期间不能发生任务切换否则当前任务切出后其他任务可能也要访问Flash或者从Flash取指这时候就乱了。最简单的做法是在调用这两个函数的外层加临界区保护或者干脆在写Flash期间把调度器挂起。具体怎么做取决于项目实时性要求但千万不要在无保护状态下让Flash擦写和实时中断并发执行。4.4 扫描页状态找到写指针和最新有效块每次上电后存储模块不知道当前应该在哪个页继续写也不知道哪个块最新必须通过扫描Flash来确定。/* 判断某个块是否整块空闲全0xFF */ static int block_is_blank(uint32_t addr) { const uint8_t *p (const uint8_t *)addr; for (int i 0; i FLASH_BLOCK_SIZE; i) { if (p[i] ! 0xFF) return 0; } return 1; } /* 读取块并进行CRC校验 */ static int block_read(uint32_t addr, flash_block_t *blk) { memcpy(blk, (const void *)addr, sizeof(*blk)); if (blk-magic ! BLOCK_MAGIC) return -1; if (crc32((const uint8_t *)blk, 108) ! blk-crc) return -1; return 0; } /* 扫描某一页返回第一个可用的空闲块索引满页返回 FLASH_BLOCK_COUNT */ static int scan_free_block(uint32_t page_addr) { for (int i 0; i FLASH_BLOCK_COUNT; i) { uint32_t blk_addr page_addr i * FLASH_BLOCK_SIZE; if (block_is_blank(blk_addr)) return i; } return FLASH_BLOCK_COUNT; } /* 扫描两个页返回最新有效块的页号和块号 */ static int find_latest_block(uint8_t *page_idx, int *block_idx) { uint32_t best_seq 0; int found -1; uint8_t best_page 0; int best_block 0; for (int p 0; p 2; p) { uint32_t page_base FLASH_BASE (p ? STORAGE_PAGE_B_INDEX : STORAGE_PAGE_A_INDEX) * FLASH_PAGE_SIZE; for (int i 0; i FLASH_BLOCK_COUNT; i) { flash_block_t blk; if (block_read(page_base i * FLASH_BLOCK_SIZE, blk) 0) { if (blk.seq best_seq) { best_seq blk.seq; best_page (uint8_t)p; best_block i; found 0; } } } } *page_idx best_page; *block_idx best_block; return found; }这里crc32是一个纯软件CRC32计算函数可以用标准查表实现保证与写入时一致。CRC覆盖的范围从块首一直到CRC字段之前共108字节注意不要漏掉magic、len、seq和data。初始化流程如下int storage_init(void) { uint8_t latest_page; int latest_block; /* 先找全Flash里最新的有效块 */ if (find_latest_block(latest_page, latest_block) 0) { active_page latest_page; flash_block_t blk; uint32_t page_base FLASH_BASE (active_page ? STORAGE_PAGE_B_INDEX : STORAGE_PAGE_A_INDEX) * FLASH_PAGE_SIZE; block_read(page_base latest_block * FLASH_BLOCK_SIZE, blk); next_seq blk.seq 1; return 0; } /* 一片数据都没有默认用A页 */ active_page 0; next_seq 0; return 0; }4.5 写入与压缩逻辑写数据的完整过程分为两步看当前页有没有空块没有就执行压缩有空块就直接写下一块。RAM_FUNC int storage_write(const uint8_t *data, uint16_t len) { if (len DATA_MAX_LEN) return -1; uint32_t page_base FLASH_BASE (active_page ? STORAGE_PAGE_B_INDEX : STORAGE_PAGE_A_INDEX) * FLASH_PAGE_SIZE; int free_idx scan_free_block(page_base); if (free_idx FLASH_BLOCK_COUNT) { /* 当前页已满先压缩 */ if (storage_compact() ! 0) return -2; page_base FLASH_BASE (active_page ? STORAGE_PAGE_B_INDEX : STORAGE_PAGE_A_INDEX) * FLASH_PAGE_SIZE; free_idx 0; } flash_block_t blk; memset(blk, 0xFF, sizeof(blk)); blk.magic BLOCK_UNCOMMITTED; blk.data_len len; blk.seq next_seq; memcpy(blk.data, data, len); blk.crc crc32((const uint8_t *)blk, 108); uint32_t blk_addr page_base free_idx * FLASH_BLOCK_SIZE; uint32_t *words (uint32_t *)blk; /* 先写所有非magic字段 */ for (int i 1; i sizeof(blk) / 4; i) { if (storage_flash_program_word(blk_addr i * 4, words[i]) ! 0) return -3; } /* 最后提交magic */ if (storage_flash_program_word(blk_addr, BLOCK_MAGIC) ! 0) return -4; return 0; }写非magic字段时从下标1开始也就是跳过magic本身。这样能保证在写入过程中magic字段始终是0xFFFFFFFF直到最后一步才变成0xA5A5A5A5。4.6 压缩函数实现压缩函数按之前的策略把当前活动页最新有效块复制到另一个页的第0块然后擦除当前活动页。RAM_FUNC int storage_compact(void) { uint8_t latest_page; int latest_block; flash_block_t latest; if (find_latest_block(latest_page, latest_block) ! 0) return -1; uint32_t latest_addr FLASH_BASE ((latest_page 0) ? STORAGE_PAGE_A_INDEX : STORAGE_PAGE_B_INDEX) * FLASH_PAGE_SIZE; latest_addr latest_block * FLASH_BLOCK_SIZE; if (block_read(latest_addr, latest) ! 0) return -2; /* 目标页当前活动页的另一页 */ uint8_t target_page (uint8_t)(1 - active_page); uint32_t target_addr FLASH_BASE ((target_page 0) ? STORAGE_PAGE_A_INDEX : STORAGE_PAGE_B_INDEX) * FLASH_PAGE_SIZE; /* 目标页必须先擦除保证第0块是空白状态 */ storage_flash_erase_page(target_addr); /* 把最新块写到目标页第0块注意写的时候最后提交magic */ uint32_t words[sizeof(flash_block_t) / 4]; memcpy(words, latest, sizeof(latest)); for (int i 1; i sizeof(flash_block_t) / 4; i) { if (storage_flash_program_word(target_addr i * 4, words[i]) ! 0) return -3; } if (storage_flash_program_word(target_addr, BLOCK_MAGIC) ! 0) return -4; /* 擦除旧活动页 */ uint32_t old_addr FLASH_BASE (active_page ? STORAGE_PAGE_B_INDEX : STORAGE_PAGE_A_INDEX) * FLASH_PAGE_SIZE; storage_flash_erase_page(old_addr); /* 切换活动页 */ active_page (uint8_t)(1 - active_page); next_seq latest.seq 1; return 0; }注意压缩开始时“目标页必须先擦除”这一步。如果目标页之前存的是上一轮压缩的数据不擦掉直接写会导致新旧数据混在一起。上一轮压缩结束后目标页本来就是空的所以这里擦除操作通常只是确认状态耗时也不会浪费太多。5. 实测中的坑与调优心得5.1 最容易翻车的Flash函数没跑在RAM里这个坑我栽过两次。第一次是刚把代码从F1系列移植到G070时以为HAL库已经处理好了结果一调用HAL_FLASH_Program就死机。后来查了参考手册才确认G070 Flash不支持在擦写期间从同一片Flash取指必须把Flash操作函数放进RAM。如果你用的是CubeIDE可以在链接脚本里查看是否已经有.ramfunc段。如果没有就按前面代码里那样手动加。验证方法很简单在Flash操作函数里加个GPIO翻转用逻辑分析仪看函数执行期间是否正常跑完如果卡住或者HardFault九成是执行位置不对。5.2 4字节对齐写入地址和写入长度G070的Flash编程粒度是32位所以HAL_FLASH_Program的Address参数必须4字节对齐Data参数必须是32位整型。很多刚上手的人会尝试这样一个操作HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (uint32_t)flash_buffer[1], byte_data);地址偏移了1字节直接写进去会触发错误。正确做法是一定把待写内容放到一个32位对齐的结构体或数组中按整字写入。另外写入长度也必须是4的倍数否则最后一个字缺少的部分会写入错误数据。这也是为什么我设计块格式时把所有字段都按4字节对齐来排布。5.3 掉电测试验证方案是否真的可靠代码调通之后不要急着上线。我建议做一个专门的掉电循环测试用一个可编程电源给板子供电上位机不停发送“立即写参数并掉电”的指令反复测试几千次每次上电后检查参数是否能正确恢复、seq是否连续、是否存在写坏块。这个测试能暴露很多静态分析看不出来的问题。我做这个测试时发现过一个有趣的现象有极少数情况下写入的数据块CRC校验能通过、magic也有效但读出来的数据其实不对。原因出在写入顺序上如果写数据字段时中途掉电Flash里落下的是一半新数据一半旧数据因为旧数据中的0无法通过编程变回1CRC字段还没写所以CRC校验能识别出来。但如果掉电发生在CRC字段已经写入、magic还没提交之前而CRC恰好和半截数据对得上——这种概率极低但理论上存在。为了把风险降到更低可以在写magic之前再读一遍这个块并做CRC校验确认无误后再提交magic。代价是多一次读操作换来的是更强的完整性保障。5.4 页满之后要触发压缩的时机storage_write里是在写入前检查scan_free_block如果返回满页就触发压缩。这里有个小优化空间如果连续写入特别频繁可以提前在还有1个备用块的时候就触发压缩避免写入流程里夹带耗时的擦除操作。比如实际项目中我在采集任务里会定时调用storage_check_and_compact()在系统空闲阶段主动压缩写入路径只负责追加数据。这样即使写入请求来得再频繁也不会出现一次写入被擦除拖到几十毫秒的情况。5.5 磨损估算与页数扩展假设Flash擦写寿命是1万次每天保存50次参数单页直接擦写方案1万次保存约200天。分块双页方案一页16块每16次保存才擦除一次页磨损摊到两个页上等效约32万次保存约17年。这个量级对绝大多数产品都绰绰有余了。如果需要更极端的寿命可以把双页扩展成四页、六页磨损进一步摊平但代价是驱动逻辑更复杂需要引入“页组”概念。从我的经验看双页轮转在绝大多数场景下都是性能和复杂度的最佳平衡点。6. 最后再分享一点个人经验分块存储这个方案本身不复杂但真正落地时你会发现大量工程细节藏在我上面没有展开的地方。比如next_seq在掉电恢复时怎么保证不回退我的做法是在初始化时读最新块的seq后直接加1因为写入顺序是线性的不存在并发写。再比如如果应用数据本身需要区分不同类型的参数配置参数、运行统计、故障记录建议在data区域最前面加一个type字段用seqtype联合判断这样一套存储框架可以同时管理多类数据。另外CRC32的软件实现建议用查表法对于每块112字节的小数据量耗时微乎其微不要为了省几个周期用简单求和的校验掉电场景下求和校验的漏检率会让你在排查问题时怀疑人生。如果你用的是带硬件CRC外设的STM32系列也可以把CRC计算交给硬件模块确认一下CRC多项式、初始值、输入输出反转配置和软件版本保持一致就行。这套方案我已经在量产产品上稳定跑了一年多中途没有发生一次参数丢失。希望这篇文章能帮你少走一些弯路。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进