news 2026/10/20 6:36:38

把参数写进STM32F103内部Flash:掉电保存与双页备份方案

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
把参数写进STM32F103内部Flash:掉电保存与双页备份方案

简介:针对STM32F103内部Flash读写需求的一份可直接运行的例程资源,适合嵌入式初学者、单片机工程师在固件升级、参数存储或日志记录场景中参考。内容围绕片内Flash的分区结构、扇区擦除、编程与读回校验展开,演示了解锁、擦除、写入、锁定等完整操作流程,并给出基于HAL库的代码实现。压缩包共72个文件,以29个h头文件、28个c源文件为主,搭配8个汇编启动文件及完整工程配置,整体仅948KB,便于快速导入编译调试;另含相关PDF手册,方便对照数据手册理解底层寄存器行为。已有10760人学习下载,例程目录结构清晰、代码注释明确,可直接移植到实际项目中,对理解STM32F103非易失性存储应用很有帮助。

1. 为什么要把参数写进STM32F103内部Flash:一个掉电保存的硬需求

做某采集设备的时候,项目组遇到一个很现实的问题:校准系数和累计运行时长必须掉电不丢,最初打算外挂一颗EEPROM,结果算下来物料成本、采购周期、贴片位置都要抠。某开发者后来直接把目光转向了 STM32F103内部Flash读写——数据就存在 MCU 自己的主存储区里,省料、省面积、不用改板,听起来是笔划算买卖。但内部 Flash 不是普通 RAM,不能按字节随意写:必须先整页擦除、按半字/字编程、地址对齐,还得处理写一半断电的场景。这篇方案就围绕这套思路展开,把内部 Flash 当成一块“不怕掉电的小硬盘”来用,讲清楚分区、擦写、校验、双页备份和验收的完整做法,适合正在做嵌入式课设、毕设的同学,以及想从板上省掉一片存储芯片的开发者。下面从总体设计开始讲。

2. 总体设计:存储分区规划与读写流程的职责划分

2.1 先盘清楚F103的Flash资源:容量、页大小与寿命约束

动手写代码之前先算账,否则后面全是玄学问题。STM32F103 的内部 Flash 不是统一规格,不同密度的芯片页大小不一样,这个参数直接决定擦除地址怎么算。

密度区间主存储区容量页大小代表型号区间
中容量64~128 KB1 KB如 C8T6、RBT6
高容量256~512 KB2 KB如 RCT6、VET6、ZET6

以 512 KB 的高容量芯片为例,主存储区地址范围是 0x08000000 ~ 0x0807FFFF,最后一页的起始地址是 0x0807F800。很多人写程序时随手填一个 0x0807F000 当作“最后一页”,实际那已经是倒数第二页了,擦错页的后果就是把自己辛苦存好的参数或者代码覆盖掉。

寿命约束同样要提前算。芯片手册标称 Flash 擦写次数约 1 万次,数据保持时间在工业级温度下约 10 年。如果同一个页每天写 10 次,1 万次额度只能撑 10000 / 10 / 365 约 2.7 年;要是一天写 100 次,100 天就把额度用完了。这也是为什么后面要做双页轮换——把擦写压力分担到两个页上,等效寿命直接翻倍,同样每天写 10 次可以撑到约 5.5 年。

提示:F103 不同容量的页大小并不固定,动手前先查对应型号的 Memory Map,不要凭“F103 统一都是 2 KB”的印象去算地址。

2.2 参数区放在哪:把程序区和数据区彻底分开

参数区的位置选择有一个基本原则:永远放在 Flash 靠后的页,不要放在 0x08000000 附近。原因有三个。

第一,F103 上电后从 0x08000000 取指,中断向量表也在那里,代码区从低地址往高地址排,参数区放在尾部天然不冲突。第二,程序升级时 bootloader 通常只擦除应用区,不会去碰尾部数据页,参数可以跨升级保留。第三,部分产品做全片擦除升级,这种情况必须单独把参数页排除在擦除范围外,否则一次升级就把所有校准数据清空了。

数据页内部我会设计成“块头 + 数据体”的结构,块头固定 16 字节:

偏移内容长度说明
0x00魔数4 字节固定 0xA5A5A5A5,用于判断页内是否有有效数据
0x04版本号4 字节数据格式版本,方便以后升级
0x08数据长度2 字节业务数据实际字节数
0x0ACRC162 字节对 data 区做校验
0x0C保留字段4 字节对齐到 16 字节,方便按 32 位写入
0x10业务数据N 字节真正要保存的参数

为什么一定要有魔数?因为 Flash 擦除后内容全是 0xFF,软件启动时先看页首 4 字节是不是 0xA5A5A5A5,不是就直接判定为“无有效数据”,不用再去跑 CRC,省掉很多无谓的启动逻辑。

2.3 读写流程的状态设计:解锁、擦除、编程、校验四步

内部 Flash 的写入流程是固定四步,一步都不能省。

第一步解锁,向 FLASH_KEYR 寄存器写入固定键值,HAL 库封装成了 HAL_FLASH_Unlock。第二步擦除,按页擦除,擦完后整个页都是 0xFF。第三步编程,F103 支持按半字或者字写入,最少写 16 位。第四步上锁并做读回校验,HAL_FLASH_Lock 防止后续误操作继续改 Flash。

这里最容易犯的错是擦和写之间断电。擦除完成但数据没写进去时,页是空的,程序重启后读到的全是 0xFF。所以读流程必须容忍“空页”状态,把它当作“没有参数、用默认值”,而不是当成系统崩溃来处理。我一般会在启动阶段打一条日志,记录这次读回是正常数据、空页还是校验失败,方便现场排查参数丢失的根因。

模块职责上,对外只暴露三个接口:参数初始化、参数保存、参数读取。上层业务代码不关心页地址、不关心 CRC、不关心擦写时序,只关心“存进去了没有”以及“读出来对不对”。所有 Flash 操作失败都返回负值错误码,不允许静默忽略,这样问题一出来就能定位到具体是擦除失败、编程失败还是校验失败。

3. 核心实现:HAL库接口与可复用的参数区读写封装

3.1 先看底层四步:HAL_FLASH_Unlock、Erase、Program、Lock

直接看最底层的调用,这一段是整篇方案的基石。下面是擦写最后一页的完整代码,以 512 KB 芯片为例:

/* 目标地址:512KB芯片的最后一页,页大小2KB */ #define TARGET_PAGE_ADDR 0x0807F800u void flash_demo_erase_write_one_page(void) { /* 第一步:解锁Flash,允许擦写操作 */ HAL_FLASH_Unlock(); /* 第二步:配置页擦除参数 */ FLASH_EraseInitTypeDef erase_cfg = { .TypeErase = FLASH_TYPEERASE_PAGES, /* 按页擦除 */ .PageAddress = TARGET_PAGE_ADDR, /* 目标页起始地址 */ .NbPages = 1 /* 每次擦1页 */ }; uint32_t page_err = 0; HAL_StatusTypeDef erase_st = HAL_FLASHEx_Erase(&erase_cfg, &page_err); /* 注意:返回值HAL_OK不代表页擦干净了,page_err才是关键 */ if ((erase_st != HAL_OK) || (page_err != 0xFFFFFFFFu)) { HAL_FLASH_Lock(); return; /* 擦除失败,page_err里是出错的页地址 */ } /* 第三步:按32位写入一个数据 */ uint32_t data = 0x01020304u; if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, TARGET_PAGE_ADDR, data) != HAL_OK) { HAL_FLASH_Lock(); return; /* 编程失败,可能是写保护或地址非法 */ } /* 第四步:上锁,防止后续误操作改动Flash */ HAL_FLASH_Lock(); }

这段代码对应到寄存器层面:HAL_FLASH_Unlock 是向 FLASH_KEYR 写键值,擦除操作往 FLASH_CR 的 PER 和 STRT 位写配置,编程操作通过 FLASH_AR 指定地址、FLASH_CR 的 PG 位置位来触发。HAL 库把这些细节包住了,但底层原理要懂,否则调试时面对一条 HAL_OK 的返回根本不知道下一步看哪里。

编程时建议用 32 位写入而不是半字写入,原因是减少循环次数、缩短断电窗口。注意 F103 支持 32 位编程,但移植到 F0、F3 系列时要先确认 HAL 库里是否开放了对应宏,不同系列 Flash 控制器差异很大。

3.2 存储块定义与保存/读取封装:内部Flash参数区读写就这么用

底层四步验证通过后,就可以封装成业务接口。先定义存储块结构体,这套结构就是页内布局的直接映射:

#define STORAGE_MAGIC 0xA5A5A5A5u #define STORAGE_VERSION 0x01u #define STORAGE_DATA_MAX 512u /* 业务数据上限 */ typedef struct { uint32_t magic; /* 魔数:判断该页是否存在有效数据 */ uint32_t version; /* 格式版本,升级时用于兼容 */ uint16_t data_len; /* 有效数据字节数 */ uint16_t crc; /* 对 data 区做累加校验 */ uint32_t reserved; /* 对齐到16字节 */ uint8_t data[STORAGE_DATA_MAX]; } storage_block_t;

保存函数负责一整条链路:填充块头、解锁、擦除、循环写、上锁、读回对比。下面是可复用的实现:

static uint16_t storage_calc_crc(uint8_t *buf, uint16_t len) { uint32_t sum = 0; for (uint16_t i = 0; i < len; i++) { sum += buf[i]; /* 示例用累加校验,工程可换CRC16 */ } return (uint16_t)(sum & 0xFFFF); } int storage_save(storage_block_t *blk, uint32_t page_addr) { uint32_t *src = (uint32_t *)blk; uint32_t word_n = (sizeof(storage_block_t) + 3u) / 4u; /* 填充块头 */ blk->magic = STORAGE_MAGIC; blk->version = STORAGE_VERSION; blk->crc = storage_calc_crc(blk->data, blk->data_len); blk->reserved = 0; HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_cfg = { .TypeErase = FLASH_TYPEERASE_PAGES, .PageAddress = page_addr, .NbPages = 1 }; uint32_t page_err = 0; if (HAL_FLASHEx_Erase(&erase_cfg, &page_err) != HAL_OK || page_err != 0xFFFFFFFFu) { HAL_FLASH_Lock(); return -1; /* 擦除失败 */ } for (uint32_t i = 0; i < word_n; i++) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, page_addr + i * 4u, src[i]) != HAL_OK) { HAL_FLASH_Lock(); return -2; /* 编程失败,可能是写保护或地址越界 */ } } HAL_FLASH_Lock(); /* 读回校验:把刚写入的内容和源数据逐字节对比 */ if (memcmp((void *)page_addr, blk, sizeof(storage_block_t)) != 0) { return -3; /* 数据不一致,掉电窗口写入不完整 */ } return 0; }

对应的读取函数要处理三种异常:空页、版本不匹配、数据损坏。

int storage_load(storage_block_t *blk, uint32_t page_addr) { storage_block_t *src = (storage_block_t *)page_addr; /* 0xFF区域不可能是魔数,直接判定为空页 */ if (src->magic != STORAGE_MAGIC || src->version != STORAGE_VERSION) { return -1; } if (src->data_len > STORAGE_DATA_MAX) { return -2; /* 长度异常,可能是损坏数据 */ } if (src->crc != storage_calc_crc(src->data, src->data_len)) { return -3; /* CRC不匹配 */ } memcpy(blk, src, sizeof(storage_block_t)); return 0; }

几个参数细节要和实际项目对齐。第一,word_n 用(sizeof + 3) / 4向上取整,保证循环次数是整数,不会漏写尾部字节。第二,读回校验用的是 memcmp 而不是逐个 uint32 比较,因为 memcmp 能在第一个不相等的字节处提前退出,掉电写一半时能更快发现坏块。第三,示例里 CRC 用的是累加和,代码短、好理解,但产线上我会换成查表法 CRC16,覆盖所有数据字节,防止多位翻转的低概率事件。

这套接口之所以说“好用”,是因为上层业务只跟storage_save和storage_load打交道,页地址、CRC、擦除时序全部封装在内部。下表是接口清单,方便直接抄进自己的工程。

接口入参返回职责
storage_save数据块指针、页地址0 成功 / 负值失败擦除、写入、上锁、读回校验
storage_load数据块输出指针、页地址0 成功 / 负值失败魔数、长度、CRC 三重校验
storage_calc_crc数据指针、长度16 位校验值可替换为查表 CRC16

3.3 双页轮换与掉电自恢复:把寿命和断电两个问题一起解决

单页方案的致命弱点是写一半断电。擦除完成后、数据写完整前掉电,整页就是 0xFF,旧数据和新数据都没了。解决办法是加一个备份页,永远保持“两页里至少有一页是完整的”。

#define PARAM_PAGE_A 0x0807F800u /* 最后一页 */ #define PARAM_PAGE_B 0x0807F000u /* 倒数第二页 */ static uint32_t active_page = 0; /* 当前有效数据所在页,0表示未初始化 */ int param_state_init(void) { storage_block_t *a = (storage_block_t *)PARAM_PAGE_A; storage_block_t *b = (storage_block_t *)PARAM_PAGE_B; if (a->magic == STORAGE_MAGIC) { active_page = PARAM_PAGE_A; return 0; } if (b->magic == STORAGE_MAGIC) { active_page = PARAM_PAGE_B; return 0; } return -1; /* 两页都为空,交给上层初始化默认参数 */ } int param_write(const storage_block_t *blk) { /* 写入目标永远是“非当前页” */ uint32_t target = (active_page == PARAM_PAGE_A) ? PARAM_PAGE_B : PARAM_PAGE_A; storage_block_t tmp = *blk; tmp.magic = STORAGE_MAGIC; if (storage_save(&tmp, target) != 0) { return -1; /* 写新页失败,旧页数据依然完好 */ } active_page = target; /* 新页写入并校验通过后,再切换 */ return 0; }

这个设计的掉电安全逻辑是这样的:当前有效数据在 A 页,下一次写入目标是 B 页。如果 B 页写到一半断电,重启后param_state_init发现 B 页魔数不对,仍然用 A 页的旧数据启动,最多损失最近一次修改,不会连旧数据一起丢。写入流程要求先在 RAM 里把整个块准备好,再一次性写入 Flash,避免在 Flash 里搬数据。

磨损均衡方面,每次保存自动切换页面,两个页交替承受擦写,等效寿命翻倍。这个方案有个适用边界:业务数据块必须小于半页,保证一个页能完整放下一份数据块。如果单条数据超过页大小的 80%,就要改成多页队列式管理,复杂度会上一个台阶,对绝大多数参数保存场景来说没必要。

4. 常见问题排查:写不进去、读出来不对的四个高频翻车点

真遇到问题的时候,别急着怀疑芯片坏了。内部 Flash 读写翻车,九成是下面四类原因。每条按“现象 → 原因 → 解决”的顺序写,可以直接对着查。

4.1 读出来全是0xFF,代码却返回“保存成功”

现象:断电重启后参数全部回到默认值;调试器里看目标页地址,内容全是 0xFF。但调用 storage_save 时返回值是 0,看起来一切正常。

原因:最常见的是写保护被开启。有些板子出厂时在选项字节里设了写保护,HAL_FLASH_Program 执行时实际上没有写入,但 HAL 返回 HAL_OK。另一个隐藏原因:擦除后写入了一部分数据,在继续写的过程中断电,整页在下次上电时没有任何有效数据,如果读回校验没有严格遵守,就会漏掉这个错误。

解决:第一件事检查选项字节的 WRP 写保护配置,开发阶段直接在烧录工具里把写保护清掉。代码层面,storage_save 里的 memcmp 读回校验不能去掉,它是拦截“假成功”的最后一道防线。如果读回对比都做了还是全 0xFF,基本可以锁定是写保护或者地址越界,优先去查地址合法性。

4.2 一调擦写函数就进HardFault:地址越界、未对齐、中断打断

现象:调用 HAL_FLASHEx_Erase 或 HAL_FLASH_Program 后,调试器直接停在 HardFault_Handler,调用栈都来不及看。

原因:排在前面的三个原因都很典型。地址超过芯片 Flash 容量,比如 512 KB 芯片把页地址写成 0x08080000;按 32 位编程时地址没有 4 字节对齐,Cortex-M3 直接触发总线错误;擦写期间来了中断,中断服务程序又去读 Flash,把 Flash 控制器的状态搞乱。

解决:在擦写之前加一层地址合法性断言:

void assert_flash_addr(uint32_t addr, uint32_t len) { /* 地址必须落在主存储区有效范围内 */ if ((addr < 0x08000000u) || (addr + len > 0x08000000u + FLASH_SIZE)) { while (1); /* 让问题在调试器里立即暴露,而不是非法访问 */ } }

同时所有编程地址先做addr & ~0x3u对齐处理。擦写段前后用__disable_irq()和__enable_irq()包住,防止中断穿插。关中断的时间不能太长,所以更稳的做法是把擦写函数放到 RAM 执行,用__attribute__((section(".ramfunc")))标记,这样 CPU 不占用 Flash 总线,关中断窗口能缩到极短。

4.3 复位后参数莫名回滚成默认值

现象:写入时一切正常,调试器里读回也对,断电 10 秒后再上电,参数又变成默认值,而且不是每次必现。

原因:这种“偶尔丢一次”的问题最难查。常见的有三类:第一,bootloader 或者启动代码在 main 之前对 Flash 做了搬运、压缩,覆盖了参数页;第二,CRC 范围只覆盖了块头前 16 字节,后面的数据坏了但校验仍然通过;第三,芯片进入了读保护状态,读回的数据被硬件拦成 0xFF。

解决:在 main 入口第一行就调用 storage_load 并把校验结果打到串口,确认参数在读业务代码之前是否还在。CRC 覆盖范围扩大到 data_len 的全部字节,不是只算固定长度的头部。然后检查链接脚本,确认参数页地址没有落在 .text 或 .data 段内。如果工程里有 bootloader,还要确认它擦除应用区时不会把尾部参数页一并擦掉。

4.4 擦写期间看门狗复位

现象:系统正常运行几个月没事,但只要触发了参数保存,设备很快就复位,而且复位时间点总在写入动作附近。

原因:F103 从 Flash 取指执行擦写流程时,Flash 控制器正忙着擦除或编程,CPU 访问 Flash 会被拖住,执行时间拉长。喂狗的代码恰好也在 Flash 里,擦写过程的执行时间超过喂狗周期,看门狗就兜不住。

解决:喂狗动作放到定时器中断里,并加一个计数,允许擦写期间跳过一两次喂狗。更彻底的做法是降低擦写频率,把多次零星修改累积到固定周期批量写一次,避免频繁进入擦写窗口。规划阶段就一定要算清楚:一次完整保存需要多长时间,看门狗周期必须大于这个时间并留出余量,这是嵌入式项目里最常见的节奏性设计失误。

5. 验收与答辩:怎么证明这套内部Flash读写方案可靠

验收不能只靠“写进去读出来一样”这种单测。我一般按下面四个维度做,每个维度都有明确的通过标准。

测试项测试方法通过标准
读回一致性连续写读 10000 次,每次 memcmp 对比0 次不一致
断电测试写入过程中随机断电,重启检查参数200 次测试无参数丢失
整页边界测试数据写满整页后再写一次返回错误且旧数据可恢复
长稳寿命估算按一天 10 次写入估算双页轮换理论寿命约 5.5 年

断电测试的做法是给开发板供电回路串一个继电器,程序在擦除和写入阶段随机断电,断电后再上电检查两页里是否至少有一页魔数有效。这个测试建议跑 200 次,能覆盖掉电窗口的绝大多数情况。整页边界测试容易被人忽略:把数据长度填到接近页大小,写入后继续写第二次,看系统能不能正确返回错误而不是把程序区改坏。

答辩时评委大概率会问三个问题,提前准备好答案。为什么用内部 Flash 而不用外部 EEPROM,回答思路是成本、面积、采购周期三个约束,再加一句“本方案的可靠性由双页备份和 CRC 补偿”。怎么证明不会丢数据,用断电测试 200 次通过、读回一致性 10000 次零失败来回答。Flash 寿命不够怎么办,直接给出双页轮换后 5.5 年的估算公式。表达顺序建议按“需求约束 → 设计补偿 → 测试数据”来讲,先讲清楚为什么这么做,再展示代码和结果,比单纯贴代码可信得多。

从那以后,我不管在哪个项目里做参数存储,都会先在文档第一页写清楚“容量、寿命、断电窗口、校验覆盖范围”这四行字,再让代码去对齐这四行字。这套方案不一定是最花哨的,但走完这些流程,至少敢在答辩时说一句“断电丢数据的概率,在测试范围内是零”。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 6:36:35

生态美丽溪流治理设计:先定水、再定岸、后做景的工程方法论

简介&#xff1a;《泉州生态美丽溪流水系治理景观方案》是一份面向城市规划、水利工程与景观设计从业者的综合性PDF文件&#xff0c;聚焦惠安县林铜溪流域生态修复与城市景观提升&#xff0c;系统解决城市扩张与水环境矛盾、河道侵占淤积、污染源治理等现实问题。资源包共收录1…

作者头像 李华
网站建设 2026/10/20 6:36:04

用Vibe Coding搞定AI Infra:从资源调度到故障恢复的实践

说实话&#xff0c;大模型项目做得越多&#xff0c;越发现最让人失眠的往往不是模型指标差一截&#xff0c;而是AI Infra那堆“听起来不难、跑起来要命”的破事。最近我换了一种玩法&#xff0c;把最头疼的训练任务管理整体重写了一遍&#xff0c;全程几乎都是用Vibe Coding完成…

作者头像 李华
网站建设 2026/10/20 6:36:03

Artcraft:一种以像素级意图与物理可信度驱动的前端工程范式

1. “Artcraft”不是新词&#xff0c;而是被低估的创作范式重构信号最近在多个设计社区、开源项目讨论区和创意工作坊里&#xff0c;“artcraft”这个词频繁跳出来——它不像“AI绘画”或“生成式设计”那样自带流量标签&#xff0c;也没有明确指向某款工具或平台。但有意思的是…

作者头像 李华
网站建设 2026/10/20 6:35:57

diagram-design:从草图到系统,图表设计方法论与实战

1. 从一张草图到一套系统&#xff1a;diagram-design 到底在解决什么问题第一次听到 “diagram-design” 这个词&#xff0c;很多人会以为它只是“画图工具”的另一个说法。但真正在项目里被图表折磨过的人知道&#xff0c;问题从来不是“画不出来”&#xff0c;而是“画得乱、…

作者头像 李华
网站建设 2026/10/20 6:35:40

单片机毕业设计-基于STM32的指纹门禁摄像头监控与云端管理系统设计 基于物联网的智能门禁视频监控与云平台远程控制系统设计(033009)

博主介绍&#xff1a;✌️码农一枚 &#xff0c;专注于大学生项目实战开发、讲解和毕业&#x1f6a2;文撰写修改等。全栈领域优质创作者&#xff0c;博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于嵌入式单片机&#xff0c;Java、小程序技术领域和毕业项目实战 ✌️…

作者头像 李华
网站建设 2026/10/20 6:34:20

webpack入口配置全解析:从单页到多页的构建演进路径

聊webpack配置&#xff0c;很多人第一反应是去逐条背module.rules和plugins&#xff0c;可真正决定打包流程走向的&#xff0c;往往是那个看起来不起眼的entry字段。它就像一张依赖地图的起始坐标&#xff1a;webpack拿到它&#xff0c;顺着import递归展开整棵依赖树&#xff0…

作者头像 李华