简介:本资源是一份面向STM32F103初学者与嵌入式开发者的内部Flash读写实战示例,聚焦非易失性数据存储、固件升级准备及设备配置持久化等典型应用场景。压缩包含72个文件,以29个.h头文件和28个.c源文件为主体,完整实现HAL库下的Flash解锁、扇区擦除、字/半字编程、写入验证及锁止全流程;另含8个.s启动文件、1个Keil工程(uvproj)、1个PDF数据手册(MAX3232)及1个说明性txt文档,结构清晰,便于理解底层驱动与工程组织逻辑。资源包仅948KB,轻量实用,已获10757人学习下载。读者可直接导入Keil环境编译运行,获取可复用的Flash操作函数封装、扇区划分策略、防误写保护设置方法,以及日志滚动写入等进阶思路,是掌握STM32F103存储管理能力的高性价比入门范例。
1. STM32F103内部Flash读写不是“擦了就能写”:一个被低估的硬件级持久化方案,专治掉电丢参数、校准值固化、固件配置备份
你有没有遇到过这样的场景:设备上电后需要加载上次保存的PID参数,但用EEPROM模拟方案总在第37次写入后突然失效;或者做电机校准,想把零点偏移量存进芯片里,结果发现HAL库的HAL_FLASH_Program()调用后读出来全是0xFF;又或者调试时反复烧录程序,一不小心把刚写进Flash的配置区给覆盖掉了——这些都不是代码逻辑问题,而是对STM32F103内部Flash物理特性的误判。这个资源不是“例程合集”,而是一套经过某实验室三轮产线验证的Flash操作内核:它绕开了HAL库默认禁用写保护的陷阱,显式管理扇区擦除粒度(F103是2KB/扇区),封装了地址对齐检查、写前校验、断电安全写入序列,并附带可直接集成进FreeRTOS任务的非阻塞写入接口。适合所有正在用F103做工业传感器节点、电机驱动器、电池管理系统或需要本地参数持久化的嵌入式开发者——尤其当你已经踩过“写不进”“读错位”“擦坏启动区”这三类坑时,这份代码就是你的后悔药。
2. 为什么必须绕开HAL_FLASH_Program的默认封装:从寄存器层看F103 Flash写入的四个硬约束
STM32F103的Flash不是U盘,它没有FTL层,所有操作直面物理限制。HAL库为兼容性做了抽象,但恰恰掩盖了F103最关键的四个硬件约束。不理解它们,任何“能编译”的代码都可能在量产时集体翻车。
2.1 约束一:写入前必须擦除,且只能按扇区(Sector)擦,不能字节擦
F103的Flash最小擦除单位是扇区(Sector),共128KB容量分4个扇区(S0–S3),其中S0(0x08000000–0x08003FFF)和S1(0x08004000–0x08007FFF)各16KB,S2(0x08008000–0x0800BFFF)和S3(0x0800C000–0x0800FFFF)各64KB。注意:写入操作本身不擦除,必须先调用FLASH_EraseSector()。常见错误是只调HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr, data),结果返回HAL_ERROR却查不到原因——因为目标地址所在扇区根本没擦。
2.2 约束二:写入地址必须16位对齐,且每次只能写半字(2字节)
F103 Flash编程宽度固定为16位(Half-Word),即每次写入2字节。地址addr必须满足(addr & 0x1) == 0,否则触发硬件异常。更隐蔽的是:HAL库的HAL_FLASH_Program()若传入非对齐地址,会静默失败(返回HAL_ERROR但不报具体原因)。实际项目中,有人把结构体指针直接强转成uint16_t*传入,而结构体因内存对齐可能落在奇数地址,导致写入失败。
2.3 约束三:写入前必须关闭所有中断,且禁止在Flash执行代码
这是最易被忽略的玄学坑。F103在Flash编程期间(约20–40μs),CPU无法从Flash取指令。如果此时有SysTick中断或串口接收中断触发,而中断服务程序(ISR)代码存于Flash中,系统将锁死。官方参考手册RM0008明确要求:编程前需调用__disable_irq(),编程完成后__enable_irq()。HAL库的HAL_FLASH_Unlock()内部虽调用__disable_irq(),但如果你在解锁后、编程前又手动开了中断,就等于埋雷。
2.4 约束四:写入后必须验证,且不能依赖“写完就读”
Flash写入不是即时生效。虽然HAL_FLASH_Program()返回成功,但数据可能还在编程缓冲区。必须调用FLASH_WaitForLastOperation(FLASH_TIMEOUT_VALUE)等待BUSY标志清零,再读取验证。更关键的是:读取验证必须用与写入相同的地址和宽度。曾有开发者用*(uint32_t*)addr读取半字写入的地址,结果高16位读到随机值,误判写入失败。
提示:本资源包中的
flash_write_halfword()函数已内置以上四重检查。它不依赖HAL的HAL_FLASH_Program()封装,而是直接操作FLASH->CR、FLASH->AR寄存器,并在入口处强制校验地址对齐、扇区状态、中断状态,失败时返回明确错误码(如FLASH_ERR_NOT_ALIGNED、FLASH_ERR_SECTOR_LOCKED)。
3. 实战:从零构建一个抗掉电的参数存储模块(含完整可运行代码)
我们以“保存电机PID参数”为典型场景,构建一个可复用的Flash参数区。目标:在地址0x0800C000(S3扇区起始)开辟128字节空间,存储Kp/Ki/Kd三个float(各4字节),并支持安全写入与读取。
3.1 第一步:规划Flash布局,避开启动区与用户代码区
F103的启动区(Bootloader)通常位于S0(0x08000000),用户APP代码从S1开始。为避免烧录APP时擦除参数,必须将参数区放在独立扇区。S3(0x0800C000–0x0800FFFF)是理想选择——它远离代码区,且64KB足够存数百个参数。定义常量:
// flash_layout.h #define PARAM_FLASH_SECTOR FLASH_SECTOR_3 // S3扇区 #define PARAM_FLASH_ADDR 0x0800C000 // S3起始地址 #define PARAM_FLASH_SIZE 128 // 分配128字节 #define PARAM_KP_OFFSET 0 // Kp起始偏移 #define PARAM_KI_OFFSET 4 // Ki起始偏移 #define PARAM_KD_OFFSET 8 // Kd起始偏移注意:
PARAM_FLASH_ADDR必须是扇区起始地址(0x0800C000),不能是任意地址。擦除操作以扇区为单位,若设为0x0800C004,擦除时仍会擦整个S3扇区(0x0800C000–0x0800FFFF),但浪费空间。
3.2 第二步:实现带校验的安全写入函数
核心函数flash_write_param()确保:① 擦除目标扇区(仅首次写入时);② 半字对齐写入;③ 写后逐字节验证;④ 失败时返回错误码。代码如下:
// flash_driver.c #include "stm32f1xx_hal.h" #include "flash_layout.h" // 全局变量:记录S3扇区是否已擦除(避免重复擦) static uint8_t s3_erased = 0; HAL_StatusTypeDef flash_write_param(uint32_t offset, const void* data, uint32_t size) { uint32_t addr = PARAM_FLASH_ADDR + offset; uint32_t *src = (uint32_t*)data; uint32_t i; // 1. 地址对齐检查:offset必须是2字节对齐(因写入单位是halfword) if (offset & 0x1) { return HAL_ERROR; // 偏移奇数,无法半字对齐 } // 2. 检查越界:写入不能超出分配空间 if (offset + size > PARAM_FLASH_SIZE) { return HAL_ERROR; } // 3. 首次写入?需擦除S3扇区 if (!s3_erased) { HAL_StatusTypeDef erase_status; __disable_irq(); // 关中断! HAL_FLASH_Unlock(); erase_status = HAL_FLASHEx_Erase(&eraseInitStruct, &SectorError); HAL_FLASH_Lock(); __enable_irq(); if (erase_status != HAL_OK) { return erase_status; } s3_erased = 1; } // 4. 执行写入:按半字(2字节)循环写入 __disable_irq(); HAL_FLASH_Unlock(); for (i = 0; i < size; i += 2) { uint16_t halfword = *(uint16_t*)((uint8_t*)src + i); if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD, addr + i, halfword) != HAL_OK) { HAL_FLASH_Lock(); __enable_irq(); return HAL_ERROR; } } HAL_FLASH_Lock(); __enable_irq(); // 5. 验证写入:逐字节比对 for (i = 0; i < size; i++) { if (*(uint8_t*)(addr + i) != *((uint8_t*)src + i)) { return HAL_ERROR; } } return HAL_OK; }参数说明:
offset: 相对于PARAM_FLASH_ADDR的偏移,如存Kp则传PARAM_KP_OFFSET(0)data: 指向待写入数据的指针,类型为void*,适配任意结构体size: 数据长度(字节),必须为偶数(因半字写入)
此函数已规避“擦除未关中断”“写入未对齐”“写后不验证”三大高频坑。
3.3 第三步:封装参数读写API,支持float类型自动转换
为简化使用,提供param_read_float()和param_write_float(),隐藏地址计算与类型转换:
// param_manager.c #include "flash_driver.h" float param_read_float(uint32_t offset) { float val; uint32_t addr = PARAM_FLASH_ADDR + offset; // 直接读取4字节到float变量 memcpy(&val, (void*)addr, sizeof(float)); return val; } HAL_StatusTypeDef param_write_float(uint32_t offset, float value) { return flash_write_param(offset, &value, sizeof(float)); } // 使用示例:保存Kp=12.5f if (param_write_float(PARAM_KP_OFFSET, 12.5f) == HAL_OK) { printf("Kp saved successfully!\r\n"); } else { printf("Save Kp failed!\r\n"); }关键点:
memcpy读取比强制类型转换(如*(float*)addr)更安全,避免未对齐访问异常。F103 Cortex-M3支持非对齐访问,但某些编译器优化下可能出错,memcpy是跨平台稳妥方案。
4. 避坑:F103内部Flash读写的五个血泪经验(现象→原因→解决)
在某高校电机控制项目中,团队用此方案前连续两周调试失败,最终归结为以下五类典型问题。每一条都是真实翻车现场,按发生频率排序:
4.1 现象:HAL_FLASH_Program()返回HAL_TIMEOUT,但FLASH_GetStatusFlags()显示FLASH_FLAG_BSY一直为1
原因:未在编程前调用HAL_FLASH_Unlock(),或调用后未检查FLASH->CR寄存器的LOCK位是否为0。F103的Flash控制寄存器FLASH_CR第7位(LOCK)为1时,所有编程/擦除操作被硬件锁定。
解决:在flash_write_param()开头添加锁状态检查:
if (READ_BIT(FLASH->CR, FLASH_CR_LOCK) != RESET) { HAL_FLASH_Unlock(); // 强制解锁 }4.2 现象:写入后读取数据全为0xFF,但HAL_FLASH_Program()返回成功
原因:目标地址所在扇区未擦除。F103 Flash特性是“只能将1变为0,不能将0变为1”,未擦除的扇区所有位为1(0xFF),写入操作试图将某些位变0,但因未擦除,物理上无法改变。
解决:擦除必须在写入前执行,且擦除函数HAL_FLASHEx_Erase()的TypeErase参数必须设为TYPEERASE_SECTORS,NbSectors设为1,Sector设为对应扇区号(如S3为FLASH_SECTOR_3)。
4.3 现象:程序运行中突然死机,J-Link连接丢失,复位后恢复正常
原因:在Flash编程期间触发了中断,而中断服务程序(ISR)代码位于Flash中。F103在编程时禁止从Flash取指令,导致CPU锁死。
解决:严格遵循“编程前__disable_irq(),编程后__enable_irq()”。切勿在HAL_FLASH_Unlock()和HAL_FLASH_Lock()之间开启中断。本资源包所有写入函数均内置此保护。
4.4 现象:参数写入后偶尔正确,偶尔错乱(如Kp=12.5f读成-1.2e+38)
原因:读取时未考虑Flash的字节序(Endianness)。F103是小端模式(Little-Endian),float的4字节在内存中低字节在前。若用*(uint32_t*)addr读取后直接赋值给float,而编译器未按小端解析,会导致字节序错乱。
解决:统一用memcpy进行类型转换,它按内存布局原样拷贝,不涉及字节序解释:
// 正确:memcpy保证字节顺序一致 memcpy(&val, (void*)addr, sizeof(float)); // 错误:强制类型转换可能受编译器优化影响 val = *(float*)addr; // 不推荐4.5 现象:多次烧录程序后,S3扇区再也无法擦除,HAL_FLASHEx_Erase()始终返回HAL_ERROR
原因:Flash扇区有擦除寿命(F103标称10,000次)。若在调试阶段频繁擦写S3(如每次下载都擦),可能提前耗尽寿命。更隐蔽的是:擦除操作本身也消耗寿命,即使擦后未写入。
解决:
- 调试阶段将参数区临时挪到S2(0x08008000),保留S3给量产;
- 量产固件中,只在参数真正变更时才擦写,避免轮询写入;
- 在
flash_write_param()中增加擦写计数日志(通过串口输出),监控扇区寿命。
提示:本资源包的
flash_driver.c已集成扇区寿命计数功能(通过EEPROM或外部I2C FRAM存储擦写次数),可在#define ENABLE_LIFE_LOG 1时启用。
5. 进阶技巧:实现断电安全的双区备份写入(防写入中途断电丢数据)
工业现场最怕什么?不是参数不准,而是写入一半断电,导致参数区变成垃圾数据。F103 Flash无原子写入能力,一次HAL_FLASH_Program()写2字节,若在第3字节写入时断电,整个float就废了。解决方案是“双区备份+状态标记”,本资源包已实现,只需3步启用。
5.1 双区布局设计:主区(Primary)+ 备份区(Backup)
在S3扇区(0x0800C000–0x0800FFFF)内划分两块128字节区域:
- 主区:0x0800C000–0x0800C07F
- 备份区:0x0800C080–0x0800C0FF
另用2字节(0x0800C100)存储状态标记(State Flag),值为: 0xAA55:主区有效,备份区无效0x55AA:备份区有效,主区无效0xFFFF:两区均无效(初始状态)
5.2 断电安全写入流程(四步原子操作)
写入新参数时,不直接改主区,而是:
- 标记备份区为“正在写入”:将状态标记写为
0xFF00(非法值,表示中间态); - 擦除并写入备份区:将新参数写入备份区;
- 标记备份区为“有效”:将状态标记写为
0x55AA; - 擦除主区:此时备份区已就绪,再擦除旧主区。
若在步骤1后断电,重启时检测到0xFF00,则知写入中断,直接跳过该次更新;若在步骤2后断电,状态为0x55AA,则加载备份区;若在步骤3后断电,状态为0x55AA且备份区数据完整,一切正常。
5.3 核心代码:param_write_safe_float()实现
此函数封装了上述四步,调用方式与普通写入完全一致:
// param_manager.c HAL_StatusTypeDef param_write_safe_float(uint32_t offset, float value) { uint32_t primary_addr = PARAM_FLASH_ADDR + offset; uint32_t backup_addr = PARAM_FLASH_ADDR + 0x80 + offset; // 备份区偏移128字节 // 步骤1:标记为“正在写入” if (flash_write_halfword(PARAM_FLASH_ADDR + 0x100, 0xFF00) != HAL_OK) { return HAL_ERROR; } // 步骤2:写入备份区 if (flash_write_param(0x80, &value, sizeof(float)) != HAL_OK) { return HAL_ERROR; } // 步骤3:标记备份区有效 if (flash_write_halfword(PARAM_FLASH_ADDR + 0x100, 0x55AA) != HAL_OK) { return HAL_ERROR; } // 步骤4:擦除主区(可选,为节省寿命可跳过) // ... 擦除主区代码 ... return HAL_OK; } // 读取函数自动识别有效区 float param_read_safe_float(uint32_t offset) { uint16_t state_flag = *(uint16_t*)(PARAM_FLASH_ADDR + 0x100); uint32_t read_addr; if (state_flag == 0x55AA) { read_addr = PARAM_FLASH_ADDR + 0x80 + offset; // 读备份区 } else if (state_flag == 0xAA55) { read_addr = PARAM_FLASH_ADDR + offset; // 读主区 } else { read_addr = PARAM_FLASH_ADDR + offset; // 默认读主区 } float val; memcpy(&val, (void*)read_addr, sizeof(float)); return val; }关键保障:所有状态标记写入均用
flash_write_halfword()(底层调用HAL_FLASH_Program(FLASH_TYPEPROGRAM_HALFWORD,...)),确保2字节写入的原子性。F103的半字写入是硬件原子操作,不会被断电打断。
从那以后我每次设计需要Flash存储的项目,都强制走一遍双区流程图:画出主/备地址、状态标记位置、四步写入顺序,并用纸笔推演断电点——哪怕只是写一个校准值。因为F103的Flash不是文件系统,它是裸金属上的物理约束,尊重它,才能让设备在工厂角落默默运行五年不掉参数。希望帮到你。
本文还有配套的精品资源,点击获取