简介:面向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 / C6 | 16 / 32 KB | 1 KB | 16 / 32 |
| STM32F103C8 / CB | 64 / 128 KB | 1 KB | 64 / 128 |
| STM32F103RC / RD / RE | 256 / 384 / 512 KB | 2 KB | 128 / 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,但换来的是断电后至少有一个完整副本可恢复,代价非常值。
我第一次在某产品里做断电存储时只用一个槽,结果样机在写参数到一半时被人拔电,重新上电后参数区显示成随机值,从那以后我每次新板子都会强制走一遍“双槽读写 + 模拟断电回读”的验证流程,再开始写业务逻辑。这套例子里最值得学的其实不是那几行寄存器操作,而是它把“擦、写、验、备份”四件事都拆成了可独立测试的模块。希望帮到你。
本文还有配套的精品资源,点击获取