简介:面向STM32L4xx系列MCU开发者的内部FLASH读写参考代码,聚焦嵌入式底层存储操作这一常见需求。资源基于STM32L452RET6芯片实测调通,采用LL库完成寄存器级配置,因此改用HAL库时只需调整相关定义即可复用,适合已具备一定STM32基础、希望深入理解FLASH擦写机制的开发者参考。压缩包共2个文件,包含1个c源文件与1个h头文件,分别承载FLASH读写函数实现与接口声明,整体仅2KB,轻量易读,便于直接嵌入工程或逐行研读。已有1935人学习下载,具备一定的实践参考价值。作者还补充了在STM32L471VETx上的调试经验:因FLASH页码不连号导致擦页失败,需修改擦页函数中的页码号方可成功,并提示该代码不适用于STM32L4x1系列。读者可从中获得寄存器级擦写流程、跨型号适配思路及实际排错线索,减少底层调试的试错成本。
1. 从一次掉参数的现场说起:STM32L4xx 内部 FLASH 读写到底解决什么问题
设备断电重启后,标定参数全变回默认值,现场同事一脸茫然,我第一反应是 EEPROM 没写进去,拆开一看板子上压根没贴 EEPROM。这类场景在 STM32L4xx 项目里太常见了:板子成本压到极限,外部存储能省就省,可配置参数、校准系数、运行计数又必须掉电不丢。这份资源给的就是一条最直接的路——用芯片自带的内部 FLASH 当非易失存储,基于 LL 库和寄存器操作完成擦除、编程、读取,不依赖 HAL 的 FLASH 封装,也不占外部器件。适合正在做 L4 系列产品、需要存少量参数又不想加料的开发者,也适合想搞清楚 FLASH 编程时序和寄存器位定义的人。它不是一个能直接编译的完整工程,而是一份围绕“读写内部 FLASH”这个动作的代码与寄存器操作素材,拿过去嵌进自己的工程最合适。
2. 技术选型拆解:为什么是 LL 库加寄存器,而不是 HAL 封装
2.1 LL 库与寄存器操作在 FLASH 场景下的取舍
STM32L4xx 的 FLASH 控制器比 F1 那代复杂不少,多了双 Bank、页擦除粒度、ECC、选项字节这些概念。HAL 库把 FLASH 操作包成HAL_FLASH_Program、HAL_FLASHEx_Erase这类函数,好处是跨系列统一,坏处是中间层厚,出问题时你得一层层往下翻才能看到到底哪个寄存器位没置对。这份资源走的是 LL 库加直接寄存器操作的路子,核心动作就是往FLASH->KEYR写解锁序列、查FLASH->SR的状态位、操作FLASH->CR的编程与擦除位。对 FLASH 这种“写错一次就可能锁死芯片”的外设,贴近寄存器的写法反而更容易排查——每个状态位对应什么错误一目了然。
我一般会这样权衡:如果项目里 FLASH 只用来存几十字节参数,一年写不了几次,LL 加寄存器足够,代码量小、可控;如果要做 Bootloader 跳转、选项字节批量配置、双 Bank 切换,那还是建议把 HAL 的 FLASHEx 拿来参考,别自己从零拼时序。这份素材属于前者,定位很清晰。
2.2 L4 系列 FLASH 的关键参数与页结构
动手前必须把几个硬参数记牢,写错一个就是整页数据没了。STM32L4xx 的 FLASH 通常按 2KB 一页划分(部分型号双 Bank 各 512KB,页大小 2KB),编程粒度是 64 位双字(double word),也就是一次必须写 8 字节,且目标地址必须 8 字节对齐。擦除最小单位是页,不能字节擦除。这些约束决定了你的参数存储布局:要么把参数结构体凑成 8 字节整数倍,要么在写入时做补齐。
| 参数项 | 典型值 | 影响 |
|---|---|---|
| 页大小 | 2KB | 擦除最小单位,改一个字节也要擦整页 |
| 编程粒度 | 64 位双字 | 写入长度必须是 8 的倍数,地址 8 字节对齐 |
| 编程电压 | 由内部电荷泵产生 | 无需外部 VPP,但供电要稳 |
| 擦写寿命 | 约 1 万次每页 | 频繁写同一页会提前耗尽 |
| 读保护 | RDP 等级可配 | 开了 RDP 后调试口行为会变 |
提示:不同 L4 子型号(如 L476、L496、L4R9)的 Bank 数量和页大小略有差异,落地前先翻对应型号的参考手册 FLASH 章节,别拿一份代码套所有片子。
2.3 目录与文件构成:这份素材里有什么
资源是压缩包形式,解压后通常包含 FLASH 读写的核心源文件、寄存器位定义、以及一个可参考的调用示例。实际使用时不需要把它当独立工程编译,而是把源文件拖进你自己的 Keil 或 STM32CubeIDE 工程,补上对应的 CMSIS 头文件和 LL 库依赖即可。下面是一个典型的文件构成,具体文件名以你解压看到的为准:
stm32l4_flash/ ├── flash_ll.c # 解锁、擦除、编程、读取的实现 ├── flash_ll.h # 对外接口与参数宏定义 ├── main_example.c # 调用示例:写参数、读回校验 └── README # 使用说明与注意事项接入时最容易翻车的是头文件路径和 LL 库版本对不上,stm32l4xx_ll_flash.h里某些宏在不同 Cube 版本里名字会变。我的习惯是先把工程编译过一遍空壳,确认 LL 头文件能正常包含,再往里塞 FLASH 代码,避免一上来就被一堆未定义符号淹没。
3. 核心代码拆解:解锁、擦除、编程、读取四步链路
3.1 解锁 FLASH 控制器:KEYR 序列不能被打断
L4 的 FLASH 上电默认是锁定的,任何擦写操作前必须先往FLASH->KEYR依次写两个固定密钥。这段序列中间如果被中断打断,或者写错顺序,控制器会置位错误标志并拒绝后续操作,严重时得复位才能恢复。
/* 解锁 FLASH 控制器,允许擦除与编程 */ static int flash_unlock(void) { if (FLASH->CR & FLASH_CR_LOCK) { /* 先判断是否已锁定 */ FLASH->KEYR = 0x45670123U; /* 密钥 1,顺序不可换 */ FLASH->KEYR = 0xCDEF89ABU; /* 密钥 2 */ } /* 回读 LOCK 位确认解锁成功 */ return (FLASH->CR & FLASH_CR_LOCK) ? -1 : 0; }逻辑说明:先读FLASH_CR_LOCK位判断当前状态,已解锁就跳过,避免重复写密钥触发错误。两个密钥是芯片固定的,写反或中间插入其他寄存器访问都可能失败。函数返回 0 表示解锁成功,返回 -1 时不要继续往下走,先查FLASH->SR里的错误位。参数上没有可调的,唯一要注意的是这段代码执行期间最好关中断,防止时序被打断。
3.2 页擦除:先清标志再置 PER,最后等 BSY
擦除是整页操作,流程是清错误标志、置页擦除位、填页地址、置启动位、轮询忙标志。顺序错了要么擦不掉,要么把不该擦的页擦了。
/* 擦除指定页,page_addr 为该页起始地址 */ int flash_erase_page(uint32_t page_addr) { if (flash_unlock() != 0) return -1; while (FLASH->SR & FLASH_SR_BSY) { } /* 等上一次操作结束 */ FLASH->SR = FLASH_SR_ALL_ERR; /* 清所有错误标志,写 1 清零 */ FLASH->CR |= FLASH_CR_PER; /* 选择页擦除模式 */ FLASH->CR |= FLASH_CR_PNB_Msk & ((page_addr - FLASH_BASE) / FLASH_PAGE_SIZE) << FLASH_CR_PNB_Pos; FLASH->CR |= FLASH_CR_STRT; /* 启动擦除 */ while (FLASH->SR & FLASH_SR_BSY) { } /* 等待完成 */ FLASH->CR &= ~FLASH_CR_PER; /* 退出页擦除模式 */ return (FLASH->SR & FLASH_SR_ALL_ERR) ? -2 : 0; }逻辑说明:FLASH_SR_ALL_ERR是把所有错误位或起来的掩码,L4 上错误标志是写 1 清零,这点和很多外设相反,写 0 是没用的。页号通过(page_addr - FLASH_BASE) / FLASH_PAGE_SIZE算出来再左移到PNB位域,双 Bank 型号还要额外处理 Bank 选择位。返回 -2 表示擦除过程中出了错,常见是写保护没解除或供电跌落。参数page_addr必须是页起始地址,传页中间地址算出来的页号虽然对,但语义上容易误导后来人。
3.3 双字编程:8 字节对齐是硬约束
编程只能按 64 位双字写,长度不是 8 的倍数时,末尾要补 0xFF。下面这段把一段缓冲区按双字逐个写入。
/* 从 dst 开始写入 len 字节,len 内部按 8 字节向上取整 */ int flash_write(uint32_t dst, const uint8_t *src, uint32_t len) { if (flash_unlock() != 0) return -1; if (dst % 8 != 0) return -3; /* 地址必须 8 字节对齐 */ FLASH->CR |= FLASH_CR_PG; /* 进入编程模式 */ for (uint32_t i = 0; i < len; i += 8) { uint64_t dw = 0xFFFFFFFFFFFFFFFFULL; /* 默认补 0xFF */ uint32_t n = (len - i >= 8) ? 8 : (len - i); for (uint32_t k = 0; k < n; k++) { dw &= ~((uint64_t)0xFF << (k * 8)); dw |= (uint64_t)src[i + k] << (k * 8); } *(volatile uint64_t *)(dst + i) = dw; /* 写双字触发编程 */ while (FLASH->SR & FLASH_SR_BSY) { } if (FLASH->SR & FLASH_SR_ALL_ERR) { FLASH->CR &= ~FLASH_CR_PG; return -2; } } FLASH->CR &= ~FLASH_CR_PG; return 0; }逻辑说明:先校验地址对齐,不对齐直接返回 -3,别硬写。循环里把不足 8 字节的尾部用 0xFF 补齐,因为 FLASH 编程本质是把 1 变 0,补 0xFF 等于不改变这些位。写双字用volatile uint64_t指针,编译器不会优化掉这次写。每写一个双字都要等BSY清零并查错误位,连续写多个双字时不能跳过等待。参数len传实际字节数即可,函数内部处理补齐。
3.4 读取与校验:直接寻址加回读比对
内部 FLASH 读取不需要任何特殊命令,它就是内存映射地址空间,直接按指针读即可。真正要做的是写完后的回读校验,确认数据真的落盘。
/* 从 FLASH 读回 len 字节到 buf,并可与原始数据比对 */ int flash_read(uint32_t src, uint8_t *buf, uint32_t len) { for (uint32_t i = 0; i < len; i++) { buf[i] = *(volatile uint8_t *)(src + i); /* 直接内存映射读取 */ } return 0; } /* 写入后回读比对,返回 0 表示一致 */ int flash_verify(uint32_t addr, const uint8_t *expect, uint32_t len) { for (uint32_t i = 0; i < len; i++) { if (*(volatile uint8_t *)(addr + i) != expect[i]) return -1; } return 0; }逻辑说明:读取用volatile防止编译器缓存优化,虽然 FLASH 内容不会变,但加上更稳妥。flash_verify是血泪经验——曾经有一次编程时序没问题但供电瞬间跌落,写进去的数据高位错了,没校验就上线,现场调了三天。参数src是 FLASH 内地址,buf是 RAM 缓冲区,长度按实际数据给。
4. 落地实操:把 FLASH 读写嵌进自己的工程
4.1 工程接入步骤与链接脚本确认
拿到源文件后,第一步不是急着编译,而是确认链接脚本里 FLASH 的地址范围,确保你打算用来存参数的页没有被代码占用。以常见的 512KB FLASH 型号为例,代码从0x08000000开始,末尾几页留给参数区最稳妥。
# 在工程根目录确认链接脚本中的 FLASH 区域定义 grep -n "FLASH" *.ld # 典型输出:FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K逻辑说明:这条命令只是帮你快速定位链接脚本里的 FLASH 定义,ORIGIN是起始地址,LENGTH是总容量。假设你决定用最后一页存参数,那参数区起始地址就是ORIGIN + LENGTH - 2K。改链接脚本时要把这一页从代码区排除,否则编译器可能把常量放进去,擦除时一起没了。参数上唯一要改的是LENGTH减去预留页大小,具体写法各工具链不同,Keil 在 Target 配置里改,GCC 改.ld文件。
接入顺序建议是:先把flash_ll.c/.h加入工程,确认能编译;再在main里调用一次flash_read读参数区,确认地址不越界;最后才试擦写。别一上来就擦,先读一遍看看那页里有没有东西。
4.2 参数存储布局与写入时机
参数结构体建议按 8 字节对齐,并在结构体末尾留一个校验字段(CRC 或魔数),读出来先校验再使用。写入时机上,别在中断里写 FLASH,擦除一页动辄几十毫秒,会拖垮实时性。我的做法是设一个脏标志,主循环空闲时统一落盘。
typedef struct { uint32_t magic; /* 固定魔数,用于判断是否已初始化 */ float kp, ki, kd; /* 标定参数 */ uint32_t run_count; /* 运行计数 */ uint32_t crc; /* 前面字段的校验值 */ } __attribute__((aligned(8))) param_t;逻辑说明:aligned(8)保证结构体起始地址满足双字对齐要求,字段顺序上把校验值放最后,计算 CRC 时跳过它自己。magic用来区分“从没写过”和“写过但数据损坏”,首次上电读到全 0xFF 就加载默认值并写一次。参数上kp/ki/kd只是示例,换成你自己的业务字段即可,注意总大小别超过一页。
4.3 用状态机管理擦写,避免阻塞主循环
擦除和编程都是阻塞操作,直接在主循环里调用会让其他任务卡住。简单做法是用一个状态机分步推进:空闲、请求擦除、等待擦除完成、请求写入、等待写入完成、校验、完成。
typedef enum { FS_IDLE, FS_ERASE, FS_WRITE, FS_VERIFY, FS_DONE, FS_ERR } flash_state_t; void flash_task(void) { static flash_state_t st = FS_IDLE; switch (st) { case FS_IDLE: if (param_dirty) { st = FS_ERASE; } break; case FS_ERASE: st = (flash_erase_page(PARAM_PAGE_ADDR) == 0) ? FS_WRITE : FS_ERR; break; case FS_WRITE: st = (flash_write(PARAM_PAGE_ADDR, (uint8_t *)&g_param, sizeof(g_param)) == 0) ? FS_VERIFY : FS_ERR; break; case FS_VERIFY: st = (flash_verify(PARAM_PAGE_ADDR, (uint8_t *)&g_param, sizeof(g_param)) == 0) ? FS_DONE : FS_ERR; break; case FS_DONE: param_dirty = 0; st = FS_IDLE; break; case FS_ERR: /* 记录错误,等待下次重试或上报 */ st = FS_IDLE; break; } }逻辑说明:每个状态只做一步,主循环每次调用flash_task推进一格,虽然单步仍是阻塞的,但整体不会长时间占住 CPU。param_dirty由业务层置位,FS_ERR里可以做重试计数,连续失败就上报。参数PARAM_PAGE_ADDR就是前面算出来的参数区起始地址,改地址只改这一个宏。
5. 避坑与排查:FLASH 操作最容易翻车的五个地方
5.1 现象:擦除后读出来全是 0x00 而不是 0xFF
原因:擦除没真正执行,或者擦错了页。常见是PNB位域算错,双 Bank 型号没设 Bank 选择位,导致擦的是另一个 Bank 的页。解决:擦除后立刻回读该页首地址,正常应是 0xFF;不是就打印FLASH->SR的错误位,重点看PROGERR、WRPERR、PGSERR。另外确认写保护(WRP)没覆盖目标页。
5.2 现象:编程返回成功但数据错位
原因:写入地址没 8 字节对齐,或者len不是 8 的倍数且尾部没补齐。L4 编程按双字进行,地址不对齐时硬件行为不可预期。解决:在flash_write入口加对齐断言,尾部统一补 0xFF,写完必须走flash_verify。我现在的习惯是任何 FLASH 写入后都强制校验一遍,不校验等于没写。
5.3 现象:调试时能跑,脱机运行偶尔丢参数
原因:供电跌落。FLASH 编程和擦除对电压敏感,尤其擦除瞬间电流较大,电源余量不足时状态位会报错但代码没检查。解决:擦写前后查FLASH->SR全部错误位,失败就重试;硬件上在 FLASH 操作期间避免大电流外设同时动作。这条是玄学重灾区,示波器抓一下 VDD 波形往往能看出问题。
5.4 现象:开了读保护后程序跑飞
原因:RDP 等级配置不当,或者选项字节编程时断电导致芯片进入异常状态。解决:选项字节操作单独做,别和普通 FLASH 擦写混在一起;改 RDP 前确认调试口还能用,必要时准备恢复流程。这类操作一旦翻车,后悔药就是重新上电进 bootloader 解锁,所以量产前务必在样机上验证。
5.5 现象:同一页频繁写,几个月后写不进去
原因:擦写寿命耗尽。L4 每页约 1 万次擦写,如果每次参数变化都擦同一页,高频场景很快到顶。解决:做磨损均衡,把参数区划成多页轮换写,或者只在参数真正变化且稳定后才落盘,减少无效擦写。运行计数这类高频字段建议单独放一页并做增量记录,别每次加一就擦整页。
6. 二次开发:把这份素材改造成可靠的参数存储模块
拿到这份 FLASH 读写素材,直接嵌进工程只是起点,真正要落地得往上加两层:一层是参数管理,一层是可靠性保障。我一般会先定义一个参数表,把每个字段的偏移、默认值、校验方式列出来,读写都走这张表,而不是到处memcpy结构体。这样字段增删只改表,不动读写逻辑。
typedef struct { uint16_t offset; /* 字段在参数区内的偏移 */ uint16_t size; /* 字段字节数 */ const void *def; /* 默认值指针 */ } param_item_t; static const param_item_t param_table[] = { { offsetof(param_t, kp), sizeof(float), &default_kp }, { offsetof(param_t, ki), sizeof(float), &default_ki }, /* 按需扩展 */ };逻辑说明:offsetof在编译期算出字段偏移,def指向默认值常量。加载时逐项比对魔数和 CRC,不通过就按表恢复默认值。参数上param_table的长度用sizeof(param_table)/sizeof(param_table[0])算,别写死。
第二层是双页备份加版本号。参数区用两页,一页存当前有效数据,另一页存上一版,写入时先擦备份页、写新数据、校验通过后再更新有效页标记。这样即使写入过程中断电,至少还有一版可用。版本号用单调递增的uint32_t,加载时选版本号大的那页。这套机制不复杂,但能把“写一半断电导致参数全丢”的概率压到很低。
验证方法上,我习惯做三组测试:正常写入后断电重启读回、写入过程中随机断电(用调试器强制复位模拟)、连续擦写一千次后检查数据完整性。第三组测试能提前暴露寿命问题,别等量产后再补。改造时还要注意一点:如果项目用了 Bootloader 加 App 的结构,参数区地址要在两边保持一致,最好抽一个公共头文件定义,避免一边改了另一边没改,跳转后读到错位数据。
从那以后我每次接 FLASH 存储的活,都强制先跑一遍“擦除后回读 0xFF、写入后回读比对、断电重启再读”这三步,少一步都不敢往下写业务。希望帮到你。
本文还有配套的精品资源,点击获取