简介:面向STM32L4xx系列MCU开发者的内部FLASH读写参考代码,聚焦嵌入式底层存储操作这一常见需求。资源以LL库实现,核心为寄存器级配置,因此改用HAL库时只需调整相关宏定义即可复用,适合已具备一定STM32基础、希望理解FLASH擦写底层机制的开发者参考。压缩包共2个文件,包含1个c源文件与1个h头文件,分别承载FLASH读写与擦除逻辑及对外接口声明,整体仅2KB,轻量易读。作者基于STM32L452RET6实测调通,并补充说明在STM32L471VETx上因FLASH页码不连号导致擦页失败的问题,指出需修改擦页函数中的页码号,同时提示不适用于STM32L4x1系列,这类排错经验对实际移植颇具参考价值。目前已有1935人学习下载,可帮助读者快速掌握页擦除、编程写入等关键流程,少走弯路。
1. 从一次掉参数的现场说起:STM32L4xx 内部 FLASH 读写到底能解决什么
去年调一块低功耗采集板,设备在实验室跑了一周都正常,装到现场断电重启后,标定系数全变回默认值。查了半天才发现,参数只存在 RAM 里,掉电就没了,而板子上又没贴外部 EEPROM。这种场景下,STM32L4xx 片内 FLASH 就是最省事的非易失存储:不用改板、不用加料,直接拿程序区之外的一页当参数区用。这份资源围绕的就是这件事——用 LL 库和寄存器操作,把 L4 系列内部 FLASH 的擦除、编程、读取跑通,适合正在做参数保存、固件配置、简易数据记录,又不想外挂存储芯片的嵌入式开发者。它不教你 HAL 怎么点鼠标生成代码,而是把双字编程、页擦除、解锁上锁这些底层动作摊开讲,新手能照着跑,熟手能直接抠进自己的工程。
2. 技术选型与工程结构:为什么是 LL 库加寄存器,而不是 HAL 一把梭
2.1 LL 库在 FLASH 操作上的取舍
STM32L4xx 的 FLASH 控制器有几个硬性约束:编程必须以双字(64 位)为单位,擦除必须以页(2KB)为单位,而且擦写期间取指会被阻塞。HAL 的HAL_FLASH_Program封装得比较厚,出错时返回的错误码要再翻一层才能定位到是 PGAERR 还是 WRPERR;LL 库则把LL_FLASH_Unlock、LL_FLASH_Program_DoubleWord、LL_FLASH_ErasePage这些动作直接暴露出来,配合FLASH->SR状态寄存器读标志位,排查问题时心里有数。这份资源走的就是后者:能用 LL 就不碰 HAL,个别 LL 没覆盖的位(比如某些选项字节相关操作)直接写寄存器。
选 LL 还有一个现实原因——代码体积。L4 系列不少型号 FLASH 只有 128KB 或 256KB,HAL_FLASH 模块连带依赖拉进来,占用不算小;LL 的 FLASH 部分基本是薄薄一层内联函数,对资源紧张的工程更友好。代价是要自己管解锁、自己清标志、自己判忙,少一步就翻车,这也是后面避坑章节要重点说的。
2.2 目录结构与文件职责
这类工程通常不会太大,但分层要清楚,否则参数区和程序区混在一起,改一次地址全乱。按常见组织方式,目录大致是这样:
stm32l4_flash_demo/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32l4xx_it.h │ │ └── stm32l4xx_ll_conf.h // 使能 LL FLASH 头文件 │ └── Src/ │ ├── main.c // 初始化与调用示例 │ ├── stm32l4xx_it.c │ └── system_stm32l4xx.c ├── Drivers/ │ ├── CMSIS/ // 内核与设备头文件 │ └── STM32L4xx_LL_Driver/ │ ├── Inc/stm32l4xx_ll_flash.h │ └── Src/stm32l4xx_ll_flash.c // 若使用源码版 LL ├── BSP/ │ ├── Inc/ │ │ └── bsp_internal_flash.h // 参数区读写接口声明 │ └── Src/ │ └── bsp_internal_flash.c // 擦除/编程/校验实现 └── App/ ├── Inc/app_param.h └── Src/app_param.c // 参数结构体与默认值BSP层是这份资源的核心,把 FLASH 的物理细节(页号、地址、双字对齐)挡在接口后面;App层只关心「存一个参数结构体、读回来校验」。这样换芯片型号时,改的是 BSP 里的页地址宏,上层不动。stm32l4xx_ll_conf.h里要确认LL_FLASH_MODULE_ENABLED是打开的,否则编译时LL_FLASH_*全是未定义,这个坑新手经常踩。
2.3 参数区选址:别和程序区抢地盘
L4 的 FLASH 按页划分,每页 2KB,起始地址 0x08000000。参数区一般放在最后一页或倒数第二页,具体取决于芯片容量。以 256KB 型号为例,最后一页起始地址是 0x0803F800。选址时有两个硬要求:一是这一页不能被链接器分配给代码,二是擦除粒度决定了参数区最小就是 2KB,哪怕你只存 16 字节,也得占一整页。
链接脚本里要把这一页排除掉,常见做法是在.ld文件里把 FLASH 长度减去 2KB:
/* 以 256KB FLASH 为例,留出最后一页给参数区 */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 254K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 96K }LENGTH = 254K就是 256K 减 2K,链接器不会把代码放到最后一页。如果忘了改,程序一编译就可能占用参数页,运行时擦除直接把代码擦了,现象是设备跑着跑着 HardFault,这种问题用调试器看 PC 落在 0x0803F8xx 附近就能确认。参数区地址建议用宏统一定义,别在代码里散落魔法数字:
/* bsp_internal_flash.h */ #define PARAM_FLASH_PAGE_ADDR 0x0803F800UL /* 最后一页起始地址 */ #define PARAM_FLASH_PAGE_NUM 127U /* 页号,按型号核对 */ #define PARAM_FLASH_SIZE 0x800UL /* 2KB */页号在不同容量型号上不一样,128KB 型号最后一页是第 63 页,256KB 是第 127 页,512KB 是第 255 页。写代码前先翻对应型号参考手册的 FLASH 章节,别照抄别的工程。
3. 核心实现拆解:解锁、擦除、双字编程、回读校验四步走
3.1 解锁与上锁:顺序错了直接锁死
L4 的 FLASH 控制器上电默认锁定,任何擦写操作前必须先解锁,往FLASH->KEYR依次写两个固定密钥。顺序不能反,中间不能被打断,写完要检查FLASH->CR的LOCK位是否清零。LL 库把这两步包成了LL_FLASH_Unlock(),但底层就是下面这个动作:
/* 解锁 FLASH 控制器,擦写前必须调用 */ static void flash_unlock(void) { if (LL_FLASH_IsLocked()) { LL_FLASH_Unlock(); /* 内部依次写 KEY1、KEY2 到 KEYR */ } /* 解锁失败通常意味着之前有未清除的错误标志,先清再试 */ if (LL_FLASH_IsLocked()) { __disable_irq(); FLASH->SR = FLASH_SR_CLEAR; /* 清所有错误标志 */ LL_FLASH_Unlock(); __enable_irq(); } }LL_FLASH_IsLocked()读的是FLASH->CR的 LOCK 位。如果解锁后仍然是锁定状态,多半是FLASH->SR里有残留错误标志(比如上次操作失败留下的 PGSERR),控制器拒绝再次解锁。这时先写FLASH->SR清标志再重试,注意清标志是写 1 清零,不是写 0。上锁用LL_FLASH_Lock(),擦写完成后立刻调用,别让 FLASH 长时间处于可写状态,误操作窗口越小越好。
3.2 页擦除:先擦后写是铁律
FLASH 的物理特性决定了只能把 1 写成 0,不能把 0 写回 1,所以改任何数据前必须先擦除整页。擦除流程是:解锁 → 清标志 → 置 PER 位并写页号 → 置 STRT 启动 → 等 BSY 清零 → 查错误标志 → 上锁。用 LL 库写出来是这样:
/* 擦除指定页,返回 0 成功,负值失败 */ int flash_erase_page(uint32_t page) { flash_unlock(); /* 等待上一次操作结束,避免在忙时改配置 */ while (LL_FLASH_IsActiveFlag_BSY()) { } /* 清掉可能残留的错误标志 */ LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL); /* 配置页擦除:PER 置位,页号写入 PNB */ LL_FLASH_EnablePageErase(); LL_FLASH_SetPageToErase(page); /* 启动擦除并等待完成 */ LL_FLASH_StartErase(); while (LL_FLASH_IsActiveFlag_BSY()) { } /* 检查错误:PGSERR 配置错、WRPERR 写保护、PROGERR 编程错 */ int ret = 0; if (LL_FLASH_IsActiveFlag_OPERATION_ERR()) { ret = -1; } LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL); LL_FLASH_Lock(); return ret; }LL_FLASH_SetPageToErase(page)写的是FLASH->CR的 PNB 字段,页号必须和地址对应,写错页号会擦掉别的页,这是最危险的一类错误。LL_FLASH_StartErase()置 STRT 位后硬件开始干活,2KB 一页擦除时间在毫秒级,轮询 BSY 就够了,不用上中断。擦完必须查OPERATION_ERR,因为擦除失败不会让程序跑飞,只会让后续写入数据错乱,不查标志等于把问题埋到几个月后。
3.3 双字编程:64 位对齐是硬约束
L4 的 FLASH 编程最小单位是双字,也就是 8 字节,地址必须 8 字节对齐。想存一个 4 字节的uint32_t,也得凑成 8 字节写进去。LL 库提供LL_FLASH_Program_DoubleWord(address, data0, data1),两个 32 位参数拼成一个 64 位双字。写一个参数结构体时,常见做法是按 8 字节对齐逐块写:
/* 从指定地址开始写入 buf,长度按 8 字节向上取整 */ int flash_write(uint32_t addr, const uint8_t *buf, uint32_t len) { if ((addr & 0x7U) != 0U) { return -1; /* 地址未 8 字节对齐,直接拒绝 */ } flash_unlock(); while (LL_FLASH_IsActiveFlag_BSY()) { } LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL); uint32_t written = 0; while (written < len) { uint64_t dw = 0xFFFFFFFFFFFFFFFFULL; /* 未用字节保持擦除态 */ uint32_t chunk = (len - written >= 8U) ? 8U : (len - written); memcpy(&dw, buf + written, chunk); LL_FLASH_Program_DoubleWord(addr + written, (uint32_t)(dw & 0xFFFFFFFFULL), (uint32_t)(dw >> 32)); while (LL_FLASH_IsActiveFlag_BSY()) { } if (LL_FLASH_IsActiveFlag_OPERATION_ERR()) { LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL); LL_FLASH_Lock(); return -2; /* 编程失败,多半是目标区未擦除 */ } written += chunk; } LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL); LL_FLASH_Lock(); return 0; }几个参数要盯住:addr必须 8 字节对齐,不对齐直接返回错误,别指望硬件帮你兜;dw初始化为全 1,是因为 FLASH 擦除后就是全 1,未写满的双字剩余字节保持 1 才符合物理状态;chunk处理尾部不足 8 字节的情况,用memcpy把有效字节填进低地址,高地址留 1。编程失败最常见的原因是目标地址没擦除,写进去的位和原有 0 冲突,硬件报 PROGERR,所以「先擦后写」不是建议,是必须。
3.4 回读与校验:写完不算完,读回来对得上才算
写入完成后直接从 FLASH 地址读数据即可,FLASH 在读取上和普通内存没区别,不需要解锁。但工程上不能只写不验,参数区出错的代价往往是现场设备行为异常,排查成本远高于多读一遍。常见做法是写入后逐字节比对,再在参数结构体里加一个 CRC 或魔数:
/* 写入并校验,返回 0 表示写入且比对一致 */ int flash_write_verify(uint32_t addr, const uint8_t *buf, uint32_t len) { if (flash_write(addr, buf, len) != 0) { return -1; } /* 直接按内存读回比对 */ if (memcmp((const void *)addr, buf, len) != 0) { return -2; } return 0; }memcmp的第二个参数直接强转 FLASH 地址,在 Cortex-M4 上读 FLASH 和读 RAM 一样走总线,没有额外步骤。参数结构体建议首字段放魔数(比如 0x5A5AA5A5),读取时先判魔数再判 CRC,魔数不对说明这页从没写过或是被擦过,直接加载默认值,别拿随机数据当参数用。CRC 用软件实现即可,参数区数据量小,不必上硬件 CRC 单元。
4. 跑通与验证:从空工程到参数掉电不丢的完整步骤
4.1 编译环境与最小验证工程
这份资源是 Keil 工程的话,打开后先确认三件事:器件型号选对(比如 STM32L431RCTx)、stm32l4xx_ll_conf.h里LL_FLASH_MODULE_ENABLED已定义、链接脚本或 Keil 的 IROM 配置已经把参数页排除。用 Keil 的话在 Target 选项的 IROM1 里把 Size 改成 0x3F800(256KB 减 2KB),和前面 ld 脚本是一个意思。
最小验证不用接任何外设,主循环里做三件事:上电读参数区,魔数不对就写入一组默认值,然后每隔几秒把计数值加一写回去。用调试器看参数区地址的内容变化,或者把读回的值通过串口打印出来。串口配置用 LL 库的 USART 初始化,波特率 115200,这些和 FLASH 无关,按平时习惯配就行。
/* main.c 中的验证逻辑片段 */ int main(void) { /* 系统时钟、串口等初始化省略 */ app_param_t param; if (app_param_load(¶m) != 0) { /* 首次上电或参数区损坏,写入默认值 */ app_param_default(¶m); app_param_save(¶m); printf("param init done\r\n"); } else { printf("param loaded, boot_cnt=%lu\r\n", param.boot_cnt); } param.boot_cnt++; app_param_save(¶m); while (1) { /* 业务逻辑 */ } }app_param_load内部先判魔数再算 CRC,任一不过返回非 0;app_param_save内部先擦页再写结构体再回读校验。跑一次断电重启,串口打印的boot_cnt每次加一,说明参数真的落到了 FLASH 里。这个验证看着简单,但能把解锁、擦除、编程、校验四条链路全串起来,任何一环有问题都会在这里暴露。
4.2 用调试器确认页地址和内容
跑通之后别急着收工,用调试器的 Memory 窗口看参数页地址(比如 0x0803F800),确认三件事:擦除后整页是 0xFF、写入后目标区域是预期数据、结构体尾部没有被意外改写。如果看到某几个字节是 0x00 而不是预期值,多半是双字编程时memcpy长度算错,把未初始化内存写进去了。还可以在擦除函数前后各打一个断点,单步看FLASH->CR的 PER、PNB、STRT 位变化,比读文档直观。
另一个实用手段是看 map 文件,搜索参数页地址,确认没有任何函数或常量落在这一页。Keil 的.map文件在 Objects 目录下,IAR 在 List 目录下,搜0x0803f8没有结果就说明链接器没占用。这一步花两分钟,能省掉后面「程序莫名跑飞」的几天排查。
4.3 低功耗场景下的额外注意
L4 系列主打低功耗,如果工程里用了 STOP 模式,FLASH 操作要在进入低功耗前完成,别在唤醒中断里直接擦写。擦除期间电流会上升,而且 BSY 轮询依赖系统时钟,STOP 模式下时钟停了,轮询会卡死。常见做法是把参数保存请求置个标志,主循环检测到标志再执行擦写,写完再允许进低功耗。另外擦写期间如果发生复位,那一页可能处于半擦半写状态,所以读取时的魔数加 CRC 双重校验不能省,它是掉电中断后的最后一道保险。
5. 避坑与排查:五条现场踩出来的记录
5.1 擦除后程序跑飞,PC 落在参数页附近
现象:调用擦除函数后设备立刻 HardFault,调试器显示 PC 在 0x0803F8xx 区间。
原因:链接器把某个函数或常量表放到了最后一页,擦除时把代码擦了。根源是 IROM 长度没减掉参数页,或者减了但分散加载文件没同步改。
解决:核对 Keil 的 IROM1 Size 或 ld 脚本的 FLASH LENGTH,确保等于总容量减 2KB;改完重新编译,看 map 文件确认参数页地址无占用。改链接配置后一定要全量重编,增量编译有时不会重新布局。
5.2 写入返回成功但读回全是 0xFF
现象:flash_write返回 0,回读比对却失败,Memory 窗口看目标地址还是擦除态。
原因:编程前没清FLASH->SR里的错误标志,或者LL_FLASH_Program_DoubleWord调用时 BSY 还没落,硬件忽略了这次编程。也可能是页号写错,擦的是别的页,目标页仍是旧的锁定数据。
解决:每次擦写前统一执行LL_FLASH_ClearFlag(LL_FLASH_FLAG_ALL),并在编程后检查OPERATION_ERR;页号和地址用宏绑定,别手写两处。回读校验必须保留,它是唯一能发现「假成功」的手段。
5.3 解锁一直失败,LOCK 位清不掉
现象:调用LL_FLASH_Unlock()后LL_FLASH_IsLocked()仍返回真,后续擦写全部无效。
原因:FLASH->SR里有未清除的 PGSERR 或 SIZERR,控制器处于错误状态,拒绝解锁。也可能是代码在中断里重复调用解锁,两次解锁之间状态被打断。
解决:解锁前先写FLASH->SR = FLASH_SR_CLEAR清标志,再解锁;擦写函数加一个静态标志防重入,或者在调用期间关中断。清标志是写 1 清零,写 0 没效果,这点和普通寄存器赋值习惯相反。
5.4 参数结构体加字段后旧数据全乱
现象:给参数结构体新增一个成员,重新烧录后读出的旧参数值错位,CRC 校验失败。
原因:结构体布局变了,旧数据按新布局解析,字段偏移全错。FLASH 里存的还是老格式,代码却按新格式读。
解决:参数结构体首字段放版本号,读取时版本不匹配就走默认值并重写;或者结构体末尾预留若干保留字节,加字段时从保留区挪。升级固件时参数格式变更是常态,版本号字段是成本最低的后悔药。
5.5 擦写期间进低功耗导致卡死
现象:带 STOP 模式的工程里,参数保存偶尔卡在while (LL_FLASH_IsActiveFlag_BSY())出不来。
原因:擦除启动后系统进入 STOP,FLASH 操作依赖的时钟停了,BSY 位不再更新,轮询死循环。
解决:擦写动作放在主循环,执行期间禁止进低功耗;或者擦写前关掉低功耗唤醒源,完成后恢复。别在中断里直接擦写,中断上下文里做毫秒级阻塞操作本身就是隐患。
6. 二次开发改造:把参数区做成可扩展的小型存储层
拿到这份代码后,多数人不会停在「存一个结构体」上,而是想把它扩成能存多组配置、能记录运行日志的小存储层。改造的核心思路是把「页」抽象成「槽」,一页 2KB 切成若干固定大小的槽,每个槽带独立魔数和 CRC,读写按槽号寻址。这样改一组参数不影响另一组,擦除粒度也从整页降到单槽逻辑。
具体做法是先定义槽布局:每槽 64 字节,头部 8 字节放魔数、版本、长度、CRC,后面 56 字节放数据,一页正好 32 个槽。写入时先读目标槽判断是否已是相同内容,相同就跳过,减少擦写次数——FLASH 擦写寿命有限,L4 的数据手册给的擦写次数是万级,频繁写同一页会提前耗尽。不同内容才触发整页擦除加重写所有有效槽,这个「读-改-写」流程是小型存储层的标准套路。
/* 槽头结构,8 字节,和双字编程粒度对齐 */ typedef struct { uint32_t magic; /* 0x5A5AA5A5 表示槽有效 */ uint16_t version; /* 数据格式版本 */ uint16_t len; /* 有效数据长度,最大 56 */ uint32_t crc; /* 数据区 CRC32 */ } slot_hdr_t; /* 按槽号写入,内部做读-改-写,返回 0 成功 */ int slot_write(uint32_t slot_id, const void *data, uint16_t len);slot_write内部先把整页读到 RAM 缓冲(2KB,L4 的 RAM 够用),改掉目标槽,擦页,再把缓冲整体写回。这个流程的代价是每次写都搬 2KB,但换来的是任意槽独立更新和掉电后单槽可恢复。如果写频率高,可以再加一个「脏页合并」策略:多次写只标记脏槽,空闲时统一落盘。
验证方法也简单:写槽 0 到槽 31 各一组不同数据,逐个读回比对;然后随机改其中几个槽,断电重启再全量校验,确认只有被改的槽变化、其余槽数据完好。再模拟一次写入过程中复位(在擦除后、写回前手动触发复位),重启后应该能通过 CRC 识别出损坏槽并回退到默认值,而不是整页数据报废。
我自己的习惯是,任何碰 FLASH 的改动,先在 RAM 里跑一遍逻辑仿真,把槽布局、CRC、读-改-写流程用普通数组验证通过,再切到真实地址。直接上板调擦写,一次失误就是一页数据没了,调试器都救不回来。从那以后我每次改参数区代码,都强制先在 RAM 仿真里走一遍全流程,再烧板子。希望帮到你。
本文还有配套的精品资源,点击获取