news 2026/10/20 2:53:14

STM32F103内部Flash读写实战:从寄存器操作到断电备份设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32F103内部Flash读写实战:从寄存器操作到断电备份设计

简介:面向STM32F103开发者的Flash读写示例工程,适合正在做固件升级、配置参数掉电保存或运行日志记录等功能的嵌入式开发者参考学习。示例基于标准外设库封装了完整的内部Flash操作流程,包括解锁Flash控制器、扇区擦除、按字或半字写入、回读校验以及操作完成后重新锁存等步骤,并特别解释了写入前必须擦除、编程不能跨页等关键规则,可帮助初学者避开常见时序和地址管理错误。资源压缩包共72个文件,以C源码、H头文件和S启动文件为主,配合Keil工程配置文件附带说明文档及相关PDF资料,整体体积仅948KB,目录区分明确,方便按模块查阅。作者还给出了一个可直接运行的工程模板,便于读者在开发板上实测验证并迁移到自己的项目中。目前已有10760人学习浏览,实用性和上手友好度均已得到众多学习者验证。

1. STM32F103 内部 Flash 读写:一个被说烂但又没几个人讲透的功能

STM32F103 内部 Flash 读写,表面上是芯片手册里“存储器和总线架构”那一章的一段说明,实际用起来远比大多数人想象得麻烦。我最早在某个数据采集项目里想拿 F103 的尾页存标定参数,结果读回来全是 0xFF,折腾了一晚上才发现是擦除时序没走对,后来拿到这套“内部 Flash 读写例子”当模板对照,才理顺了从解锁、页擦除、半字写入到回读校验整条链路。它适合在裸机工程里要做开机参数存储、OTA 升级、故障日志掉电保存的开发者;内部 Flash 的使用既省一颗外部 EEPROM,又能放到原厂生命周期的体系里管理,代价是细节多,一步错就整页数据报废。这篇会把它逐层拆开:存储布局、寄存器操作、状态标志、工程坑,最后给一个断电也能救回来的备份设计。

2. 把 F103 的 Flash 家底盘清楚:容量、页大小与地址映射

写基于寄存器或标准库的 Flash 操作前,必须先搞清楚手头的 F103 属于哪个密度等级。内部 Flash 的页大小不是统一的,同一个系列里容量不同,页大小和页数也不同,直接套他人代码而不改边界参数是翻车的主要原因。

2.1 容量不是猜出来的:先查后缀再写代码

一般接触到的 STM32F103 芯片按内部 Flash 容量可分为小容量、中容量和大容量三档,常用型号的粗略分布如下:

型号系列(以尾缀为参考)内部 Flash 容量页大小典型页数
STM32F103C4 / C616 / 32 KB1 KB16 / 32
STM32F103C8 / CB64 / 128 KB1 KB64 / 128
STM32F103RC / RD / RE256 / 384 / 512 KB2 KB128 / 192 / 256

注意这里的页大小和页数是常见标称,个别批次或官方手册更新会有细微调整,以你手里芯片 Reference Manual 和芯片丝印为准。但有一点是确定的:F103 内部 Flash 统一分布在0x08000000开始的地址空间,程序就烧写在这段区域。做参数存储时,最稳妥的做法是把目标地址放到主存储区最后几页,比如 F103C8T6 的 Flash 容量 64KB,那尾页地址大约是0x0800F000附近,而不是随便挑一个中段地址用——中段地址很可能已经被你的固件代码占用,擦除了就是事故。

页大小直接影响擦除函数里的地址对齐。中容量芯片 1KB 页,页地址低 10 位必须是 0;大容量芯片 2KB 页,页地址低 11 位必须是 0。我习惯先写一个offset_to_page_addr()做掩码对齐,防止传入带偏移的地址导致擦除越界。

2.2 最小操作粒度:按页擦、按半字写

Flash 的特性决定了它不是普通 RAM,写之前处于“已擦除”状态时读出来全是0xFF。擦除的最小单位是页,写入的最小单位是 16 位半字。这个“半字”是很多刚接触的人会看漏的约束——你没法直接往任意字节地址写一个uint8_t,F103 的编程操作以半字为最小粒度,地址必须 2 字节对齐,数据必须是 16 位。

参数字节如果只有 1 个字节,常见的处理是把它和相邻字节拼成一个半字再写入。比如一个结构体里有多个uint8_t字段,把它们一对一对凑成uint16_t写入,读出时再拆开。这样比单独操作字节地址安全得多,也避免了“写入一个字节结果把旁边字节冲掉”的误解。

另外记住一个特性:已经写入过非0xFF数据的地址,在整页擦除之前不能再写。即便你只改了其中一个字节,也得先把整页擦成0xFF,然后把页内所有有效数据重新写回去,这直接引出后面读改写时的“页缓冲”问题。

2.3 读操作不触发擦写时序,但等待周期必须配够

读内部 Flash 本身没有额外的“解锁”动作,直接用指针解引用就行。比如想读地址0x0800F800的半字,一句*(volatile uint16_t *)0x0800F800就能取到。这也是我认为这套例子里“读写链路”设计合理的原因:读是透明的,注意力全放在擦和写。

真正影响读可靠性的是 Flash 等待周期LATENCY。F103 的 Flash 接口在对高主频取指时需要插入等待周期,系统主频越高插入的等待周期越多。常理来说当 SYSCLK 在 48MHz 以下时可以配 1 个等待周期,跑到 72MHz 就得配 2 个等待周期。这个参数写在FLASH_ACR寄存器的LATENCY位段里,如果你改了系统时钟却没同步更新等待周期,轻则 Flash 读取出随机错误,重则在温度变化时偶发 HardFault。

3. 能直接抄的寄存器级读写函数:解锁、擦除、半字写入

这一章直接给能落到工程里的 C 函数。我尽量用寄存器操作写,这样你用标准外设库也好、寄存器版也好,都能对照着理解和改。

3.1 解锁:两个键值必须一口气写完

F103 上电后 Flash 控制寄存器处于锁定状态,直接写FLASH->CR的PG或PER位是无效的。必须先向FLASH->KEYR依次写入两个键值,分别是0x45670123和0xCDEF89AB。

void flash_unlock(void) { /* 检查 LOCK 位,如果还没解锁才执行解锁序列 */ if (FLASH->CR & FLASH_CR_LOCK) { FLASH->KEYR = 0x45670123; /* 第一个键值 */ FLASH->KEYR = 0xCDEF89AB; /* 第二个键值,两次写入之间不要插其他 Flash 操作 */ } }

逻辑说明:FLASH->CR的 bit7 是LOCK,上电默认置 1,写入两个键值后硬件自动清零。两个键值写入之间一旦插入了其他对 Flash 控制寄存器的访问,解锁序列会被硬件判定失败,必须重新来。

参数说明:键值顺序不能写反,也不能写成 32 位合并值一次性写。标准外设库里也提供FLASH_Unlock()函数,本质就是这个序列,但内部还多做了等待 BSY 的操作。

对应地还有锁回操作:

void flash_lock(void) { FLASH->CR |= FLASH_CR_LOCK; /* 重新置 LOCK 位 */ }

这步在每次擦写完成后都建议执行,避免误操作把 Flash 内容改坏。

3.2 页擦除:PER、AR、STRT 三件事缺一不可

擦除前先确认目标页地址对齐,然后走“置PER、写页地址、置STRT、等BSY清零”的顺序。

#define FLASH_BSY_FLAG 0x00000001 /* SR 寄存器 bit0 */ #define FLASH_EOP_FLAG 0x00000020 /* SR 寄存器 bit5,擦写完成标志 */ uint8_t flash_page_erase(uint32_t page_addr) { /* 等待上次操作完全结束 */ while (FLASH->SR & FLASH_BSY_FLAG); /* 根据你的芯片页大小做地址对齐;1KB 页用 ~0x03FF,2KB 页用 ~0x07FF */ page_addr &= ~0x03FFu; FLASH->CR |= FLASH_CR_PER; /* 选择“页擦除”操作模式 */ FLASH->AR = page_addr; /* 写入要擦除的页基地址 */ FLASH->CR |= FLASH_CR_STRT; /* 启动擦除 */ /* 等待擦除完成 */ while (FLASH->SR & FLASH_BSY_FLAG); /* 检查是否有写保护等错误 */ if (FLASH->SR & FLASH_SR_WRPRTERR) { FLASH->CR &= ~FLASH_CR_PER; return 1; } /* 清除完成标志和错误标志 */ FLASH->SR |= FLASH_EOP_FLAG; FLASH->CR &= ~FLASH_CR_PER; return 0; }

逻辑说明:先把PER置 1,让 Flash 控制器知道接下来执行的是“页擦除”;把目标页地址放入地址寄存器;置STRT后擦除才真正启动。之后必须轮询BSY位,等它从 1 变回 0 才代表擦除结束。

参数说明:page_addr要落到某一页的起始地址,传入带偏移的页内地址也不会有额外报错,但硬件按页擦除,容易把整页数据清掉。返回值 1 表示遇到写保护错误,代码里加了WRPRTERR检查,方便定位问题。

3.3 半字写入:PG 位置位后直接赋值,别做额外操作

写入函数看起来极简单,但隐含细节最多:

uint8_t flash_write_halfword(uint32_t addr, uint16_t data) { if (addr & 0x01u) return 2; /* 地址必须是半字对齐 */ while (FLASH->SR & FLASH_BSY_FLAG); /* 等上一步彻底结束 */ FLASH->CR |= FLASH_CR_PG; /* 开启编程模式 */ *(volatile uint16_t *)addr = data; /* 把数据“写”到 Flash */ while (FLASH->SR & FLASH_BSY_FLAG); /* 等本次编程完成 */ FLASH->SR |= FLASH_EOP_FLAG; /* 清 EOP 标志 */ FLASH->CR &= ~FLASH_CR_PG; /* 退出编程模式 */ return 0; }

逻辑说明:置PG位后,对目标地址做一次 16 位写操作,硬件就会把数据搬运到 Flash 阵列,普通赋值语句就是写入指令。写入期间 Flash 控制器会拉起BSY,你必须等它结束再操作其他地址。

参数说明:addr是绝对地址,例如0x0800F800;data是 16 位数据。返回码 2 表示地址未对齐。实际工程里写入前最好再确认一下目标区域已经擦除过,否则即使写操作“成功”,数值也可能不对。

3.4 组合起来:读一个半字并回读验证

写完数据后回读是整套例子里最重要的一步:

uint16_t flash_read_halfword(uint32_t addr) { return *(volatile uint16_t *)addr; /* Flash 读取不需要解锁 */ } uint8_t flash_write_and_verify(uint32_t addr, uint16_t data) { uint8_t ret = flash_write_halfword(addr, data); if (ret != 0) return ret; if (flash_read_halfword(addr) != data) return 3; /* 3 表示回读不一致 */ return 0; }

逻辑说明:回读验证能直接暴露没擦除、地址越界、写保护等问题。我一般会要求写入后立刻读一次,再在重启后读一次,两层校验。参数说明:返回码 3 专门代表“回读不一致”,调试时一眼能看懂是写入链路的问题,而不是业务逻辑的问题。

4. 藏在时序里的状态机:BSY、EOP、写保护与中断

Flash 擦写在硬件层面是一个状态机,寄存器操作只是喂给它命令。很多人抄代码只抄了写寄存器那几行,忽略了状态轮询和标志清理,于是问题变得非常“玄学”。

4.1 忙标志轮询必须带超时,否则一个坏块害死整个系统

F103 的 Flash 控制器在擦写期间总把BSY置 1,标准的做法是死等它清零。但产品环境里任何 while 都可能因为异常变成死循环,比如 Flash 老化、电压跌落导致控制器没有正常结束。必须给它一个边界。

uint8_t flash_wait_ready(uint32_t timeout_us) { uint32_t tick = 0; while (FLASH->SR & FLASH_BSY_FLAG) { if (++tick > timeout_us) return 1; /* 超时,不能继续走流程 */ } return 0; }

逻辑说明:这个函数把“轮询等待”和“超时处理”独立出来,擦除、写入之前都调它一次。参数说明:timeout_us的量级参考数据手册里的擦除时间典型值,一般给 50ms 到 200ms 足够,不用调到毫秒级以下。如果返回 1,就不要接着做写操作,先把系统状态标记成“Flash 异常”。

4.2 EOP、PGERR、WRPRTERR 三个标志才是真正的问题源

擦写完成后EOP(End of Operation)会被硬件置 1,表示一次操作正常结束。这个标志是软件可写的,读完后建议手动写 1 清掉。不清会出现一种现象:上一次操作的EOP还没清,下一次操作刚启动,你用标准库函数查标志时读到了残留的EOP,误以为本次擦写已经完成,然后立刻对 Flash 做了下一步操作,轻则警告重则数据错乱。

PGERR表示编程错误,常见于你向未擦除的地址写非0xFF数据。WRPRTERR表示写保护错误,常见于设置了保护选项字节的区域。这两个错误标志一旦置起,后续 Flash 操作会被硬件忽略。我的习惯是在每个擦写步骤结束后都探测一次这三个标志位,有任何一个异常就直接返回错误码,不继续执行后续写入。

4.3 中断上下文中做擦写:代码执行流必须避开目标页

这是我在一个模拟项目里踩出的教训:某跨平台系统的固件在 IAP 升级流程里,直接在中断回调里调用了页擦除函数,结果擦除恰好擦到了中断向量表所在页,程序当场跑飞。原因很简单:擦除期间 Flash 控制器不能同时响应取指,而你的中断处理函数本身就存放在 Flash 里,取指被卡住,CPU 自然跑飞。

常见做法有两条。一是把擦写函数放到 RAM 执行,比如用__attribute__((section(".arm_ramfunc")))把关键函数搬进内存,保证擦写过程中 CPU 取指不落在被擦的 Flash 页。二是做 IAP 这类整体跳转场景时,先把要擦除的页排除掉当前正在执行的代码段和中断向量表,等系统跳转到备份区后再擦旧区。

至少记住一条硬性约束:擦除哪一页,当前 CPU 正在执行的代码就不能放在那一页,也不能放在与它共用取指通道的相邻页。

5. 避坑与常见问题:我在内部 Flash 上踩过的五个实战坑

这章不铺垫,直接列现象、原因和解决办法。每条都是我或身边的人在真实项目里遇到的问题。

现象一:页擦除后读回全是 0xFF,但重新写入后读到的还是 0xFF。

原因是写入时没有等待上一次BSY完全清除,或者写入地址没有落在已经擦除的区域。更隐蔽的一种情况是,你在擦除函数里只清了EOP,但下一次写操作开始前没有重新等待BSY,控制器还没进入空闲状态就收到了新的命令。解决方法是把flash_wait_ready(200)放到每次擦除和写入的最前面,并且保证写地址经过掩码对齐,写入前用回读验证一次。

现象二:程序执行到擦除函数附近突然 HardFault。

原因基本是擦除或编程期间 CPU 从同一页 Flash 取指。尤其当你的擦除函数和待擦除数据页在同一个 1KB 或 2KB 页内时,擦除动作一启动,后续指令全部取不到。解决方法是把 Flash 操作函数放进 RAM 执行,或者调整链接脚本,把该函数放到不会被擦除的地址段。调试时可以把目标页临时改到最后一页,再把函数放到代码区开头,先验证问题是不是出在取指冲突。

现象三:写入时没报错,但回读数据被“撕”成两半,例如高字节和低字节对不上。

F103 半字写入时如果地址是奇数地址,而结构体字段按字节排布,就会把一个字段拆到两个半字里。解决方法是定义参数结构体时强制每个字段按 2 字节对齐,或者干脆所有参数都以uint16_t为单位存储,不用单个uint8_t落地。实际操作中我习惯在参数结构体里把偶数个字节字段两两合并,统一按半字写。

现象四:Debugger 突然连不上芯片,读出来的 Flash 内容一片空白。

非常像“芯片废了”的症状,实际多数是开启了读保护。F103 的选项字节里如果把RDP设成 Level 1,调试口就禁止访问 Flash,擦写命令也可能被忽略。解决方法是不要在手头只有一把烧录器的场合随意写选项字节;如果已经开了,一般需要先在调试软件里执行“整片擦除”并移除读保护,再重新烧录。更稳妥的做法是把选项字节的配置和读写操作单独封装好,别在业务代码里顺手设置。

现象五:同一套代码在室温下反复擦写正常,低温或高主频下偶发回读错误。

这类问题十有八九和 Flash 等待周期有关。主频跑在 72MHz,但FLASH_ACR的LATENCY还是 0 或 1,Flash 取指时序不满足,极端温度下就会冒出随机数据错误。排查时先确认时钟初始化代码里那个FLASH_SetLatency(FLASH_Latency_2)确实被执行了,而不是被优化掉了。这个坑最气人的地方在于它不会每次都复现,所以我会把主频和等待周期的配置写在一个固定初始化函数里,并在启动自检时回读一段固定模式的数据做校验。

6. 进阶:把参数区设计成双份备份,断电也能把数据救回来

到这一步,你已经能正确擦、正确写、正确验证,但产品环境里还有一个没法靠寄存器解决的场景:写入过程中突然断电。Flash 半字写入一旦被中断,该地址的数据可能处于既不是旧值也不是新值的中间态。我的做法是把参数区设计成双份备份。

具体思路是:在 Flash 末尾预留两个相同的参数槽位,每个槽位占一页,槽内头部放一个 16 位版本号,紧跟着参数数据和 16 位校验和。写入时先写槽 A,再写槽 B;启动时对比两个槽的版本号和校验和,选择有效的那份加载。这个设计比单页存储多费一页 Flash,但换来的是断电后至少有一个完整副本可恢复,代价非常值。

我第一次在某产品里做断电存储时只用一个槽,结果样机在写参数到一半时被人拔电,重新上电后参数区显示成随机值,从那以后我每次新板子都会强制走一遍“双槽读写 + 模拟断电回读”的验证流程,再开始写业务逻辑。这套例子里最值得学的其实不是那几行寄存器操作,而是它把“擦、写、验、备份”四件事都拆成了可独立测试的模块。希望帮到你。

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

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

Shell函数实战指南:从定义语法到模块化设计

说明项目标题 "Shell 编程之函数" 是一个典型的 Linux/Unix 脚本开发主题,面向有一定命令行基础、想提升脚本质量的开发者与运维人员。下面这篇内容按从业者视角展开,覆盖定义、参数作用域、嵌套递归、模块化设计、调试与实战,并附…

作者头像 李华
网站建设 2026/10/20 2:53:08

从基础文档到智能中台:国产知识协作平台深度评测

最近帮一家客户做知识管理选型,前后试了四五个国产知识协作平台。坦白说,有一款以前只当“在线文档”看待的产品,这次试用后让我彻底改观。它已经不只是“能多人同时编辑一份文档”的工具,而是把知识库、权限体系、智能搜索、审计…

作者头像 李华
网站建设 2026/10/20 2:53:07

强化学习训练效率优化实战:slime框架核心模块解析

做强化学习的人,大概都经历过这种时刻:调好一个算法,满怀期待地启动训练,然后看着 reward 曲线像心电图一样横跳,一宿过去了,还是没动静。我此前在某个机器人控制项目里也被这种问题折磨了很久——不是算法…

作者头像 李华
网站建设 2026/10/20 2:52:16

无人驾驶车辆模型预测控制:MATLAB仿真代码实战与二次开发

简介:这套由北京理工大学提供的无人驾驶车辆模型预测控制第二版程序代码,面向需要深入理解模型预测控制理论并落地实践的自动驾驶研究者、研究生与工程师,覆盖路径规划、速度控制、避障等典型驾驶场景。压缩包共349个文件,约1.52M…

作者头像 李华
网站建设 2026/10/20 2:51:16

ARM架构下Harbor 2.4.0离线安装全流程与避坑指南

简介:面向ARM架构服务器运维及容器开发人员,提供Harbor v2.4.0离线安装包,解决内网或无外网环境下镜像仓库部署难的问题。整包共6个文件,压缩后约402MB,包含两个Shell脚本(一键安装入口及公共函数&#xff…

作者头像 李华
网站建设 2026/10/20 2:51:11

2023电赛E题:运动目标控制与自动追踪系统高分冲刺指南

简介:2023年电赛E题参考是一份面向全国大学生电子设计竞赛参赛者的完整技术方案,核心聚焦于使用意法半导体公司生产的STM32F1系列微控制器来构建自动追光云台系统,涵盖光敏传感器检测、微控制器编程、电机驱动控制、PID算法调节以及电源管理等…

作者头像 李华