ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

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

2026/8/8 15:29:29 拓冰建站 浏览量
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存储器的操作遵循一个严格的顺序,这是由它的物理特性决定的:

  1. 擦除:这是必须先行的操作。FLASH的每一位初始状态是‘1’(对于STM32,通常意味着数据是0xFF)。你只能将‘1’变成‘0’,而不能直接将‘0’变成‘1’。擦除操作就是将整个页(或扇区)的所有位一次性重置为‘1’(0xFF)。因此,在写入新数据前,如果目标地址所在的页不是全新的(即不全为0xFF),就必须先擦除该页。
  2. 写入:擦除完成后,才能进行写入(编程)。写入操作可以将特定的‘1’翻转为‘0’。HAL库提供的写入函数,最小可以按字节、半字(16位)、字(32位)或双字(64位)进行,这取决于芯片架构。对于Cortex-M3/M4/M33内核,通常推荐以32位(字)为单位进行写入,因为总线效率最高。
  3. 读取:读取操作没有任何限制,可以按任意字节宽度随时读取,就像读取RAM一样简单。

2.3 至关重要的寿命与对齐问题

这是两个实战中极易出错的坑:

  • 擦写寿命:STM32内部FLASH的典型擦写寿命是10,000次(即每个页可以承受约1万次擦除-写入循环)。这意味着你不能像操作RAM一样频繁地更新FLASH的某个地址。对于需要频繁更新的数据(如运行计数器),必须设计“磨损均衡”算法,轮流使用不同的页。对于只是偶尔保存的配置参数,1万次的寿命完全足够。
  • 地址对齐:写入的起始地址必须对齐到写入数据宽度的整数倍。例如,如果你使用HAL_FLASH_Program_IT函数以FLASH_TYPEPROGRAM_WORD(32位)模式写入,那么目标地址必须是4的倍数(0x00, 0x04, 0x08...)。不对齐的写入会导致硬件错误,程序进入HardFault。一个稳妥的做法是,无论你实际数据多大,在FLASH中都以4字节(32位)为单位进行存储和管理,这样地址自然对齐。

3. 实战:简约版FLASH读写驱动设计

理解了规则,我们就可以开始设计驱动了。我们的目标是封装两个最核心的函数:FLASH_WriteFLASH_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(); } }

这个流程的精髓:

  1. 读验证:上电后先读,用魔数和CRC确保数据可信。不可信则用默认值。
  2. 写前检查:写入前检查页是否为空,避免不必要的擦除。每次擦除都消耗寿命。
  3. RAM副本:所有操作都在RAM中的全局变量gSystemParams上进行,只在需要持久化时才操作FLASH。这保证了运行效率。
  4. 初始化保存:第一次运行时,自动将默认参数写入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 优化擦写寿命:简易磨损均衡

如果你的参数更新非常频繁(比如每分钟几次),直接在一个固定页上反复擦写,很快会达到寿命极限。这时需要引入磨损均衡。

一个极简的思路(双页备份法):

  1. 划分两个页:Page A 和 Page B。
  2. 每次保存数据时,写入到当前未使用的那个页。
  3. 写入成功后,擦除旧数据所在的页。
  4. 通过一个固定的标志位(比如在每个数据块的头部存一个递增的序列号)来判断哪个页的数据是最新的。

这样,擦写次数就在两个页之间轮换,寿命理论上翻倍。这个方案实现起来比单页复杂,但对于频繁保存小量数据的场景是必要的。

5.3 调试时的致命陷阱:代码被优化掉

问题现象:你在调试时,单步执行到FLASH操作函数,一切正常。但全速运行时,数据就是写不进去,或者程序异常。可能原因:编译器优化。如果你在调试时,用于测试的FLASH写入代码,其写入的地址和结果没有被其他代码使用,聪明的编译器可能会认为这段代码是“无用代码”,直接将其优化删除。解决方案:将FLASH操作相关的变量声明为volatilevolatile关键字告诉编译器,这个变量可能被硬件或其他线程意外改变,不要对它进行优化。

volatile uint32_t flashTestData = 0x12345678; volatile uint32_t *flashTestAddr = (volatile uint32_t *)0x08007800; // 在写入/读取时使用这些volatile指针和变量

在调试FLASH驱动时,这是一个非常隐蔽的坑。

5.4 地址计算错误导致覆盖程序

最严重的错误:如果你错误计算了用户参数区的起始地址,使其与程序代码区重叠,那么一次擦除操作就可能将你自己的程序代码抹掉,导致芯片“变砖”,只能通过调试器重新下载程序。绝对守则

  1. 仔细计算地址,并使用FLASH_USER_START_ADDR这样的宏定义,不要使用魔数。
  2. FLASH_WriteDataFLASH_ErasePage函数开头,务必添加地址范围检查,确保目标地址绝对落在你预留的参数区内。
  3. 查看编译后生成的.map文件,确认你的程序代码实际占用了多大空间,确保参数区在代码区之后。

操作内部FLASH就像在芯片的“大脑”里直接修改记忆,需要格外谨慎。遵循“先理解规则,再规划布局,最后小心操作”的原则,它就能成为一个稳定可靠的免费“EEPROM”。希望这个“简约版”的梳理,能帮你绕过我踩过的那些坑,顺利实现项目中的数据存储需求。