
做低功耗仪表、传感器节点这类产品时我经常用STM32G070xx。这颗料是Cortex-M0内核主频64MHzFlash从16KB到64KB都有功耗低、外设够用价格又便宜很适合做带无线模块或小屏的采集设备。不过真正把“参数存储”落到代码里时很多人会在Flash写入上栽跟头断电要保存配置、校准值、运行计数大家按RAM变量的思路直接往固定地址写结果第二次就写不进去读出来要么全是0xFF要么直接进HardFault。原因很简单Flash不是EEPROM写之前必须擦除而擦除的最小单位是物理页不是字节。STM32G070xx的页大小一般是2KB如果你的业务数据只有64字节却要每次更新都擦掉一页那同一页里的其他数据也会被一起抹掉擦写寿命也消耗得飞快。于是就有了标题里那种成熟做法把Flash按物理页分块逻辑上划分固定数据块利用页内多槽位和页间轮转实现一次擦除、多次写入。这篇文章不是复述芯片手册而是把我实际在G070上用HAL库做这套方案的结构设计、关键代码、寿命估算和调试器报错排查过程完整记录下来给后面接手同类需求的工程师一个可直接抄作业的参考。1. 为什么STM32G070xx的Flash适合“分块存储”1.1 NOR Flash的物理特性只能由1写0页是最小擦除单位要理解整个方案得先把NOR Flash的底层脾气讲透。它的存储单元本质是一个浮栅晶体管擦除动作会把整个存储阵列恢复到逻辑“1”所以擦除后的页读出来全是0xFF而写入动作只能把“1”写成“0”不能把“0”写回成“1”。换句话说Flash只支持由1写0、不支持原地把0恢复为1想把0变回1就必须执行擦除。这里有个容易被忽略的细节擦除的最小单位是物理页不是字节。STM32G070xx的Flash页大小通常是2KB一次擦除最少抹掉2KB你没法只擦除其中64字节。而编程写入的最小粒度也不一样G070系列主推64位双字编程也就是一次至少写8字节地址还必须8字节对齐。很多没有经验的工程师习惯按字节或半字去写结果在G0系列上一调就翻车。参照G070xx数据手册Flash的三个关键参数大概是这样的参数典型值说明页大小2KB擦除最小单位一次擦一整页编程粒度64位8字节使用FLASH_TYPEPROGRAM_DOUBLEWORD擦除后内容0xFF所有位回到1典型擦写寿命1万次左右高温、高电压条件下数字会下降这里想强调一个概念所谓“一次擦除多次写入”并不是改变了硬件特性而是通过软件把多次业务写入请求分摊到多个已经处于“已擦除”状态的块或槽位上。每次业务更新时只找空白槽位顺序写下去并不是每一次都要动员一次成本高昂的页擦除。1.2 每次更新都擦一页三个不划算的理由有人可能会想反正参数更新频率不高我每次写入前先擦掉整页不就行了表面看代码最简单实际工程里却很吃亏。第一擦除范围远超数据范围。如果一页2KB里同时存了A、B、C三个固定数据块业务上只想改A结果整页擦掉后B和C也得重新回写。一旦回写过程中掉电B和C的数据就彻底丢了。这种设计把故障影响面人为放大是整个方案里最不抗风险的方式。第二擦除耗时不可控。Flash擦一页通常是几毫秒到十几毫秒还要等内部充电泵稳定期间内核访问Flash会被阻塞。在一些对时序敏感的场景比如无线模块正在收发包、电机正在换相突然被一个毫秒级的擦除操作打断很容易引入抖动甚至通信丢包。第三寿命损耗太快。NOR Flash的擦写寿命标称在1万次左右注意这个指标是按“一次擦除”算的。如果你每天更新50次参数每次都先擦后写200天左右就把这一页的寿命耗尽。而采用分槽方案后擦除次数可以被稀释到写入次数的八分之一、十六分之一甚至更低寿命直接拉高一个数量级。所以分块存储不只是为了逻辑清晰更是在擦除成本、掉电风险和Flash寿命三者之间找到了一个更合理的平衡点。1.3 一次擦除多次写入的整体架构思路打个比方就很好理解了Flash的一个页就像一张稿纸写满一行后不能单独擦掉这行只能整张纸作废。那我们就干脆准备一个本子每张纸内预先画好很多行一行写不下就换下一行一张纸写满了就翻到下一张纸整个本子都快写满时再挑一张最久没用的纸撕掉重写。把这个比喻落到代码里“行”就是一个固定槽位“张纸”就是一个物理页“固定数据块”就是每个槽位里存放的业务数据结构。这个架构落到STM32G070xx上典型实现分三层物理页层至少准备两个物理页一般建议更多互为备份和轮转空间。页内槽位层把每个2KB页切成N个固定大小的槽位比如每个槽128字节一页16个槽。数据管理层对外提供初始化、写入、读取接口内部维护序列号、CRC校验和活动页索引。写入时先找当前页的第一个空白槽位直接写入新数据当前页写满后再擦除备用页并用最新数据接管之后继续在备用页上顺序填充。这样一次物理擦除最少可以支持16次业务写入多页轮转后还能把总寿命继续扩大这就是“一次擦除多次写入”的本质。2. 固定数据块存储的结构设计与页轮转策略2.1 块内布局槽头、序号、CRC与数据区如何组织槽位大小不能拍脑袋定。首先必须满足G070一次编程8字节、地址8字节对齐的要求所以槽长最好是8的倍数。其次要考虑实际业务数据长度以及将来扩展空间。我一般建议用户数据如果不超过64字节槽位取128字节如果数据有128字节槽位就取192或256字节。以128字节槽位为例我推荐的布局如下偏移长度内容作用02字节magic固定0xA5A5粗筛防止把垃圾数据当有效数据22字节status0x01有效0x00删除0xFFFF为未写44字节序列号单调递增用于判断哪一版数据最新82字节CRC16对数据区做校验防止半写入数据被读走1064字节用户数据实际业务参数可按需调整7454字节保留区始终填充0xFF给未来扩展留余地全部加起来正好128字节。你会注意到我把magic放在了最前面但真正写入时这个头部不是第一个被写入的。原因后面会专门讲这是掉电安全的关键设计。这里为什么要同时用magic和CRC两套校验因为Flash在写入过程中遇到掉电会出现“写了一半”的状态数据区可能部分为0、部分还停留在0xFFmagic碰巧写对但数据不完整的情况也存在。magic只能做粗定位要确认这槽数据能不能用必须看CRC序列号则解决“同一份数据在多个页面、多个槽位都有拷贝时谁最新”的判定问题。三者合在一起才是一个可靠的槽位头。2.2 页内多槽位 双页轮转的写入流程单页内多槽位已经能把擦除频率降低了但只有一页还不够。试想当前页16个槽全部写满后下一步要擦除这页才能腾出空间可这页里还存着当前有效的最新数据一擦就没了。解决办法就是至少准备两页双页轮转。双页轮转的完整写入流程大致是这样启动时扫描所有页的所有槽位找到序列号最大且CRC校验通过的槽位它就是当前有效数据。写入新数据时在当前活动页内顺序找第一个空白槽位直接把新槽写进去。如果当前活动页已经没有空白槽先判断备用页是否能擦除。确认备用页里的最新数据已经完整保存后擦除备用页。把新数据写到备用页的槽0然后把活动页索引切换为备用页。后续写入继续在新活动页上顺序找空白槽。这里有个细节值得注意角色切换时备用页里如果还存着稍旧的历史有效数据千万不要直接擦因为最新数据还在老活动页上。正确做法是先把最新数据复制到备用页并确认写入成功再擦掉老活动页。实际操作中我会加一个“切换标志”比如专门用一个槽位记录当前活动页编号避免启动时两边都有数据却分不清哪个新。如果项目对可靠性要求更高还可以扩展到三页甚至四页轮转。页数越多越不容易因为意外断电导致两页同时不可用。但页数增加也会带来启动扫描时间变长、内存占用增加的问题一般两页是最低配置三页是我的推荐配置尤其是产品要过严苛的掉电浪涌测试时。2.3 参数容量与Flash空间规划一个算例写代码之前先算算空间够不够。我拿一个常见配置举例STM32G070RBT6Flash总共64KB地址范围0x08000000到0x0800FFFF。假设固件编译出来占38KB那么0x08009800到0x0800FFFF约26KB是空闲的可以规划给数据存储。选页不能太随意要避开固件实际占用的末尾。编译生成的map文件看一下记下程序最大结束地址然后向上对齐到页边界。比如程序结束在0x08009800这一地址正好是一页边界那存储区就从0x08009800开始。如果程序结束在0x08009920那存储区必须从0x0800A000开始保证不会擦到代码。存储区从0x0800A000开始到0x0800FFFF结束共24KB也就是12个物理页。按每个槽128字节、每页16个槽计算总共192个槽位。如果业务更新频率不算高这个容量绰绰有余如果数据长度只有32字节甚至可以把槽位缩到64字节总槽数翻倍到384个擦除频率进一步降低。有一个规划原则我反复踩坑后总结出来的槽位大小取用户数据长度的2倍左右比较合适太小会导致槽头频繁占用空间太大又浪费Flash容量。宁可在设计阶段多留一点扩展保留区也不要把槽压得太满因为后面产品升级加参数时重新改Flash布局的代价比多花几KBFlash大得多。3. 基于HAL库的实操实现从擦除到读写一整套代码3.1 Flash驱动层解锁、页擦除、双字编程的最小实现先看驱动层。G070系列上操作Flash核心就是三个动作解锁、擦除/编程、上锁。下面这组代码是完整可用的最小实现我实际在G070RB和G070K8上都验证过。#include stm32g0xx_hal.h #define DATA_PAGE_SIZE 0x800u /* 2KB */ #define FLASH_BASE_ADDR 0x08000000u /* STM32G0内部Flash基地址 */ static uint32_t Flash_GetPageIndex(uint32_t addr) { return (addr - FLASH_BASE_ADDR) / DATA_PAGE_SIZE; } void Flash_ErasePage(uint32_t addr) { FLASH_EraseInitTypeDef erase; uint32_t pageError 0; HAL_FLASH_Unlock(); erase.TypeErase FLASH_TYPEERASE_PAGES; erase.Page Flash_GetPageIndex(addr); erase.NbPages 1; if (HAL_FLASHEx_Erase(erase, pageError) ! HAL_OK) { /* 这里可以记录错误至少不要在正式产品里静默吞掉 */ } HAL_FLASH_Lock(); }页擦除的入参是页号不是地址所以需要在函数里做一次换算。如果你用的是G070不同封装或不同容量的型号页大小依然是2KB但总页数不同地址范围也不同。建议直接用FLASH_PAGE_SIZE这种HAL库宏去定义不要手写死。编程写入是下一个关键部分。G070一次编程8字节使用HAL_FLASH_Program类型为FLASH_TYPEPROGRAM_DOUBLEWORD。我通常封装成下面这样void Flash_WriteBytes(uint32_t addr, const uint8_t *buf, uint32_t len) { uint64_t tmp64 0; /* 建议调用前检查 addr 按 8 字节对齐len 为 8 的倍数 */ HAL_FLASH_Unlock(); for (uint32_t i 0; i len; i 8) { memcpy(tmp64, buf[i], 8); HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr i, tmp64); if (HAL_FLASH_GetError() ! HAL_FLASH_ERROR_NONE) { break; } } HAL_FLASH_Lock(); }一个非常容易踩的坑HAL_FLASH_Program返回HAL_OK不代表数据一定写正确了尤其是地址不是8字节对齐或者len不是8的倍数时行为很怪。所以我的习惯是写入后立刻调用HAL_FLASH_GetError检查错误标志再读回数据与源缓冲逐字节比较。这是成本最低的保险。另外G070在执行Flash擦除或编程期间内部Flash控制器会把总线占住如果代码还在Flash上执行会出现明显的阻塞甚至异常。严格的产品代码里我会把Flash_ErasePage和Flash_WriteBytes放到RAM中执行用__attribute__((section(.ramfunc)))修饰同时在这两个函数执行期间关闭可屏蔽中断函数退出后再恢复。G070没有复杂的中断嵌套场景这样处理足够稳。3.2 数据管理层初始化、写入与读取接口驱动层只负责“擦”和“写”真正的业务逻辑在数据管理层。先定义槽位结构体和对外接口#define DATA_PAGE_NUM 2u #define DATA_PAGE_SIZE 0x800u #define SLOT_SIZE 128u #define SLOT_PER_PAGE (DATA_PAGE_SIZE / SLOT_SIZE) #define DATA_START_ADDR 0x0800A000u /* 按实际编译结果调整 */ #define SLOT_MAGIC 0xA5A5u #define SLOT_STATUS_VALID 0x0001u typedef struct { uint8_t sn[16]; uint16_t cal_value; uint32_t boot_count; } AppConfig_t; typedef struct { uint16_t magic; uint16_t status; uint32_t seq; uint16_t crc16; uint8_t data[64]; uint8_t reserved[54]; } Slot_t; static uint32_t g_lastSeq 0; static uint16_t CalCrc16(const uint8_t *p, uint32_t len) { uint16_t crc 0xFFFF; for (uint32_t i 0; i len; i) { crc ^ p[i]; for (uint8_t j 0; j 8; j) { if (crc 0x0001) { crc (crc 1) ^ 0xA001; } else { crc 1; } } } return crc; }CalCrc16用的是最常用的Modbus CRC16算法虽然网上有很多查表优化写法但这里数据量小、调用不频繁逐位计算足够了。用CRC的意义在于它能识别出Flash写入了一半、数据位反转这类静默错误比单纯比较几个头字段可靠得多。初始化函数的任务只有一个启动时扫描所有页的所有槽位找到最新有效的那一版数据把序列号加载到全局变量里便于后续新写入递增。int FlashStore_Init(AppConfig_t *cfg) { Slot_t slot; uint32_t bestAddr 0; uint32_t bestSeq 0; int found 0; for (uint32_t page 0; page DATA_PAGE_NUM; page) { uint32_t pageBase DATA_START_ADDR page * DATA_PAGE_SIZE; for (uint32_t i 0; i SLOT_PER_PAGE; i) { uint32_t slotAddr pageBase i * SLOT_SIZE; memcpy(slot, (const void *)slotAddr, SLOT_SIZE); if (slot.magic ! SLOT_MAGIC) { continue; } if (slot.status ! SLOT_STATUS_VALID) { continue; } if (CalCrc16(slot.data, sizeof(slot.data)) ! slot.crc16) { continue; } if (slot.seq bestSeq) { bestSeq slot.seq; bestAddr slotAddr; found 1; } } } if (!found) { /* 没有任何有效数据返回默认配置 */ memset(cfg, 0xFF, sizeof(*cfg)); g_lastSeq 0; return -1; } memcpy(slot, (const void *)bestAddr, SLOT_SIZE); memcpy(cfg, slot.data, sizeof(*cfg)); g_lastSeq bestSeq; return 0; }这段代码有一个细节读取槽位时直接用memcpy把整个128字节搬到RAM栈上再做判断。有人会觉得浪费栈空间但128字节在G070上完全可接受换来的是逻辑简洁和避免Flash接口多次访问。如果你在中断处理函数里调用这个函数就要把栈调大或者改用静态缓冲否则嵌套调用多了会把栈打爆。写入函数是整套实现的核心。它要做四件事组槽、找空位、写入、必要时切页。int FlashStore_Write(const AppConfig_t *cfg) { Slot_t newSlot; uint32_t currentAddr 0; int foundFree 0; memset(newSlot, 0xFF, SLOT_SIZE); newSlot.magic SLOT_MAGIC; newSlot.status SLOT_STATUS_VALID; newSlot.seq g_lastSeq 1; memcpy(newSlot.data, cfg, sizeof(*cfg)); newSlot.crc16 CalCrc16(newSlot.data, sizeof(newSlot.data)); /* 1. 在当前可能的活动页中寻找第一个空白槽 */ for (uint32_t page 0; page DATA_PAGE_NUM; page) { uint32_t pageBase DATA_START_ADDR page * DATA_PAGE_SIZE; for (uint32_t i 0; i SLOT_PER_PAGE; i) { uint32_t slotAddr pageBase i * SLOT_SIZE; uint64_t firstWord *(volatile uint64_t *)slotAddr; if (firstWord 0xFFFFFFFFFFFFFFFFULL) { currentAddr slotAddr; foundFree 1; break; } } if (foundFree) { break; } } if (!foundFree) { /* 2. 两页都满了执行轮转擦除一页再写 */ Flash_ErasePage(DATA_START_ADDR); /* 注意项目里要选最旧的一页 */ currentAddr DATA_START_ADDR; } /* 3. 写入槽位这里采用“先写数据、后补头”的顺序 */ Flash_WriteBytes(currentAddr offsetof(Slot_t, data), newSlot.data, sizeof(newSlot.data)); Flash_WriteBytes(currentAddr 0, (uint8_t *)newSlot, 8); g_lastSeq newSlot.seq; return 0; }注意在找空白槽时我用*(volatile uint64_t *)slotAddr判断前8字节是否为全0xFF这个方法很直接。前8字节恰好覆盖了magic和status如果写过内容就必然不是全0xFF。有人可能会问为什么Flash_WriteBytes要先写数据体再写头部8字节这正是掉电安全的核心。如果先写了头部系统在写数据体途中掉电启动时会找到一个magic正确但数据区CRC错误的半成品槽虽然能靠CRC识别但处理起来要额外绕一圈。改成先写数据、最后写头部后数据没写完时头部仍然全是0xFF启动扫描直接跳过这个槽逻辑干净很多。3.3 轮转调度与掉电恢复什么时候该擦除上一节代码里我在两页都写满时直接擦除了DATA_START_ADDR这页这只是示意。实际工程里需要先判断哪一页更旧再决定擦哪一页否则可能擦掉包含最新数据的页。判断哪页更旧的简单办法是启动初始化时分别记录两页各自的“最大序列号”。写入调度时优先擦除最大序列号更小的那一页因为那页里的数据版本比较老丢了对系统影响最小。更进一步可以在页尾固定地方写一个页头标志专门记录本页存储了哪些槽位、最新序列号是多少启动时只需要看页头就能快速定位活动页不用逐个槽扫描。掉电恢复是整个方案里最重要也最容易忽略的一环。基于上面的结构掉电恢复逻辑其实已经隐含在内了刚写入数据没写完时掉电该槽头部不是全0xFF就是magic不对启动时被忽略系统读取到的是上一个完整有效槽。写完数据区、还没写头部时掉电该槽数据区有内容但头部全0xFF同样被忽略。写完整个槽之后掉电新槽完整有效启动时会根据更大的序列号选中它。这套规则能成立的根本原因是把“槽位是否有效”的判定收敛到头部那8字节上而头部写入放在最后一步。很多在Flash上做环形日志的方案也是这个套路理解了这一点以后不管换什么芯片都能很快设计出安全的存储层。4. 常见问题与排查技巧实录4.1 调试器下载失败target dll has been cancelled 背后的原因开发调试阶段最常碰到的抓狂问题就是点击下载后KEIL/IAR报Error: flash download failed - target dll has been cancelled。这个报错字面意思是调试器在下载Flash时连接被取消但真正原因往往不是电脑或调试器坏了。第一类是芯片读保护被意外打开。如果你在代码里配置过RDP读保护等级或者用STM32CubeProgrammer操作过option bytes芯片会拒绝调试器访问Flash下载自然失败。解决办法是先用STM32CubeProgrammer把读保护等级调回Level 0这个过程会自动擦除整个Flash原来代码会丢需要注意备份。第二类是SWD引脚被复用成GPIO。G070的PA13/PA14默认是SWDIO/SWCLK但如果你在初始化代码里把它们重映射成了普通IO第二次下载时调试器就连接不上。处理方法是在复位时序里抓住机会下载也就是按住板子复位键点击下载的瞬间松开或者用CubeProgrammer的“Connect under reset”模式。第三类是目标板供电或时钟问题。G070内部虽然有时钟但如果外部晶振未焊稳、供电电流不足调试器的Connect命令都发不进去。我会先量一下VDD是否稳定、NRST脚有没有被拉低再检查SWD两根线上的上拉电阻是否太小。很多时候不是代码问题就是硬件上少了一个4.7kΩ上拉。下面是一张排查速查表我每次遇到下载失败都会按这个顺序过一遍现象常见原因快速验证处理方法下载报target dll cancelledRDP读保护开启CubeProgrammer读option bytes设置为Level 0自动擦除下载报Cannot access targetSWD被复用复位瞬间点连接Connect under reset供电正常仍连接失败外部晶振/复位电路问题示波器看NRST波形查上电时序偶尔下载成功偶尔失败SWD信号质量差降低SWD频率调低调试器时钟到1MHz4.2 写入失败与数据校验异常的处理如果你已经能正常下载运行但程序运行时发现Flash写不进去或者写进去的数据读出来是乱的通常就三种情况。第一种是没擦除就写入。Flash上当前地址如果不全是0xFF编程操作会把数据写成不可预知的值虽然HAL_FLASH_Program有时会返回成功但读回来已经不是原始数据了。所以写入前一定确保目标地址已经擦除代码里最好加一个CheckSlotBlank()这样的预检函数发现地址不是0xFF就返回错误。第二种是对齐问题。G070的FLASH_TYPEPROGRAM_DOUBLEWORD要求地址8字节对齐数据也要是8的倍数。如果地址跨了页边界比如一个槽位跨越两页也会出现奇怪错误。设计槽位时要注意每个槽都不能跨页槽大小必须是页大小的约数。我这边的128字节槽、2KB页16个槽正好装满一页就不会踩这个坑。第三种是Flash写保护。G070支持多块写保护区域如果你的option bytes里把某些页设成了写保护程序跑起来后即使解锁成功也没法编程。这个错误在调试时最容易忽略因为代码编译下载都是好的就是运行时写不了。用CubeProgrammer把WRP区域清掉或者确认代码里没有设置过FLASH_OB_WRP_PAGES。数据读回校验异常还有一个容易被忽视的点读Flash时用普通指针访问会经过缓存写入后马上读可能读到旧值或缓存乱序。我的习惯是每次读槽位都用volatile限定符或者先执行__DSB()指令做一次数据同步屏障。在G070这种M0内核上虽然没有复杂Cache但还是能看到写完没刷新导致读回全0xFF的情况加上一个编译屏障就能解决。4.3 寿命估算与磨损均衡设计算清楚寿命账设计才不心虚。G070数据手册给出的典型擦写寿命是1万次左右。如果采用每页16槽、双页轮转的方案每16次业务更新才会触发一次实际页擦除那么有效写入次数大约是16万次。假设一台设备每10分钟更新一次参数一天144次16万次可以跑1111天大约3年。如果产品生命周期需要5年那就得把槽位从128字节缩到64字节或者增加一页轮转空间也可以把部分高频写入的数据挪到外部SPI Flash做环形日志把内部Flash只留给低频配置项。磨损均衡方面最简单有效的策略就是顺序轮转不要每次固定往同一个槽位写。我在第2节给出的方案本质就是天然的磨损均衡所有槽位按顺序使用每个槽位的擦写次数基本均匀。如果项目里需要更高强度的均衡可以引入随机起始偏移每次启动时随机选一个页作为首个活动页避免某页被频繁优先选中。但对绝大多数配置存储场景来说顺序轮转已经足够。另外有一个体验建议擦除动作尽量放在系统空闲时段或主循环里做不要放在高优先级中断回调里。即使Flash擦除只有几毫秒一旦在无线收发或PWM控制的中断里触发很容易引入不可预期的时间抖动。我会用一个“待擦除页队列”记录哪些页需要擦除然后放到后台任务或主循环空闲时统一处理。结尾这套“分块存储、固定数据块、一次擦除多次写入”的方案我在好几个低功耗表计项目里跑过最大的体会是Flash编程模型并不复杂复杂的是把掉电、写保护、擦除寿命这些边界条件提前考虑清楚。尤其是“先写数据区、最后写头部”这个顺序让我少处理了无数个掉电后数据损坏的投诉。如果你也正在用STM32G070xx做参数存储建议先按文章里的槽位结构搭一版最小代码跑一遍反复断电和随机掉电测试再根据实际需求调整页数和槽大小。找到稳定方案后这套设计也能顺延到后续G0、G4系列的其他芯片上值得花时间沉淀成自己的基础模块。