资讯详情

资讯详情

STM32 Flash擦写规则与模拟EEPROM:从基础原理到工程实践

前阵子做一个小设备需要把一组校准参数在掉电后继续保留我第一次把 Flash 当 SRAM 用直接往地址写数据。程序跑起来看着没问题参数能读写结果换了个数据长度再存一次就全乱套了查了半天才发现是压根没搞懂 STM32 Flash 的擦写规则。Flash 存储器操作在 STM32 开发里属于那种不碰没事、一碰就出鬼的模块从烧录代码到掉电保存参数、再到 IAP 在线升级全都绕不开它。这篇进阶篇就把它彻底讲透内容包括 Flash 的分区结构、为什么必须先擦后写、HAL 库的完整操作链路、用 Flash 模拟 EEPROM 做参数存储外加几个我实际踩过的坑。适合已经会跑 LED 点灯、能用串口打印、但对存储体系还是半懂不懂阶段的开发朋友。1. Flash 在 STM32 系统里的位置先搞清楚它管什么1.1 三种存储介质的分工别再把它们混为一谈刚入门的时候很多人会把 Flash、SRAM、EEPROM 混在一起理解。其实这三样在 STM32 里的角色是彻底分开的。Flash 是掉电不丢的只读为主存储芯片一上电 CPU 就拿 Flash 里保存的指令来执行工程编译出来的 .bin 文件就是被烧写到 Flash 里的。SRAM 是掉电就丢的运行时内存我们的全局变量、局部变量、堆栈全都在里面速度快但断电后什么也不剩。还有一类叫 EEPROM 的东西有些老型号单片机自带按字节擦写很方便但 STM32F1 系列和 F4 系列的大多数型号并没有内部 EEPROM数据和程序一样都放在 Flash 里。所以你会看到一个现象数据手册里写 STM32F103CBT6 的 Flash 容量是 128KBSRAM 是 20KB如果你的需求是保存几条配置数据这些数据占用的也是那 128KB Flash 里的空间而不是另有一块专门的数据区。很多人上来就在 Bootloader 之外留了一块地址放参数没有提前规划地址布局代码稍大一点参数区就覆盖了程序区设备一上电直接跑飞。1.2 Flash 容量和页/扇区划分是各种通用教程不讲的第一层坑STM32 内部 Flash 的擦除最小单位不叫字节叫页Page或者扇区Sector。不同系列的划分方式差异很大这一步没搞清楚后面所有操作都会翻车。我做了个常用的对照表大家先有个概念。芯片系列典型型号内部 Flash 容量擦除最小单位写入最小单位F1 中容量STM32F103C8T6 / CBT664KB / 128KB1KB 页字节/半字/字F1 大容量STM32F103ZET6512KB2KB 页字节/半字/字F4 系列STM32F407ZGT61MB16KB 起若干扇区字节/半字/字/双字H7 系列STM32H750VBT6128KB128KB 用户区字节/半字/字注意 F4 的扇区可不是等宽的扇区 0 到 3 各 16KB扇区 4 是 64KB后面 5 到 11 全是 128KB。这个结构直接决定了你选择参数存储位置时必须按扇区边界对齐。如果你把参数放在扇区 4 的中间擦除的时候就要把整个 64KB 都抹掉代价非常大。所以做数据存储规划时不要把数据零散地扔在程序后面要专门划分一整页或一整扇区作为数据页这在前面的表格里也隐含了每个系列的擦除粒度差异决定了不同的模拟 EEPROM 策略。我把这些表格给了一个做 F4 项目的同事看他原来的代码在 F1 上好好的换到 F4 之后就老是擦不干净因为他的擦除函数写死了页地址而 F4 根本没有页这个概念只有扇区。这个细节值得每一位换平台开发的朋友记下来。2. Flash 物理特性决定的两条铁律为什么写入前必须擦除2.1 1 变 0 容易0 变 1 只能靠擦除当年第一次在 STM32 上写 Flash我以为只要像操作数组一样往某个地址塞个新值就算更新了。后来发现Flash 的本质是浮栅晶体管擦除和编程的物理机制完全不同。编程写入时电荷被注入浮栅使单元从 1 变成 0而擦除时是把电荷从浮栅里抽出来让单元从 0 变回 1。换句话说Flash 单元只允许朝一个方向变把已经存在的 1 写成 0 是可以的但如果你想改掉某个已经是 0 的位让它变成 1就必须对整个页/扇区做擦除操作。我打过一个比方这就像在一张只能涂黑不准涂白的纸上写字你写错了想改唯一办法是把整页纸撕掉换一张新的。这个特性带来了一个非常指导性的结论Flash 的操作顺序永远是先擦除再写入不存在覆盖写。你往一个全是 0x00 的 Flash 区域直接写 0xFF表面上好像写进去了实际上因为写入只能在 1 变 0 的方向上生效0x00 是没法变成 0xFF 的。很多初学者在调试时发现写不进去十有八九就是这个原因。2.2 铁律一改写前必须先整体擦除最小单位是页/扇区既然只能往 0 的方向变那每次要更新数据就必须先选中一个页/扇区把它全部擦成 0xFF然后再把新数据写进去。擦除的时间不短以 F1 中容量的 1KB 页为例典型擦除时间在 20ms 量级F4 一个 128KB 扇区就更慢了需要几百毫秒。在这段时间里Flash 控制器处于忙状态如果代码或者中断服务函数大量访问同一个 Flash 区域CPU 会被总线卡住。这在全国大学生电子设计竞赛那种追求快速响应的项目里是个容易被忽略但很致命的点。2.3 铁律二写入尽可能按字对齐擦写寿命要按 10K 次设计写入虽然支持字节、半字、字甚至双字但 STM32 官方文档和 HAL 库都建议尽量用 32 位字为单位写入一是速度快二是防止跨字边界时的边界擦除行为产生意外。做嵌入式这么多年我的经验是就算你只需要存 1 个字节的参数也要用掩码运算把它包装成一个 32 位字再写这样地址对齐是 4 的倍数后续维护时少很多麻烦。另外还要记住 Flash 的擦写寿命STM32 内部 Flash 标称通常是 10,000 次擦写循环。10K 次听起来不少但如果你的设备每秒存一次参数一天 86400 次不到一个小时就把一个页写穿了。所以当你的应用是频繁存储时不能只盯着某一页反复擦必须引入磨损均衡——这也是本文第 4 章要展开的核心内容。3. HAL 库实操从解锁到读回验证的完整链路3.1 为什么操作前必须先解锁STM32 的 Flash 控制器默认是锁定状态目的就是防止程序因为指针乱飞等原因把代码区或者关键配置区域意外擦掉。使用 HAL 库操作内部 Flash 的标准流程是解锁 Flash - 擦除目标页/扇区 - 写入数据 - 锁定 Flash。解锁代码很简单但很多人会忘掉最后一步或者在中途发生了复位导致 Flash 一直敞开着。我的习惯是无论函数是否执行成功都在函数出口调用HAL_FLASH_Lock()。另外还要提醒如果芯片开启了 RDP 读保护或者某个区域有写保护WRP解锁之后依然写不进去返回错误码会带有HAL_FLASH_ERROR_WRP需要在 CubeMX 里检查 option bytes 的配置。HAL_FLASH_Unlock(); // ... 擦除、写入操作 ... HAL_FLASH_Lock();3.2 擦除与写入代码以 F103 为例的完整流程我拿最经典的 F103CBT6 做例子把擦除和写入写成一段可以直接抄的代码。假设我要在 Flash 顶部留一个 1KB 的页做数据存储地址是0x0801F800128KB 的最后一页页大小 1KB起始地址 0x08000000 (127 * 1024) 0x0801F800。#include stm32f1xx_hal.h #define DATA_PAGE_ADDR 0x0801F800u /* 最后一页起始地址 */ #define DATA_PAGE_SIZE 1024u void Flash_Page_Erase(uint32_t page_addr) { FLASH_EraseInitTypeDef erase_cfg {0}; uint32_t page_error 0; HAL_FLASH_Unlock(); erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; /* F1 按页擦除 */ erase_cfg.PageAddress page_addr; erase_cfg.NbPages 1; if (HAL_FLASH_Erase(erase_cfg, page_error) ! HAL_OK) { Error_Handler(); /* 检查 HAL_FLASH_GetError() 定位原因 */ } HAL_FLASH_Lock(); } void Flash_Write_Test(void) { uint32_t data[4] {0x11111111, 0x22222222, 0x33333333, 0xA5A5A5A5}; uint32_t i; Flash_Page_Erase(DATA_PAGE_ADDR); /* 先擦后写顺序不能倒 */ HAL_FLASH_Unlock(); for (i 0; i 4; i) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, DATA_PAGE_ADDR i * 4, data[i]) ! HAL_OK) { Error_Handler(); } } HAL_FLASH_Lock(); /* 读回验证从同一个地址直接以 volatile uint32_t 指针读取 */ volatile uint32_t *read_back (volatile uint32_t *)DATA_PAGE_ADDR; for (i 0; i 4; i) { printf(addr[%d] 0x%08lX\r\n, i, read_back[i]); } }注意HAL_FLASH_Program第三个参数是写入的数据值不是数据地址。有些人刚开始会习惯性地写data[i]编译也能过但结果完全不对。F1 的 Program 支持字节、半字、字三种类型F4 还支持双字。地址必须按写入类型的宽度对齐写 32 位字的地址必须是 4 的倍数否则会返回HAL_FLASH_ERROR_PGA。3.3 烧录后的实测输出一个编译出来的调试输出大概长这样addr[0] 0x11111111 addr[1] 0x22222222 addr[2] 0x33333333 addr[3] 0xA5A5A5A5第一次跑通这个流程之后我特意做了个缺德测试不擦除直接重新写一组新数据进去结果读出来的值变成了新旧数据的按位与结果。比如之前写 0x11111111 的位置改写成 0x22222222读回来是 0x00000000因为0x11111111 0x22222222 0x00000000。这个现象非常经典建议每位新手都亲手做一次能比看十遍文档更深刻地理解为什么 Flash 必须先擦后写。3.4 关中断、喂狗、调度器这些平时想不到的因素擦除和写入期间主频的 Flash 读取会受影响。最稳妥的做法是在擦除大扇区之前关闭可能触发中断的定时器或者至少保证中断服务函数不访问 Flash 区域。如果你的项目用了独立看门狗 IWDG擦除时间可能超过喂狗周期需要提前喂狗或者临时屏蔽看门狗刷新否则擦除擦到一半芯片复位多半会留下一个半擦除的坏页。这个坑特别隐蔽因为 IWDG 的默认溢出时间是几百毫秒到几秒而 F4 大扇区擦除加上等待时间偶尔会撞上。4. 数据掉电保存方案用 Flash 模拟 EEPROM 的工程化玩法4.1 实际项目要的不是能写一次 Flash而是反复存参数不掉链子出了学校真正在产品上跑的代码很少会像第 3 章那样写一次就完事。更多场景是用户设置了一个阈值设备希望断电后还能记住或者设备累计运行时间每小时存一次下次开机接着累计。这种频繁小量数据保存的需求在 STM32 上没有独立 EEPROM就得靠 Flash 模拟。Flash 模拟 EEPROM 的核心矛盾是擦除粒度大1KB 或更大、寿命有限约 10K 次、写入粒度小几个字节。工程化的思路就是用页磨损均衡来解决。4.2 双页交替方案的基本设计我常用的一个方案是双页交替还可以扩展到多页在数据区里划分两个页每页按照下面的结构布局页头 4 字节写一个固定魔数0xA5A5A5A5标识该页包含有效数据紧接着是参数块通常 64 字节左右结构体打包后存进去末尾追加 4 字节 CRC用来判断数据完整性每次更新数据时先读当前有效页把旧数据加载到内存修改后写入另一个空页再把旧页擦除。这样每个页并不是每次更新的目标两个页轮流用等效擦写寿命提高了近一倍。如果再拆成 4 页轮流寿命可以再翻倍。这就是磨损均衡的本质让擦写操作均匀分布在多个页上。4.3 核心代码交替写入与恢复逻辑我这里给一个简化但可运行的双页版本。它把最新数据写入当前页的下一页然后通过页头的魔数来标记有效页。#define PAGE0_ADDR 0x0801F800u /* 页0 */ #define PAGE1_ADDR 0x0801F400u /* 页1 */ #define PAGE_MAGIC 0xA5A5A5A5u typedef struct { uint32_t magic; uint32_t threshold; uint32_t crc32; } ParamBlock; static uint32_t CRC32_Calc(const uint32_t *buf, uint32_t len); void Eeprom_Init(void) { /* 上电后先扫描两页判断哪页有效 */ ParamBlock *p0 (ParamBlock *)PAGE0_ADDR; ParamBlock *p1 (ParamBlock *)PAGE1_ADDR; if (p0-magic PAGE_MAGIC p1-magic ! PAGE_MAGIC) current_page 0; else if (p1-magic PAGE_MAGIC p0-magic ! PAGE_MAGIC) current_page 1; else { /* 双页同时有效或者同时无效说明上次掉电时机不对做异常恢复 */ /* 更完善的方案再比较 CRC 和写入序号这里简化为选 CRC 正确的一页 */ if (CRC32_Calc((uint32_t *)p0, 2) p0-crc32) current_page 0; else current_page 1; } } void Eeprom_Update(uint32_t threshold) { uint32_t next_page (current_page 0) ? 1 : 0; ParamBlock new_block; new_block.magic PAGE_MAGIC; new_block.threshold threshold; new_block.crc32 CRC32_Calc((uint32_t *)new_block, 2); /* 擦除下一页 */ HAL_FLASH_Unlock(); FLASH_EraseInitTypeDef erase_cfg; erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.PageAddress (next_page 0) ? PAGE0_ADDR : PAGE1_ADDR; erase_cfg.NbPages 1; uint32_t error_page; HAL_FLASH_Erase(erase_cfg, error_page); /* 写入新页 */ uint32_t *src (uint32_t *)new_block; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (next_page 0) ? PAGE0_ADDR : PAGE1_ADDR, src[0]); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (next_page 0) ? PAGE0_ADDR 4 : PAGE1_ADDR 4, src[1]); HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (next_page 0) ? PAGE0_ADDR 8 : PAGE1_ADDR 8, src[2]); HAL_FLASH_Lock(); /* 把旧页标记为无效只擦除旧页的页头 4 字节即可避免擦除整页 */ HAL_FLASH_Unlock(); uint32_t invalid 0x00000000; HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (current_page 0) ? PAGE0_ADDR : PAGE1_ADDR, invalid); HAL_FLASH_Lock(); current_page next_page; }这个版本的巧妙之处在于每次更新只擦除下一页之前已被清空旧页只是被写了一个 0x00000000 的开头把它标记成无效不需要立即擦除。下一次更新时轮到旧页做目标页才把它整页擦掉。所以每次更新包含一次页擦除和几字节的页头写磨损非常均衡。4.4 掉电安全的兜底策略前面代码里我埋了一个恢复逻辑。真正恶劣的工业环境下Flash 写入途中掉电非常常见。可能发生的情况有新页还没写完整、CRC 没来得及写、旧页已经标记无效但新页不完整。所以读取时不能只看页头魔数必须校验 CRC如果 CRC 不对就回退到上一页。更稳妥的方案是在页头再加一个递增的写入序号用来判断哪一页才是最新的。当两页的魔数都有效时序号大的赢当一页有效且 CRC 错误时用另一页。这套策略做好之后掉电好几次都不会丢失参数。5. 几个容易翻车的细节坑与规避思路5.1 擦除与写入参数搞错的经典报错用 HAL 库时最常遇到的就是HAL_FLASH_Erase返回HAL_ERROR。排查顺序我总结成一句话先看HAL_FLASH_GetError()返回的是什么。如果是HAL_FLASH_ERROR_PGA说明地址不是按页/扇区边界对齐如果是HAL_FLASH_ERROR_WRP说明目标区域有写保护如果是HAL_FLASH_ERROR_PGS说明编程时选的类型和地址宽度不匹配。有一次我发现自己的错误是FLASH_TYPEERASE_PAGES和FLASH_TYPEERASE_SECTORS选错了——F103 上用了扇区擦除库直接报错。这个错误在代码编译期不会出现因为枚举值都存在只有运行到那里才会爆出来。5.2 编译器优化导致的读旧值陷阱有个问题我当时排查了很久写入成功后立刻用普通指针去读地址读回来的竟然还是旧值。后来发现是编译器优化它把 0x0801F800 这个地址当成了普通的全局常量地址直接在编译期把值替换成了旧值压根没有重新访存。解决方法是把地址指针声明成volatile或者在读取之前执行一次__DSB()数据同步屏障指令。我在第 3 章的代码里已经用了volatile uint32_t *read_back这就是防优化的一部分。经验之谈凡是操作寄存器地址、Flash 地址、MMIO 地址一律用volatile修饰可以省掉大量玄学问题。5.3 DMA、缓存和中断同时访问 Flash 导致的卡死如果程序里用了 DMA 传输DMA 搬运时 CPU 和 DMA 都可能访问 SRAM 和 Flash。虽然不会直接冲突但在 F4/H7 这类带缓存架构的芯片上如果开启了 ART 缓存或者 D-CacheFlash 内容被修改后缓存还留着旧值读出来依然可能是新数据没了。解决办法是在擦除写入完成后调用SCB_CleanInvalidateDCache()清理数据缓存H7 之前还建议关掉 Flash 的 ART 加速再操作。这个问题在 F1 上不存在所以很多老代码带到 F4 上才会炸。5.4 地址冲突参数区覆盖了程序区还有一种很隐蔽的坑程序编译出来越来越大或者你换了个编译器优化等级代码体积超过了某个范围直接把你预留的参数区地址给压进去了。这时程序跑起来以后一旦执行 Flash 写入操作等于在篡改自己正在执行的代码结果就是死机重启程序莫名其妙消失。规避方法有两个一是养成看.map文件的习惯编译完成后查一下FLASH段用了多少字节二是在代码里做一个编译期检查用类似_Static_assert或链接脚本的方式保证代码末尾地址 数据区起始地址。我强烈推荐后者因为人工看 map 文件实在太容易漏了。6. 同一话题的延伸片内 Flash 容量选型与外部 Flash 扩展6.1 芯片选型时要盯住 Flash 容量不只盯内核看到大家都在搜 GD32F103CBT6 的 Flash 大小这里多说一句。GD32F103CBT6 的 Flash 容量和 STM32F103CBT6 一样都是 128KB引脚也基本兼容很多国产替代项目直接换。但注意 Flash 控制器细节有差异比如页大小、擦除等待时间不完全一致最好用各自厂商的库或者至少实测擦除和写入时序再批量切换。做选型时如果项目要跑 RTOS、图形界面、语音播放这类资源大户128KB 的片内 Flash 可能很快就满了这时候要么换 512KB 的 F103ZET6要么走片外 Flash 扩展路线。6.2 代码不够放STM32H750 的片外 Flash 扩展思路STM32H750 这颗芯片很特殊内部只有 128KB Flash但它主频高、外设强很多人想拿它跑大程序比如 LVGL 图形界面或者音频算法。常规做法是把代码放进外部 QSPI Flash上电后由 Bootloader 搬运到 RAM 或者直接在 QSPI 的 memory-mapped 模式执行。这时候链接脚本.ld文件就要改把原来的FLASH段地址从内部地址指到外部映射区同时启动代码要初始化 QSPI 外设。这部分内容够单独开一篇进阶篇了但它的核心依然建立在对 Flash 操作的理解上——外部 SPI Flash 的页编程是 256 字节扇区擦除是 4KB比内部 Flash 大得多读写时序也完全不同。6.3 图片/字库存到外部 Flash和刷屏又是什么关系很多人搜图片存储到外部flash及刷屏这种场景通常是用 W25Q64/W25Q128 这类 SPI NOR Flash 存字库、图片、图标资源。片内 Flash 一共就几百 KB放几张大图片就没了外部 Flash 成本低、容量大、擦写寿命高达 10 万次非常适合存静态资源。刷屏的过程就是先从外部 Flash 读出图片数据到 SRAM再通过 LTDC 或者 DMA2D 刷到屏幕。想提速的话可以把 Flash 的数据读取放在内存映射模式CPU 直接像访问数组一样访问图片数据但代价是这个地址映射需要固定占用一个片选。这块的操作重点已经从内部 Flash 的先擦再写变成了SPI 时序、缓存页大小、DMA 配合逻辑完全不同以后可以单独专门写一次。在我自己的项目里最终那套参数的掉电保存就是用双页交替方案解决的。头几次测试还挺顺利后来把看门狗打开擦除期间触发了一次复位旧页被标记成无效、新页又没写完数据直接回到出厂值。从那以后我就学乖了任何 Flash 写入函数里要么临时关闭看门狗要么确保喂狗周期比最长擦除时间大一个数量级同时恢复逻辑里必须有 CRC 和写入序号的双保险。还有个小技巧调试 Flash 问题时直接在代码里用HAL_FLASH_GetError()打印错误码比盲猜地址快得多。Flash 这块只要耐心把先擦后写、按页对齐、寿命有限这三件事刻在脑子里就基本不会出大问题了。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →