STM32F103内部Flash数据存储实战:从原理到带磨损均衡的工程实现

发布时间:2026/7/30 5:51:04
STM32F103内部Flash数据存储实战:从原理到带磨损均衡的工程实现 1. 项目缘起为什么需要片上Flash来掉电保存数据在嵌入式开发中数据掉电不丢失是一个基础且高频的需求。无论是记录设备的运行时长、保存用户的配置参数还是存储传感器校准值我们都需要一个可靠的“非易失性存储器”。很多开发者第一时间会想到外置的EEPROM或者铁电存储器FRAM这当然没问题。但对于成本敏感、PCB面积受限或者对数据量要求不大的应用为STM32F103这类经典MCU额外挂一颗存储芯片有时就显得“杀鸡用牛刀”了。其实STM32F103内部集成的Flash存储器除了存储程序代码其剩余空间完全可以被我们“征用”来存储数据。这相当于把MCU自带的一块“自留地”利用起来省去了外部器件的成本和布线复杂度。我最近在一个电池供电的便携式仪表项目里就用了这个方法需要保存几个关键的校准系数和序列号数据量很小用内部Flash完美解决BOM成本直接降了一截。当然用Flash模拟EEPROM或者说做数据存储和直接用EEPROM是有区别的核心在于Flash的擦写特性它只能将“1”写成“0”而将“0”变回“1”则需要以“扇区”为单位进行擦除操作。这意味着我们的写操作需要更精巧的设计不能像操作RAM那样随意。网上能找到的代码片段很多但往往只解决了“读写”本身对于实际工程中的关键问题比如磨损均衡、数据备份、写保护等要么语焉不详要么存在隐患。这篇文章我就结合一个完整的工程实例把STM32F103内部Flash用于数据存储的里里外外都讲透并提供经过实际项目验证的、可直接复用的代码模块。2. 深入理解STM32F103的Flash内存布局与操作特性在动手写代码之前我们必须像熟悉自己家客厅一样搞清楚STM32F103这片Flash的“户型图”和“居住规则”。盲目操作轻则数据错误重则导致程序“变砖”。2.1 Flash的物理结构与地址空间以最常见的STM32F103C8T664KB Flash为例它的Flash内存通常被组织成若干页Page在F1系列里这个“页”更常被称为“扇区”Sector。对于小容量产品≤128KB整个Flash被分为若干1KB大小的扇区。对于中容量256KB~512KB和大容量≥512KB产品扇区结构会更复杂前部分为1KB/2KB的小扇区后部分为16KB/128KB的大扇区。为什么必须关注扇区因为擦除操作的最小单位就是扇区。你想修改某个地址的一个字节如果这个字节所在的整个扇区没有被擦除即恢复为全0xFF状态你是无法成功写入的。这是我们设计存储策略的基石。对于F103C8T664KB其Flash地址从0x0800 0000开始到0x0800 FFFF结束。通常我们的程序从0x0800 0000开始存放。假设我们的程序编译后大小为40KB0xA000字节那么程序占用的空间就是0x0800 0000 ~ 0x0800 9FFF。剩下的空间从0x0800 A000到0x0800 FFFF这24KB0x6000字节的空间我们就可以规划为数据存储区。注意在规划数据区地址时必须确保其起始地址是扇区1KB的整数倍并且要留足余量避免与程序末端冲突。最好在链接脚本.ld文件或.sct文件中明确指定程序段的结束地址为数据区预留固定空间这是最规范的做法。2.2 关键操作解锁、擦除、写入与上锁操作内部Flash不像读写变量那么简单它需要与芯片内部的Flash接口寄存器打交道。标准库Standard Peripheral Library或HAL库都提供了相关函数但其底层逻辑是一致的。解锁FLASH_UnlockFlash默认是写保护的防止程序跑飞意外修改代码区。进行写或擦除操作前必须先向特定的密钥寄存器FLASH_KEYR写入两个正确的密钥KEY1和KEY2来解锁。擦除FLASH_ErasePage传入要擦除的扇区地址。执行后该扇区所有内容变为0xFF。这是最“耗时”且“伤寿命”的操作一次擦除典型时间在几十ms量级。写入FLASH_ProgramWord / FLASH_ProgramHalfWordFlash写入必须以16位半字或32位字为单位。即使你只想写一个字节也必须以半字或字的形式操作未写入的部分保持原状通常是0xFF。向一个非0xFF的地址写入会失败。上锁FLASH_Lock操作完成后建议重新上锁提高安全性。这里有一个非常重要的细节STM32F103的Flash写入只能将位从“1”变为“0”而不能从“0”变回“1”。擦除操作是唯一能将整个扇区的位全部拉回“1”0xFF的方法。这导致了我们最常见的错误未擦除即写入。如果你往一个地址写入0x55AA二进制 0101 0101 1010 1010成功。之后你想把它改成0xAA551010 1010 0101 0101在不擦除的情况下直接写结果会怎样Flash的“与”特性会导致最终结果是两次写入数据的“按位与”AND即 0x55AA 0xAA55 0x0080。这显然不是你想要的数据。因此可靠的写数据流程必须是备份该扇区内其他有效数据 - 擦除整个扇区 - 写入新数据连同备份的旧数据。2.3 寿命与可靠性10万次擦写意味着什么STM32F103的Flash典型擦写寿命是10万次每扇区。这个数字听起来很大但如果你设计不当寿命会消耗得飞快。反面案例假设你的产品每分钟需要保存一次时间戳到Flash。如果你每次都固定写入同一个扇区的同一个地址那么你需要先擦除1次、再写入。一天是1440分钟那么大约70天1440*70≈100k就会达到该扇区的寿命极限导致该扇区损坏数据无法再可靠存储。正确思路必须采用“磨损均衡”策略。核心思想是让写操作均匀分布到整个数据区的所有物理空间上避免对单一存储单元进行频繁擦写。一种简单有效的策略是“扇区轮转”或“页式管理”我们会在下一章详细实现。3. 工程实战设计一个带磨损均衡的Flash数据存储模块理解了原理我们开始动手设计。我们的目标是实现一个用于存储“系统参数”比如校准值、序列号、运行时间等的模块。这些参数的特点是数据量小几十到几百字节、需要频繁更新如运行时间、需要掉电保存。3.1 存储结构设计扇区管理与状态标记我们使用两个连续的Flash扇区Sector来组成一个“存储池”实现简单的磨损均衡和备份。为什么是两个一个作为“活动扇区”另一个作为“备份/擦除扇区”可以交替使用。我们定义每个扇区的起始地址#define FLASH_DATA_SECTOR_1_START_ADDR ((uint32_t)0x0800A000) // 假设扇区10 #define FLASH_DATA_SECTOR_2_START_ADDR ((uint32_t)0x0800A400) // 假设扇区11 #define FLASH_SECTOR_SIZE (1024) // 1KB每个扇区的内部我们将其划分为若干个相同大小的“数据槽”每个槽存储一条完整的参数集。为了管理这些槽我们需要一个“管理头”。管理头设计在每个扇区的开头我们预留一小块区域比如32字节作为管理头。管理头包含扇区状态标记例如0xFFFF表示扇区空闲已擦除0xAAAA表示扇区正在使用0x5555表示扇区数据满或准备切换。最新数据槽的索引指示本扇区内最后一个有效数据存放在哪个槽位。擦写计数可选记录该扇区被擦除的次数用于监控寿命。数据槽设计管理头之后的空间平均分为N个数据槽。每个数据槽存储一份完整的参数数据并附带一个“写入状态”标记如0xCCCC表示有效0x0000表示无效或旧数据。当需要更新参数时我们就在当前活动扇区中寻找下一个空闲或可覆盖的数据槽将新数据连同状态标记一起写入。写入时只写入不擦除。当一个扇区写满后将其状态标记为“满”然后切换到另一个扇区先擦除再设置为“活动”。在切换前需要将最新的一份有效参数复制到新扇区。这样两个扇区被轮流使用磨损就被均摊了。3.2 核心代码实现初始化、读、写下面给出基于STM32标准外设库的核心代码片段。在实际项目中请将这些函数封装成独立的.c/.h文件。首先是一些宏定义和变量声明#include “stm32f10x_flash.h” // 假设我们的参数结构体 typedef struct { uint32_t runTimeSeconds; float calibrationFactor; uint16_t deviceID; uint8_t checksum; // 用于校验数据完整性 } SystemParams_t; // 数据槽大小必须是2的倍数且大于等于参数结构体大小 #define DATA_SLOT_SIZE 64 // 每个扇区数据槽的数量 #define SLOTS_PER_SECTOR ((FLASH_SECTOR_SIZE - 32) / DATA_SLOT_SIZE) // 扇区状态定义 #define SECTOR_STATUS_EMPTY 0xFFFF #define SECTOR_STATUS_ACTIVE 0xAAAA #define SECTOR_STATUS_FULL 0x5555 // 数据状态定义 #define DATA_STATUS_VALID 0xCCCC #define DATA_STATUS_INVALID 0x0000 // 当前在RAM中维护的参数副本 SystemParams_t g_current_params; // 当前活动扇区基地址 uint32_t g_active_sector_addr;Flash底层驱动函数封装库函数// 等待Flash操作完成 static void FLASH_WaitForLastOperation(void) { while (FLASH_GetStatus() ! FLASH_COMPLETE) { // 可选加入超时机制防止死等 } } // 擦除一个扇区 static uint8_t FLASH_EraseSector(uint32_t sector_addr) { FLASH_Status status FLASH_COMPLETE; FLASH_Unlock(); FLASH_ClearFlag(FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR); FLASH_WaitForLastOperation(); status FLASH_ErasePage(sector_addr); // 擦除指定地址所在的扇区 FLASH_Lock(); return (status FLASH_COMPLETE); } // 写入一个16位数据半字 static uint8_t FLASH_WriteHalfWord(uint32_t addr, uint16_t data) { FLASH_Status status FLASH_COMPLETE; FLASH_Unlock(); FLASH_WaitForLastOperation(); status FLASH_ProgramHalfWord(addr, data); FLASH_Lock(); return (status FLASH_COMPLETE); } // 写入一块数据以半字为单位 static uint8_t FLASH_WriteBlock(uint32_t start_addr, uint16_t *pdata, uint16_t len) { uint16_t i; FLASH_Unlock(); for (i 0; i len; i) { if (FLASH_ProgramHalfWord(start_addr i*2, pdata[i]) ! FLASH_COMPLETE) { FLASH_Lock(); return 0; // 写入失败 } FLASH_WaitForLastOperation(); } FLASH_Lock(); return 1; // 写入成功 }存储模块初始化函数这个函数在上电时调用负责扫描两个扇区确定哪个扇区是当前有效的活动扇区并从中读出最新的参数数据到RAM中。uint8_t FlashStorage_Init(void) { uint16_t sector1_status *(volatile uint16_t*)FLASH_DATA_SECTOR_1_START_ADDR; uint16_t sector2_status *(volatile uint16_t*)FLASH_DATA_SECTOR_2_START_ADDR; // 情况1两个扇区都为空首次使用 if ((sector1_status SECTOR_STATUS_EMPTY) (sector2_status SECTOR_STATUS_EMPTY)) { // 擦除扇区1并标记为活动 if (!FLASH_EraseSector(FLASH_DATA_SECTOR_1_START_ADDR)) return 0; if (!FLASH_WriteHalfWord(FLASH_DATA_SECTOR_1_START_ADDR, SECTOR_STATUS_ACTIVE)) return 0; g_active_sector_addr FLASH_DATA_SECTOR_1_START_ADDR; // 初始化RAM中的参数为默认值 g_current_params.runTimeSeconds 0; g_current_params.calibrationFactor 1.0f; g_current_params.deviceID 0x1234; g_current_params.checksum CalculateChecksum(g_current_params); // 将默认值写入活动扇区的第一个数据槽 return FlashStorage_SaveParams(); } // 情况2扇区1活动扇区2为空或满 else if (sector1_status SECTOR_STATUS_ACTIVE) { g_active_sector_addr FLASH_DATA_SECTOR_1_START_ADDR; } // 情况3扇区2活动扇区1为空或满 else if (sector2_status SECTOR_STATUS_ACTIVE) { g_active_sector_addr FLASH_DATA_SECTOR_2_START_ADDR; } // 情况4其他异常状态如都标记为ACTIVE需要错误恢复机制这里简化处理 else { // 尝试恢复这里选择擦除扇区1并作为起始 FLASH_EraseSector(FLASH_DATA_SECTOR_1_START_ADDR); FLASH_WriteHalfWord(FLASH_DATA_SECTOR_1_START_ADDR, SECTOR_STATUS_ACTIVE); g_active_sector_addr FLASH_DATA_SECTOR_1_START_ADDR; return 0; // 返回初始化失败需要应用层处理 } // 从活动扇区中查找最新的有效数据 return FlashStorage_LoadLatestParams(); }查找并加载最新参数的函数static uint8_t FlashStorage_LoadLatestParams(void) { uint32_t sector_base g_active_sector_addr; uint16_t sector_status *(volatile uint16_t*)sector_base; if (sector_status ! SECTOR_STATUS_ACTIVE sector_status ! SECTOR_STATUS_FULL) { return 0; // 扇区状态异常 } uint32_t latest_slot_addr 0; uint16_t latest_index 0; // 从管理头后开始遍历每个数据槽 for (uint16_t i 0; i SLOTS_PER_SECTOR; i) { uint32_t slot_addr sector_base 32 i * DATA_SLOT_SIZE; uint16_t data_status *(volatile uint16_t*)slot_addr; // 数据槽开头2字节是状态标记 if (data_status DATA_STATUS_VALID) { latest_slot_addr slot_addr; latest_index i; } } if (latest_slot_addr 0) { // 没有找到任何有效数据 return 0; } // 找到了最新数据槽将其内容拷贝到RAM中 // 注意从Flash读取数据直接解引用即可但要注意地址对齐和数据类型 SystemParams_t *pFlashParams (SystemParams_t*)(latest_slot_addr 2); // 跳过2字节的状态标记 uint8_t calc_cksum CalculateChecksum(pFlashParams); if (calc_cksum pFlashParams-checksum) { memcpy(g_current_params, pFlashParams, sizeof(SystemParams_t)); return 1; // 加载成功 } else { // 校验失败数据可能已损坏 return 0; } }保存参数函数核心中的核心这个函数实现了磨损均衡的关键逻辑寻找空闲槽写入写满后扇区切换。uint8_t FlashStorage_SaveParams(void) { uint32_t sector_base g_active_sector_addr; uint16_t sector_status *(volatile uint16_t*)sector_base; uint16_t latest_index 0xFFFF; uint16_t empty_slot_index 0xFFFF; // 1. 计算新参数的校验和 g_current_params.checksum CalculateChecksum(g_current_params); // 2. 扫描当前活动扇区找到最新数据索引和第一个空槽 for (uint16_t i 0; i SLOTS_PER_SECTOR; i) { uint32_t slot_addr sector_base 32 i * DATA_SLOT_SIZE; uint16_t data_status *(volatile uint16_t*)slot_addr; if (data_status DATA_STATUS_VALID) { latest_index i; } else if (data_status DATA_STATUS_INVALID || data_status 0xFFFF) { if (empty_slot_index 0xFFFF) { empty_slot_index i; // 记录第一个空槽 } } } uint32_t write_slot_addr; // 3. 决定写入位置 if (empty_slot_index ! 0xFFFF) { // 情况A当前扇区还有空槽直接写入空槽 write_slot_addr sector_base 32 empty_slot_index * DATA_SLOT_SIZE; } else { // 情况B当前扇区已满需要切换扇区 // 3.1 确定另一个扇区地址 uint32_t next_sector_base (sector_base FLASH_DATA_SECTOR_1_START_ADDR) ? FLASH_DATA_SECTOR_2_START_ADDR : FLASH_DATA_SECTOR_1_START_ADDR; // 3.2 擦除目标扇区 if (!FLASH_EraseSector(next_sector_base)) return 0; // 3.3 将目标扇区标记为ACTIVE if (!FLASH_WriteHalfWord(next_sector_base, SECTOR_STATUS_ACTIVE)) return 0; // 3.4 将原活动扇区标记为FULL可选也可标记为EMPTY等待下次擦除 if (!FLASH_WriteHalfWord(sector_base, SECTOR_STATUS_FULL)) { // 标记失败处理这里简化 } // 3.5 更新活动扇区指针 g_active_sector_addr next_sector_base; // 3.6 新扇区的写入地址就是第一个数据槽索引0 write_slot_addr next_sector_base 32; // 第一个数据槽地址 } // 4. 执行写入操作 // 4.1 写入数据状态标记 if (!FLASH_WriteHalfWord(write_slot_addr, DATA_STATUS_VALID)) return 0; // 4.2 写入参数数据 // 将参数结构体转换为uint16_t数组进行写入 uint16_t *pData (uint16_t*)(g_current_params); uint16_t dataLenInHalfWords (sizeof(SystemParams_t) 1) / 2; // 字节数转半字数1为了向上取整 if (!FLASH_WriteBlock(write_slot_addr 2, pData, dataLenInHalfWords)) return 0; return 1; // 保存成功 }4. 避坑指南与高级优化策略代码跑起来只是第一步要让它在产品中稳定可靠地工作数年还需要注意很多细节。4.1 中断与写操作致命的时序问题坑点在Flash编程擦除/写入期间CPU会暂停执行指令即“挂起”直到操作完成。此时如果系统中断使能且中断服务函数或其调用的函数位于正在被擦写操作的Flash扇区系统就会崩溃。因为CPU试图从正在变化的Flash中取指令结果不可预知。解决方案关键操作关中断在执行FLASH_Unlock()之后FLASH_Lock()之前关闭总中断__disable_irq()。这是最简单粗暴但有效的方法适用于数据操作不频繁的场景。FLASH_Unlock(); __disable_irq(); // ... 擦除或写入操作 ... __enable_irq(); FLASH_Lock();将中断向量表和关键代码放到RAM中运行这是更高级、更复杂的方案。通过修改链接脚本和启动代码将中断服务函数等关键代码在初始化时拷贝到RAM中执行。这样Flash操作期间即使发生中断CPU也是从RAM取指不会冲突。这对实时性要求高的系统是必要的。4.2 数据完整性校验应对意外掉电坑点在写Flash的过程中尤其是先写标记再写数据系统突然掉电可能导致数据处于“半截”状态即标记写了但数据不完整或者反之。下次上电初始化时可能会加载到错误的数据。解决方案写前校验写后验证写入数据后立刻回读与原始数据对比确保一致。不一致则标记本次写入失败尝试恢复旧数据。采用事务机制借鉴数据库的“原子性”思想。方法A双备份每个参数保存两份A和B每次更新时先写B验证B成功后再写A。读取时总是读取版本号更新的那一份。如果A损坏就用B恢复。方法B状态机为每个数据槽设计更精细的状态准备中PREPARING、写入中WRITING、有效VALID、无效INVALID。写入流程变为将旧槽标记为INVALID。将新槽标记为PREPARING并写入数据。数据写入并验证无误后将新槽标记为VALID。 这样即使在步骤2掉电上电后看到的是PREPARING状态知道该数据无效可以回退到上一个VALID的数据。加入校验和/CRC如示例代码中的checksum字段。在加载数据时强制校验校验失败则丢弃该数据寻找上一个有效版本。4.3 延长Flash寿命的进阶策略基础的扇区轮转已经大大提升了寿命。还可以进一步优化差异化数据更新频率将频繁变化的数据如运行时间和几乎不变的数据如序列号、校准值分开存储。可以为运行时间单独分配一个小的、采用更激进磨损均衡策略的存储区而将不变的数据放在另一个很少擦写的区域。“脏”数据累计写入不是每次参数变化都立刻保存。可以在RAM中维护一个“脏”标志当参数改变时置位。然后在一个低优先级的后台任务或定时器中断中定期检查“脏”标志如果置位则执行保存操作并清除标志。这可以将保存操作“攒起来”执行显著减少擦写次数。但要注意两次保存之间的时间窗口内掉电会丢失未保存的更改。监控擦写次数在每个扇区的管理头记录擦写计数。当计数接近极限如9万次时通过日志或指示灯告警提示设备寿命将至。4.4 调试技巧与常见错误Flash Download failed - “Cortex-M3”这个错误通常发生在使用下载器如ST-Link通过IDEKeil, IAR下载程序时。根本原因下载算法Flash Programming Algorithm无法识别或操作目标Flash地址。在我们操作了程序区之后的Flash空间时有时会因为下载配置不当触发此问题。解决方案在IDE的下载配置中明确设置Flash的编程算法和地址范围。确保你的下载配置中Start和Size覆盖了你程序占用的空间以及你规划的数据区空间。如果你只下载程序不下载数据区内容确保“Erase Full Chip”或“Erase Sectors”选项不会擦除你的数据区有时需要选择“Erase Used Sectors Only”。no algorithm found for: xxxxxh同上是下载算法地址范围不匹配的问题。检查并修正下载配置中的Flash大小和起始地址。数据读出来全是0xFF或0x00首先检查地址是否正确用调试器直接查看内存Memory Window确认。如果是0xFF可能是该扇区已被擦除或从未写入。如果是0x00可能是写入失败或发生了意外的擦除。检查写操作函数的返回值并确保在写之前该地址处于已擦除0xFF状态。操作Flash后程序跑飞极大可能是触犯了“中断与写操作”的坑。检查是否在Flash操作期间发生了中断并且该中断服务程序位于正在被操作的Flash扇区。解决方法就是关中断或将中断服务程序搬到RAM。5. 从模块到应用集成与测试要点当你把Flash存储模块编写完成后集成到主应用中还需要注意以下几点初始化时机FlashStorage_Init()应在系统时钟、尤其是Flash相关时钟如等待周期配置完成之后但在其他依赖这些参数的模块初始化之前调用。通常放在main()函数中硬件初始化部分靠后的位置。保存触发条件不要过于频繁地调用FlashStorage_SaveParams()。可以在以下时机触发系统进入低功耗模式如Standby, Stop前。参数发生变更后启动一个延时保存定时器如5秒后保存避免连续快速修改参数导致频繁擦写。接收到明确的保存命令如通过串口。RAM副本的使用应用程序应始终操作RAM中的参数副本g_current_params。当需要持久化时再调用保存函数。这避免了每次访问参数都要读Flash的低效操作。测试验证单元测试在开发板上编写测试代码循环写入、读取、验证数据成千上万次统计错误率和观察扇区切换是否正常。掉电测试这是最关键的测试。在写入Flash的不同阶段刚擦除完、刚写完状态标记、刚写完部分数据、刚写完所有数据手动断电然后重新上电检查系统能否正确初始化并恢复到最后一个有效状态。这个测试能暴露出数据完整性设计的漏洞。边界测试填满所有扇区测试扇区切换逻辑模拟Flash损坏可通过调试器手动修改Flash内容制造错误状态测试错误恢复机制是否健壮。通过以上从原理到实践从基础代码到高级策略的完整梳理你应该能够为STM32F103设计出一个健壮、可靠的内部Flash数据存储方案。这个方案的核心思想——扇区轮转、状态机管理、数据校验、谨慎处理中断——可以扩展到其他系列的STM32甚至其他品牌的MCU上。最终代码的复杂度取决于你对数据安全性和寿命的要求对于大多数应用本章提供的代码框架已经是一个坚实可靠的起点。在实际项目中我通常会在此基础上增加CRC32校验和更详细的状态日志以便在出现极端问题时能够定位原因。