STM32F103内部Flash读写实战:掉电保存与参数存储完整方案
简介面向STM32F103嵌入式开发者的内部Flash读写示例工程重点解决固件升级、参数保存、运行日志记录等场景下的片上Flash操作难题。压缩包共72个文件、约948KB其中29个h头文件与28个c源文件构成完整标准外设库工程8个s汇编文件负责启动初始化另有Keil工程文件uvproj/uvopt、PDF说明文档和txt文本整体目录按CMSIS、FWlib、USER划分方便对照芯片手册或直接导入MDK验证。目前已有10760人学习/下载。示例完整演示了Flash解锁、扇区擦除、编程写入、读回校验和重新加锁的流程并特别说明写入前必须先擦除、读写不能跨页等限制读取则通过地址映射直接完成附带的PDF资料也能帮助理解Flash工作机制。同时可直接支撑IAP固件升级、参数掉电保存、多扇区日志记录等常见功能减少底层寄存器调试成本便于移植到实际产品中复用。1. 一个例程解决掉电保存难题STM32F103 内部 Flash 读写的正确打开方式在嵌入式开发里给 STM32F103 做内部 Flash 读写是个绕不开的活儿设备参数想掉电不丢、OTA 前想存一笔升级标志、产线上想记录校准值靠的都是这片片内 Flash。这份 stm32f103_内部flash读写例子工程的价值在于它把读、写、擦除、校验这条完整链路都封装好了下载下来改个地址就能直接用上手非常快。它适合正在做参数存储、写 Bootloader、调校准数据保存的开发者尤其是不想额外挂一颗外部 EEPROM 的场合。相比外挂存储内部 Flash 不增加 BOM 成本、读取零延时代价是必须先擦后写、按页操作这些细节恰恰是新手最容易踩坑的地方。接下来我把例程从头拆到尾从寄存器时序到常见翻车现场都覆盖到照着改就能落到自己的板子上。2. 技术底座内部 Flash 的硬件特性与例程的分层设计2.1 存储布局主存储块、页大小与地址范围STM32F103 的内部 Flash 按容量分为小容量16KB/32KB、中容量64KB/128KB和大容量256KB 以上主存储块统一从0x08000000开始映射到内存地址空间。以最常见的 F103C8T6 为例它拥有 64KB 主存储块地址范围是0x08000000 ~ 0x0800FFFF每页大小 1KB如果是 F103ZET6 这类 512KB 的大容量芯片每页则变成 2KB。例程里一般会把页大小单独定义成一个宏方便换芯片时统一修改这属于典型的可移植设计。除了主存储块芯片还有一段独立的信息块System Memory和选项字节区Option Bytes。信息块里装的是出厂 Bootloader平常不会被应用代码访问选项字节区则控制读保护、写保护、硬件看门狗等配置这也是后面很多“怪问题”的来源之一。读写例程默认只动主存储块如果你的需求涉及读保护等级切换就要额外操作选项字节区两者寄存器访问流程完全不同不能混为一谈。把例程拆开看它的数据存储区通常放在主存储块靠后的几个页面上。比如工程默认预留了最后的 4KB0x0800F000起作为参数区前面全部留给应用程序。这个设计非常讲究因为地址规划一旦定错要么覆盖掉自己的代码导致启动即崩溃要么和中断向量表冲突引发 HardFault后面避坑章节会专门讲。2.2 三类操作的本质差异读是访存、写是编程、擦除是重置内部 Flash 最迷惑人的一点是读、写、擦除在硬件层面走的是完全不同的路径。读操作最简单Flash 直接映射到 CPU 的寻址空间你可以像访问普通变量一样用指针去取数不需要任何前置流程这也是“内部 Flash 读写”中“读”最省事的原因。写操作则要复杂得多。F103 的 Flash 控制器 FPECFlash Program/Erase Controller规定编程数据宽度是 16 位半字一次只能把 1 写成 0。这意味着如果你想修改某个字节不能只擦掉那一个字节而必须把整个页先擦掉擦除操作会把整页恢复成 0xFF然后再重新写入完整的新数据。这个“先擦后写”的约束是理解所有 Flash 应用架构的前提也是例程里把数据区按页对齐的根本原因。擦除操作以页为单位执行期间 Flash 控制器要占用内部总线此时 CPU 如果还在从 Flash 里取指就会出现停顿甚至卡死。例程里的擦除函数在进入擦除前会等待 BSY 标志清空擦除完成后会检查 EOP 标志这种严谨的时序处理是稳跑的关键照抄即可。2.3 例程的分层模块设计整个例程按嵌入式惯用的“应用层 驱动层”思路组织。应用层是main.c里的测试流程负责调用驱动层提供的 Flash API往指定地址写入一组测试数据再读回来逐一比对驱动层是flash.c和flash.h封装了底层寄存器操作向上层暴露解锁、页擦除、半字编程、连续读写等函数。这样的分层比直接在 main 里裸操作寄存器有四个明显优点第一上层业务代码无需关心 FPEC 时序只要传入地址和缓冲区即可第二换芯片型号时只改驱动层的容量宏和地址宏业务代码零改动第三驱动层统一处理了等待、标志位清理、写保护检查等脏活避免每个调用处重复犯错第四Flash 操作失败时的返回值可以直接打印定位问题。例程虽小但这个分层方式值得沿用到你自己的工程里。3. 工程骨架跑起来之前先看懂目录和关键配置3.1 目录结构与文件职责例程的工程目录继承了一套很经典的 STM32 标准外设库结构。核心目录是这四个USER/ main.c、stm32f10x_it.c、系统时钟配置 HARDWARE/ flash.c、flash.h、usart.c、usart.h CORE/ startup_stm32f10x_hd.s、core_cm3.c LIB/ stm32f10x 标准外设库源码与头文件USER目录里的main.c是入口完成时钟初始化、串口初始化和 Flash 自检流程HARDWARE目录里的flash.c是本例程的精华所在对外开放了 5 个函数接口后面核心代码章节会逐个拆解CORE目录放启动文件和内核文件直接决定芯片上电后从哪里开始执行LIB目录是标准外设库用到的主要是stm32f10x_flash.c和stm32f10x_rcc.c两个源文件。如果你拿到的是基于 HAL 库的版本目录结构会稍有不同但职责边界保持一致启动与时钟归 System 层Flash 驱动归 Hardware 层业务流程归 App 层。新手最容易犯的错误是把 Flash 操作代码直接写进 main 函数里临时调通是快后期一旦要加 Bootloader 或换芯片就要从头拆代码了。3.2 时钟配置与 Flash 等待周期两年经验都可能忽视的隐藏依赖F103 的最高主频是 72MHz而内部 Flash 的访问速度远跟不上 CPU 的取指速度因此时钟树里必须插入等待周期Latency。当 SYSCLK 为 72MHz 时需要配置 2 个等待周期当主频降为 48MHz 以下时可以只用 1 个等待周期甚至 0 个。这个配置在SystemInit阶段通过设置FLASH-ACR寄存器的LATENCY位域完成。例程里这一步已经自动处理好了但如果你把这个工程的时钟初始化部分搬到别的板子上而目标板的晶振频率不同这里就是第一个“翻车”点等待周期配置偏少Flash 读出来的指令偶尔出错表现为程序随机跑飞、函数指针乱跳非常难排查。我一般会习惯性地在 Flash 驱动初始化时单独校验一下FLASH-ACR的值确保等待周期与当前 SYSCLK 匹配而不是默认它一定正确。另一个时钟细节是Flash 控制器本身挂在 APB1 总线上FPEC 工作时需要保证 APB1 时钟稳定。如果调试时发现擦除操作时好时坏请先确认 APB1 的分频系数没有在初始化之后被意外修改。3.3 下载到你的板子前先改这三个参数例程默认配置是按 F103C8T6 写的如果你的板子不是这个型号必须修改以下三个地方缺一不可。配置项默认值说明FLASH_PAGE_SIZE1024字节中容量芯片每页 1KB大容量芯片每页 2KB改错会导致擦除地址错位DATA_START_ADDR0x0800F000数据区起始地址必须按页对齐且与代码区保持足够间隙DATA_PAGE_COUNT4数据区占用的页数影响掉电保存的总容量和磨损均衡空间改完这三个宏之后还有一处隐性的配置如果你的芯片是 128KB 以上容量启动文件必须是startup_stm32f10x_hd.s如果是 64KB 的中容量则对应startup_stm32f10x_md.s。启动文件和芯片容量不匹配时芯片根本无法启动这也是“为什么我烧录成功却跑不起来”的高频原因之一。4. 核心代码拆解flash.c 里的一整套读写流程4.1 解锁三个 KEY 序列背后的保护机制F103 的 Flash 控制器上电后默认处于锁定状态FLASH-CR寄存器的LOCK位为 1此时任何编程和擦除操作都会被硬件层面忽略。解锁的方式是向FLASH-KEYR寄存器依次写入两个固定的密钥值序列完全固定不可颠倒。void flash_unlock(void) { /* 如果已经是解锁状态直接返回避免重复解锁 */ if ((FLASH-CR FLASH_CR_LOCK) 0) return; FLASH-KEYR 0x45670123U; /* 第一个密钥官方固定值 */ FLASH-KEYR 0xCDEF89ABU; /* 第二个密钥官方固定值 */ }这段代码的逻辑很简单但它背后有两个值得注意的点。第一解锁操作本身也可能失败如果你是在调试器连接的状态下运行且芯片被设置了读保护解锁后紧接着的任何操作都会触发 HardFault这点后面避坑章节会再展开。第二密钥值属于芯片厂商定义的硬件常量必须与数据手册严格一致少写一位或顺序写反LOCK位都不会被清掉。更严谨的做法是在解锁后增加一个判断确认FLASH-CR的LOCK位确实被清零如果没清零就上报错误而不是继续往下执行。例程里考虑到教学演示的简洁性没有这样做但你做产品代码时建议加上。4.2 读 Flash把数据区当成普通内存指针读操作不需要解锁也不需要任何前置准备因为它本身就是 CPU 的总线访问操作完全透明。例程实现里给出的是最典型的指针读取方式/** * brief 从指定地址读取 16 位数据 * param addr: 目标地址范围应在主存储块内 * return 读取到的半字数据 */ uint16_t flash_read_halfword(uint32_t addr) { /* 直接通过指针访存Flash 区域映射在 0x08000000 ~ 0x0800FFFF */ return *(volatile uint16_t *)addr; }这里的volatile关键字不能省。如果不加volatile编译器在开启优化后可能认为该地址的值在两次读取之间没有变化从而把第二次读取优化成第一次读到的缓存值。这在普通 RAM 变量上问题不大但 Flash 的数值可能在中断服务程序或 Bootloader 跳转后被修改优化掉的读取会导致拿到过期数据。同理如果要读取一段连续数据到内存数组可以用memcpy或循环逐字复制。一个常见误区是直接拿char指针逐个字节去读这在功能上没问题但要留意 Flash 的最小可寻址单元是字节而不是半字所以字节读取是合法的被禁止的只是“按字节写入”。4.3 页擦除完整时序与标志位检查的每一步擦除是三类操作里时序最严格、用户最容易写错的一步。完整的擦除流程是等待上一次操作结束、选择页擦除模式、写入待擦除页地址、启动擦除、等待擦除完成、清理标志位、退出页擦除模式。例程的实现按这个流程一步步来uint8_t flash_erase_page(uint32_t page_addr) { /* 擦除前先确保解锁 */ flash_unlock(); /* 步骤 1: 等待 FPEC 空闲前一次编程/擦除未完成时不能操作 */ while (FLASH-SR FLASH_SR_BSY); /* 步骤 2: 设置 PER 位进入页擦除模式 */ FLASH-CR | FLASH_CR_PER; /* 步骤 3: 把页地址写入 AR 寄存器硬件据此锁定擦除目标 */ FLASH-AR page_addr; /* 步骤 4: 置 STRT 位启动擦除指令 */ FLASH-CR | FLASH_CR_STRT; /* 步骤 5: 等待 BSY 清零擦除期间不可发起新的操作 */ while (FLASH-SR FLASH_SR_BSY); /* 步骤 6: 检查 EOP 标志确认擦除顺利完成然后手动清除 */ if (FLASH-SR FLASH_SR_EOP) FLASH-SR FLASH_SR_EOP; /* 步骤 7: 清除 PER 位退出页擦除模式 */ FLASH-CR ~FLASH_CR_PER; /* 始终加锁防止后续误操作破坏 Flash 内容 */ flash_lock(); return 0; }这段代码里最容易被忽略的是第 3 步和第 6 步。FLASH-AR寄存器必须在置STRT之前写入否则擦除地址是未知的可能把整个 BANK 擦掉那将是灾难性的后果EOP标志需要软件写 1 来清除不清除的话会影响下一次操作的标志位判断。还有一点擦除期间FLASH-SR的BSY位会保持为 1如果你的系统在擦除期间有中断发生中断服务程序里又访问了 Flash就会形成总线冲突。关于这个问题怎么处理避坑章节会给出具体方案。4.4 半字编程与跨页连续写入页擦除完成后该页所有字节都恢复为 0xFF这时才能执行写入。F103 的 FPEC 要求写入必须以半字16 位为单位进行例程把这步封装成了flash_program_halfword并在此基础上提供了一个连续写入的便捷函数uint8_t flash_program_halfword(uint32_t addr, uint16_t data) { flash_unlock(); /* 等待 FPEC 空闲 */ while (FLASH-SR FLASH_SR_BSY); /* 进入编程模式 */ FLASH-CR | FLASH_CR_PG; /* 对目标地址执行写操作写入动作即触发编程时序 */ *(volatile uint16_t *)addr data; /* 等待编程完成 */ while (FLASH-SR FLASH_SR_BSY); /* 清理 EOP 标志 */ if (FLASH-SR FLASH_SR_EOP) FLASH-SR FLASH_SR_EOP; /* 退出编程模式 */ FLASH-CR ~FLASH_CR_PG; flash_lock(); return 0; } uint8_t flash_write_buf(uint32_t addr, uint16_t *buf, uint16_t len) { uint16_t i; /* 先擦除目标地址所在页因为写入只能把 1 变 0 */ flash_erase_page(addr ~(FLASH_PAGE_SIZE - 1)); /* 逐半字写入 */ for (i 0; i len; i) { flash_program_halfword(addr i * 2, buf[i]); } return 0; }flash_write_buf是整个例程对外最常用的入口。它先根据起始地址算出所在页的页基地址然后整页擦除最后逐一写入省去调用方自己算页地址的麻烦。这里有两个使用前提必须说清楚一是写入的数据必须按 16 位对齐存放如果你要保存的是一个字符串长度需要凑成偶数二是擦除是按整页执行的即使你只改一个字节同页的其他数据也会被擦掉所以数据区设计时通常把一个“逻辑记录”放到独立的一页里避免频繁擦写互相影响。4.5 main 里的自检逻辑读出来和写进去一致才算数例程的main.c不只是简单调用写入函数它自带了一套验证流程先清空目标地址所对应的页再写入一段已知数据然后读回来逐字节比对全部一致才输出 OK。这段设计的思路值得借鉴它本质上是一个最简回环测试用来证明你的 Flash 驱动在目标板子上真的能正常工作。int main(void) { uint16_t test_data[32]; uint16_t read_data[32]; uint16_t i; /* 初始化时钟和串口 */ SystemInit(); USART_Init(); /* 准备测试数据 */ for (i 0; i 32; i) { test_data[i] 0x1000 i; } /* 写数据区起始地址在最后一个页避免覆盖代码 */ flash_write_buf(DATA_START_ADDR, test_data, 32); /* 读回来校验 */ for (i 0; i 32; i) { read_data[i] flash_read_halfword(DATA_START_ADDR i * 2); } if (memcmp(test_data, read_data, sizeof(test_data)) 0) { printf(flash test ok\r\n); } else { printf(flash test fail, please check addr or bus\r\n); } while (1); }如果例程在你的板子上打印出flash test fail不要上来就怀疑 Flash 芯片坏了。先确认三件事目标地址是否越界、页擦除函数是否把地址正确对齐、flash_read_halfword是否因为指针类型问题读到了错误的宽度。绝大多数情况下问题都出在地址计算而不是硬件本身这也是我调试这套流程时最重要的经验。5. 避坑指南Flash 读写最常见的五个翻车现场这段内容是踩坑经验最密集的地方。内部 Flash 读写调试起来有个特点很多问题不是逻辑上讲不通而是“看起来一切正常但结果就是不对”非常玄学。我按现象、原因、解决的三个步骤整理了五个高频问题基本能覆盖你拿到例程后可能遇到的大部分情况。问题一烧录后程序一跑就 HardFault连 main 都进不去。现象是板子供电后仿真器显示程序停在 HardFault_Handler 里串口无任何输出。原因大概率是芯片被开启了读保护或者 Flash 选项字节被改过。很多开发板出厂时带有读保护调试器在连接时无法访问 FlashCPU 取指直接触发总线错误。解决方法是先去掉工程里的读保护设置通过调试器接口执行“整片擦除”擦除会连选项字节一并复位之后再重新烧录。如果整片擦除也报错说明保护等级是最高级需要先做“解除读保护”操作并让芯片断电重启一次然后再烧录。问题二页擦除后读出来的数据不是 0xFF而是全 0 或者老数据。现象是明明调用了擦除函数读回来却不像是擦过的。原因有两个方向一是擦除函数里设置PER位后没有正确写入页地址导致擦到了别的页二是本身处于写保护状态擦除操作被硬件忽略。先排查写保护检查FLASH-CR的LOCK位是否真的被清掉再确认FLASH-SR的WRPRTERR位是否被置位如果置位说明当前区域被选项字节写保护了。解决方式是在工程配置里关闭写保护选项或者执行解除保护序列后重新上电。问题三写字符串数据最后一位总是丢的。现象是调用flash_write_buf写入一个长度为奇数的字符串读回来后最后一个字节是 0xFF。原因是 Flash 写入的最小单位是 16 位半字你用一个 8 位长度的缓冲区去写入时最后一个半字的高 8 位是未知的垃圾值。解决方法是写入前把字符串按半字对齐补齐自动补一个 0x00 或用数组把奇数长度凑成偶数。我一般会在驱动层做一个“按字节写入”的封装先把字节流打包成半字数组再调flash_program_halfword这样上层只管字节流底层负责对齐。问题四掉电后参数丢了明明写过一次。现象是设备下次上电读取参数区发现数据全被擦掉了。这往往不是写失败而是擦除和写入过程刚好遇到掉电——擦除耗时最长中间掉电时该页处于半擦半写状态恢复后数据不可靠。解决方式之一是软件上的“双页轮换”策略把数据交替写入两个页面每次写入前先检查两个页的校验和选最新的页加载数据即使一页损坏还有另一页兜底。更彻底的办法是硬件上给 Flash 供电加一个掉电检测电路检测到电压跌落时利用电容余电完成当前写入这个在产品设计阶段就要考虑。问题五擦除期间程序卡死偶发但极其致命。现象是系统运行中调用擦除函数程序直接卡在while (FLASH-SR FLASH_SR_BSY)出不来。原因是擦除操作期间 Flash 控制器占用内部总线而你的执行代码本身也在 Flash 里CPU 取指被阻塞。如果此时还有高优先级中断触发且中断服务程序里访问了 Flash总线竞争会进一步加剧。解决的思路有两种一是擦除前关闭所有可能访问 Flash 的中断擦除完成后恢复简单有效但会加大中断延迟二是把擦除函数整体搬到 RAM 里执行用memcpy把代码复制到 SRAM再从 SRAM 跳转执行擦除流程这样 CPU 取指不经过 Flash彻底规避冲突。量产代码建议用第二种方案代价是要处理函数重定位但稳定性提升非常明显。6. 二次开发从例程到一套可落地的参数存储方案6.1 按用途划分三个独立分区例程在flash.c里已经把地址宏集中定义二次开发时第一个动作就是把它扩展成一张分区表。我一般会规划三个区应用代码区、参数存储区、日志区。应用代码区从0x08000000开始参数区放最后几个页日志区居中。这样做的好处是互不干扰日志区频繁擦写不会影响参数区的数据完整性。分区起始地址页数用途APP_CODE0x08000000按固件大小应用程序与中断向量表LOG_AREA0x0800C0004运行日志循环覆盖PARAM_AREA0x0800F0002设备参数掉电保存在头文件里把这三个宏定义好所有代码禁止硬编码地址这是让项目长期可控的前提。6.2 磨损均衡把一万次寿命用出十万次的擦写效果F103 的 Flash 擦写寿命是典型值 1 万次直接往固定地址写参数的话产品生命周期内可能不够用。例程只演示了单页读写我建议改成双页轮换模式两个页面上各保存一份带版本号的数据写入时擦除较旧的一页读取时选择版本号较新的。这样平均磨损降到一半配合“只在参数变化时写入”的触发策略绝大多数产品的寿命瓶颈就不再是 Flash 了。举个简单的实现思路数据布局可以设计成 2 字节版本号 2 字节数据长度 N 字节数据 2 字节 CRC16。写入前读取两个页的版本号选大的那页作为“上一版本”擦除另一页写入新版本。这样即使写入中途掉电也还有一份完整的旧数据可回退这就是“后悔药”的实现方式。6.3 与 Bootloader 联动跳转标记与 App 起始地址如果你后面要做 IAP 升级这套 Flash 例程还能顺势扩展成 Bootloader 与 App 的桥梁。在参数区里预留一个“升级标记”变量Bootloader 启动时检查该标记若为特定值则跳转到 App 的复位向量执行App 收到升级指令后先擦写新固件再写一个“升级完成”标记并软复位。这个方案里唯一要注意的是 App 工程必须设置正确的 Flash 起始地址和中断向量偏移否则跳转后中断全部失联。我个人的习惯是拿到一个新工程先做一次完整的 Flash 地址规划把所有分区定义写在头文件最顶部然后才动功能代码。哪怕当前版本用不到日志区我也会把地址预留出来因为后期扩充功能时再挪地址会涉及固件重新烧录和兼容性处理代价比预想的大得多。从那以后我每次做 STM32 项目都强制自己先过一遍地址规划流程如果后续要换芯片也只是改头文件的宏定义而已。希望这篇拆解能帮你把内部 Flash 读写从“玄学”变成一件有确定性的事情。本文还有配套的精品资源点击获取