STM32内部FLASH读写实战:从原理到可靠数据存储方案

📅 2026/8/8 15:28:41
STM32内部FLASH读写实战:从原理到可靠数据存储方案
1. 项目缘起与核心价值最近在做一个基于STM32的物联网数据采集终端项目里有个需求需要在设备断电重启后能记住一些关键参数比如Wi-Fi的SSID/密码、传感器的校准系数、设备的工作模式等等。最开始我图省事直接用了STM32内部的EEPROM模拟库但很快就遇到了问题一是读写速度慢二是频繁擦写后模拟的EEPROM区域很快就出现了坏块导致数据丢失。这让我不得不重新审视一个更底层、更可靠的方案——直接操作STM32芯片内部的FLASH。你可能也遇到过类似场景产品需要保存几十到几百个字节的配置数据外挂一颗EEPROM或FRAM芯片虽然稳定但会增加BOM成本和PCB面积。对于成本敏感或空间紧凑的项目如果能用好芯片自带的FLASH无疑是最经济、最简洁的方案。但一提到内部FLASH读写很多朋友的第一反应是复杂、容易把芯片“写死”、要考虑擦写寿命。确实相比简单的HAL_FLASH_Program函数调用背后有一系列的“规矩”需要遵守。网上关于STM32 HAL库操作FLASH的教程不少但要么过于庞杂把启动代码、选项字节、写保护全讲一遍让人望而生畏要么过于简略只给个代码片段缺了关键的限制条件和避坑指南照着做大概率会卡住。所以我决定结合自己最近在STM32G0系列上的实际踩坑经验写一个“简约版”的指南。这个“简约”不是功能阉割而是聚焦于最核心、最常用的“数据存储”场景剔除那些不常用的高级功能把操作流程、限制条件和常见陷阱讲透让你能快速、安全地将这套方案集成到自己的项目中。2. 理解STM32内部FLASH的“游戏规则”在动手写代码之前我们必须先搞清楚STM32内部FLASH的物理特性和操作约束这是避免一切奇怪问题的前提。你可以把它想象成一个巨大的、划分成很多个小格子的笔记本但这个笔记本有它独特的“使用说明书”。2.1 FLASH的物理结构页、扇区与块不同系列的STM32其内部FLASH的组织结构差异很大这是第一个容易混淆的点。我们常说的F1、F4、G0、H7等系列它们的FLASH划分方式完全不同。STM32F1系列主流型它的FLASH通常按“页”来划分每页大小为1KB或2KB。这是比较早期的组织方式。STM32F4/F7/H7系列高性能型这些系列的FLASH通常按“扇区”来组织且扇区大小不一致。例如STM32F407它的扇区从16KB到128KB不等。操作时必须以整个扇区为单位进行擦除。STM32G0/L0/L4系列低功耗/主流型这些较新的系列普遍采用了“双区”或“单区”结构并按“页”来管理通常每页大小为2KB。我们今天的示例将以常见的2KB页大小为例。关键点在编写代码前第一件事就是查阅你所使用芯片型号的《参考手册》找到“Flash memory”章节确认FLASH的总大小、页/扇区大小以及起始地址。绝对不要想当然地套用其他系列的代码。2.2 核心操作约束擦、写、读的三部曲FLASH存储器的操作遵循一个严格的顺序这是由它的物理特性决定的擦除这是必须先行的操作。FLASH的每一位初始状态是‘1’对于STM32通常意味着数据是0xFF。你只能将‘1’变成‘0’而不能直接将‘0’变成‘1’。擦除操作就是将整个页或扇区的所有位一次性重置为‘1’0xFF。因此在写入新数据前如果目标地址所在的页不是全新的即不全为0xFF就必须先擦除该页。写入擦除完成后才能进行写入编程。写入操作可以将特定的‘1’翻转为‘0’。HAL库提供的写入函数最小可以按字节、半字16位、字32位或双字64位进行这取决于芯片架构。对于Cortex-M3/M4/M33内核通常推荐以32位字为单位进行写入因为总线效率最高。读取读取操作没有任何限制可以按任意字节宽度随时读取就像读取RAM一样简单。2.3 至关重要的寿命与对齐问题这是两个实战中极易出错的坑擦写寿命STM32内部FLASH的典型擦写寿命是10,000次即每个页可以承受约1万次擦除-写入循环。这意味着你不能像操作RAM一样频繁地更新FLASH的某个地址。对于需要频繁更新的数据如运行计数器必须设计“磨损均衡”算法轮流使用不同的页。对于只是偶尔保存的配置参数1万次的寿命完全足够。地址对齐写入的起始地址必须对齐到写入数据宽度的整数倍。例如如果你使用HAL_FLASH_Program_IT函数以FLASH_TYPEPROGRAM_WORD32位模式写入那么目标地址必须是4的倍数0x00, 0x04, 0x08...。不对齐的写入会导致硬件错误程序进入HardFault。一个稳妥的做法是无论你实际数据多大在FLASH中都以4字节32位为单位进行存储和管理这样地址自然对齐。3. 实战简约版FLASH读写驱动设计理解了规则我们就可以开始设计驱动了。我们的目标是封装两个最核心的函数FLASH_Write和FLASH_Read并处理好擦除逻辑。这里以STM32G0系列页大小2KB为例代码可以轻松适配到其他系列。3.1 第一步定义存储布局在写代码前先规划好你的FLASH空间。通常程序从FLASH起始地址0x0800 0000开始存放。我们需要找一块程序绝对不会用到的区域来存数据。// flash_ops.h #ifndef __FLASH_OPS_H #define __FLASH_OPS_H #include “stm32g0xx_hal.h” // 根据你的芯片系列修改 /* 参数存储区定义 */ // 假设你的芯片FLASH总大小为64KB (0x10000) // 程序代码占用一部分我们决定从倒数第2个2KB页开始存储参数 // FLASH起始地址: 0x0800 0000 // 页大小: 2KB 0x800 // 总页数: 64KB / 2KB 32页 // 最后一页地址: 0x0800 0000 31 * 0x800 0x0800 7A00 // 我们使用倒数第二页: 第30页地址 0x0800 0000 30 * 0x800 0x0800 7800 #define FLASH_PAGE_SIZE ((uint32_t)0x800) // 2KB #define FLASH_USER_START_ADDR ((uint32_t)0x08007800) // 用户参数区起始地址 #define FLASH_USER_END_ADDR ((uint32_t)0x08007FFF) // 用户参数区结束地址该页末尾 // 我们定义一个结构体来管理要存储的参数 typedef struct { uint32_t magicNumber; // 魔数用于验证数据有效性例如 0xAA55AA55 uint32_t deviceID; char ssid[32]; char password[64]; float sensorCalibFactor; uint32_t updateCounter; uint32_t crc32; // 可选用于校验数据完整性 } SystemParams_t; /* 函数声明 */ HAL_StatusTypeDef FLASH_WriteData(uint32_t dstAddr, void *pData, uint32_t size); void FLASH_ReadData(uint32_t srcAddr, void *pData, uint32_t size); HAL_StatusTypeDef FLASH_ErasePage(uint32_t pageAddress); uint8_t FLASH_IsPageEmpty(uint32_t startAddr, uint32_t size); #endif为什么这么设计使用固定页简单直接避免动态管理带来的复杂性。对于参数存储一个2KB的页绰绰有余。定义结构体将所有参数打包一次性读写避免了多次单独写FLASH带来的寿命和一致性问题。加入魔数和CRC这是数据可靠性的关键。魔数用于快速判断这个页里是否有我们格式的数据CRC用于校验数据在存储过程中是否因意外断电等原因被破坏。没有这些机制你可能读出一堆乱码还以为是有效数据。3.2 第二步实现页擦除函数擦除是风险较高的操作务必小心。// flash_ops.c #include “flash_ops.h” /** * brief 擦除指定地址所在的FLASH页 * param pageAddress: 目标页内的任意一个地址 * retval HAL status */ HAL_StatusTypeDef FLASH_ErasePage(uint32_t pageAddress) { HAL_StatusTypeDef status; FLASH_EraseInitTypeDef eraseInit; uint32_t pageError 0; // 1. 计算页号 (对于G0页大小固定可以直接计算) // 注意更通用的做法是使用HAL_FLASHEx_GetError()和芯片特定宏这里为简化使用计算 uint32_t pageNum (pageAddress - FLASH_BASE) / FLASH_PAGE_SIZE; // 2. 解锁FLASH HAL_FLASH_Unlock(); // 3. 清除所有之前的错误标志重要 __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); // 4. 配置擦除参数 eraseInit.TypeErase FLASH_TYPEERASE_PAGES; eraseInit.Banks FLASH_BANK_1; // 单Bank芯片 eraseInit.Page pageNum; eraseInit.NbPages 1; // 只擦除1页 // 5. 执行擦除 status HAL_FLASHEx_Erase(eraseInit, pageError); if (status ! HAL_OK) { // 擦除失败可以在这里打印pageError查看具体错误码 // 例如pageError 0xFFFFFFFF 表示成功否则是出错的页号 } // 6. 锁定FLASH HAL_FLASH_Lock(); return status; }避坑指南必须先解锁FLASH默认是锁定的防止意外写入。HAL_FLASH_Unlock()是必须的。务必清错误标志FLASH控制器有各种错误标志位编程错误、写保护错误等。如果上次操作留下了错误标志本次操作可能会直接失败。__HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS)这个操作非常关键很多莫名其妙的失败都源于此。擦除耗时擦除一页2KB的FLASH可能需要几十毫秒。在此期间CPU会被阻塞所有中断都无法响应。如果你的系统有实时性要求如电机控制、通信需要考虑在系统空闲时如看门狗喂狗后、低功耗模式前进行擦写操作或者使用RTOS创建一个低优先级任务来处理。3.3 第三步实现数据写入函数写入函数需要处理地址对齐和按正确数据宽度编程。// flash_ops.c (续) /** * brief 向FLASH写入数据 * param dstAddr: 目标起始地址必须已擦除且满足对齐要求 * param pData: 源数据缓冲区指针 * param size: 要写入的字节数 * retval HAL status */ HAL_StatusTypeDef FLASH_WriteData(uint32_t dstAddr, void *pData, uint32_t size) { HAL_StatusTypeDef status HAL_OK; uint32_t *pSrc (uint32_t *)pData; uint32_t *pDst (uint32_t *)dstAddr; uint32_t wordsToWrite (size 3) / 4; // 计算需要写入的32位字数向上取整 uint32_t i; // 0. 安全检查地址是否在允许范围内 if (dstAddr FLASH_USER_START_ADDR || (dstAddr size) FLASH_USER_END_ADDR) { return HAL_ERROR; } // 1. 解锁FLASH HAL_FLASH_Unlock(); __HAL_FLASH_CLEAR_FLAG(FLASH_FLAG_ALL_ERRORS); // 2. 循环写入以字32位为单位 for (i 0; (i wordsToWrite) (status HAL_OK); i) { status HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, (uint32_t)pDst, *pSrc); pDst; pSrc; } // 3. 锁定FLASH HAL_FLASH_Lock(); return status; }关键细节与陷阱对齐处理这个函数假设调用者传入的dstAddr是4字节对齐的并且size最好是4的倍数。如果写入非4倍数长度的数据比如一个10字节的字符串函数会多写一些内容最多3个字节的“垃圾数据”来自pData缓冲区之后的内存。更健壮的做法是在调用此函数前确保你的数据缓冲区长度是4字节对齐的或者在结构体定义时使用__attribute__((packed, aligned(4)))来强制对齐和紧凑布局。HAL_FLASH_Program的选择这里用了FLASH_TYPEPROGRAM_WORD。对于Cortex-M0/M0内核如STM32G0可能只支持按字32位编程。对于M3/M4/M7可能还支持双字64位编程速度更快。请根据芯片手册选择。写入过程中的中断与擦除类似每次调用HAL_FLASH_Program写入一个字的时间很短但循环写入大量数据时仍会阻塞CPU。可以考虑使用带中断的编程函数HAL_FLASH_Program_IT并在中断回调中处理但这会大大增加程序复杂性。对于参数存储这种低频操作阻塞式写入更简单可靠。3.4 第四步实现数据读取与空页检查读取非常简单但空页检查是写入前的重要步骤。// flash_ops.c (续) /** * brief 从FLASH读取数据 * param srcAddr: 源起始地址 * param pData: 目标缓冲区指针 * param size: 要读取的字节数 * retval None */ void FLASH_ReadData(uint32_t srcAddr, void *pData, uint32_t size) { uint32_t *pSrc (uint32_t *)srcAddr; uint32_t *pDst (uint32_t *)pData; uint32_t wordsToRead (size 3) / 4; uint32_t i; for (i 0; i wordsToRead; i) { *pDst *pSrc; } // 注意这里可能会多拷贝最多3个字节使用时需根据实际size处理缓冲区边界。 } /** * brief 检查FLASH一段区域是否为空全为0xFF * param startAddr: 起始地址 * param size: 要检查的字节数 * retval 1: 为空0: 非空 */ uint8_t FLASH_IsPageEmpty(uint32_t startAddr, uint32_t size) { uint8_t *pAddr (uint8_t *)startAddr; for (uint32_t i 0; i size; i) { if (pAddr[i] ! 0xFF) { return 0; // 发现非0xFF字节页非空 } } return 1; // 所有字节都是0xFF页为空 }4. 应用层一个完整可靠的参数存储流程有了底层驱动我们如何在应用层安全地使用它下面是一个包含数据验证、擦写决策的完整流程。// app_params.c #include “flash_ops.h” SystemParams_t gSystemParams; // 在RAM中维护一个参数副本 const uint32_t PARAMS_MAGIC 0xAA55AA55; /** * brief 从FLASH加载参数到RAM * retval 1: 加载成功且数据有效0: 加载失败或数据无效 */ uint8_t Params_LoadFromFlash(void) { SystemParams_t paramsInFlash; // 1. 从FLASH指定地址读取整个结构体 FLASH_ReadData(FLASH_USER_START_ADDR, paramsInFlash, sizeof(SystemParams_t)); // 2. 初步校验魔数是否正确 if (paramsInFlash.magicNumber ! PARAMS_MAGIC) { // 魔数不对说明FLASH里没有有效数据可能是第一次使用或数据损坏 printf(“[Params] No valid data found in Flash. Using defaults.\n”); return 0; } // 3. 深度校验计算CRC并与存储的CRC对比可选但推荐 // uint32_t calculatedCRC Calculate_CRC32((uint8_t*)paramsInFlash, sizeof(SystemParams_t)-4); // if (calculatedCRC ! paramsInFlash.crc32) { // printf(“[Params] CRC check failed! Data may be corrupted.\n”); // return 0; // } // 4. 数据有效拷贝到RAM中的全局变量 memcpy(gSystemParams, paramsInFlash, sizeof(SystemParams_t)); printf(“[Params] Loaded from Flash successfully.\n”); return 1; } /** * brief 将RAM中的参数保存到FLASH * retval HAL status */ HAL_StatusTypeDef Params_SaveToFlash(void) { HAL_StatusTypeDef status HAL_OK; // 1. 准备要写入的数据 gSystemParams.magicNumber PARAMS_MAGIC; // gSystemParams.crc32 Calculate_CRC32((uint8_t*)gSystemParams, sizeof(SystemParams_t)-4); // 更新CRC gSystemParams.updateCounter; // 更新计数器可用于简单磨损均衡判断 // 2. 检查目标页是否为空全0xFF if (!FLASH_IsPageEmpty(FLASH_USER_START_ADDR, sizeof(SystemParams_t))) { // 页不为空需要先擦除 printf(“[Params] Flash page not empty, erasing...\n”); status FLASH_ErasePage(FLASH_USER_START_ADDR); if (status ! HAL_OK) { printf(“[Params] Flash erase failed! Aborting save.\n”); return status; } } // 3. 执行写入 printf(“[Params] Writing to Flash...\n”); status FLASH_WriteData(FLASH_USER_START_ADDR, gSystemParams, sizeof(SystemParams_t)); if (status HAL_OK) { printf(“[Params] Save to Flash successful.\n”); } else { printf(“[Params] Save to Flash failed! Error: %d\n”, status); } return status; } /** * brief 初始化参数系统 * note 上电后调用尝试加载失败则使用默认值 */ void Params_Init(void) { // 设置默认参数 memset(gSystemParams, 0, sizeof(gSystemParams)); gSystemParams.deviceID 0x12345678; strncpy(gSystemParams.ssid, “Default_SSID”, sizeof(gSystemParams.ssid)-1); strncpy(gSystemParams.password, “Default_Pass”, sizeof(gSystemParams.password)-1); gSystemParams.sensorCalibFactor 1.0f; // 尝试从FLASH加载 if (!Params_LoadFromFlash()) { // 加载失败使用默认值并立即保存一次初始化FLASH存储区 printf(“[Params] Initializing Flash with default params.\n”); Params_SaveToFlash(); } }这个流程的精髓读验证上电后先读用魔数和CRC确保数据可信。不可信则用默认值。写前检查写入前检查页是否为空避免不必要的擦除。每次擦除都消耗寿命。RAM副本所有操作都在RAM中的全局变量gSystemParams上进行只在需要持久化时才操作FLASH。这保证了运行效率。初始化保存第一次运行时自动将默认参数写入FLASH完成初始化。5. 进阶话题与避坑大全即使按照上面的流程在实际项目中你还是可能会遇到一些棘手的问题。下面是我踩过或见过的坑。5.1 中断与FLASH操作冲突问题现象在擦写FLASH时如果发生了中断并且中断服务程序ISR或其调用的函数也位于内部FLASH中程序可能会跑飞或卡死。根因分析在擦写FLASH的瞬间CPU去取指令如果目标指令所在的FLASH扇区正在被擦写访问就会失败。解决方案方案A简单粗暴在擦写FLASH的整个期间从解锁到锁定关闭全局中断。__disable_irq(); // ... 执行FLASH擦写操作 ... __enable_irq();优点简单有效。缺点会阻塞所有中断包括系统滴答定时器SysTick可能导致RTOS任务调度出问题、通信超时等。只适用于对实时性要求极低且中断服务非常简短的场景。方案B推荐将关键的中断服务程序特别是SysTick、通信接口等搬运到RAM中执行。可以通过链接脚本和函数属性如__attribute__((section(“.RamFunc”)))实现。这样即使FLASH被擦写正在RAM中运行的ISR也不会受影响。这是更专业和可靠的做法。5.2 优化擦写寿命简易磨损均衡如果你的参数更新非常频繁比如每分钟几次直接在一个固定页上反复擦写很快会达到寿命极限。这时需要引入磨损均衡。一个极简的思路双页备份法划分两个页Page A 和 Page B。每次保存数据时写入到当前未使用的那个页。写入成功后擦除旧数据所在的页。通过一个固定的标志位比如在每个数据块的头部存一个递增的序列号来判断哪个页的数据是最新的。这样擦写次数就在两个页之间轮换寿命理论上翻倍。这个方案实现起来比单页复杂但对于频繁保存小量数据的场景是必要的。5.3 调试时的致命陷阱代码被优化掉问题现象你在调试时单步执行到FLASH操作函数一切正常。但全速运行时数据就是写不进去或者程序异常。可能原因编译器优化。如果你在调试时用于测试的FLASH写入代码其写入的地址和结果没有被其他代码使用聪明的编译器可能会认为这段代码是“无用代码”直接将其优化删除。解决方案将FLASH操作相关的变量声明为volatile。volatile关键字告诉编译器这个变量可能被硬件或其他线程意外改变不要对它进行优化。volatile uint32_t flashTestData 0x12345678; volatile uint32_t *flashTestAddr (volatile uint32_t *)0x08007800; // 在写入/读取时使用这些volatile指针和变量在调试FLASH驱动时这是一个非常隐蔽的坑。5.4 地址计算错误导致覆盖程序最严重的错误如果你错误计算了用户参数区的起始地址使其与程序代码区重叠那么一次擦除操作就可能将你自己的程序代码抹掉导致芯片“变砖”只能通过调试器重新下载程序。绝对守则仔细计算地址并使用FLASH_USER_START_ADDR这样的宏定义不要使用魔数。在FLASH_WriteData和FLASH_ErasePage函数开头务必添加地址范围检查确保目标地址绝对落在你预留的参数区内。查看编译后生成的.map文件确认你的程序代码实际占用了多大空间确保参数区在代码区之后。操作内部FLASH就像在芯片的“大脑”里直接修改记忆需要格外谨慎。遵循“先理解规则再规划布局最后小心操作”的原则它就能成为一个稳定可靠的免费“EEPROM”。希望这个“简约版”的梳理能帮你绕过我踩过的那些坑顺利实现项目中的数据存储需求。