STM32L4xx内部FLASH读写实战:LL库与寄存器操作指南
简介面向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、写入后回读比对、断电重启再读”这三步少一步都不敢往下写业务。希望帮到你。本文还有配套的精品资源点击获取