简介:面向STM32L4xx系列的内部FLASH读写实现,适合需要在片内FLASH上进行数据存储、参数保存或OTA升级的嵌入式开发者,也适合想深入理解寄存器配置的初学者。代码基于LL库并直接操作寄存器,相比HAL库更精简,已在STM32L452RET6上实际调通;由于底层配置均为寄存器级,换用HAL库时也只需调整相关类型定义即可复用,方便不同工程间迁移。压缩包共2个文件,包含1个C源文件与1个头文件,分别承载FLASH读写函数实现与接口声明,整体大小仅2KB,结构紧凑,便于快速移植或逐行理解读写流程。目前已有1935人学习/下载,说明该实现具备实际参考价值。资源还额外注明一个关键坑点:在STM32L4x1系列(如L471VETx)上擦页可能失败,原因是FLASH页码不连续,修改擦页函数中的页码号即可成功;这一排错思路能帮助使用者根据具体芯片型号调整代码,避免重复踩坑,也提醒了不同型号间FLASH布局差异,便于快速定位问题。
1. 把内部 Flash 读写讲透:一份 LL 库与寄存器双版本代码包能帮你避开哪些坑
有开发者把页擦除函数一调用就卡死在 BSY 等待上;有人往 Flash 里存参数,掉电重启后读出来全是 0x00。这类问题我在几个嵌入式群里见过不止一次,多数不是硬件坏了,而是没搞懂 STM32L4 系列内部 Flash 的擦写时序和最小编程单位。这份压缩包里包含 LL 库和寄存器操作两套读写内部 Flash 的工程代码,覆盖解锁、页擦除、双字编程、读取校验以及掉电存储的完整链路。无论你要做参数保存、OTA 升级还是运行日志,都能直接对照移植。本文把代码包的实现逻辑、关键参数和翻车现场一并拆给你看。
2. 先看懂 L4 的内部 Flash:双 Bank、2KB 页与 ECC 带来的三个边界
2.1 存储器组织:主 Flash、系统 Flash 与选项字节
STM32L4 系列内部 Flash 从地址 0x08000000 开始映射,前面是主存储器,用于放代码和用户数据;0x1FFF0000 附近是系统存储器,出厂 Bootloader 就在这里;再往上还有选项字节区域,用来配置读保护等级、看门狗选项和双 Bank 模式。读写内部 Flash,大多数时候碰的是主存储器这一段,选项字节一般只在出厂配置或升级时才动。
主存储器的页大小在 L4 系列上统一是 2KB,这点和 F1 系列的 1KB 或 4KB 都不一样。擦除操作的最小单位就是一整页,没有半页擦除的说法。你哪怕只想改一个字节,也必须先把所在的整页擦掉,再重新把整页内容写回去,所以做参数存储前一定要先在 RAM 里缓存整个页的数据。
另一个容易被忽略的点是双 Bank 结构。L4 的 Flash 分成 Bank1 和 Bank2,如果选项字节里没切换成单 Bank 模式,那么从 0x08000000 开始的前一半属于 Bank1,后一半属于 Bank2。擦写其中一个 Bank 时,如果代码正在另一个 Bank 里执行,通常不受影响;但如果你的代码和数据在同一 Bank 里,擦写期间取指令就会冲突。这是一个非常重要的边界条件,后面第 4 章会展开讲。
2.2 关键参数:页大小、双 Bank 边界与 ECC
下面这张表是 L4 内部 Flash 最常用到的一组硬件参数,代码包里的地址宏和擦写循环都依赖这些数字。不同型号 Flash 总容量有差异,但页大小和双字编程单位在全系列是统一的。
| 参数名称 | 典型值 | 说明 |
|---|---|---|
| 页大小 | 2 KB | 擦除的最小单位,页擦除一次清掉 2KB |
| 编程最小单位 | 双字 64 位(8 字节) | 写入必须按 8 字节对齐,否则报错 |
| 双 Bank 边界 | 容量一半处 | 例如 512KB 型号边界在 0x08040000 |
| 擦写寿命 | 典型 10 万次 | 数据区页磨损后需要换页 |
| ECC | 每双字附带校验位 | 读出时硬件自动校验,错误会置位标志 |
L4 的 Flash 自带 ECC 校验,每写入一个双字(8 字节)会附带生成校验位。这意味着读出时如果数据本身损坏,硬件会在读总线阶段就报错并可能触发 NMI,而不是像 F1 那样直接给你一个错值。好处是数据可靠性高,坏处是你不能把任意碎数据硬塞进去,编程时地址和长度都要对到双字边界。
2.3 擦写寿命与写入最小单位
官方标称的 10 万次擦写寿命针对的是每个页。如果一个参数页被频繁写入,可能几个月就到寿命。所以工业产品里基本都会做磨损均衡:把数据循环写到一组页里,而不是每次都写同一个地址。
L4 擦写寿命还有一个隐藏前提:编程前必须先擦除。你在已写入的页上直接编程,虽然硬件会报错但不会主动帮你处理旧数据,必须先擦后写。如果有项目里出现“写入成功但读回是旧数据”的怪问题,先检查是不是擦除时序被跳过了。
2.4 底层接口选型:寄存器、HAL 与 LL 怎么选
同一份功能,官方给了 HAL、LL 和寄存器三层写法。HAL 封装最厚,函数调用方便但代码量大,Flash 擦写这种高频时序操作里还夹带了不少超时检查;寄存器操作最直接,一条语句就能触发擦除,但你必须自己维护 PEKEY、BSY 和错误标志,少写一个步骤就是翻车;LL 库介于两者之间,它本质是对寄存器操作的薄封装,函数名和寄存器一一对应,又没有 HAL 那层抽象开销。
我一般会这样选:项目里本来就依赖 HAL 做外设初始化,就顺手用 LL 做 Flash 读写,省得混用两套风格;如果只是写个 Bootloader 或者对体积敏感的小模块,直接用寄存器操作,把解锁、擦除、编程封装成三四个函数就够了。这份压缩包里两套都给了,正好对照着看底层寄存器到底做了什么。
3. 从解锁到校验:LL 库与寄存器操作对照实现读写
3.1 先看完整时序:解锁、等待 BSY、操作、上锁
内部 Flash 在复位后默认是上锁状态,直接写控制寄存器不会生效。读写 Flash 的标准流程可以概括为四步:先写两个解锁键值解除锁;然后等待 BSY 标志为 0,确保 Flash 空闲;执行页擦除或双字编程;操作完成后重新上锁。
这一步的顺序问题比想象中更重要。解锁键值必须连续写入,中间不能插入别的 Flash 操作;等待 BSY 也不能省,否则上一个操作还没结束你就往 CR 里写新命令,行为是未定义的。下面的解锁代码分别给出 LL 和寄存器写法:
// 解锁:LL 库方式 LL_FLASH_Unlock(); // 内部实际做了两件事: // 向 FLASH->PEKEYR 依次写入 0x89ABCDEF 和 0x02030405 // 解锁:寄存器方式 FLASH->PEKEYR = 0x89ABCDEF; FLASH->PEKEYR = 0x02030405; // 写完后建议回读 FLASH->CR 的 LOCK 位,确认确实解锁成功代码逻辑上,PEKEYR 是 Flash 编程/擦除解锁寄存器,两个固定键值顺序写错任何一个,LOCK 位都不会清掉。LL 库的LL_FLASH_Unlock()内部就是把这两条语句封装起来,你少记一个键值也能写对。需要留意的是,解锁只对当前 Bank 生效,L4 的双 Bank 模式里,Bank1 和 Bank2 是独立的锁,操作哪个 Bank 就要解哪个 Bank 的锁。
3.2 页擦除:寄存器版与 LL 库版对照
页擦除的触发时序是:在 CR 寄存器里把 PER 位置 1,选对 Bank,把要擦除的页号写到地址寄存器,最后把 STRT 位置 1。硬件收到命令后会清掉整页,同时把 BSY 置位,完成后自动清 BSY。代码里必须循环等待 BSY 清零,并且顺手检查写保护错误标志。
// 页擦除:寄存器方式,目标地址为 page_addr void flash_erase_page(uint32_t page_addr) { // 1. 解锁(前面已做) // 2. 等待上次操作完成 while (FLASH->SR & FLASH_SR_BSY); // 3. 配置擦除命令:页擦除、选择 bank FLASH->CR |= FLASH_CR_PER; // 使能页擦除 if (page_addr >= 0x08040000) // 根据备份边界选 bank FLASH->CR |= FLASH_CR_BNK; else FLASH->CR &= ~FLASH_CR_BNK; // 4. 写入要擦除的页地址(低 12 位忽略) FLASH->ACR &= ~FLASH_ACR_PROGRAM_ADDRESS_MASK; FLASH->ACR |= (page_addr & 0x000FFFFF) & ~0x7FF; // 5. 触发擦除 FLASH->CR |= FLASH_CR_STRT; // 6. 等待完成 while (FLASH->SR & FLASH_SR_BSY); // 7. 清错误标志、关闭页擦除命令 FLASH->SR = FLASH_SR_EOP | FLASH_SR_WRPERR | FLASH_SR_PGAERR; FLASH->CR &= ~FLASH_CR_PER; }这段代码有几个参数值得说明。FLASH_CR_BNK位只在双 Bank 模式才有意义,如果你启用了单 Bank 模式,这个位不存在,需要按参考手册确认;page_addr & 0x000FFFFF取出 Flash 地址的低 20 位,而~0x7FF是为了把页内偏移清零,因为擦除命令只关心页号,不关心页内地址。严格来说擦除命令里更常用的是直接写FLASH->CR的 PNB 页号段,但用地址换算页号更容易读。
LL 库版更简单,官方把命令拼接都封装好了:
LL_FLASH_Erase(LL_FLASH_BANK_1, page_addr); // 参数1:bank 编号,LL_FLASH_BANK_1 或 LL_FLASH_BANK_2 // 参数2:页起始地址,函数内部会换算成页号 while (LL_FLASH_IsActiveFlag_BSY()); // 等待擦除完成 LL_FLASH_ClearFlag_EOP(); // 清结束标志LL 库版的参数很直接:第一个参数选 Bank,第二个参数传页地址。要注意擦除前 BSY 检查和擦除后 BSY 检查都不能省,LL 库里没有帮你等待,只是封装了寄存器的读写。擦除动作本身要几十毫秒量级,这段期间主循环别去读同一 Bank 的 Flash,否则取指会阻塞。
3.3 双字编程:注意 8 字节对齐
L4 的编程命令是双字(64 位)编程。不是 F1 那种半字写入,也不是按字节写入。这意味着你每次写数据前,必须把 64 位数据打包好,地址必须是 8 的倍数。如果你的数据长度不是 8 的倍数,最后一段要补零凑满一个双字。
// 双字编程:寄存器方式 uint64_t data = *(uint64_t *)buffer; // 把两个 32 位拼成 64 位 FLASH->CR |= FLASH_CR_PG; // 使能编程 if (addr >= 0x08040000) FLASH->CR |= FLASH_CR_BNK; else FLASH->CR &= ~FLASH_CR_BNK; // 写入地址(双字对齐) FLASH->ACR &= ~FLASH_ACR_PROGRAM_ADDRESS_MASK; FLASH->ACR |= (addr & 0x000FFFFF); // 地址低 20 位 // 写入数据寄存器 FLASH->PD_64 = data; // 等待完成 while (FLASH->SR & FLASH_SR_BSY); if (FLASH->SR & FLASH_SR_PGAERR) { // 地址对齐错误或擦除不完整 } FLASH->CR &= ~FLASH_CR_PG;代码里FLASH->PD_64就是 64 位数据寄存器,写入一个整双字。需要注意,编程前页必须是擦除状态,也就是全 0xFF,否则硬件会置位 PGAERR,表示编程对齐错误或页上有旧数据。LL 库对应的写法:
LL_FLASH_Program(LL_FLASH_PROGRAM_DOUBLEWORD, addr, data); // 第一参数固定传 LL_FLASH_PROGRAM_DOUBLEWORD // 第二参数是目标绝对地址,需 8 字节对齐 // 第三参数是 uint64_t 数据 while (LL_FLASH_IsActiveFlag_BSY()); LL_FLASH_ClearFlag_PGAERR();LL 库里的LL_FLASH_Program要求你自己拼好uint64_t。常见错误是把两个uint32_t按数组顺序分别传,那只会写入第一个 32 位,第二个数据整个丢失。如果你用结构体存参数,建议直接在声明时加__attribute__((aligned(8)))或者编译选项里做结构体对齐。
3.4 读取与校验:直接地址访问与 CRC 配合
读 Flash 不需要任何命令,直接*(uint32_t *)addr就能拿数据。L4 的 Flash 是挂在内部总线上的,读操作和 SRAM 一样快但受 Flash 等待状态影响,频率高时要配好延迟。校验逻辑一般分两级:写完后立刻回读比对,以及周期性对整个存储区做 CRC 校验。
// 写入后立即回读校验 uint64_t read_back = *(uint64_t *)addr; if (read_back != data) { // 回读不一致,记录错误并重试 } // 周期性 CRC 校验 uint32_t crc = HAL_CRC_Calculate(&hcrc, (uint32_t *)flash_addr, 32); // 参数2:数据起始地址 // 参数3:要校验的 32 位字数量,不是字节数这个回读步骤的价值在于:很多写入失败是静默的,等你掉电重启后才暴露。回读时发现不一致,至少知道是擦除没完成还是编程没完成。CRC 校验则适合验证整段数据完整性,比如程序版本信息或者配置参数集。要注意HAL_CRC_Calculate第三个参数是字数量,不是字节数量,写成 32 会校验 128 字节而不是 32 字节。
3.5 从 RAM 执行:关键函数放进 SRAM
第四个边界条件,也是 Bootloader 开发绕不开的问题:当 Flash 擦写操作和目标地址在同一 Bank 时,CPU 从 Flash 取指令的行为会和擦写过程冲突。解决方法是把擦写函数整体搬进 SRAM 执行。
// 把函数放到 RAM 执行的典型声明 void flash_erase_page_sram(uint32_t page_addr) __attribute__((section(".ramfunc"))); // 函数体与普通擦除函数完全一致 // 但编译器会把代码段放到 SRAM,启动时从 Flash 拷贝实际工程里,我会用链接脚本把一组擦写函数放进固定 RAM 段,然后在跳转前用memcpy从 Flash 里的镜像拷贝到 SRAM 再调用。也有个土办法:利用 L4 的双 Bank 特性,把数据放在 Bank2 而代码在 Bank1 跑,只要不同时访问同一个 Bank,也不会冲突。两种方案看项目复杂度,Bootloader 里我推荐 RAM 执行方式,因为升级时会面临 Bank1/Bank2 同时都要擦的情况。
4. 避坑排查:五个实测翻车现场的教训与对策
4.1 解锁失败:CR 寄存器写不进去,LOCK 位一直为 1
现象:用寄存器方式直接给FLASH->CR写0x01,回读发现寄存器纹丝不动,芯片像没反应一样。
原因:没有先写 PEKEYR 解锁键值,或者两个键值之间的顺序反了。L4 的 Flash 控制寄存器在 LOCK 位为 1 时对所有写操作置为无效,不管你怎么赋值都会弹回去。
解决:先把解锁流程单独抽成一个函数,写完后回读 LOCK 位判断是否成功。LL 库用户直接调用LL_FLASH_Unlock(),然后马上读LL_FLASH_IsActiveFlag_LOCK()做断言。如果确认键值没错仍解锁失败,检查是不是已经开启了读保护级别 1,RDP 开启状态下调试访问受限,看上去像解锁不生效。
4.2 BSY 卡死:擦除函数死循环在 while (BSY)
现象:页擦除触发后程序一直停在while (FLASH->SR & FLASH_SR_BSY),看门狗复位都救不回来。
原因:最典型的是擦除期间 CPU 还在从同一 Bank 的 Flash 取指令。L4 在擦除时,如果总线侧还在访问该 Bank 的 Flash,擦除命令会一直等待总线空闲,表现就是 BSY 永远为 1。另一个常见原因是 Flash 控制器时钟没有使能,或者频率设置异常导致命令未真正触发。
解决:把擦除函数挪到 RAM 执行,或者确保擦除期间代码跑在另一 Bank。同时检查RCC->AHBENR里 Flash 接口时钟是否打开。代码里加一个超时退出的手段:比如等待 BSY 超过 1 秒就强制复位,至少能保住现场可排查。实际项目里我在擦除和编程后都会加超时判断,不裸等。
4.3 只写 16 位或 32 位导致写入无效
现象:通过FLASH->PD_32 = value或直接用某 HAL 函数写 32 位数据,写完回读地址发现全是 0x00,甚至相邻数据也被破坏。
原因:L4 的编程命令最小单位是双字。你只提供 32 位数据时,Flash 控制器不会帮你补齐另一半,写入操作要么被拒绝要么产生未定义结果,某些库把 32 位当成非法参数直接返回错误。
解决:所有写入统一走 64 位数据接口。先用两个 32 位变量拼一个uint64_t,或者定义一个 8 字节对齐的联合体,一次性写入。数据不足 8 字节的部分用 0xFF 填充。代码包里给出一个flash_write_buffer函数,内部自动补齐双字并对齐地址。
4.4 掉电瞬间写入导致参数区损坏
现象:产品在写 Flash 参数时突然断电,重启后参数区读出来是乱码,部分页变成 0xFF 或全 0x00。
原因:写 Flash 过程中掉电,正好停在擦除后编程前,或者编程只完成一半。此时页里的内容就是不确定的,ECH 标志也许没触发,但数据已经废了。
解决:结构上做两层防护。第一,参数区留两个副本,每次先写备份区再写主区;第二,写入流程里先擦备份页,写备份,校验成功后再擦主页。代码包里默认采用双页备份方案,主区和备份区各占一页。如果掉电,下次启动检测到主区 CRC 不正确就自动从备份区恢复。这个方案简单可靠,比单纯加电容持久化更省成本。
4.5 擦出错误标志不清导致后续操作全部失败
现象:一次页擦除触发了 PGAERR 或 WRPERR,但你没有手动清除错误标志,后续第二次擦除、编程操作全部异常。
原因:Flash 的 SR 错误标志一旦置位,会在下次命令执行时干扰命令状态机。有工程师调试时发现第一页能写、第二页怎么都写不进去,一查 SR 里 WRPERR 还是 1。
解决:每次擦除、编程结束后统一清标志,用FLASH->SR = FLASH_SR_WRPERR | FLASH_SR_PGAERR | FLASH_SR_EOP一次性清掉。注意这些标志是写 1 清除,不是写 0。写 0 不会有任何效果。LL 库对应调用LL_FLASH_ClearFlag_WRPERR()等函数。养成这个习惯后,Flash 读写的大部分诡异问题都能提前暴露而不是越挫越深。
5. 进阶用法:把 Flash 当掉电存储前,先做这三件事
第一件事,写一个存储管理器而不是裸调擦写函数。很多开发者一开始图省事,直接在主流程里flash_erase然后flash_write,数据少没问题,数据量一大就乱。我会在代码包里单独划一个storage.c,把所有擦写动作收敛成两个接口:读一块数据和写一块数据。内部做页缓存、双页备份和 CRC 校验。上层应用完全不知道页号和 Bank 的存在,只关心数据是不是读对了。
第二件事,做掉电测试不要只按复位键。按复位键是完整复位的冷启动,和真正拔电的瞬间行为不一样。掉电时 VDD 是缓降的,外设可能在半电压状态下跑几毫秒。要复现“擦除中掉电”这种场景,应该用程控电源,把电压在擦写过程中直接切断。我自己测试时会在擦除后、编程前留一个 GPIO 翻转点,用逻辑分析仪看掉电发生在擦写流程的哪个阶段。这个习惯能帮你验证双页备份到底能不能兜住最坏情况。
第三件事,磨损均衡不复杂,一个计数器就能实现。选一组 4 页 Flash 轮流存放参数包,每页头部写序列号,启动时选序列号最大的页作为当前有效页,写入时写到下一页。当写满一圈,再做整组擦除。这样 10 万次擦写寿命被摊到 4 个页上,实际可用次数翻了四倍。这个思路比复杂的动态分配算法实用得多,代码量也小。
最后的收尾技巧是加一个“工厂复位”开关。在参数区第一个双字里存一个魔数,比如0xA5A5A5A5,启动时如果发现魔数不对,就用默认参数重写整个存储区。项目调试阶段这个开关帮我避开了无数次“参数被改乱但又不知道默认值是什么”的局面。从那以后我每次写掉电存储相关代码,都强制把双页备份、CRC 校验和魔数复位这三件事先做完,再开始调业务逻辑。希望帮到你。
本文还有配套的精品资源,点击获取