news 2026/9/9 22:15:55

STM32L4内部FLASH读写实战:从擦除对齐到掉电安全

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32L4内部FLASH读写实战:从擦除对齐到掉电安全

简介:面向STM32L4系列嵌入式开发者,这份代码包提供基于LL库的寄存器级内部FLASH读写实现,已在STM32L452RET6芯片上调通。作者将FLASH解锁、擦除、写入、读取等操作封装在独立的C源文件与头文件中,底层均为寄存器配置,因此使用HAL库时只需调整少量定义即可复用,适合需要精细控制存储流程的进阶开发者。值得关注的是实践中的兼容性问题:改用STM32L471VETx后遇到擦页失败,最终定位为L4系列部分型号的FLASH页码不连续,仅需修改擦页函数的页码号即可解决;但该代码不直接适用于STM32L4x1系列,需自行调整。压缩包共2个文件,分别为FLASH.c与FLASH.h,整体仅2KB,轻量无冗余,查看代码即可理解完整流程。目前已有1930人学习或下载,说明这一小巧实现能帮助读者避开内部FLASH操作中的典型坑点。 做嵌入式的,谁没在“保存参数却一掉电就丢”这件事上栽过跟头。网上一搜“STM32L4xx 读写内部FLASH”,下载下来往往是这样一个压缩包:一个HAL库工程、几个.c和.h文件、一段很简短的示例。可真正自己跑起来,才发现“能跑”和“能用”之间隔着好几道坎:擦除粒度多大、为什么写入要按64位、双Bank工程怎么分区、操作到一半掉电会不会把固件搞坏。这篇文章就来把这些事讲透,结合我实际调这块的经验,把代码、参数、坑位一次说清楚。

1. 项目拆解:这个压缩包到底在解决什么问题

1.1 内部FLASH读写,本质上是在解决“掉电不丢参数”的需求

STM32L4xx系列内部都带了一块NOR FLASH,程序就存放在这里。程序写进去了当然不用天天改它,但在实际项目里,我们往往需要把一批“掉电不能丢”的数据也塞进去:设备校准系数、累计运行时间、用户配置、蓝牙配对信息、最后关机前的状态等等。

这类需求听起来很简单——“把数据存到FLASH里嘛”,但它和单片机里RAM变量最大的区别就是:CPU不能像写内存那样直接往FLASH地址写数据。写入前必须先擦除,擦除的最小单位不是字节而是页(Sector/Page),而且L4系列还有ECC校验、等待周期、双Bank切换等一系列约束。这就是为什么会有诸如“hdfs读写流程”“W25Q64读写 掉电不丢失”“匿名地面站 STM32参数读写”这类搜索词,本质上大家都在找一套可靠的“非易失存储方案”。内部FLASH的好处是零外围成本、读取速度快,坏处是擦写次数有限、操作不当会直接带崩程序,而外置W25Q64则是反过来。所以先把内部FLASH玩明白,是每个做低功耗/物联网设备的人绕不开的基本功。

1.2 和常见外设方案放在一起看,才能理解方案的选型边界

热词里不少人在找“SPI读写W25Q64”“I2C读写EEPROM”“RC522读写卡程序”这类代码,看得出大家最终目的都一样:找一种稳定的介质存数据。内部FLASH和外置EEPROM/SPI FLASH最常见的对比维度有三点:

对比项STM32L4内部FLASH外置EEPROM(如AT24C02)外置SPI FLASH(如W25Q64)
擦写寿命约1万次(官方标称)约100万次约10万次
写入粒度64位(双字)字节页(一般256字节)
擦除粒度页(L4多为2KB,视型号而定)扇区(一般4KB)
是否需要外围电路不需要需要I2C引脚需要SPI引脚
掉电数据保持好,但写一半掉电有风险
能否执行代码可以(XIP)不能部分型号支持映射执行
成本已包含在MCU内

如果只是存几个字节的配置参数、擦写频率很低,内部FLASH完全够用,还能省掉一颗外部存储器、一个引脚和一段驱动代码。但如果要存大量日志、字库、音频资源,就该用W25Q64这种大容量外部存储。如果参数需要频繁更新、每次只改一个字节,EEPROM更合适。搞清楚这个边界,你才会明白自己真正需要的是哪份参考代码。

2. 原理先行:L4内部FLASH的关键特性与踩坑点

2.1 双Bank结构与擦除粒度:别把扇区管理搞错

STM32L4系列(这里以常见的STM32L476/496为例)内部FLASH容量大多是1MB,划分成两个Bank:Bank1从0x08000000开始,Bank2从0x08080000开始。每个Bank内部又分成若干个2KB的扇区(部分型号页大小不同,拿到具体型号后第一件事就是查参考手册里的Flash organization表,别凭感觉)。

这个结构带来两个直接影响。第一,擦除操作是“按扇区”进行的,你哪怕只想改一个字节,也得先把整个2KB扇区擦掉。所以设计存储布局时,一般把“频繁修改的一组数据”集中放在同一个扇区里,避免跨扇区操作;把不常改的校准值、版本号分开存放。第二,L4支持双Bank交叉访问,内置了Read-While-Write能力,可以做“在线升级时继续执行代码”这类应用。但如果你没用到OTA,只用内部FLASH存参数,那大多数时候是“程序在Bank1跑,参数擦写在Bank2”,互不干扰。

我实际用过的芯片里,L4的擦除是最慢的一类,擦一个2KB扇区耗时在几十毫秒级别,具体时间取决于时钟配置和芯片主频。这一特性直接影响你在哪一层调用擦除函数——绝不能放在定时器中断里,更不能放在对实时性要求苛刻的路径上。

2.2 每64位一组ECC校验:为什么写入不能只写一个字节

STM32L4的内部FLASH带有ECC(Error Correction Code)校验机制,数据在写入时按“双字(64位)”为单位计算校验码。这造成一个非常关键的限制:写入时地址必须按8字节对齐,数据长度必须是8字节的整数倍。如果你直接往一个非8字节对齐的地址写数据,或者用HAL_FLASH_Program往FLASH里写一个uint32_t,轻则写失败,重则触发ECC错误,甚至进HardFault。

那项目里明明只需要存一个“温度阈值”,就是一个uint16_t,怎么办?正确做法是“读-改-写”:先把目标双字地址里原来的64位数据读出来,把要修改的字节对应位置换掉,再把完整64位一次性写回。做这个操作有两个前提:一是这段地址已经擦除过(全0xFF);二是你读出来的旧数据和要写的新数据组合后是完整64位。

顺带一提,L4的ECC还带来一个行为差异:读取FLASH内容时,如果某个双字区域从来没被写入过(也就是擦除后的全0xFF状态),读出来是正常的0xFF;但如果是“擦除后只写了一部分字节”,这块区域的ECC信息可能不完整,此时读取可能触发NMI或HardFault。这是很多人在自行封装“写任意字节”函数时踩得最深的一个坑——自己拼数据时没按8字节整块处理。

2.3 时钟等待周期和解锁流程,缺一不可

内部FLASH虽然挂在CPU的地址空间上,但它的读取速度是有限制的。当CPU主频提高时,CPU需要插入等待周期(Wait States)才能正确读取FLASH内容。STM32L4手册里给出了一个表格,比如在3.3V供电下,80MHz主频通常需要4个等待周期,这个参数在CubeMX生成工程时会自动配好。你要是手工改过时钟树,把频率调上去了却忘了同步修改FLASH延迟寄存器,程序就会莫名其妙地跑飞、死机,而且毫无规律。

另外,内部FLASH在复位后默认是“上锁”状态,直接调用擦写指令会被硬件拒绝。标准操作流程是:先调用HAL_FLASH_Unlock()往KEYR寄存器写入解锁密钥,然后执行擦除、编程操作,结束后再用HAL_FLASH_Lock()重新上锁。从安全角度讲,写完立刻上锁是一个好习惯,能避免程序跑飞后误擦FLASH。早期我做产品时偷懒没上锁,结果一次电源跌落导致芯片异常复位,维护代码里碰巧有擦写FLASH的指令,直接把整片程序区给干掉了。从那以后我写内部FLASH的驱动,第一件事就是规定“用完后必须Lock”。

3. 实操过程:从HAL初始化到读写回读的完整流程

3.1 工程搭建,以及FLASH驱动初始化前必须确认的三件事

不管是基于STM32CubeMX生成工程,还是裸写寄存器,我建议你先确认三件事:芯片具体型号、内部FLASH总容量、每个扇区的起始地址和大小。以STM32L476RG为例,FLASH基址0x08000000,总容量1MB,Bank1含256个2KB扇区(实际上具体扇区数量看型号,L476RG是256页+256页),Bank2从0x08080000开始。在代码里,优先用头文件里提供的宏定义而不是直接写死地址,比如用FLASH_BASE作为基址,再自己定义一个“参数存储区”的宏:

#define APP_FLASH_BASE 0x08000000u #define PARAM_SECTOR_ADDR 0x0807F000u /* 选在Bank1末尾空闲区域,注意别覆盖代码区 */

这里要特别强调“别覆盖代码区”。链接脚本决定了你的程序占用多长FLASH,取反就是最大可用地址。STM32L4内置的Bootloader和选项字节都占据特定地址,自己写参数时更要避开这些区域。稳妥的做法是在编译后查看.map文件,找到最后只读数据的结束地址,把参数区定义在它之后、且对齐到扇区边界。

初始化其实没有额外的“FLASH初始化函数”,HAL库在SystemInit时已经把时钟等待周期配置好了。你真正要做的只是调用HAL_FLASH_Unlock()和HAL_FLASH_Lock()。但有一点容易被忽略:如果开启了指令缓存(ICache)和数据缓存(DCache),对FLASH写入后缓存里可能还残留旧值。调试时用读回验证往往看不到问题,等到认真校验时就发现读回来的居然是旧数据。我的习惯是在擦写前后调用SCB_InvalidateDCache()做一次缓存失效。

3.2 擦除和写入:HAL库API如何使用才不出错

L4的HAL库提供了两个核心函数:HAL_FLASHEx_Erase用于擦除页,HAL_FLASH_Program用于写入数据。擦除时先构造一个FLASH_EraseInitTypeDef结构体:

FLASH_EraseInitTypeDef eraseInit = {0}; uint32_t pageError = 0; HAL_StatusTypeDef status; eraseInit.TypeErase = FLASH_TYPEERASE_PAGES; eraseInit.PageAddress = PARAM_SECTOR_ADDR; eraseInit.NbPages = 1; HAL_FLASH_Unlock(); status = HAL_FLASHEx_Erase(&eraseInit, &pageError); if (status != HAL_OK) { /* 处理擦除失败,pageError会给出出错的页地址 */ }

这里参数的含义要说明白。TypeErase可以是页擦除或批量擦除(FLASH_TYPEERASE_MASSERASE),批量擦除会把整个主存储区都清掉,一般只在Bootloader里用,千万别在应用里乱调。PageAddress是你要擦除的那个扇区的起始地址,必须落在扇区边界上,如果你传了一个扇区中间的地址,函数行为就是不明确的。NbPages要擦除的扇区数,如果在Bank1和Bank2各选了一个扇区,就要分两次擦除。

页擦除完成后才能写入。L4的HAL_FLASH_Program在L4上要传的数据类型是uint64_t:

uint64_t writeData = 0x1122334455667788ULL; status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, PARAM_SECTOR_ADDR, writeData);

注意看第三个参数:不是一个指针,而是一个值。这意味着HAL库默认你是“准备好了一个双字再整块写入”的。如果你的数据结构是多个字段,比如一个结构体里面有float、uint8_t、uint16_t,就自己先把它memcpy到一个临时uint64_t缓冲区,再调用HAL_FLASH_Program。用memcpy而不是手工拼字节,可以避免因为结构体对齐问题导致的字节错位,这也是从实战中总结出来的经验。

写完之后记得调用HAL_FLASH_Lock()。如果还需要做代码读回校验,直接用volatile指针读取对应地址即可:

uint64_t readBack = *(volatile uint64_t *)PARAM_SECTOR_ADDR;

读内部FLASH不需要任何解锁操作,但使用volatile是必须的,否则编译器可能在优化时直接复用旧值,让你看到“写入没生效”的假象。

3.3 一套简单的“写入任意长度数据”封装思路

实际项目中你不太可能每次都手动拼64位。我会在驱动层封装一个“写参数块”的函数,基本流程是这样:先检查参数长度是否超过一个扇区容量,超过就返回错误;然后把整个扇区读入RAM缓冲区(一块静态数组),在RAM里修改对应字段,最后整片擦除、整片写回。说句实话,“先搬回RAM再写回”这个方案在内存足够时最省心,因为它天然满足8字节对齐要求,也不会出现“写一半掉电导致扇区里有新有旧”的尴尬局面。代价是擦写窗口变长,但只要调用频率低就可以接受。

更高级的做法是“双扇区轮流存储”:参数区用两个扇区,交替写入,每次先读旧扇区、改写、写新扇区、最后标记旧扇区失效。即使中途掉电,重启后也能根据标记自动从有效扇区恢复。如果做的是需要OTA升级的产品,还可以把参数区单独放在一个Bank里,利用L4的双Bank特性实现“升级代码时不丢参数”,这个扩展方向很值得研究。

4. 常见问题与排查技巧实录

4.1 写进去之后读出来全是0xFF,或偶发HardFault

读出来0xFF基本就是两种情况:一是根本没写成功,二是写了但读到的是擦除后的旧数据。先检查写入前是否做了擦除操作——FLASH只能把1写成0,擦除后全为1,如果你在旧数据区域上直接写,想写的位本来就是1的会保持1,想从0改成1的则写不进去,最终读出来就是下半字节是0xFF。第二,检查是否因为ICache/DCache导致读回缓存旧数据,调用缓存失效函数再看。第三,如果你的数据长度不是8的倍数,比如只写了4个字节,那另一半0x00000000会被当成有效数据参与ECC计算,某些情况下读的时候会触发ECC错误。

HardFault则大概率是写入地址越界或地址非对齐。排查时可以打开Keil/IAR的Fault报告,看触发异常的PC和访问地址;更直接的办法是在HAL_FLASH_Program之前加断言,确保地址满足:(addr >= FLASH_BASE) && (addr < FLASH_BASE + FLASH_SIZE) && (addr % 8 == 0)。这类错误在写内联汇编或寄存器版本时特别常见,HAL库帮你挡掉了一部分。

4.2 程序在FLASH操作时卡死或跑飞

这个问题的高发原因有三个:中断、时钟、代码执行位置。先说中断,HAL的擦除和写入函数内部会等待BSY位清零,在这个等待循环期间如果来了一个高优先级中断,而且中断服务程序里又去操作了FLASH(比如在中断里执行参数存取),就会形成死锁。所以我在项目里明确规定:FLASH擦写必须放在主循环或低优先级任务中执行,操作期间用临界区保护,禁止嵌套调用。如果是RTOS环境,还要考虑给这段操作加互斥信号量。

再说时钟,前面提到过,主频调高后必须同步配置FLASH等待周期。这个错误很隐蔽,因为大多数时间程序运行正常,只有在执行长跳转、大量连续取指时才突然死机。用CubeMX重新生成时钟配置,和你的源码对比一下FLASH_ACR寄存器值,基本就能找到问题。

最后说代码执行位置,如果你的程序是从内部FLASH运行的,那么擦写操作期间,同一个Bank的FLASH暂时无法被CPU访问(不同Bank可以RWW),如果你擦的扇区恰好是当前正在执行的代码区,CPU取不到下一条指令,必然跑飞。这种事情常发生在“在应用代码里全片擦除”这种错误设计里。因此,牵涉到代码区升级的操作一定要在RAM中运行,或者跳到另一个Bank执行。

4.3 擦写寿命与掉电安全:正式产品必须考虑的可靠性设计

L4内部FLASH擦写寿命标称通常是一万次,听起来不少,但如果某个整机每小时存一次参数,一天24次,一年不到就逼近8000次了。再加上调试阶段反复擦写,寿命压力是真实存在的。我的建议是:高频率参数放在RAM里,只有在关机、掉电检测、或定时落盘时写入FLASH;每次写入前先判断数据是否发生变化,没变化就直接跳过。别小看这个判断函数,它能把擦写次数降一个数量级。

掉电安全方面,L4的FLASH有两个天然优势:一个是ECC能在一定程度上发现数据错误,另一个是双Bank设计。但ECC只是检错纠错,不能防止“写到一半掉电”造成的数据错乱。为了应对掉电,我一般在擦写操作前先关掉全局中断、等待外部掉电检测引脚拉高后延时几毫秒,再开始擦写;如果实在来不及完成,就在另一个扇区保留上一次的有效副本。启动时读取两个副本的CRC,选合法的那份用。这个方法在老式EEPROM产品里叫“双备份滚动存储”,放到内部FLASH上一样很实用。

4.4 内部FLASH和外置W25Q64、EEPROM怎么选

最后再聊聊选型这件事。有一个很常见的误区:单片机的FLASH价格“包含在MCU里”,所以优先用内部FLASH,结果把寿命和容量都算进去,发现产品改一版数据存储逻辑要花很长时间,不如一开始用一颗W25Q64来得痛快。反过来,有些人无脑上外部存储,结果因为SPI驱动有bug、掉电时序设计不当,反而引入更多故障。

我的判断标准很简单:看写入频率、数据量、以及对实时性的要求。2KB以内的结构化参数、每天写入不超过几次的,内部FLASH完全够,优先推荐,省BOM省代码;需要存几十条历史记录、字库、升级包的,上W25Q64;需要每次改一个字节且写入次数极多的,选EEPROM。没有哪个方案是绝对最优的,只有“在这个产品约束下更合适”的方案。

代码调试方面,拿到“STM32L4xx_读写内部FLASH”这类参考工程后,不要直接往产品里搬。先把它的读、写、擦三个函数逐个单步过一遍,确认扇区地址、数据对齐规则和你实际需求一致,再改造成自己的驱动。很多时候,参考工程本身就是“能编译过但没考虑掉电”的demo代码,真正的可靠性要靠自己补齐。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 22:15:36

STM32 GPIO扩展实战:74HC595级联驱动原理与代码详解

简介&#xff1a;STM32驱动74HC595的可级联函数包&#xff0c;面向嵌入式系统开发人员&#xff0c;特别适合数码管动态扫描、LED点阵显示和多通道并行输出等应用&#xff0c;能够有效节省微控制器引脚资源。代码基于Keil MDK工程编写&#xff0c;使用普通GPIO模拟SPI时序&#…

作者头像 李华
网站建设 2026/9/9 22:15:11

libmodbus 3.1.6在QT上位机中的集成与Modbus通信实战

简介&#xff1a;libmodbus-3.1.6-master 是一套面向嵌入式开发者的 Modbus 通信协议库源码包&#xff0c;重点解决 libmodbus 在 ARM A7 架构 imx6ull 平台上的交叉编译与定制集成问题。资源共 158 个文件&#xff0c;以 C 源文件、头文件、configure 配置脚本和 Makefile 工程…

作者头像 李华
网站建设 2026/9/9 22:13:08

localStorage 与 sessionStorage 怎么存 JSON 数据?存储 API 完整用法

localStorage 与 sessionStorage 怎么存 JSON 数据&#xff1f;存储 API 完整用法 【免费下载链接】33-js-concepts &#x1f4dc; 33 JavaScript concepts every developer should know. 项目地址: https://gitcode.com/GitHub_Trending/33/33-js-concepts 在浏览器应用…

作者头像 李华
网站建设 2026/9/9 22:13:05

2025年TDD面试实战:Spring Boot单元测试与Mockito最佳实践

“TDD 都写了三四年了&#xff0c;面试官一问还能把我问住&#xff1f;”这是我去年帮一个朋友做模拟面试时&#xff0c;他亲口说的话。他并不是不会写测试&#xff0c;而是在面试高压下&#xff0c;把“写测试”和“TDD”混为一谈&#xff0c;一被追问“先写测试还是先写实现”…

作者头像 李华
网站建设 2026/9/9 22:12:44

seaborn进阶:如何用Python轻松绘制统计图形?

上礼拜有个刚转行做数据分析的朋友问我&#xff1a;matplotlib我都还没学明白&#xff0c;怎么到处都在说seaborn&#xff1f;我给他打了个比方&#xff1a;matplotlib是原木&#xff0c;什么都能做&#xff0c;但所有事都得自己动手&#xff1b;seaborn是给你切好拼好的板材&a…

作者头像 李华
网站建设 2026/9/9 22:11:51

TLS加密套件深度解析:从套件原理到IoT设备选型实战

先交代一下背景。前阵子帮朋友排查一批智能网关设备连不上服务器的问题&#xff0c;抓包一看&#xff0c;握手阶段直接卡在ClientHello和ServerHello之间&#xff0c;服务端报“no shared cipher”。设备端跑的是裁剪过的TLS栈&#xff0c;只支持两三个套件&#xff0c;服务端却…

作者头像 李华