ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F103内部Flash数据存储详解:从硬件原理到掉电保护实战

STM32F103内部Flash数据存储详解:从硬件原理到掉电保护实战 简介这套STM32F103内部Flash读写例程专为嵌入式开发者设计围绕ARM Cortex-M3内核的Flash编程需求给出了基于标准外设库FWlib的完整工程覆盖Flash解锁、扇区擦除、数据编程和读取校验等关键环节可直接用于固件升级、运行参数存储或日志记录等场景。压缩包共72个文件主要包含29个h头文件、28个c源文件、8个s启动文件以及Keil工程文件uvproj/uvopt、配置ini和一份PDF资料总大小仅948KB目录按CMSIS、FWlib、USER划分清晰展示启动文件、外设库与用户代码的层次方便在MDK中直接打开编译调试。例程已被10726人浏览学习代码注释详尽完整展现Flash操作的关键流程与约束初学者可以对照掌握写入前必须擦除、操作后需锁定等要点也能直接复用其中的读写函数加速项目开发。包内附带的TXT说明与PDF资料提供了额外参考能帮助规避常见配置错误是一份实用且易上手的嵌入式学习参考。 搞嵌入式的人应该都有过这种经历产品要断电保存参数第一反应是外挂一颗EEPROM或者铁电但改版、布线、成本一算下来又有点肉疼。其实对于很多场景STM32F103内部Flash就能直接搞定数据存储既省了一片芯片又省了PCB面积。但是内部Flash写起来不像RAM那么随意有擦除粒度、写入寿命、掉电保护这些讲究用不好轻则丢数据重则把代码区冲掉直接变砖。我之前在项目里反复用过几套内部Flash读写方案踩过不少坑也总结了一些好用的套路。这篇就把我实际验证过的读写例子、背后的硬件机制、需要注意的边界条件一次说清楚。不管你是刚接触STM32的新手还是想评估内部Flash是否够用的老手这篇都值得参考。1. 为什么要把数据放在内部Flash而不是外挂存储芯片STM32F103的内部Flash本来就是用来放固件的拿它存数据相当于一块地方干两份活。很多人一听就担心把数据写到Flash里会不会把程序冲掉会不会影响稳定性这些担心合理但搞清楚机制之后你会发现内部Flash存数据其实是被官方支持的常规操作。1.1 内部Flash存数据的核心优势先说省成本。一颗工业级I2C接口的EEPROM比如AT24C02批量采购大概几毛钱看着不贵但量产后BOM表里每一毛钱都是利润。Flash是芯片自带的相当于白嫖。而且少一颗物料备货、贴片、来料检验的麻烦全省了。然后是可靠性。EEPROM虽然也能存数据但I2C通信存在总线被拉死、地址冲突、时序干扰这些幺蛾子内部Flash直接挂在芯片内部总线上不存在外部通信失败的问题。对于工业现场这种电磁环境恶劣的场景少一条外部通信链路就少一个故障点。还有一个常被忽略的好处内部Flash的擦写由硬件状态机管理代码只需要操作寄存器不需要实现复杂的通信协议。使用标准外设库或者HAL库时API简洁明了半小时就能调通。1.2 什么场景下内部Flash是合适的存储介质不是所有场景都适合用内部Flash替代外部存储。根据我实际项目的经验下面几类场景用起来很顺手设备参数保存比如校准系数、设备地址、通信波特率、用户配置等这类数据量小几十到几百字节、改动频率低一天改几次就算多了。运行日志记录掉电前的状态信息、最近一次错误码、累计运行时间等每次写入几个字一天写不了几次。升级标志和版本信息Bootloader和App之间传递升级标志、固件版本号、CRC校验值这些数据需要掉电不丢失又必须和程序代码隔离存放。工厂生产数据序列号、生产日期、测试结果等产线下线时写一次后面基本只读。反过来如果系统需要频繁记录数据比如每秒都要写一次传感器历史曲线那就要认真算一下Flash寿命了这个问题下一节细讲。另外数据量如果超过几十KB或者需要随时改写任意字节内部Flash也不合适这种需求还是老实上外部Flash或者SD卡。一句话总结我的选型经验小数据量、低频改写、掉电不能丢、不想加硬件成本优先考虑内部Flash大数据量、高频写入、要按字节随意改选外部存储器。2. 动手之前必须搞懂的内部Flash硬件机制直接抄代码很容易但出了问题就抓瞎。我在调内部Flash时好几次都是因为没搞懂硬件机制白折腾了大半天。这里把我认为最重要的几个知识点拆开讲清楚理解了这些后面的代码才有底气。2.1 存储结构与扇区划分STM32F103的内部Flash容量从16KB到512KB不等不同容量的芯片扇区划分也不一样。这一点特别坑网上很多例程是针对大容量芯片写的直接往小容量芯片上套运行起来要么擦除失败要么地址越界。以我常用的几种芯片为例芯片型号Flash容量页大小页数量主存储区地址范围STM32F103C8T664KB1KB64页0x08000000 - 0x0800FFFFSTM32F103RBT6128KB1KB128页0x08000000 - 0x0801FFFFSTM32F103ZET6512KB2KB256页0x08000000 - 0x0807FFFF标准库里的FLASH_ErasePage()函数参数是页地址注意是页的首地址不是页编号。比如C8T6要擦除第60页应该传入0x08000000 60 * 1024也就是0x0800F000。这里很多人第一次用会写错。另外STM32F103的Flash编程宽度是16位半字写入时最小单位是半字不是字节。也就是说哪怕你只想改一个字节也要按16位的方式写入高字节填0xFF。这一点在跨平台移植代码时特别容易踩坑比如从STM32F4移植过来F4是按32位编程的地址对齐要求也不一样。2.2 擦除和写入的最小单位写1变0擦0变1这是Flash和RAM最本质的区别。RAM可以任意地址随意改写但Flash的物理特性决定了编程操作只能把1写成0不能把0写成1想把0恢复成1只能执行擦除操作而擦除是按整个扇区页进行的。举一个具体例子假设某页里存了一个结构体里面包含设备地址、波特率、校准值三个字段总共占16字节。如果只是把设备地址从0x01改成0x02不能直接对这个地址执行编程操作因为原来存的0x01二进制是0b00000001要写的0x02是0b00000010其中第1位要从0变成1Flash做不到。正确做法是先把整页擦掉所有字节变成0xFF再把包含新地址的完整16字节重新写进去。这带来一个设计思路上的转变向内部Flash写数据本质上是整页重写不是原地修改。所以存数据时最好做一个映射把需要修改的数据集中存放在少数几个页里每次修改都擦除重写这几个页。2.3 写入寿命和掉电保护STM32F103的数据手册里Flash擦写寿命是10,000次典型值。这个数值看着不大但要注意它是按页算的。如果每次只擦写同一个页那这个页写到一万次就废了但如果程序里做了均衡磨损多个页轮流用整个芯片的可用寿命可以成倍提升。我见过有人拿内部Flash当数据记录仪用每秒钟写一条日志结果没到3个小时就把一个页写穿了。这不是芯片不行是用法不对。后来改成4个页轮流写寿命变成4倍再把写入频率降到每分钟一次产品用几年完全没问题。掉电保护也是个大坑。擦除和写入过程中如果突然断电Flash里的数据可能处于中间状态——要么没擦干净要么写了一半。如果正好擦的是存放关键参数的页设备重新上电后可能读到一堆0xFF或者残缺数据。解决办法通常是双页备份同一份数据存两份写入时先写备份页再写主用页上电读取时校验两份数据的CRC哪个完整用哪个。这个思路在后面的代码实现里会给出示例。3. 内部Flash读写例程代码逐段拆解下面进入正题给出一份我实际验证过的内部Flash读写例程。代码基于标准外设库V3.5芯片用的是STM32F103C8T6也就是最常见的蓝色 pill 开发板。工程环境是Keil MDK5。先声明一点这个例程的代码不是我闭门造车写的而是借鉴了不少开源例程后针对实际项目中遇到的问题反复修改出来的版本。它最大的特点是在公司量产过的产品上稳定运行过不是那种只在开发板上点个灯就完事的Demo。3.1 文件结构和接口定义我把Flash操作封装成一个独立模块包含两个文件flash_if.h和flash_if.c。接口设计尽量简洁上层业务代码不需要关心Flash的页大小、擦除时序这些细节。// flash_if.h #ifndef __FLASH_IF_H #define __FLASH_IF_H #include stm32f10x.h // 用户数据区的起始地址和页数 // C8T6 的Flash共64KB最后2KB留给用户数据区 #define USER_FLASH_START_ADDR 0x0800F800 #define USER_FLASH_PAGE_SIZE 1024 #define USER_FLASH_PAGE_NUM 2 // 单次读写的数据块大小按16位对齐 #define USER_FLASH_DATA_SIZE 128 // 返回状态定义 typedef enum { FLASH_IF_OK 0, FLASH_IF_ERR, FLASH_IF_PARAM_ERR, FLASH_IF_CRC_ERR } flash_if_status_t; // 对外接口 flash_if_status_t flash_if_write(uint32_t addr, uint8_t *data, uint16_t len); flash_if_status_t flash_if_read(uint32_t addr, uint8_t *data, uint16_t len); flash_if_status_t flash_if_erase_pages(uint32_t start_addr, uint16_t num_pages); #endif这里把用户数据区放在Flash最后两页也就是0x0800F800到0x0800FFFF。这样做的原因有两个代码区放在Flash开头编译出来的固件一般不会超过60KB最后两页基本不会被占用。即使以后固件膨胀还有机会重新规划地址。搭配Bootloader使用时升级程序只擦写App所在区域不影响末尾的数据区。3.2 写入流程的实现细节写入操作是整个模块的核心。标准库提供了FLASH_ProgramHalfWord()函数来执行半字编程但直接用它有几个问题需要处理地址对齐、校验、擦除时机。// flash_if.c #include flash_if.h static uint32_t get_page_addr(uint32_t addr) { return addr ~(USER_FLASH_PAGE_SIZE - 1); } flash_if_status_t flash_if_erase_pages(uint32_t start_addr, uint16_t num_pages) { uint16_t i; uint32_t page_addr get_page_addr(start_addr); // 检查地址范围 if ((start_addr USER_FLASH_START_ADDR) || (start_addr num_pages * USER_FLASH_PAGE_SIZE) (USER_FLASH_START_ADDR USER_FLASH_PAGE_NUM * USER_FLASH_PAGE_SIZE)) { return FLASH_IF_PARAM_ERR; } FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); for (i 0; i num_pages; i) { if (FLASH_ErasePage(page_addr i * USER_FLASH_PAGE_SIZE) ! FLASH_COMPLETE) { FLASH_Lock(); return FLASH_IF_ERR; } } FLASH_Lock(); return FLASH_IF_OK; }注意几个细节FLASH_Unlock()必须在擦除或编程之前调用这是ST设计的写保护机制防止程序跑飞时乱写Flash。写完记得FLASH_Lock()重新上锁。FLASH_ClearFlag()要提前清掉状态寄存器里的错误标志。如果上一次操作出错后没有清除这一次操作可能直接返回错误。get_page_addr做页对齐传入的地址如果不是页首地址会向下取整。这个设计是为了防止调用者传了一个页中间的地址导致擦错区域。3.3 读取操作比写入简单得多读Flash和读RAM一样直接通过总线访问不需要解锁也不需要检查忙状态。所以读取函数写起来非常简单flash_if_status_t flash_if_read(uint32_t addr, uint8_t *data, uint16_t len) { uint16_t i; uint8_t *flash_ptr (uint8_t *)addr; if ((addr USER_FLASH_START_ADDR) || (addr len) (USER_FLASH_START_ADDR USER_FLASH_PAGE_NUM * USER_FLASH_PAGE_SIZE)) { return FLASH_IF_PARAM_ERR; } for (i 0; i len; i) { data[i] flash_ptr[i]; } return FLASH_IF_OK; }这里有个性能问题值得说。虽然Flash读操作本身是很快的但如果频繁调用读取函数每次都逐字节拷贝可能略显浪费。实际项目中我通常配合memcpy使用一次拷完。为什么读取不需要解锁因为ST设计Flash读路径是直接映射到CPU地址空间的读操作不会造成数据破坏所以不需要额外的保护机制。但写操作会把数据线拉起来、产生高压脉冲如果不小心在写过程中读取同一块Flash区域会导致CPU取指异常。标准库的FLASH_ProgramHalfWord()函数内部其实已经做了处理它会等编程完成后才返回所以程序不会跳到正在写的区域去取指。3.4 带CRC校验的整块写入上面给的是最基础的读写函数但实际产品里裸用还不够。我习惯把数据组织成固定大小的块加上CRC校验后整块写入。这样上电后读取时先算CRC对不上就说明数据损坏可以启用备份数据或者恢复出厂设置。// 数据块格式前4字节CRC32紧接着是有效数据 #define FLASH_BLOCK_CRC_SIZE 4 typedef struct { uint32_t crc; uint8_t data[USER_FLASH_DATA_SIZE - FLASH_BLOCK_CRC_SIZE]; } flash_block_t; flash_if_status_t flash_if_write_block(uint32_t block_index, uint8_t *data, uint16_t len) { flash_block_t block; uint32_t target_addr; if (block_index USER_FLASH_PAGE_NUM || len sizeof(block.data)) { return FLASH_IF_PARAM_ERR; } // 填装数据并计算CRC memset(block, 0xFF, sizeof(block)); memcpy(block.data, data, len); block.crc crc32_compute(block.data, sizeof(block.data)); // 目标地址 target_addr USER_FLASH_START_ADDR block_index * USER_FLASH_PAGE_SIZE; // 先擦除再写入 if (flash_if_erase_pages(target_addr, 1) ! FLASH_IF_OK) { return FLASH_IF_ERR; } FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); // 按16位逐半字写入 uint16_t *src (uint16_t *)block; uint32_t i; for (i 0; i sizeof(block) / 2; i) { if (FLASH_ProgramHalfWord(target_addr i * 2, src[i]) ! FLASH_COMPLETE) { FLASH_Lock(); return FLASH_IF_ERR; } } FLASH_Lock(); return FLASH_IF_OK; }这段代码里有一个容易被忽略的点memset(block, 0xFF, sizeof(block))。因为是整页擦除后写入未写入的字节应该是0xFF。如果用了0x00填充写入后那些位置会变成0CRC校验时就算不出来了。这个细节我一开始没注意排查了好久。CRC32的计算可以自己实现查表法也可以用STM32F10x标准库里没有但网上满天飞的现成函数。这里不贴完整查表代码了直接说思路CRC的输入是整块有效数据输出存到块头读取时重新计算比对即可。如果你的项目对算法大小有要求也可以用CRC16碰撞概率在小型参数存储场景完全够用。4. 实战中的避坑记录从错误到正确的完整排查过程代码看起来没什么问题了但实际跑起来就是会遇到各种诡异现象。我把这几个问题单独拎出来写一节是因为它们不是个例几乎每个用内部Flash的人都会碰到其中之一。4.1 擦除后写入为什么读出来的还是一堆0xFF有朋友照着例程写发现擦除成功、写入也返回FLASH_COMPLETE但重新读回来的数据全是0xFF。他一度怀疑芯片坏了。这个问题的根因在于地址用的Flash地址但指针类型写错了。看这行uint8_t *flash_ptr (uint8_t *)addr;如果addr是uint32_t类型这本身没问题。但如果调用者传入的地址是页编号而非页首地址比如传入58而不是0x0800E800那读的时候实际访问的是0x0000003A这个地址——这是芯片的SRAM区或者外设区读出来当然不是想要的数据。排查方法很简单在写入完成后用调试器直接在Memory窗口查看目标地址的原始内容。如果显示的是FF FF FF FF说明写入操作本身没成功问题出在解锁、地址或时序如果显示的就是写入数据但程序读出来不对那问题出在读取端的指针或地址转换。我推荐的做法是写一个自检函数先写入已知数据读回来比对然后把Flash区清零写入0x00再读回来确认。几个来回就能锁定问题出在写链路还是读链路。4.2 程序在Flash写入过程中跑飞、卡死或进入HardFault这个现象在调试时很常见尤其是代码里用了RTOS或者中断比较频繁的情况下。原因有两类第一类中断里访问了正在编程的Flash区域。比如中断服务函数里用了某个常量数组或者字符串字面量而这些内容恰好存放在正在被擦写的那一页。擦除期间CPU访问该区域会触发总线错误直接进HardFault。解决办法是把中断里用的常量放到不会擦写的区域或者在Flash操作期间临时屏蔽相关中断__disable_irq()慎用最好只屏蔽自己可控的中断源。第二类没有等待编程完成就继续执行后续代码。标准库的FLASH_ProgramHalfWord()内部其实已经等待了BSY标志清零但如果你绕过标准库直接操作寄存器或者把FLASH_COMPLETE的判断条件写反了就可能在下一条指令执行时Flash还在忙。我的经验是除非对性能有极端要求否则永远用标准库函数别自己裸操作寄存器。还有一个容易忽略的问题Keil的优化等级开太高有时会把关键代码的执行顺序重排。比如编译器认为FLASH_Lock()和前面的写入操作无依赖关系就把它提前执行了导致后面的半字编程因为没有解锁而失败。对于这类问题可以在FLASH_ProgramHalfWord调用前后加__DMB()内存屏障指令或者把相关操作封装到一个单独编译单元里并把这个文件优化等级设为-O0。4.3 上电后偶尔能读到数据、偶尔全是0xFF这类问题最闹心因为不是稳定复现。如果排除硬件供电问题多半是掉电时序造成的。现场设备的供电往往不是理想的5V/3.3V掉电时电压是逐渐下降的。MCU在电压跌落到复位门槛之前程序可能还在运行。如果这时候正好执行了Flash擦除或者写入操作而Flash编程电压已经不足以完成操作就会留下残缺数据。下次上电读到一半是旧数据、一半是0xFFFFFFFF。应对思路有两个层面硬件层面在电源入口加掉电检测比如用PVD可编程电压检测器的中断当电压跌到阈值以下时立刻停止Flash操作把关键参数保存在SRAM里或者直接进入低功耗挂起模式。软件层面用双备份机制。具体做法是同一份参数存两份每份都带CRC写入时先写第0页再写第1页上电读取时先看第0页的CRC对不对不对再看第1页。这样即使某次写入只完成了一半另一份完整数据还在。我量产的项目里双份备份配合CRC校验基本上把掉电损坏导致的数据丢失问题降到了零。代价是多用一页Flash以及写入时间翻倍但对于低频参数保存场景完全值得。5. 把读写例程升级成可落地的存储方案基础的读写例程掌握了接下来要考虑的是怎么把它变成真正能跑在产品上的存储方案。这里给出我在实际项目里用的几个改进策略都是踩过坑之后总结出来的。5.1 用双页备份写入计数器延长寿命并防掉电损坏双页备份的策略具体展开如下把Flash的最后4页4KB划为参数区其中2页存放主数据块2页存放备份数据块。主块和备份块各占2页是因为有些参数结构体比较大一页放不下。每页开头存一个4字节的写入计数器每次写入时递增后面是CRC和有效数据。写入时先写备份区再写主区。读取时比较两边的写入计数器取较大的那个作为有效数据如果其中一边CRC校验失败自动用另一边恢复。这里写入计数器的作用不只是判断新旧——它还兼作均衡磨损的轮换标志。当主区的写入次数达到某一阈值比如1000次就把主区和备份区的地址互换把磨损分散到不同页。虽然STM32F103的Flash寿命只有一万次但配合这个策略参数保存场景下基本可以做到产品生命周期内无忧。5.2 数据格式设计预留结构体大小的坑参数存储最常见的做法是定义结构体一次性把整块数据写进Flash。这里有个隐藏问题结构体的内存对齐和编译选项有关。同一个结构体在Keil默认设置下编译出来占用的字节数和GCC编译出来可能不一样。如果产品后面换了工具链或者Bootloader和App用不同编译器编译读取旧数据时结构体解析就可能错位。规避方法有两个结构体里所有成员都显式指定__attribute__((packed))或者使用#pragma pack(1)强制按1字节对齐。不用结构体改用固定偏移的数据布局第0字节放版本号第1到第4字节放设备地址第5到第8字节放校准值……写死偏移不管谁编译、用哪个编译器解析规则都不会变。我在量产固件里用的是第二种方式再加一个版本号字段。这样即使以后固件升级、参数结构变化也能通过版本号做兼容处理——老版本固件读新参数区的数据时能识别出版本不同走默认值初始化流程。5.3 把Flash写入融入系统架构掉电保存任务的实现实际产品里Flash写入很少是业务代码直接调用的。我习惯做一个叫ParamManager的中间层业务代码只调用ParamManager_Read(param_id, value)和ParamManager_Write(param_id, value)。ParamManager内部维护一份SRAM缓存上电时从Flash加载写入时先改缓存再标记脏数据。主循环或者空闲任务里检测到脏数据标志延迟去重写Flash。延迟的意义在于如果业务代码连续多次修改同一参数只在最后一次修改后写Flash避免频繁擦写。掉电前如果系统有UPS或者大电容能维持几十毫秒供电可以在掉电检测中断里强制执行一次Flash写入把最后的参数落下。这套架构让上层业务完全感觉不到Flash的存在。写参数就是改内存变量稳定性和效率都高。6. 例程实测效果与性能参考例程写好了方案也定了最后给出一组我实测的数据供大家评估内部Flash是否满足需求。测试环境STM32F103C8T6主频72MHzFlash时钟配置为24MHz数据手册要求Flash访问等待周期为2个等待状态使用标准库V3.5。操作耗时备注擦除1页1KB约20ms数据手册典型值为20ms实测接近写入128字节64个半字约2ms不含擦除时间写入1KB整页约16ms半字编程等待逐半字执行读取128字节小于1us直接总线访问几乎无耗时完整读写路径擦写CRC校验约22ms掉电保存时典型的操作耗时从这个表格能看出来内部Flash的写入速度远快于I2C接口的EEPROM。AT24C02写一页8字节需要5ms写128字节要80ms8个页内部延时相比之下内部Flash快了一个量级。而且整个过程MCU不需要等待总线代码可以继续跑当然要注意中断访问冲突的问题。写入寿命方面按每天写10次计算单页一万次寿命可以用约1000天。配合4页均衡磨损寿命扩展4倍约4000天超过11年。大多数消费级和工业级产品的设计寿命都在这个范围内。如果还不够可以用16页轮流写寿命延长到16倍——此时你真正需要担心的是别的问题比如Flash本身的老化速度。7. 我的最终建议内部Flash读写这个功能代码量不大原理也不复杂但它处在存储和安全的交界处出问题时往往比较隐蔽。上面所有的细节——页对齐、半字编程、擦写寿命、掉电保护、双备份、CRC校验——任何一个环节没处理到位产品在客户现场都可能出现偶发的数据丢失。我个人的经验是能用内部Flash解决的问题尽量不要加外部存储芯片。STM32F103的内部Flash虽然不是为数据存储设计的但只要控制好写入频率、做好备份和校验它在绝大多数参数保存场景下都是可靠且经济的选择。如果你在项目里遇到了内部Flash相关的诡异问题不妨回到这篇文章里对号入座——大概率是地址没对齐、结构体被编译器填充、擦写期间中断访问冲突、或者掉电时序没处理好这四类原因之一。定位到根因修复起来就很快了。本文还有配套的精品资源点击获取
返回列表