简介:STM32F103内部FLASH读写资源是一套面向嵌入式初学者的完整工程例程,围绕STM32F103系列芯片讲解程序代码、配置数据在Flash中的存储与读写方法,并演示如何通过软件模拟EEPROM以降低频繁擦写损耗。资源共包含156个文件,以70个h头文件、60个c源文件为主体,涵盖标准外设库、FLASH驱动、串口指令解析等模块,同时附带hex烧录文件、启动文件与工程配置,整体压缩包仅544KB,便于下载与快速部署。目前已有4153人学习,例程中通过串口指令封装Flash的地址设定、数据写入与读取操作,支持远程调试与验证;LCD与定时器代码亦为工程提供了直观的显示和控制参考。对于准备掌握嵌入式存储管理、非易失性数据保存以及Flash保护机制的开发者,这套工程可直接在Keil等环境下编译运行,是梳理STM32内存架构与Flash编程流程的实用参考。
1. STM32F103内部FLASH读写:掉电不丢失的参数保存方案
一台设备要保存校准系数、累计次数、开关标志,第一反应是外挂一片EEPROM。但板子已经画完,飞线加芯片不现实,主控片上其实还有富余的FLASH。STM32F103内部FLASH读写,就是这种场景下最省事的方案:不新增芯片,掉电数据不丢,唯一的门槛是它的擦写规则和普通RAM不一样。
这篇笔记把整条链路讲透:从手册上的结构图、页大小、解锁序列,到读取、页擦除、半字写入的驱动移植,再到地址规划、掉电安全、双区备份。读完可以直接照着改,落地在参数存储和简易日志这类需求上。
适合谁看:已经跑通基础外设、想在现有工程里加参数保存的开发者。如果零基础刚点亮LED,先别碰FLASH擦写——下文踩坑部分你会看到后果。
2. F103的FLASH结构与手册要点:为什么写入前必须先擦除
2.1 主存储区、信息块与页大小
先说底线:F103的FLASH不是一整块。手册把片内FLASH分成主存储区、信息块、系统存储区和选项字节。用户程序一般放在主存储区,从0x08000000开始。不同容量的芯片,页大小还不一样:
| 容量级别 | 典型容量 | 页大小 |
|---|---|---|
| 小容量 | 16KB / 32KB | 1KB |
| 中容量 | 64KB / 128KB | 1KB |
| 大容量 | 256KB / 512KB | 2KB |
代入具体型号更实在。C8T6是64KB中容量,地址范围0x08000000到0x0800FFFF,页大小1KB,最后一页起始地址是0x0800FC00。大容量ZET6是512KB,页大小2KB,最后一页起始地址是0x0807F800。这两个地址在后面做参数存储时会反复用到。
页大小决定了擦除的粒度:擦除的最小单位是一页,不能只擦一个字节。页越大,做参数存储时越要小心,擦一页就把这一片全清了。
2.2 写入前必须先擦除:电荷模型是踩坑源头
FLASH的存储原理可以通俗理解为:写入只能把单元从1改成0,不能从0改回1。要让一个字节从0变1,唯一办法是把整页擦除,擦除动作把整页恢复成全0xFF,也就是全1。
所以“修改一个字节”实际发生的步骤是:
- 读出整页数据到RAM
- 在RAM里改目标字节
- 擦除这一整页
- 把RAM里的数据重新写回这一页
这个概念理解不到位,后面会连锁翻车。有人直接在写过的页上重复调用写入接口,读出来是旧值混着新值;有人以为“写之前擦一下”只是保险,结果数据稀碎。所有上层策略——缓存、双区备份、掉电保护——本质上都在处理“擦除整页”这个动作的副作用。
2.3 手册上必须圈出来的操作时序
标准外设库封装了大部分细节,但手册上这几个点必须圈出来,否则遇到奇怪问题时没有分析框架。
先圈解锁序列。FLASH上电默认是锁定的,要往FLASH_KEYR依次写两个密钥:KEY1=0x45670123,KEY2=0xCDEF89AB。顺序不能反,反了本周期内解锁失败,还会产生操作错误标志。
再圈编程宽度。F103的FLASH编程宽度是16位,也就是每次写入一个半字。CPU从FLASH读数据可以按字节、半字、全字,但擦写控制器只认半字。按这个约束写驱动,能规避很多“数据错位”现象。
最后圈时间参数。页擦除典型时间在几十毫秒量级,半字编程典型时间在几十微秒量级。凡是写完立刻读回正常、掉电后失效的场景,多半是没等BSY彻底清零。
顺带提一句等待周期。主频在72MHz时,FLASH访问需要配置2个等待周期。这个是读FLASH的等待周期,不是写FLASH的等待周期,很多人混淆。时钟初始化漏配或者SystemInit没执行,会出现读错值、代码跑飞。
2.4 地址映射与代码/参数共存:从0x08000000开始的规划
程序代码在FLASH里占低地址空间,参数区要避开程序区。资源里的示例工程一般把参数区放在最后一页或者最后几页,这是最不容易出错的方案。比如F103C8T6的64KB容量,如果程序占了48KB,0x08000000到0x0800BFFF是代码区,那么从0x0800C000到0x0800FFFF这16KB可以作为用户数据区。若只做简单的参数保存,用最后一个页0x0800FC00到0x0800FFFF就够。
规划之前要确认代码实际占多少。工程编译后看map文件或者编译输出区里的Code+RO Data大小,得到一个字节数,向上对齐到页边界。然后让参数区的起始地址大于这个边界,同时预留出升级空间。我习惯的做法是:除非容量紧张,否则直接留出最后1到4页给参数区,不和代码挤在一起。
这里给出一个中容量芯片的地址规划模板:
| 区域 | 地址范围 | 大小 | 用途 |
|---|---|---|---|
| 中断向量表与代码区 | 0x08000000 ~ 0x0800BFFF | 48KB | 程序 |
| 参数区1 | 0x0800C000 ~ 0x0800CFFF | 4KB | 参数/日志 |
| 参数区2 | 0x0800D000 ~ 0x0800DFFF | 4KB | 备份或交替区 |
| 预留区 | 0x0800E000 ~ 0x0800FFFF | 8KB | 固件升级扩展 |
中断向量表固定在最前面,程序区的结束地址必须低于参数区起始地址。量产后升级固件前,必须再查一次边界,否则新固件膨胀就会把参数区吃掉。
提示:代码区边界应以实际链接结果为准,不能拿“好像没超过48KB”当结论。每次编译后看一眼map文件是最便宜的习惯。
3. 读取没有门槛:按地址访问与三个状态标志位
3.1 一个字大小的读:直接解引用地址即可
FLASH在总线地址空间里是线性映射的,读FLASH不需要解锁,不需要发送命令,和读普通的const数组没有区别。这意味着你可以用指针直接读出任意地址的内容。
/* 从参数区读取一个半字 */ #define PARAM_BASE_ADDR 0x0800FC00u uint16_t read_param_halfword(uint32_t offset) { uint16_t *addr = (uint16_t *)(PARAM_BASE_ADDR + offset); return *addr; }这里offset以字节为单位,必须是偶数,否则读出来的是两个相邻半字的拼接。读半字是最符合F103编程宽度的操作。如果需要读32位参数,可以读两次拼接,或者定义一个uint32_t指针直接读,CPU端支持32位读取,不会报错。
读不出数据的场景很少,如果读出来全是0xFF,先检查地址是否超出芯片容量,再检查等待周期配置,最后检查是不是在擦写过程中去读同页。擦写过程中该页数据无效,读回什么都不能当作有效数据。
3.2 BSY / PGERR / WRPRTERR:三个标志要一起看
擦除和编程是慢操作,等待完成不能只看BSY。BSY只表示控制器是否忙,不表示本次操作成功。写保护或者编程错误发生时,BSY同样会清零,但数据并没有写进去。
一个比较稳妥的等待函数是这样:
static FLASH_Status wait_flash_ready(void) { FLASH_Status status = FLASH_COMPLETE; for (uint32_t i = 0; i < 10000; i++) { if (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == RESET) { break; } } if (FLASH_GetFlagStatus(FLASH_FLAG_BSY) == SET) { status = FLASH_BUSY; } else if (FLASH_GetFlagStatus(FLASH_FLAG_WRPRTERR) != RESET) { status = FLASH_ERROR_WRP; FLASH_ClearFlag(FLASH_FLAG_WRPRTERR); } else if (FLASH_GetFlagStatus(FLASH_FLAG_PGERR) != RESET) { status = FLASH_ERROR_PG; FLASH_ClearFlag(FLASH_FLAG_PGERR); } else { status = FLASH_COMPLETE; } return status; }这个函数的逻辑是:先等BSY清零,然后看WRPRTERR和PGERR,最后返回FLASH_COMPLETE。为什么要检查两个错误标志?因为WRPRTERR表示地址有写保护或读保护,PGERR表示编程/擦除操作本身失败,比如地址不合法、时钟异常。只看BSY等于只看“车停了”,不看“货有没有送到”。
标准外设库的FLASH_WaitForLastOperation内部做了类似处理,但如果自己写寄存器版本,一定要把这套状态检查抄进去。写操作失败后,错误标志是粘滞的,不手动清除会影响下一次操作。
3.3 读错值排查第一选择:等待周期没配好
调试FLASH读写时最容易撞上的“玄学”是:数据明明写进去了,读出来却是乱的,或者第一次读正确、第二次读错误。先别怀疑写入逻辑,先查FLASH_ACR的LATENCY位。
F103主频和等待周期的对应关系是:主频不超过24MHz时等待周期可以配0;不超过48MHz时配1;72MHz时必须配2。如果使用的是标准时钟初始化代码,这个值会自动配好;如果自己写了时钟树,很容易漏掉这一步。
检查方法是在main函数最开头读FLASH_ACR寄存器,确认位[2:0]正好是2:
if ((FLASH->ACR & FLASH_ACR_LATENCY) != FLASH_ACR_LATENCY_2) { /* 修正等待周期 */ FLASH->ACR &= ~FLASH_ACR_LATENCY; FLASH->ACR |= FLASH_ACR_LATENCY_2; }注意这个等待周期只影响读FLASH数据,不影响FLASH的擦除和编程控制逻辑。有人误以为写FLASH前要配置等待周期,其实它解决的是读取稳定性的问题。时钟倍频到72MHz后读FLASH出现随机错误,九成是这里。
4. 擦除与写入:完整驱动移植与核心参数详解
4.1 页擦除:解锁、置位、等待、锁定四步走
页擦除是参数存储里最常用的操作。资源里的标准外设库驱动,核心调用链是FLASH_Unlock、FLASH_ErasePage、FLASH_Lock。先看一段可移植的封装:
void flash_erase_page(uint32_t page_addr) { FLASH_Status st; /* 解锁FLASH控制寄存器 */ FLASH_Unlock(); /* 等待控制器空闲 */ st = wait_flash_ready(); if (st != FLASH_COMPLETE) { FLASH_Lock(); return; } /* 发起页擦除,内部完成PER置位、地址写入、STRT启动 */ st = FLASH_ErasePage(page_addr); /* 擦除完成后仍要检查状态,不能只看返回值 */ if (st != FLASH_COMPLETE) { /* 记录错误码 */ FLASH_Lock(); return; } /* 写完后重新锁定,防止误操作 */ FLASH_Lock(); }Page_Address参数必须是页的起始地址,比如0x0800FC00,不能是页中间地址。FLASH_ErasePage内部会根据地址对齐关系找到所在页。传0x0800FC02也能擦掉整页,但依赖这种隐式对齐容易把自己绕晕,我习惯在调用前强制断言地址与页大小对齐。
标准外设库的FLASH_ErasePage会自己等待操作完成并检查错误,所以外层看起来简单。如果完全用寄存器写,要注意顺序:先FLASH_CR的PER置1,再写FLASH_AR地址,最后把FLASH_CR的STRT置1。置位顺序错乱,擦除操作可能启动失败。
4.2 半字写入:为什么不能按字节写
F103的编程宽度是16位,这一点再强调都不为过。数据手册写得很明确:每次编程操作写一个半字。如果按字节调用接口,实际写入行为不可控,容易把相邻字节破坏掉。
一个典型写入循环:
void flash_write_params(uint32_t addr, uint16_t *buf, uint32_t halfwords) { FLASH_Status st; uint32_t i; /* 写入前必须保证目标页已被擦除 */ FLASH_Unlock(); for (i = 0; i < halfwords; i++) { st = FLASH_ProgramHalfWord(addr, buf[i]); if (st != FLASH_COMPLETE) { /* 一旦失败立即退出,不要继续写脏数据 */ break; } addr += 2; /* 每个半字占2字节 */ } FLASH_Lock(); }buf[i]的类型是uint16_t,addr每次加2,保证地址对齐。FLASH_ProgramHalfWord内部做了三件事:置PG位、写地址、写数据。它会等待操作完成并返回状态。如果要写入多个连续参数,这种循环是最直观的写法。
如果使用FLASH_ProgramWord,一次写32位,内部会拆成两次半字编程。库函数会处理对齐和顺序,地址必须是4的整数倍。用它写32位参数会更方便,但注意它每次写入的数据量是32位,不是F103最小编程单位。
经常有人问能不能按字节写,答案是不能。你要写一个uint8_t,也得先把它转成uint16_t放到半字位置,写入后再读半字截取低8位。不要在驱动层直接发明按字节写入的接口。
4.3 从标准外设库到HAL:函数映射与移植要点
F103用标准外设库的仍然不少,但新工程很多已经切到HAL库。两个库的函数名和参数差异不小,移植时对照这张表快得多:
| 功能 | 标准外设库 | HAL库 |
|---|---|---|
| 解锁 | FLASH_Unlock | HAL_FLASH_Unlock |
| 页擦除 | FLASH_ErasePage(addr) | HAL_FLASH_Erase(&cfg, &page_error) |
| 半字写入 | FLASH_ProgramHalfWord(addr, data) | HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, data) |
| 锁定 | FLASH_Lock | HAL_FLASH_Lock |
HAL库的擦除函数需要先填一个结构体:
FLASH_EraseInitTypeDef erase_cfg = {0}; uint32_t page_error = 0; erase_cfg.TypeErase = FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress = 0x0800FC00; erase_cfg.NbPages = 1; HAL_FLASH_Unlock(); if (HAL_FLASH_Erase(&erase_cfg, &page_error) != HAL_OK) { /* 从HAL_FLASH_GetError()拿详细错误原因 */ } HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, 0x0800FC00, (uint64_t)0x1234); HAL_FLASH_Lock();HAL_FLASH_Program的data参数类型是uint64_t,写半字时只取低16位。传参时习惯性地写成(uint64_t)可以避免编译警告。page_error参数是输出,如果擦除失败,它会回填出错的页地址,这个信息排错时非常有用。
标准外设库的FLASH_ErasePage内部已经做完解锁、等待、擦除、再等一遍的完整流程;HAL库拆得更细,控制逻辑更透明,但代码量也更大。两者选一个,不要混用。混用的典型问题是库内部状态变量互相干扰,比如先调HAL_FLASH_Unlock再调用标准库的FLASH_ErasePage,可能因为各自的内部状态没同步导致操作异常。
4.4 参数区选址:把最后几页留给用户数据
参数区选址是整个方案里最该提前想清楚的部分。放低了会覆盖代码,放高了浪费容量,放中间和代码重叠则是一场灾难。最稳的方案是固定在最后几页。
用C8T6举例:
| 区域 | 地址范围 | 大小 | 说明 |
|---|---|---|---|
| 程序区 | 0x08000000 ~ 0x0800BFFF | 48KB | 假定编译结果不超过 |
| 参数区1 | 0x0800C000 ~ 0x0800CFFF | 4KB | 主参数区 |
| 参数区2 | 0x0800D000 ~ 0x0800DFFF | 4KB | 备份/交替区 |
| 预留区 | 0x0800E000 ~ 0x0800FFFF | 8KB | 固件升级余量 |
这个布局给参数区留了12KB,对绝大多数参数存储场景都够用。如果只是保存几个出厂校准值,甚至可以压缩到最后一页,其余空间留给固件升级。
选地址时还要注意编译器的链接配置。主流的ARM集成开发环境在分散加载文件里会定义FLASH起始和长度,如果参数区落在加载描述之外,某些下载算法会把它裁掉,导致程序下载后参数区被初始化成全0xFF。遇到这种情况,把分散加载文件里的FLASH长度扩展到参数区末尾,或者单独加一个只读数据段。
资源里的驱动模板我一般按“最后页起始”来写宏,这样换不同容量的芯片只需要改一个宏。
提示:双区备份只解决“擦除中掉电”的问题,不解决“两区都写坏”的极端情况,所以固件里还是要保留恢复默认参数的路径。
5. 避坑与常见问题排查:五个高频翻车现场
5.1 现象:擦除后程序卡死或复位
- 现象:调用FLASH_ErasePage之后,程序直接跑飞或者进HardFault,看门狗复位。
- 原因:擦除的页正好是CPU正在取指执行的代码页。擦除会把该页全变成0xFF,下一条指令读出来就是非法指令。
- 解决:擦除前判断目标地址是否落在代码区。代码区边界可以通过链接脚本的符号或手动宏确定,例如:
#define CODE_END_ADDR 0x0800C000u void safe_erase_page(uint32_t addr) { if ((addr >= 0x08000000u) && (addr < CODE_END_ADDR)) { /* 拒绝擦除代码区 */ return; } flash_erase_page(addr); }正确的做法是让参数区固定在程序区之上,并且每次编译后核对map文件,确保CODE_END_ADDR大于实际代码末尾。
5.2 现象:读出来全是0xFF
- 现象:擦除后读回是0xFFFF,写入后再读还是0xFFFF。
- 原因:一种是地址越界或参数区没有真正被擦除;另一种是写保护(WRP)或者读保护(RDP)开启,导致写入被控制器拒绝,但读出来依然是擦除后的全0xFF。
- 解决:先用读回校验确认擦除结果,再检查选项字节。标准外设库可以用FLASH_OB_Unlock配合FLASH_GetReadOutProtectionStatus检查。确认RDP级别,开发阶段要把读保护设为Level 0,否则调试器连不上,程序里也无法写入。
5.3 现象:返回FLASH_COMPLETE但数据不对
- 现象:FLASH_ProgramHalfWord返回值是FLASH_COMPLETE,但读回的数据不是写入的值。
- 原因:最常见的是没有等上一次操作彻底结束就发起下一次写入。BSY清零只是控制器空闲,不代表写入结果已经稳定可见。另一个原因是写入了已经写过的地址且该页未擦除,此时硬件不一定报错,但数据是旧值和新值叠加的结果。
- 解决:每写一个半字后,强制做一次读回比对:
if (*(volatile uint16_t *)addr != buf[i]) { /* 重试或标记错误 */ }写入前还要保证目标页是擦除状态,方法是先调flash_erase_page,再调flash_write_params。
5.4 现象:掉电后参数偶尔全丢
- 现象:设备运行中保存参数正常,断电再上电后有一批参数变成全0xFF或一半正确一半错误。
- 原因:页擦除过程中掉电,这一页还没有恢复成全0xFF,也没有写入新值,读出来就是中间态;或写完新数据后没有掉电保护机制,下一轮写入时擦除了旧区。
- 解决:单页方案解决不了擦除中掉电的问题,必须用双区交替方案。一个区擦除中断了,另一个区还有上次完整的数据,上电后根据魔数和CRC选择有效区。
5.5 现象:升级固件后参数区被破坏
- 现象:量产的设备升级固件后,有些参数变为默认值,有些功能异常。
- 原因:新固件代码量比旧固件大,代码区向后扩展,侵入了原来预留的参数区地址。编译器把新代码写到了参数区,旧参数自然被覆盖。
- 解决:参数区地址固定到芯片最后一个页,然后在发布前核对固件下载地址范围。用滚动升级时,把APP区末尾和参数区之间留够余量,并且每次升级后做参数区自检。没有自检机制的,可以在上电时检查魔数,魔数变了就恢复默认值,至少不会用脏数据跑业务。
6. 进阶做扎实:模拟EEPROM的双区备份与掉电自检
6.1 魔数头 + CRC校验
单页保存参数能应付大多数场景,但可靠性要求高时,数据格式里要有魔数头和校验字段。魔数用来区分“第一次上电的初始态”和“正常写入的数据”,CRC用来检测部分写失败或数据被破坏。
typedef struct { uint32_t magic; /* 0xA5A5A5A5 表示有效块 */ uint32_t crc; /* 对payload做CRC32 */ uint16_t len; /* 有效数据长度 */ uint8_t payload[64]; } ParamBlock;读取时先看magic,再看CRC。magic不匹配直接认为该区无效。CRC建议覆盖magic、len和payload,只校验payload会漏掉头部被破坏的情况。
6.2 双区交替写入
用一个简单状态机描述:
- 区A和区B都按ParamBlock格式存储
- 上电先读区A,魔数CRC正确就用区A,否则读区B
- 写入时选“非当前有效区”,先擦除再写入,写完后做读回校验
- 校验通过后,当前有效区切换
这样擦除过程即使掉电,也只是当前写入的这个区不完整,另一个区始终是上次成功的数据。代码里不用维护额外的状态变量,通过比较两个区的magic和crc就能判断哪个区有效。
6.3 验证自检流程
写完驱动后,习惯上要强制走一遍自检:
uint8_t flash_storage_self_test(void) { ParamBlock *blk = read_valid_block(); if (check_block(blk) == 0) { switch_to_backup(); blk = read_valid_block(); } if (check_block(blk) == 0) { /* 两区都失效,恢复默认参数并重新初始化 */ restore_defaults(); return 0; } return 1; }这个自检等价于“断电再上电”的软件版。从那以后我每次写完FLASH驱动都会强制走一遍:擦完验全0xFF,写完验原值,切换备份区后再验一次可读。这样能挡住绝大多数边界问题。希望帮到你。
本文还有配套的精品资源,点击获取