
简介面向STM32L4系列嵌入式工程师的一份内部Flash读写示例资源用于解决片内Flash擦除、写入与读取等常见底层操作需求可作为驱动层参考直接移植。代码基于LL库并以寄存器级配置实现作者已在STM32L452RET6上实际调通读写过程涉及解锁、擦除、写入、读取与状态检查等完整环节由于核心逻辑均为寄存器操作迁移到HAL库时只需按平台修改对应宏定义即可继续使用。值得关注的是在后续调试STM32L471VETx时发现Flash页码并非连续编号通过调整擦页函数的页码映射即可成功擦除这一排错经验可帮助开发者避开L4x1系列芯片的潜在坑点。资源包非常精简仅含2个文件C源文件实现Flash读写相关接口头文件提供函数声明与寄存器定义整体压缩后仅约2KB没有冗余依赖非常适合直接放入工程复用也适合对照参考手册学习底层寄存器配置流程。目前已有1935人学习下载适用于需要实现片内数据存储、参数保存或基础引导加载功能的STM32L4开发者。1. STM32L4xx读写内部FLASH省下的不止一颗外部存储芯片STM32L4xx读写内部FLASH听起来是老话题但真正用对的人不多。这个例程包把LL库和寄存器操作两套实现都放出来了解决的是产品里最实在的问题参数掉电保存、OTA升级标志、Bootloader与应用之间的数据交换不用外挂SPI Flash芯片省引脚也省PCB面积。适合正在做低功耗采集、表计、小体积便携设备又不愿意为几个字节数据多花一颗Flash的开发者。核心思路一句话先擦后写页擦除64位对齐编程操作期间管好中断。全文从L4的Flash结构讲起到两套代码实现、踩坑记录最后落到一个能直接用的存储封装。新手能照着把数据存进去熟手能看出哪些地方容易翻车。2. 先看Flash的脾气再动手地址布局、页大小与LL库选型2.1 地址映射与页大小擦除的最小单位是页L4系列主Flash从地址0x08000000开始容量常见256KB、512KB、1MB几种。地址映射不是随便用的对想拿内部Flash当存储的人来说第一步是把整个Flash空间在脑子里切成三块主存储区、系统存储区、选项字节区。用户能自由读写的是主存储区也就是从0x08000000往后那一片系统存储区和选项字节区不是普通应用该碰的地方。L4的页大小绝大多数型号是2KB一页L4系列则放大到8KB。这里的“页”不是逻辑上的一小段而是Flash控制器擦除操作的最小单位——你没办法只擦掉一个字节想更新一个uint32_t就得先把整页2KB擦成0xFF再写。页索引的计算方式很直接页索引 (目标地址 - 0x08000000) / 页大小举个例子0x08008000减去基地址0x08000000得到0x8000换成十进制是32768除以2048就是第16页。这个换算会在擦除参数里反复用到。大容量L4型号还引入了双Bank设计典型情况是1MB Flash被拆成两个512KB的Bank。双Bank的价值在于程序可以从Bank2运行的同时去擦写Bank1这给OTA和自升级留了操作空间。但单Bank型号就没有这个余量下面避坑章节会专门讲一个与此相关的死机问题。项目常见L4L4主Flash起始地址0x080000000x08000000页大小2KB8KBBank划分单或双视容量双Bank编程粒度双字8字节以参考手册为准用户可写区域主存储区主存储区这个表只做选型时的粗略参考具体到手上这颗芯片一切以芯片参考手册的Flash章节为准。我一般会把手册里Flash地址映射那页截图放在项目文档最前面防止后面调地址时记错。2.2 LL库、HAL与寄存器裸操作这里的选型看透明度和代码体积读写Flash有三种路线HAL库、LL库、直接操作寄存器。标准外设库在L4上已经不推荐了老项目维护还能见到新设计没必要碰。很多刚从其他平台转过来的开发者习惯直接点开HAL的Flash驱动但HAL把擦除和编程封成了一个大状态机超时处理藏得深中断模式下逻辑绕出了问题反而不容易看明白。LL库的定位是寄存器之上的一层薄封装函数命名基本就是寄存器位的名字。ll_flash.h里看到LL_FLASH_Unlock、LL_FLASH_Program几乎能猜到内部在操作PEKEYR和CR寄存器。它的好处是接近底层又不逼着你把位域拼接这种容易错的事亲力亲为。相比之下纯寄存器操作的优势是每个步骤都握在自己手里适合做定制逻辑但代码可读性差项目换人维护时压力大。方案抽象层次代码量适合场景HAL高偏大快速原型、功能验证LL低小资源敏感、需要看清细节寄存器最低看写法定制化、底层排错这个例程包把LL库和寄存器两套实现都给了我的建议是开发阶段跑LL库把业务逻辑调通后续如果需要极致精简或者遇到诡异问题再对照寄存器实现逐位排查。两套一起看还能发现HAL封装掩盖掉的一些细节比如解锁之后控制器其实要忙一小段时间。2.3 三个底层约束先擦后写、64位对齐、单飞无论用哪套实现底层约束逃不掉。第一条写入只能把位的1变成0想把0变回1唯一手段是擦除。所以更新一个已写入过的地址必须先擦整页否则写操作等于没写读回还是0xFF。第二条L4的编程粒度是双字也就是一次写入固定8字节目标地址必须是8的整数倍不是想写几个字节就写几个字节。第三条Flash控制器同一时间只能执行一个操作擦除或者编程进行期间SR寄存器里的BSY位为1软件必须等它清掉再做下一步。这三条约束直接决定了代码的组织方式。写参数不能像改RAM变量一样随手赋值要先规划页、规划地址、组装8字节块。更隐蔽的一点是Flash忙的时候CPU从Flash取指令也会被阻塞——如果擦除的页里正好跑着当前代码硬件就会“自己卡自己”。3. 读写擦除落地LL库函数与寄存器操作的两套实现3.1 解锁与加锁PEKEYR顺序不能乱Flash控制器上电后处于锁定状态写PEKEYR寄存器两个密钥是解锁的唯一途径。LL库把这步封装成了LL_FLASH_Unlock但内部逻辑就是连续写两次PEKEYR。寄存器版本则是把整个流程摊开给你看void flash_unlock_reg(void) { /* 操作 Flash 之前先确认控制器空闲 */ while ((FLASH-SR FLASH_SR_BSY) ! 0) { } /* 按顺序写入两把钥匙顺序颠倒则解锁失败 */ FLASH-PEKEYR 0x89ABCDEFu; FLASH-PEKEYR 0x02030405u; }这里特别强调顺序是因为Flash控制器内部把两次写入当成一组密钥序列第二次写入必须以第一次为前提。如果在两次写入之间插入其他Flash操作解锁状态会异常。LL版本代码更短#include stm32l4xx_ll_flash.h void flash_unlock_ll(void) { /* LL 封装了向 PEKEYR 写两个密钥的过程 */ LL_FLASH_Unlock(); }解锁之后建议立即执行想要的操作操作完成后马上LL_FLASH_Lock或往PEKEYR写0xFFFFFFFF锁回。习惯上不保持长时间解锁状态否则一个指针飘移就可能把Flash内容改写。锁回这一步很容易被新手漏掉虽然大多数时候功能也能跑但产品的健壮性差一截。3.2 页擦除触发之后必须等待BSY清掉擦除是Flash操作里最耗时的动作2KB一页典型耗时在几十毫秒量级。LL库的擦除流程是配置参数、调用LL_FLASH_Erase触发、轮询操作标志直到完成int flash_erase_page_ll(uint32_t page_index) { FLASH_EraseInitTypeDef erase_cfg {0}; flash_unlock_ll(); erase_cfg.TypeErase FLASH_TYPEERASE_PAGES; erase_cfg.Page page_index; erase_cfg.NbPages 1; LL_FLASH_Erase(erase_cfg); /* 必须等硬件把整页清干净期间不要插入其他 Flash 操作 */ while (LL_FLASH_IsActiveFlagOperation()) { } LL_FLASH_ClearFlagOperation(); flash_lock_ll(); return 0; }参数里TypeErase从FLASH_TYPEERASE_PAGES和FLASH_TYPEERASE_MASS1当中选前者按页擦后者整Bank擦。Page是页索引从0开始NbPages表示连续擦几页。如果参数区跨了页边界把NbPages设为2或更大即可硬件会顺序处理。注意LL_FLASH_Erase只是把配置写入CR并触发真正完成要看操作标志。寄存器版本把整个过程拆成位操作int flash_erase_page_reg(uint32_t page_index) { flash_unlock_reg(); /* PER 置1表示页擦模式SNB 写入页号 */ FLASH-CR | FLASH_CR_PER; FLASH-CR (FLASH-CR ~FLASH_CR_SNB_Msk) | ((page_index FLASH_CR_SNB_Pos) FLASH_CR_SNB_Msk); /* START 置1触发擦除 */ FLASH-CR | FLASH_CR_START; while ((FLASH-SR FLASH_SR_BSY) ! 0) { } /* 清除擦除模式位恢复常规状态 */ FLASH-CR ~FLASH_CR_PER; flash_lock_reg(); return 0; }寄存器版本有几个细节值得说明。位段操作前要先把SNB区域清成0用“先与后或”的方式写入页号避免残留值干扰。触发之后立刻进入BSY等待循环越早开等越不容易误判状态。等待完成后把PER清零留着这个位可能会让后续的编程操作语义错乱。3.3 双字编程为什么不能直接用指针写入L4不像F1系列可以半字编程它要求一次写入固定8字节。LL库的编程接口长这样int flash_program_dword_ll(uint32_t dst, uint64_t value) { flash_unlock_ll(); LL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, dst, value); /* 等待编程完成 */ while (LL_FLASH_IsActiveFlagOperation()) { } LL_FLASH_ClearFlagOperation(); flash_lock_ll(); /* 写后读校验值回来了才算成功 */ if (*(volatile uint64_t *)dst ! value) { return -1; } return 0; }有人会想既然Flash地址挂在总线映射上直接对地址解引用赋值不就行了理论上地址总线对Flash的写入会交给Flash控制器但L4的编程粒度是固定的8字节裸写一个4字节的uint32_t不符合控制器期望结果不可控。况且普通指针赋值走的是CPU数据总线和Flash控制器期望的编程序列对不上典型表现是写入无效甚至触发总线错误。正确的寄存器写法是打开编程允许位再用64位宽度的写入去触发int flash_program_dword_reg(uint32_t dst, uint64_t value) { flash_unlock_reg(); /* PG 置1 打开编程写入放在最后一步触发硬件动作 */ FLASH-CR | FLASH_CR_PG; *(volatile uint64_t *)dst value; while ((FLASH-SR FLASH_SR_BSY) ! 0) { } FLASH-CR ~FLASH_CR_PG; flash_lock_reg(); return (*(volatile uint64_t *)dst value) ? 0 : -1; }两个版本我都保留了写后读校验。这一步不是可有可无掉电瞬间电压跌落、Flash老化、时钟配置偏差都可能导致编程结果不合预期没有校验就发现不了。参数存成乱码还能通过校验和补救静默写坏数据才是最难受的。3.4 参数存储封装地址规划、组装双字与整页读写有了擦除和双字编程就可以封装一个可复用的参数存储模块。第一步是地址规划把Flash结尾的一页留出来做参数区段地址示例用途应用代码0x08000000 起程序代码区参数区页A0x0801F8002KB 参数存储参数区页B0x0801F000备份参数页具体地址按Flash容量调整原则是让参数页离代码区越远越好避免擦除动作波及代码所在的区域。数据结构直接定义一个固定大小的块#define PARAM_A_ADDR 0x0801F800u #define PARAM_DATA_LEN 64u typedef struct { uint32_t magic; /* 固定魔数区分有效参数和空Flash */ uint32_t sequence; /* 保存序号用于判断新旧 */ uint8_t payload[PARAM_DATA_LEN - 8]; } ParamBlock;结构体大小控制在8的整数倍这样双字流可以整齐切分。写入时先擦页再把结构体按8字节切块逐个编程static int param_write_page(uint32_t page_addr, ParamBlock *blk) { if (flash_erase_page_ll((page_addr - 0x08000000u) / 2048u) ! 0) { return -1; } for (uint32_t off 0; off sizeof(ParamBlock); off 8) { uint64_t d; memcpy(d, (uint8_t *)blk off, 8); if (flash_program_dword_ll(page_addr off, d) ! 0) { return -1; } } return 0; }memcpy在这里解决了结构体成员对齐和双字组装的问题避免自己拼位。读取则直接按地址强转先验魔数再验数据完整性static int param_read_page(uint32_t page_addr, ParamBlock *blk) { ParamBlock *raw (ParamBlock *)page_addr; if (raw-magic ! 0xA5A5A5A5u) { return -1; } *blk *raw; return 0; }实际项目里还会在块尾加CRC32我这里用魔数简化示意。魔数的作用是区分“擦除后的空页”和“有效参数页”空页全0xFF永远不会和魔数相等。双页交替留作备份的思路在第5章给出完整推导。4. 避坑记录五个让数据变成0xFF或者直接死机的操作4.1 擦除的页里有正在执行的代码现象程序调用页擦除后直接跑飞或者复位循环调试器停在HardFault异常里看PC指针指向Flash区域的无效值。原因擦除过程会占住Flash控制器而CPU当前执行取指的代码正好被擦掉Flash读到一半数据就没了取回来的指令必然是垃圾。这在单Bank的小容量L4上是典型翻车场景——程序代码和参数区挤在同一片Flash里参数页规划得太靠前一擦就把代码连同数据的屋脊掀了。解决把参数区固定在Flash最后的一页或两页链接脚本把代码安排在前面地址擦除动作只许对参数页发起。如果是双Bank型号可以把Bootloader放在Bank1、App放在Bank2从Bank2运行App时去擦Bank1的旧固件这属于OTA的标准做法。寄存器版本擦除函数的入参页索引务必和链接脚本对比过再提交代码。4.2 写入前不擦除读回永远是0xFF现象数据“写进去了”读出来却是0xFF再次写入仍然0xFF调试器看那块内存值纹丝不动。原因Flash编程逻辑只能把位从1写成0不能反方向恢复。对一个写过的地址再次写入控制器会忽略那些需要 0→1 的位变化所以数据等于没写。这是所有嵌入式Flash共有的物理特性不是芯片的问题。解决每次编程前对目标页执行一次页擦除。如果只想改4个字节而整页其他数据要保留正确做法是把整页读进RAM在RAM里修改这4个字节然后擦除整页再把整个页从RAM写回Flash。千万别图省事跳过擦除这个省事会让参数永远停在旧值。4.3 写Flash时来了中断程序直接卡死现象擦除或编程过程中一个串口中断或定时器中断触发后系统不再响应任何中断主循环也不走了看起来像彻底死机。原因中断向量表和中断服务程序都在Flash里。Flash正忙的时候CPU无法从Flash取指令中断处理程序根本进不去主循环的BSY等待又不知道要等多久双方都在等对方让出Flash形成死锁。这个坑在带RTOS的项目里尤其隐蔽高优先级中断一多就随机复现。解决操作Flash期间用临界区屏蔽可屏蔽中断做完再开uint32_t primask __get_PRIMASK(); __disable_irq(); flash_erase_page_ll(page_idx); flash_program_dword_ll(dst, value); __set_PRIMASK(primask);更彻底的做法是把中断向量表搬到RAM并设置VTOR让中断处理不必访问Flash。但RAM空间有限多数项目用临界区方案就足够了代价是擦除期间最长的几十毫秒内系统不响应中断需要评估业务能否接受。4.4 低电压或供电纹波写入偶尔失败现象设备在实验室常温下一切正常到了现场偶尔出现参数丢失复现概率低日志里查不到规律一测才发现掉电瞬间还在写Flash。原因Flash编程对供电电压有明确下限要求掉电过程中电压快速跌落编程操作执行到一半就夭折了。表现好的情况是写后读校验发现不对报错表现差的情况是数据变成半新半旧的混合体。解决用PVD掉电检测提前预警电压跌到阈值之前把关键参数保存进Flash电源输入端留足够容量的储能电容给保存动作争取时间。写入流程里必须加校验和和数据长度回读写失败就标记坏页。这个问题的现象像极了玄学但根源通常是电源设计排查时优先用示波器看VDD在擦写瞬间的跌落幅度。4.5 写保护和读保护没关干净Flash怎么都不让动现象解锁函数返回正常擦除也触发了但操作标志一直不完成Flash内容纹丝不动个别时候调试器直接连不上芯片。原因选项字节里的WRP写保护区域覆盖了目标页或者RDP读保护等级被设成了高于0的级别。RDP一旦升级不是简单地写寄存器就能降下来Flash硬件直接禁止任何外部访问。解决先用调试器读出选项字节的状态确认WRP没盖住参数区。解除RDP需要执行选项字节擦除和重新编程流程注意修改选项字节会触发系统复位不能放在业务代码里随便调用。这个坑最容易在二手板卡上遇到上一任开发者调成保护模式就转交给你排查时首先排除这个因素再谈时序。5. 进阶验证把内部Flash做成掉电不丢的参数仓库5.1 双页交替保存兼顾掉电回退和磨损均衡单页存储有个固有缺陷擦除过程中掉电整页数据全丢连回退的机会都没有。我一般用双页交替方案A页写满了写B页B页写满了再擦A页每一时刻都保留一份完整参数。读取时比较两页的sequence字段谁大用谁。这个方案多花2KB Flash换来掉电安全性和Flash寿命翻倍对参数频繁更新的设备很划算。5.2 PVD掉电检测把保存动作放在电压跌破之前PVD是芯片内部的电源电压检测器电压跌到设定阈值时产生中断从触发到VDD真正跌破Flash编程下限中间有一段可操作的窗口。配合储能电容这段窗口足够把几十字节的参数写完。关键点是保存动作里要关中断再擦写保证Flash操作一气呵成保存完再进停机模式。5.3 上电自检让每一次启动都验证存储合法性启动流程里读参数区验魔数、验CRC两个页都失效就恢复默认参数并重新初始化。这个过程要放在外设初始化之前否则外设用了野参数起跑后面查问题要翻一倍工作量。自检通过后打印一帧参数状态日志调试期靠它确认存储链路量产期靠它定位返修板。写到这里回看整个例程包最重要的一句话还是先擦后写、双字对齐、写完读回、中断隔离。从那以后我每次交付Flash读写代码都强制走一遍页对齐检查、擦除页确认、写后读回三大步缺一步宁可不上货。希望帮到你。本文还有配套的精品资源点击获取