多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

STM32 Flash读写操作详解:从原理到实战避坑指南

STM32 Flash读写操作详解:从原理到实战避坑指南 1. 项目概述为什么STM32的Flash操作是嵌入式开发的必修课在STM32的嵌入式开发中我们经常需要保存一些掉电不丢失的数据比如设备的校准参数、运行日志、用户配置甚至是OTA升级时的固件包。这时候片内Flash就成了一个既经济又可靠的选择。相比于外挂EEPROM或FRAM使用片内Flash无需增加硬件成本但它的操作也比读写RAM要复杂得多稍有不慎就会导致程序崩溃甚至芯片锁死。很多新手在第一次尝试读写Flash时都踩过“HardFault”的坑或者发现数据写入后读出来是错的。这个项目就是带你用STM32 HAL库把Flash的读写操作彻底搞明白从原理到避坑手把手实现一个稳定可靠的非易失性存储方案。2. 核心原理与硬件限制解析2.1 STM32 Flash的物理结构不是一整块“黑板”很多人把Flash想象成一块可以随意涂写的黑板这是第一个误区。STM32的片内Flash在物理上被划分为多个扇区。以常见的STM32F103系列为例其Flash容量不同扇区大小也不同小容量产品≤128KB的扇区大小是1KB而大容量产品≥256KB的扇区大小则增大到2KB。对于STM32F4/F7/H7等系列扇区划分更复杂可能存在16KB、64KB、128KB甚至256KB的大扇区。为什么要分扇区因为Flash的擦除操作是以扇区为最小单位进行的。你可以把每个扇区想象成一页纸写入数据就像用铅笔在纸上写字。但是如果你想修改某个字不能直接用橡皮擦掉那个字重写而必须把整页纸整个扇区全部擦成白纸全为0xFF然后再重新写入这一页的所有内容。这是由Flash存储单元的物理特性决定的写入只能将位从1变为0而擦除操作则一次性将整个扇区的所有位恢复为1。2.2 写入操作的“潜规则”对齐、宽度与状态理解了擦除再来看写入这里面的门道更多。首先是对齐。STM32的Flash写入操作通常要求半字16位、字32位或双字64位部分型号支持对齐。这意味着你不能随心所欲地在任意地址写入一个字节。例如在STM32F1上标准库函数FLASH_ProgramHalfWord要求地址必须是2字节对齐的。HAL库虽然封装得更友好但底层硬件限制依然存在。尝试在奇数地址写入数据是触发硬件错误HardFault的经典操作。其次是写入宽度。你一次写入的数据量必须与芯片支持的编程宽度匹配。对于大多数Cortex-M内核的STM32最小编程单位是双字64位。但这并不意味着你必须一次写8个字节。HAL库的底层驱动会处理这个问题它可能会将你传入的不足8字节的数据与目标地址原有的数据进行合并凑成一个完整的双字再进行编程。这个过程对用户透明但理解它有助于你明白为什么直接“覆盖”写入是行不通的——因为写入前目标区域必须是已擦除状态全0xFF否则合并计算会出错。最后是状态管理。在操作Flash期间CPU必须暂停对Flash的取指访问这通过Flash锁机制来实现。你必须先解锁FlashHAL_FLASH_Unlock才能进行擦写操作完成后再加锁HAL_FLASH_Lock。同时每次操作后都必须检查操作状态标志确保上一步成功了才能进行下一步。注意绝对禁止在Flash中运行的程序去擦写当前程序所在的扇区这会导致指令获取失败系统立即崩溃。你的读写操作代码和数据缓冲区必须位于RAM中。3. HAL库Flash驱动关键函数深度拆解HAL库将Flash操作封装成了一组相对清晰的API但仅仅调用它们是不够的必须理解其行为。3.1 初始化与解锁安全的第一道门任何Flash操作开始前必须执行解锁序列。这不是简单的函数调用而是为了防止程序跑飞或意外中断导致Flash被误写。// 解锁Flash控制寄存器 HAL_StatusTypeDef status HAL_FLASH_Unlock(); if (status ! HAL_OK) { // 处理错误可能Flash已被锁定且密钥错误 Error_Handler(); } // 同样重要的解锁选项字节如果需要修改读保护、写保护等 status HAL_FLASH_OB_Unlock(); // ... 操作选项字节 ... status HAL_FLASH_OB_Lock();解锁的本质是向Flash密钥寄存器FLASH_KEYR依次写入两个特定的密钥值。HAL库帮你做了这件事。解锁后FLASH_CR寄存器中的LOCK位被清零此时才能设置擦除PER或编程PG位。3.2 扇区擦除让数据“归零”擦除是后续写入的前提。你需要明确指定要擦除的哪个或哪些扇区。FLASH_EraseInitTypeDef EraseInitStruct; uint32_t SectorError 0; // 1. 填充擦除初始化结构体 EraseInitStruct.TypeErase FLASH_TYPEERASE_SECTORS; // 指定为扇区擦除 EraseInitStruct.Banks FLASH_BANK_1; // 对于单Bank芯片或指定Bank EraseInitStruct.Sector FLASH_SECTOR_5; // 要擦除的扇区号需要根据具体型号查手册 EraseInitStruct.NbSectors 1; // 擦除的扇区数量 EraseInitStruct.VoltageRange FLASH_VOLTAGE_RANGE_3; // 电压范围影响擦除时间需根据芯片工作电压选择 // 2. 执行擦除 status HAL_FLASHEx_Erase(EraseInitStruct, SectorError); if (status ! HAL_OK) { // 擦除失败SectorError中包含了出错的扇区号 printf(“Erase failed on sector: %lu\r\n”, SectorError); Error_Handler(); }这里有几个极易出错的点扇区号Sector这是最大的坑不同容量、不同系列的STM32扇区编号和大小映射表完全不同。必须查阅你所使用芯片型号的《参考手册》中的“Flash memory organization”章节。把STM32F103的扇区表套用在STM32F407上百分百出错。电压范围VoltageRange这个参数决定了擦除和编程的等待时间。通常3.3V供电选择FLASH_VOLTAGE_RANGE_3对应2.7V-3.6V。选错了可能导致擦写不成功或数据不可靠。擦除时间擦除一个扇区是毫秒级别的操作典型值1-2ms。在此期间芯片无法执行Flash中的代码因此必须确保擦除操作不被关键中断打断。一个常见的做法是在擦写前关闭全局中断__disable_irq()操作完成后再开启__enable_irq()但对于依赖SysTick等中断的系统要谨慎处理。3.3 数据写入谨慎的“落笔”擦除完成后目标地址区域的所有位都变成了10xFF。现在可以写入数据了。写入操作会将相应的位从1变为0。HAL库提供了不同数据宽度的写入函数HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data)TypeProgram: 编程类型如FLASH_TYPEPROGRAM_DOUBLEWORD64位、FLASH_TYPEPROGRAM_WORD32位、FLASH_TYPEPROGRAM_HALFWORD16位。必须与芯片支持的类型和地址对齐方式匹配。Address: 目标地址。必须是该编程类型对齐的地址如64位编程要求地址8字节对齐。Data: 要写入的数据。更实用的方法是写入一段数据。HAL库没有直接提供“多字节写入”函数需要我们自己循环。但这里有一个超级重要的技巧为了提高效率并符合硬件特性我们应该尽量以64位或32位为单位进行写入。// 假设我们要将一块数据从RAM的pBuffer写入Flash的FlashAddress // FlashAddress 必须是8字节对齐的对于64位编程 // dataSize 最好是8的倍数不足部分需要特殊处理 uint64_t *pSrc (uint64_t*)pBuffer; uint32_t numDoubleWords dataSize / 8; for(uint32_t i 0; i numDoubleWords; i) { status HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, FlashAddress (i * 8), pSrc[i]); if (status ! HAL_OK) { // 写入失败处理 HAL_FLASH_Lock(); Error_Handler(); } // 可选等待编程完成但HAL_FLASH_Program内部已包含等待 } // 处理剩余不足8字节的数据如果需要 uint32_t remainingBytes dataSize % 8; if (remainingBytes 0) { // 将剩余数据复制到一个64位临时变量未使用的字节填充为0xFF uint64_t lastData 0xFFFFFFFFFFFFFFFFULL; uint8_t *pLastData (uint8_t*)lastData; uint8_t *pRemainSrc (uint8_t*)(pBuffer numDoubleWords * 8); for(uint32_t j 0; j remainingBytes; j) { pLastData[j] pRemainSrc[j]; } // 写入最后一个双字 status HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, FlashAddress (numDoubleWords * 8), lastData); // ... 错误检查 }提示在循环中每次调用HAL_FLASH_Program后虽然可以检查状态但更高效的做法是在所有写入完成后统一检查Flash状态标志__HAL_FLASH_GET_FLAG。不过为了代码的健壮性每次检查是个好习惯。3.4 数据读取最简单的操作读取Flash是最直接的操作就像读取常量数组一样。但这里也有讲究。// 方法1直接指针访问最常用 uint32_t readData *(volatile uint32_t*)FlashAddress; // 方法2使用memcpy从Flash拷贝到RAM uint8_t buffer[128]; memcpy(buffer, (void*)FlashAddress, sizeof(buffer));关键点声明指向Flash地址的指针时一定要加上volatile关键字。这是因为编译器可能会对内存访问进行优化比如认为Flash数据不会变而缓存该值。volatile告诉编译器这个地址的内容可能被硬件改变每次都必须从该地址重新读取不能做优化。4. 实战设计一个健壮的参数存储模块了解了基本操作我们将其整合成一个实用的、带磨损均衡简单版和掉电保护的数据存储模块。假设我们要存储10组系统参数。4.1 存储结构设计我们不直接把数据写在固定地址而是设计一个小的“文件系统”头记录数据状态。#define FLASH_PARAM_START_ADDR 0x0800C000 // 假设从Sector 5开始避开主程序 #define FLASH_PARAM_SECTOR FLASH_SECTOR_5 #define PARAM_SET_SIZE 128 // 每组参数大小字节 #define MAX_PARAM_SETS 10 // 最多存储10组 #define PARAM_SET_VALID_FLAG 0xAA55CC33 // 自定义的有效标志 typedef struct { uint32_t validFlag; // 有效标志写入数据后写入此标志 uint32_t sequenceNumber; // 序列号每次更新递增用于找出最新数据 uint8_t paramData[PARAM_SET_SIZE - 8]; // 实际的参数数据 } ParamSet_t; // 在Flash中的布局连续存放 MAX_PARAM_SETS 个 ParamSet_t4.2 写入流程实现带擦除管理写入新参数时我们寻找第一个“无效”标志不为PARAM_SET_VALID_FLAG或“最旧”的位置写入。如果所有位置都有效则擦除整个扇区从头开始写。这实现了最简单的磨损均衡。HAL_StatusTypeDef Param_Save(uint8_t *data) { HAL_FLASH_Unlock(); __disable_irq(); // 关闭中断防止擦写过程被打断 ParamSet_t setToWrite; setToWrite.validFlag PARAM_SET_VALID_FLAG; // 需要从Flash中读取当前最大的序列号 setToWrite.sequenceNumber Get_Next_Sequence_Number(); memcpy(setToWrite.paramData, data, sizeof(setToWrite.paramData)); // 1. 寻找可写入的地址 uint32_t targetAddr Find_Empty_Param_Slot(); if (targetAddr 0) { // 没找到空位需要先擦除整个扇区 FLASH_EraseInitTypeDef EraseInitStruct {0}; EraseInitStruct.TypeErase FLASH_TYPEERASE_SECTORS; EraseInitStruct.Sector FLASH_PARAM_SECTOR; EraseInitStruct.NbSectors 1; EraseInitStruct.VoltageRange FLASH_VOLTAGE_RANGE_3; uint32_t SectorError; if (HAL_FLASHEx_Erase(EraseInitStruct, SectorError) ! HAL_OK) { __enable_irq(); HAL_FLASH_Lock(); return HAL_ERROR; } targetAddr FLASH_PARAM_START_ADDR; // 擦除后从起始地址开始写 } // 2. 以64位为单位写入数据 uint64_t *pData (uint64_t*)setToWrite; uint32_t sizeInDoubleWords sizeof(ParamSet_t) / 8; for (uint32_t i 0; i sizeInDoubleWords; i) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, targetAddr i * 8, pData[i]) ! HAL_OK) { // 写入失败清理现场 __enable_irq(); HAL_FLASH_Lock(); return HAL_ERROR; } } __enable_irq(); HAL_FLASH_Lock(); return HAL_OK; }4.3 读取与查找最新数据读取时我们需要遍历所有存储单元找到validFlag正确且sequenceNumber最大的那一组数据。HAL_StatusTypeDef Param_Load(uint8_t *dataBuffer) { ParamSet_t *pCurrentSet (ParamSet_t*)FLASH_PARAM_START_ADDR; ParamSet_t *pLatestSet NULL; uint32_t maxSeq 0; // 遍历所有可能的存储位置 for (uint32_t i 0; i MAX_PARAM_SETS; i, pCurrentSet) { // 注意直接读取Flash地址编译器可能优化确保使用volatile访问或直接指针 if (pCurrentSet-validFlag PARAM_SET_VALID_FLAG) { if (pCurrentSet-sequenceNumber maxSeq) { // 使用“”以处理回绕 maxSeq pCurrentSet-sequenceNumber; pLatestSet pCurrentSet; } } } if (pLatestSet ! NULL) { memcpy(dataBuffer, pLatestSet-paramData, sizeof(pLatestSet-paramData)); return HAL_OK; } return HAL_ERROR; // 未找到有效参数 }5. 高级话题与性能优化5.1 读写保护与选项字节STM32的Flash支持读保护RDP和写保护WRP。读保护一旦启用通过调试器如ST-Link或从RAM启动的程序将无法读取Flash内容保护知识产权。写保护则可以保护指定的扇区不被意外擦写。这些配置通过选项字节管理。修改选项字节是一个高风险操作因为它可能导致芯片永久性锁死Level 2读保护或无法再次下载程序。务必在充分理解后果并做好备份如已提取的固件后再尝试。HAL库提供了HAL_FLASHEx_OBProgram等函数但实践中我强烈建议在项目初期通过STM32CubeProgrammer工具图形化配置好选项字节并将其作为量产烧录流程的一部分而不是在用户代码中动态修改。5.2 减少擦除次数与磨损均衡Flash每个扇区都有擦除寿命通常10K~100K次。频繁擦写同一区域会导致该区域提前失效。前面的“参数存储模块”是一个极简的磨损均衡。更复杂的方案可以借鉴Flash文件系统如LittleFS、SPIFFS的思想使用两个或多个扇区轮流存储并加入垃圾回收机制。一个简单的双扇区备份策略数据总是写在“活动扇区”的空闲位置。当“活动扇区”写满时将有效数据全部搬运到“备用扇区”然后擦除原来的“活动扇区”两者角色互换。这样擦除操作被分摊到两个扇区寿命延长一倍。5.3 中断与临界区保护如前所述在Flash擦写期间CPU无法从Flash取指。这意味着任何中断服务程序ISR的代码都不能位于Flash中不对现代Cortex-M内核的STM32通常有指令预取和缓存机制短时间的擦写可能不会立即导致问题但这仍然是极度危险的行为。最安全的做法在调用HAL_FLASH_Unlock()之后立即关闭全局中断__disable_irq()在HAL_FLASH_Lock()之后再打开__enable_irq()。这会暂时停止系统响应所有中断包括SysTick会影响HAL_Delay。因此你的擦写代码必须尽可能快避免在临界区内进行复杂计算或循环等待。同时确保系统中没有对实时性要求极高的中断如电机控制的PWM。6. 调试技巧与常见问题排查6.1 调试时如何观察Flash内容在Keil、IAR或STM32CubeIDE的调试模式下你可以直接查看Flash内存区域。在Memory窗口输入Flash的起始地址如0x08000000即可看到内容。写入成功后你应该能看到对应地址的数据发生了变化。擦除后整个扇区应显示为0xFF。6.2 常见HardFault原因与排查地址未对齐这是最常见的原因。确保写入地址符合编程宽度要求64位/32位/16位对齐。使用assert((Address 0x7) 0)对于64位来辅助调试。操作未解锁忘记调用HAL_FLASH_Unlock()或在操作过程中调用了HAL_FLASH_Lock()。写保护尝试擦写被写保护WRP的扇区。检查选项字节配置。擦除期间的中断Flash擦除时发生中断且ISR代码位于正在被擦除的扇区。这几乎是必现的HardFault。务必在擦除关键扇区前关闭中断。访问非法地址写入的地址超出了芯片实际的Flash范围。仔细核对芯片型号和内存映射。6.3 数据校验与完整性Flash可能因物理原因如寿命将至、电源毛刺出现位翻转。对于关键数据除了写入和读取还应加入校验机制。CRC校验在存储数据时计算一段数据的CRC值一并存储。读取时重新计算CRC并对比。备份副本将同一份数据存储两份甚至三份读取时进行“投票”或选择有效的副本。ECC纠错码一些高端的STM32型号如H7系列的Flash自带ECC功能可以检测和纠正单位错误。在启用ECC的情况下操作Flash需要遵循额外的规则。6.4 电源稳定性至关重要Flash擦写操作对电源电压非常敏感。在电压不稳或过低时进行擦写可能导致操作失败甚至损坏存储单元。确保在系统电源稳定后再进行Flash操作。对于电池供电设备可以在擦写前检查电源电压如果电压低于阈值则推迟操作。最后分享一个我踩过的坑在一次产品升级中我使用了Flash来存储升级标志。代码在开机时检查标志如果有效则跳转到新固件。但有一次升级中途断电标志位只写入了一半。下次开机时因为标志位数据异常程序既无法识别为有效升级也无法回滚导致设备“变砖”。解决办法是将标志位设计为必须连续成功写入两个不同的魔数才算有效单个魔数写入成功仅表示“升级进行中”这样就能在断电后恢复并重新尝试升级。Flash操作无小事每一个细节都需要考虑异常情况。
返回列表