☰
STM32L4xx内部FLASH读写实战:LL库实现参数存储与避坑指南
2026/10/26 2:52:45 网站建设 项目流程

简介:这份资源面向使用STM32L4xx系列MCU进行底层开发的嵌入式工程师,聚焦内部FLASH的读写与擦除操作,采用LL库并直接操作寄存器实现,已在STM32L452RET6芯片上调试通过;由于核心是寄存器配置,改用HAL库时只需调整相关定义即可复用,适合希望理解FLASH编程底层机制、摆脱库函数黑盒的开发者参考。压缩包共2个文件,包含1个c源文件与1个h头文件,分别承载FLASH读写擦除的具体实现与接口声明,整体仅2KB,代码精简、便于直接嵌入工程。资源已有1935人学习下载,具备一定的实践验证基础。作者还补充了在STM32L471VETx上的调试经验:因FLASH页码不连号导致擦页失败,需修改擦页函数中的页码号,并提示该代码不适用于STM32L4x1系列,需自行调整擦页逻辑,这一排错思路对跨型号移植尤具参考价值。

1. 拿到 STM32L4xx 内部 FLASH 读写包,先别急着烧板子

做低功耗项目的人迟早会撞上一个需求:设备断电重启后,几个标定参数、运行计数、故障码还得在。外挂 EEPROM 要加料加钱,铁电贵得离谱,最后往往回到 MCU 自己的内部 FLASH 上。STM32L4xx 这颗片子内部 FLASH 结构比 F1 那代复杂不少:双 bank、页粒度 2KB、带 ECC、擦除按页走、写入还分双字对齐,稍不注意就是 HardFault 或者读出来全是 0xFF。这个压缩包给的就是一套能直接落地的读写实现,走的是 LL 库加寄存器操作的路子,不依赖 HAL 那套厚重封装,适合对代码体积和时序敏感的场景。它解决的核心问题很具体:在 L4 上安全地擦一页、写一段、读回来校验,并且把解锁、等待 BSY、清标志这些容易翻车的动作收进函数里。适合谁?手上有 L4 板子、要存少量非易失参数、又不想被 HAL_FLASH 的抽象层绕晕的嵌入式开发者。下面按「结构怎么理解 → 代码怎么跑 → 坑在哪」的顺序拆。

2. STM32L4xx FLASH 结构:双 bank、页粒度与 ECC 的约束

2.1 为什么 L4 的 FLASH 不能照搬 F1 的写法

F1 时代大家习惯按「半字写、扇区擦」,地址算一算就下手。L4 换了一套规则:FLASH 被切成两个 bank,每个 bank 又按 2KB 一页划分,擦除最小单位就是页,没有更细的选项。更关键的是 L4 带 ECC 校验,写入必须以双字(64 位)为单位对齐,你写一个字节它也会把整个双字所在区域当成一次编程操作,如果这个双字之前已经被写过而没擦,ECC 就会报错,读出来直接触发总线错误。这就是很多人第一次在 L4 上写 FLASH 就进 HardFault 的根因。所以选型上,这份资源用 LL 库而不是 HAL,不是为了炫技,是因为 LL 的LL_FLASH_Unlock、LL_FLASH_ErasePage、LL_FLASH_Program这些函数直接对应寄存器动作,等待 BSY 的时机你能自己控,出问题也好定位。HAL 的HAL_FLASH_Program内部封装了错误回调,反而把「到底哪一步失败」藏起来了。

2.2 页地址怎么算、哪些页能碰

L4 的 FLASH 起始地址是0x08000000,页大小 2KB,所以第 n 页的起始地址就是0x08000000 + n * 0x800。双 bank 的划分取决于具体型号和选项字节配置,常见做法是把参数区放在最后一个 bank 的末尾几页,避开代码区。这里有个血泪经验:别把参数页紧挨着代码段末尾放,因为编译器一优化、代码一膨胀,链接器可能就把你的参数页覆盖了。稳妥的做法是在链接脚本里显式留出一段区域,或者在代码里用固定地址加编译期断言把边界卡死。下面这段是地址计算和边界检查的常见写法:

/* FLASH 页大小 2KB,参数区放在最后 4 页 */ #define FLASH_BASE_ADDR 0x08000000UL #define FLASH_PAGE_SIZE 0x800UL #define PARAM_PAGE_INDEX (FLASH_PAGE_SIZE * 0) /* 按实际型号改 */ /* 根据页号算绝对地址,并做编译期边界断言 */ static uint32_t param_addr_from_page(uint32_t page) { return FLASH_BASE_ADDR + page * FLASH_PAGE_SIZE; } /* 编译期检查:参数区不能越过 FLASH 末尾 */ _Static_assert((PARAM_PAGE_INDEX + 4) * FLASH_PAGE_SIZE <= (512UL * 1024UL), "param area exceeds flash size");

逻辑说明:param_addr_from_page把页号线性映射到地址,避免手写魔法数;_Static_assert在编译阶段就把越界挡掉,比运行时才发现强得多。参数上要注意FLASH_PAGE_SIZE必须和你芯片实际页大小一致,L4 系列大部分是 2KB,但个别小容量型号是 1KB,查参考手册的 memory map 确认,别想当然。PARAM_PAGE_INDEX这里写成 0 只是占位,实际要按你的代码占用情况改,改完重新编译,断言不过就说明放错位置了。

2.3 解锁、擦除、写入的寄存器时序

L4 的 FLASH 控制器有几个关键寄存器:FLASH_ACR管等待周期和预取,FLASH_KEYR收解锁序列,FLASH_SR报状态,FLASH_CR控擦写。解锁必须连续写两个魔数0x45670123和0xCDEF89AB到FLASH_KEYR,顺序错了或者中间被打断就锁死,得复位。擦除时先置PER位、写页地址、置STRT,然后轮询BSY直到清零,最后清PER。写入双字时置PG位,往目标地址写两个 32 位字,同样等BSY。这套流程 LL 库已经包好了大半,但等待和清标志还是得自己盯。下面是一个擦一页再写一个双字的完整片段:

#include "stm32l4xx_ll_flash.h" /* 擦除指定页,返回 0 成功,负值失败 */ int flash_erase_page(uint32_t page_addr) { if (LL_FLASH_Unlock() != 0) return -1; /* 解锁失败直接退出 */ LL_FLASH_ClearFlag(FLASH, LL_FLASH_FLAG_ALL); /* 清掉历史错误标志 */ LL_FLASH_EnablePageErase(FLASH); /* 置 PER */ LL_FLASH_SetPageAddress(FLASH, page_addr); /* 写页地址 */ LL_FLASH_StartErase(FLASH); /* 置 STRT 启动 */ uint32_t tick = 0; while (LL_FLASH_IsActiveFlag_BSY(FLASH)) { /* 等擦除完成 */ if (++tick > 1000000) { LL_FLASH_Lock(); return -2; } } LL_FLASH_DisablePageErase(FLASH); LL_FLASH_Lock(); return 0; } /* 写一个 64 位双字,addr 必须 8 字节对齐 */ int flash_write_dword(uint32_t addr, uint64_t data) { if (addr & 0x7) return -1; /* 对齐检查 */ if (LL_FLASH_Unlock() != 0) return -2; LL_FLASH_ClearFlag(FLASH, LL_FLASH_FLAG_ALL); LL_FLASH_EnableProgram(FLASH); /* 置 PG */ *(volatile uint32_t *)addr = (uint32_t)(data & 0xFFFFFFFF); *(volatile uint32_t *)(addr + 4) = (uint32_t)(data >> 32); uint32_t tick = 0; while (LL_FLASH_IsActiveFlag_BSY(FLASH)) { if (++tick > 1000000) { LL_FLASH_Lock(); return -3; } } LL_FLASH_DisableProgram(FLASH); LL_FLASH_Lock(); return 0; }

逻辑说明:擦除函数先解锁再清标志,是因为上一次操作残留的错误标志会让新的擦除直接失败;tick超时是后悔药,防止 BSY 卡死时死循环。写入函数强制 8 字节对齐,这是 ECC 的硬要求,不对齐写进去的数据读出来会错位。参数上LL_FLASH_FLAG_ALL会清掉包括 ECC 错误在内的所有标志,如果你只想清特定标志可以换成LL_FLASH_FLAG_ECCRD之类,但调试阶段全清更省心。注意LL_FLASH_Unlock的返回值在不同 LL 版本里可能没有,如果你的头文件里它是 void,就把判断去掉,改成解锁后读FLASH_CR的LOCK位确认。

3. 把读写封装成可复用的参数存储模块

3.1 读操作的坑:直接指针访问与 ECC 错误

读 FLASH 看起来最简单,*(uint32_t*)addr就完事。但 L4 上如果目标区域 ECC 校验失败,这次读会触发总线错误,程序直接跑飞。所以读之前最好先确认这块区域是「擦干净后写过」的状态,而不是被写坏过的。常见做法是读之前先查FLASH_SR的ECCRD标志,如果置位说明上次读出过错,得先清标志再重试。更稳的做法是给参数区加一个头部魔数加 CRC,读出来先验魔数和 CRC,不对就当空处理,走默认值。下面是一个带校验的读取函数:

#define PARAM_MAGIC 0x50415241UL /* "PARA" */ typedef struct { uint32_t magic; uint32_t crc; uint32_t value[4]; } param_block_t; /* 从 FLASH 读参数块,校验失败返回 -1 */ int param_read(param_block_t *out) { const param_block_t *p = (const param_block_t *)param_addr_from_page(PARAM_PAGE_INDEX); if (p->magic != PARAM_MAGIC) return -1; /* 未初始化 */ uint32_t crc = crc32_calc((const uint8_t *)&p->value, sizeof(p->value)); if (crc != p->crc) return -2; /* 数据损坏 */ *out = *p; return 0; }

逻辑说明:魔数判断区分「从没写过」和「写过但坏了」两种情况,前者走默认值,后者可以触发告警。CRC 只算value字段,不算魔数和 crc 自身,避免自引用。参数上crc32_calc用常见的多项式 0x04C11DB7 即可,实现不在这个包里,自己补一个查表版就行。注意结构体直接映射到 FLASH 时要注意对齐和填充,param_block_t的大小最好是 8 的倍数,否则写入时跨双字会出问题,可以在结构体末尾加uint32_t pad;凑齐。

3.2 写操作的顺序:先擦后写、按双字推进

写参数块的流程是:算好目标页 → 擦整页 → 按双字逐个写 → 回读校验。这里有个容易忽略的点:擦除是整页 2KB,如果你只想改一个参数,也得把整页其他数据先读出来、改完再整体写回,否则一擦全没了。所以参数模块通常设计成「整块读写」,把一页当一个存储单元用。下面是把结构体写进 FLASH 的循环:

int param_write(const param_block_t *in) { param_block_t tmp = *in; tmp.magic = PARAM_MAGIC; tmp.crc = crc32_calc((const uint8_t *)&tmp.value, sizeof(tmp.value)); uint32_t page = param_addr_from_page(PARAM_PAGE_INDEX); if (flash_erase_page(page) != 0) return -1; const uint64_t *src = (const uint64_t *)&tmp; uint32_t n = (sizeof(tmp) + 7) / 8; /* 按双字个数推进 */ for (uint32_t i = 0; i < n; i++) { if (flash_write_dword(page + i * 8, src[i]) != 0) return -2; } return 0; }

逻辑说明:先把魔数和 CRC 填好再写,保证写进去的就是自洽的数据。n用向上取整算双字个数,结构体大小不是 8 的倍数时最后一个双字会带填充,读的时候按结构体大小截断即可。参数上page + i * 8每次前进 8 字节,严格对齐。注意擦除后到写入完成之间如果掉电,这页就是半写状态,下次上电 CRC 会失败,所以关键参数建议双页轮换写,一页坏了还有备份,这个进阶做法在最后一章展开。

3.3 用 LL 库还是寄存器直写:边界在哪

这份资源主打 LL 库加寄存器操作,实际用下来 LL 覆盖了 90% 的场景,剩下 10% 需要直写寄存器的地方主要是:清特定错误标志、读FLASH_SR的 ECC 相关位、以及在极低功耗模式下操作前手动调整FLASH_ACR的等待周期。比如从 Stop 模式唤醒后立刻写 FLASH,时钟还没稳,得先把等待周期调大再操作,否则写进去的数据不可靠。这类细节 LL 没有现成函数,得自己写:

/* 唤醒后临时提高等待周期,写完恢复 */ void flash_safe_write_after_wakeup(uint32_t addr, uint64_t data) { uint32_t old_latency = LL_FLASH_GetLatency(FLASH); LL_FLASH_SetLatency(FLASH, LL_FLASH_LATENCY_4); /* 先降频等待 */ flash_write_dword(addr, data); LL_FLASH_SetLatency(FLASH, old_latency); /* 恢复原值 */ }

逻辑说明:LL_FLASH_SetLatency改的是FLASH_ACR的LATENCY位,唤醒初期系统时钟可能还在切换,给足等待周期能避免读写出错。参数上LL_FLASH_LATENCY_4对应较高主频,具体值查你芯片的数据手册里「wait states vs frequency」那张表。注意这个函数只适合偶发调用,频繁改等待周期会影响性能,正常运行时保持固定值即可。

4. 避坑与排查:五个真实翻车现场

4.1 现象:写完读出来全是 0xFF,CRC 永远不过

原因:擦除后没等BSY清零就急着写,或者写入时PG位没置上,数据根本没进 FLASH。L4 的BSY在擦除启动后有一小段延迟才置位,如果你在STRT之后立刻查BSY,可能查到的是 0,误以为擦完了。解决:擦除后先延时几个周期再进轮询,或者直接轮询到BSY先置位再清零;写入前用LL_FLASH_IsActiveFlag_BSY确认空闲,写完再读FLASH_SR的EOP标志确认编程结束。

4.2 现象:写第二个双字时进 HardFault

原因:目标双字之前已经被写过且没擦,ECC 校验冲突。L4 不允许对同一个双字重复编程,哪怕你写的是相同数据。解决:写之前确认这页是刚擦过的;如果要做「追加写」,必须换到下一个空双字,不能原地覆盖。参数模块里整页擦除再整体写就是为了绕开这个限制。

4.3 现象:参数区偶尔被代码覆盖,上电读到乱码

原因:参数页地址和代码段重叠,链接器把常量或函数放到了那块区域。解决:在链接脚本里用MEMORY或SECTIONS显式保留参数区,或者在代码里加_Static_assert卡边界,同时编译后看 map 文件确认__etext没越过参数页起始地址。这一步别省,map 文件是黑匣子,出问题全靠它。

4.4 现象:低功耗唤醒后第一次写 FLASH 失败

原因:唤醒后系统时钟还在切换,FLASH 等待周期不够,写入时序错乱。解决:按 3.3 的做法,写之前临时提高LATENCY,写完恢复;或者在唤醒流程里加一段时钟稳定延时再操作 FLASH。

4.5 现象:擦除一页耗时忽长忽短,偶尔超时

原因:擦除时间和芯片温度、电压、磨损程度有关,L4 的页擦除典型值几毫秒,但边界条件下可能到几十毫秒。你的超时阈值设太紧就会误判失败。解决:超时阈值按最坏情况留余量,比如按 100ms 对应的循环次数设;同时检查供电电压是否在擦写允许范围内,低压下擦除会变慢甚至失败。

5. 进阶:双页轮换与掉电安全的那点事

单页存储最大的软肋是擦写到一半掉电,整页数据报废。工程上常见的补救是双页轮换:准备两页 A 和 B,每页头部带一个递增的序号,写入时总是写到序号较旧的那页,写完校验通过再把序号更新,读的时候取序号较新的有效页。这样任意时刻至少有一页是完整的。实现上在param_block_t里加一个uint32_t seq;,写之前先读两页的 seq,选小的那页擦写,写完回读比对。下面是一个简化的选择逻辑:

/* 返回应该写入的页地址:选 seq 较小的那页 */ uint32_t pick_write_page(void) { const param_block_t *a = (const param_block_t *)PAGE_A_ADDR; const param_block_t *b = (const param_block_t *)PAGE_B_ADDR; uint32_t sa = (a->magic == PARAM_MAGIC) ? a->seq : 0; uint32_t sb = (b->magic == PARAM_MAGIC) ? b->seq : 0; return (sa <= sb) ? PAGE_A_ADDR : PAGE_B_ADDR; }

逻辑说明:魔数不对的页视为 seq 为 0,优先被覆盖,这样首次上电两页都空时从 A 开始写。参数上PAGE_A_ADDR和PAGE_B_ADDR要落在不同页,且都经过边界断言检查。注意 seq 是 32 位,理论上会回绕,实际产品里写到几十万次早就该换片子了,不用过度设计。

验证方法我一般这么走:写完参数后立刻断电再上电,读出来比对;再用调试器手动改 FLASH 里一个字节模拟损坏,确认 CRC 能拦住并走默认值;最后把电压拉到擦写下限附近跑一轮,看超时阈值够不够。这套流程走下来,参数存储这块基本就稳了。从那以后我每次碰内部 FLASH,都强制先看 map 文件确认参数区没被占,再跑一遍掉电测试才敢交付。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询