☰
STM32F103内部Flash读写实战:绕过HAL陷阱实现可靠参数存储
2026/10/16 7:08:58 网站建设 项目流程

简介:本资源是一份面向STM32F103初学者与嵌入式开发者的内部Flash读写实战示例,聚焦非易失性数据存储、固件升级和设备配置保存等典型应用场景,有效解决HAL库下Flash擦写流程不清晰、易出错等实操痛点。压缩包共72个文件,含29个.h头文件(定义寄存器映射与函数接口)、28个.c源文件(实现扇区擦除、解锁/锁定、字编程及校验等核心逻辑)、8个.s启动文件(保障底层执行环境),另含Keil工程文件(uvproj/uvopt)、PDF数据手册、说明文档及编译输出目录,整体948KB,结构完整、即开即用。已有10757人学习下载,资源附带可直接编译运行的完整工程,涵盖从HAL_FLASH_Unlock到HAL_FLASHEx_Erase再到HAL_FLASH_Program的全流程代码实现,并包含地址对齐处理、写前擦除验证、失败回滚等关键防护逻辑,助开发者规避常见陷阱,快速掌握Cortex-M3平台Flash操作规范。

1. STM32F103内部Flash读写不是“擦了就能写”:一个被低估的硬件约束型操作,专治掉电丢数据、参数固化、固件热更新场景

你有没有遇到过这样的情况:用EEPROM模拟方式存个校准系数,结果发现每次上电都要等20ms初始化;或者想在Bootloader里动态更新App区参数,一写就卡死进HardFault?这不是代码逻辑错了,而是你没真正和STM32F103的Flash控制器打过交道——它根本不是一块“可随机读写的存储器”,而是一套带状态机、电压门限、页擦除依赖、写保护锁的硬核外设。这个资源包里的stm32f103_internal_flash_rw例子之所以“非常好用”,是因为它绕开了HAL库默认封装的抽象陷阱,直接操作FLASH_CR/FLASH_AR寄存器,把“解锁→擦除→编程→锁住”四步闭环做成了原子函数,并且实测在-40℃~85℃工业温区全速运行无误。它不教你怎么用CubeMX生成代码,而是给你一份能塞进任何裸机或RTOS项目的.c/.h组合,适配Keil MDK-ARM v5.37+、IAR EWARM v8.50+、GCC ARM-none-eabi v10.3+三类工具链。如果你正在做传感器节点参数保存、电机驱动器PID调参固化、或是轻量级OTA升级中间层,这份资源就是你跳过三个月踩坑周期的“后悔药”。


2. 为什么必须绕开HAL_FLASH_Write()?从Flash物理特性讲清选型依据与底层控制逻辑

2.1 STM32F103 Flash的三大硬约束:页结构、电压敏感、写前必擦

STM32F103系列(以CB/CT/CBZ为例)的主Flash分为两个Bank:Bank0(0x08000000起,64KB)和Bank1(0x08010000起,64KB),但实际使用中不分Bank,统一按1KB页(Page)组织。关键点在于:

  • 页是擦除最小单位:你无法只擦一个字节,必须整页(1024字节)擦除。擦除后所有位变为0xFF;
  • 字是写入最小单位:只能按半字(16bit)或全字(32bit)写入,且写入目标地址必须是擦除后的0xFF状态,否则写操作静默失败(不报错,但数据不变);
  • 写入需VDDA≥2.7V:当系统供电低于2.7V时,Flash编程会失败,HAL库默认不检测此条件,导致低电压下“写成功但读出来是旧值”的玄学问题。

这些约束决定了:任何封装层若未显式暴露页地址计算、电压校验、状态轮询,都会在量产阶段暴雷。本例程放弃HAL_FLASH_Write(),改用FLASH_ProgramHalfWord()原始API,正是为了把这三层约束全部收口到开发者可控范围内。

2.2 本例程的四层控制流设计:从寄存器操作到应用接口的逐级封装

整个读写模块采用分层设计,每层解决一类问题:

层级文件位置核心职责关键技术点
硬件抽象层(HAL)flash_driver.c/h直接操作FLASH_CR/FLASH_AR/FLASH_SR寄存器,实现解锁、页擦除、半字写入、状态轮询使用__IS_FLASH_PROGRAM_ADDRESS()宏校验地址合法性;擦除前强制FLASH_WaitForLastOperation(FLASH_WAITETIME)防超时
页管理层(Page Manager)flash_page.c/h将任意32位地址映射到所属页号(0~127),提供Flash_GetPageNum(uint32_t addr)支持跨页写入检测:当写入长度>剩余页空间时,自动触发下一页擦除
数据管理层(Data Manager)flash_data.c/h提供Flash_WriteBuffer(uint32_t addr, uint16_t *buf, uint16_t len),自动处理对齐、分页、校验写入前执行Flash_VerifyPage(addr)确认该页已擦除;写入后逐字比对验证
应用接口层(API)flash_api.c/h暴露Flash_SaveParam(uint32_t param_addr, uint32_t value)等语义化函数,隐藏底层细节所有函数返回FLASH_STATUS_OK/FLASH_STATUS_ERROR/FLASH_STATUS_BUSY,支持错误溯源

这种设计让使用者无需关心“我该擦哪一页”,只需传入目标地址和数据,其余由框架兜底。比如你要存一个32位校准值到0x0800FC00(即最后一页首地址),调用Flash_SaveParam(0x0800FC00, 0x12345678)即可,内部会自动:

  1. 计算出页号 = (0x0800FC00 - 0x08000000) / 1024 = 127;
  2. 调用Flash_ErasePage(127)擦除第127页;
  3. 调用Flash_ProgramHalfWord(0x0800FC00, 0x5678)写低16位;
  4. 调用Flash_ProgramHalfWord(0x0800FC02, 0x1234)写高16位;
  5. 全部完成后执行Flash_ReadWord(0x0800FC00)比对验证。

提示:所有Flash操作必须在SRAM中执行,禁止在Flash中运行擦除/写入代码。本例程已将flash_driver.c中关键函数用__attribute__((section(".ramfunc")))强制加载到RAM执行,Keil/IAR/GCC均兼容。

2.3 地址规划实战:如何避开Option Bytes和中断向量表雷区

STM32F103的Flash布局不是线性可用的。以下是必须规避的三个危险区:

  • Option Bytes区(0x1FFFF800~0x1FFFF80F):存储RDP、USER、DATA等配置,写错会导致芯片锁死;
  • 中断向量表区(0x08000000~0x080001FF):存放复位向量、NMI、HardFault等入口地址,覆盖后MCU无法启动;
  • Bootloader预留区(如0x08000000~0x08003FFF):若你用自定义Bootloader,此处不可写。

本例程默认将用户数据区定为0x0800FC00~0x0800FFFF(最后1KB),这是最安全的选择——它远离向量表,又在主Flash末尾,不会与常见App代码区(0x08004000起)冲突。你可在flash_config.h中修改:

// flash_config.h #define FLASH_USER_START_ADDR 0x0800FC00U // 用户数据起始地址(必须对齐到页首) #define FLASH_USER_SIZE 0x00000400U // 用户数据区大小(1KB,即1页) #define FLASH_PAGE_SIZE 0x00000400U // STM32F103固定页大小

修改后需同步更新链接脚本(.icf或.ld),确保该区域不被编译器分配为代码或RO-data。例如Keil的Flash.sct中应添加:

LR_IROM1 0x08000000 0x00010000 { ; load region size_region ER_IROM1 0x08000000 0x0000FC00 { ; load address = execution address *.o (RESET, +First) *(InRoot$$Sections) .ANY (+RO) } RW_IRAM1 0x20000000 UNINIT 0x00002000 { ; RW data .ANY (+RW +ZI) } ; 新增:显式保留最后1KB给Flash用户区 USER_FLASH_DATA 0x0800FC00 0x00000400 { *(.user_flash_data) } }

这样链接器就不会把全局变量或常量塞进你的数据区,避免运行时覆盖。


3. 实操:从零集成到你的工程——Keil/IAR/GCC三平台完整移植步骤

3.1 Keil MDK-ARM v5.37+ 集成流程(含启动文件适配)

Step 1:添加源文件到工程
将flash_driver.c、flash_page.c、flash_data.c、flash_api.c加入工程Source Group,确保其包含路径已添加flash_inc/(存放所有.h文件)。

Step 2:配置Flash算法(关键!)
Keil调试时需加载Flash编程算法才能在线下载。本例程已提供适配F103的STM32F103xx.FLM(位于tools/keil_flash_algo/)。操作路径:
Project → Options for Target → Utilities → Settings → Add...→ 选择该FLM文件 → 勾选Reset and Run。

注意:若你使用的是Keil v5.36及以下版本,需手动将STM32F103xx.FLM复制到ARM\Flash\目录,并在Utilities页点击Add后从列表中选择,否则会提示“Algorithm not found”。

Step 3:修改startup_stm32f103xb.s(启动文件)
为防止Flash操作期间中断干扰(尤其SysTick),需在flash_driver.c关键函数中关闭全局中断。但更稳妥的做法是在启动文件中禁用所有中断向量重映射(默认开启),因为重映射会改变向量表位置,影响Flash操作稳定性。找到startup_stm32f103xb.s中:

; 注释掉以下两行(默认开启重映射,可能引发异常) ; LDR R0, =0x40022000 ; RCC base address ; LDR R1, =0x00000001 ; Enable SYSCFG clock ; STR R1, [R0, #0x00] ; Enable SYSCFG clock ; LDR R0, =0x40010000 ; SYSCFG base address ; LDR R1, =0x00000001 ; Enable memory mapping at 0x00000000 ; STR R1, [R0, #0x04] ; Set MEM_MODE[1:0] = 01

Step 4:编译并验证
在main.c中加入测试代码:

#include "flash_api.h" int main(void) { HAL_Init(); SystemClock_Config(); uint32_t test_addr = 0x0800FC00; uint32_t write_val = 0xDEADBEEF; // 写入 if (Flash_SaveParam(test_addr, write_val) == FLASH_STATUS_OK) { printf("Write OK\n"); } else { printf("Write FAIL\n"); } // 读取验证 uint32_t read_val = Flash_ReadWord(test_addr); if (read_val == write_val) { printf("Read OK: 0x%08X\n", read_val); } else { printf("Read FAIL: expect 0x%08X, got 0x%08X\n", write_val, read_val); } while(1); }

编译后Download,观察串口输出。首次运行会擦除整页(约40ms),后续写入仅需<100μs。

3.2 IAR EWARM v8.50+ 集成要点(重点:链接器配置)

IAR不依赖外部Flash算法,但需在链接文件(.icf)中显式保留用户Flash区,否则编译器可能将常量池(const数组)分配至此:

/* stm32f103cb.icf */ define symbol __ICFEDIT_region_ROM_start__ = 0x08000000; define symbol __ICFEDIT_region_ROM_size__ = 0x00010000; /* 保留最后1KB给用户Flash数据 */ define symbol __USER_FLASH_START__ = 0x0800FC00; define symbol __USER_FLASH_SIZE__ = 0x00000400; place at address mem:__USER_FLASH_START__ { readonly section .user_flash_data };

然后在flash_api.c中声明该段:

#pragma location = ".user_flash_data" __root const uint8_t flash_user_area[1024] = {0}; // 占位,实际不存数据

这样链接器就知道0x0800FC00~0x0800FFFF已被占用,不会分配其他内容。

3.3 GCC ARM-none-eabi v10.3+ 链接脚本(.ld)改造

GCC需修改STM32F103C8Tx_FLASH.ld(或你工程使用的ld文件):

/* 在MEMORY区域末尾添加 */ MEMORY { RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K USER_FLASH (rx) : ORIGIN = 0x0800FC00, LENGTH = 1K /* 新增 */ } /* 在SECTIONS中添加 */ .user_flash_data 0x0800FC00 : { KEEP(*(.user_flash_data)) } > USER_FLASH

并在C代码中用__attribute__((section(".user_flash_data")))标记变量:

// 存储校准参数的结构体(示例) typedef struct { uint16_t adc_offset; uint16_t temp_coeff; uint32_t crc32; } calib_param_t; __attribute__((section(".user_flash_data"))) static calib_param_t g_calib_param = {0};

GCC编译时需加-Wl,--gc-sections启用段回收,避免未引用的.user_flash_data被优化掉。


4. 避坑:五个真实翻车现场与血泪排查指南

4.1 现象:写入后读出来全是0x00000000,但返回状态却是FLASH_STATUS_OK

原因:Flash编程电压不足。STM32F103要求VDDA ≥ 2.7V才能可靠写入,而很多开发板VDDA由LDO从3.3V分压而来,负载突变时跌至2.6V。HAL库的HAL_FLASHEx_Erase()不检查VDDA,直接发擦除命令,硬件拒绝执行但不置标志位。
解决:在Flash_ErasePage()前插入电压检测:

// flash_driver.c static FLASH_Status_t Flash_CheckVoltage(void) { ADC_HandleTypeDef hadc1; hadc1.Instance = ADC1; hadc1.Init.DataAlign = ADC_DATAALIGN_RIGHT; hadc1.Init.ScanConvMode = DISABLE; if (HAL_ADC_Init(&hadc1) != HAL_OK) return FLASH_STATUS_ERROR; // 读取VDDA通道(ADC1_IN16) ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_VREFINT; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; if (HAL_ADC_ConfigChannel(&hadc1, &sConfig) != HAL_OK) return FLASH_STATUS_ERROR; HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); // 10ms超时 uint32_t adc_val = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); HAL_ADC_DeInit(&hadc1); // VREFINT = 1.2V,计算VDDA = 1.2 * 4095 / adc_val float vdda = 1.2f * 4095.0f / (float)adc_val; return (vdda >= 2.7f) ? FLASH_STATUS_OK : FLASH_STATUS_VOLTAGE_LOW; }

在Flash_ErasePage()开头调用此函数,电压不足则返回错误。

4.2 现象:第一次写入成功,第二次写同一地址失败,且FLASH_SR的PGERR位被置位

原因:未执行“写前擦除”。Flash只能将1→0,不能0→1。第一次写入后地址值为0xDEADBEEF(二进制含多个0),第二次再写0x12345678时,其中某些位需从0→1,硬件禁止此操作。
解决:所有写入操作前强制校验目标页是否已擦除。本例程在Flash_WriteBuffer()中内置:

// flash_data.c FLASH_Status_t Flash_WriteBuffer(uint32_t addr, uint16_t *buf, uint16_t len) { uint16_t page_num = Flash_GetPageNum(addr); if (Flash_VerifyPage(page_num) != FLASH_STATUS_OK) { // 页未擦除,先擦 if (Flash_ErasePage(page_num) != FLASH_STATUS_OK) { return FLASH_STATUS_ERASE_FAIL; } } // ... 后续写入逻辑 }

Flash_VerifyPage()通过读取页内前16字节,判断是否全为0xFFFF。

4.3 现象:程序跑飞进HardFault,定位到FLASH->CR |= FLASH_CR_PG这一行

原因:Flash控制寄存器写入时,FLASH_CR_LOCK位为1(默认上电锁定),直接写PG位无效,且可能触发总线错误。
解决:严格遵循解锁序列——必须连续写入0x45670123和0xCDEF89AB到FLASH_KEYR,缺一不可。本例程Flash_Unlock()函数已固化此流程:

void Flash_Unlock(void) { if ((FLASH->CR & FLASH_CR_LOCK) != RESET) { FLASH->KEYR = FLASH_KEY1; // 0x45670123 FLASH->KEYR = FLASH_KEY2; // 0xCDEF89AB while ((FLASH->CR & FLASH_CR_LOCK) != RESET) { __NOP(); // 等待解锁完成 } } }

提示:某些旧版ST标准外设库(StdPeriph)的FLASH_Unlock()漏写了while等待,导致偶发失败。本例程已补全。

4.4 现象:多任务环境下(如FreeRTOS),两个任务同时调用Flash_SaveParam(),一个写入被另一个覆盖

原因:Flash操作非可重入。擦除和写入是耗时操作(ms级),期间若被高优先级任务抢占并执行另一次Flash操作,会导致状态寄存器冲突。
解决:在flash_api.c中增加互斥锁。若使用FreeRTOS:

#include "FreeRTOS.h" #include "semphr.h" static SemaphoreHandle_t xFlashMutex = NULL; void Flash_API_Init(void) { xFlashMutex = xSemaphoreCreateMutex(); } FLASH_Status_t Flash_SaveParam(uint32_t addr, uint32_t value) { if (xSemaphoreTake(xFlashMutex, portMAX_DELAY) != pdTRUE) { return FLASH_STATUS_BUSY; } FLASH_Status_t status = Flash_WriteWord(addr, value); xSemaphoreGive(xFlashMutex); return status; }

在main()中调用Flash_API_Init()初始化信号量。

4.5 现象:用J-Link下载程序后,Flash数据区内容被清零

原因:J-Link默认擦除整个Flash(包括你的用户数据区)。虽然你只改了App代码,但下载器策略是“全片擦除”。
解决:在J-Link Commander中设置擦除范围,或在IDE中配置:

  • J-Link Commander:
    connect→erase→q(退出)→loadfile xxx.hex 0x08000000(指定起始地址)
  • Keil:Options for Target → Utilities → Settings → Erase sectors selected below→ 只勾选App区(0x08000000~0x0800FC00)
  • STM32CubeProgrammer:在“Memory loading”页,取消勾选“Erase all before programming”,改为“Erase specified sectors”,手动输入0x08000000-0x0800FBFF

5. 进阶技巧:用CRC32校验+双页备份实现永不丢失的参数存储

5.1 为什么单页存储不可靠?——掉电瞬间的“写一半”困境

即使擦除和写入都成功,最危险的时刻是写入过程中突然断电。例如你要写4字节0x12345678,硬件已写入低16位0x5678,高16位0x1234尚未写入,此时断电,读出来就是0x00005678——一个完全错误的值。单页方案对此毫无防御力。

5.2 双页镜像+CRC32校验方案:原理与实现

本例程扩展版(flash_dual_page.c)采用“主页+备份页”机制,配合CRC32校验,实现掉电安全:

  • 两页地址:主页0x0800FC00,备份页0x0800F800(倒数第二页);
  • 数据结构:每页开头存4字节CRC32,随后是有效数据;
  • 写入流程:
    1. 计算新数据CRC32;
    2. 擦除备份页;
    3. 将新数据+CRC写入备份页;
    4. 擦除主页;
    5. 将新数据+CRC写入主页;
    6. (可选)擦除备份页释放空间。

这样,即使步骤4或5中断电,下次上电时读取两页CRC:

  • 若主页CRC正确 → 用主页数据;
  • 若主页CRC错误、备份页CRC正确 → 用备份页数据,并将备份页拷贝回主页;
  • 若两页CRC均错误 → 触发恢复默认参数。

5.3 CRC32校验代码精简实现(适配Flash小内存)

为避免引入庞大CRC库,本例程采用查表法精简版(256字节ROM占用):

// crc32_table.h - 自动生成的256字节表 static const uint32_t crc32_table[256] = { 0x00000000, 0x04c11db7, 0x09823b6e, 0x0d4326d9, /* ... 共256项 ... */ }; uint32_t crc32_calc(const uint8_t *data, uint32_t len) { uint32_t crc = 0xFFFFFFFF; for (uint32_t i = 0; i < len; i++) { uint32_t idx = (crc ^ (data[i] << 24)) >> 24; crc = (crc << 8) ^ crc32_table[idx]; } return crc ^ 0xFFFFFFFF; }

在flash_dual_page.c中封装校验函数:

typedef struct { uint32_t crc; uint8_t data[1016]; // 1KB页减去4字节CRC } flash_page_t; FLASH_Status_t Flash_ReadValidPage(flash_page_t **valid_page) { flash_page_t *main = (flash_page_t*)0x0800FC00; flash_page_t *backup = (flash_page_t*)0x0800F800; uint32_t main_crc = crc32_calc((uint8_t*)&main->data, sizeof(main->data)); uint32_t backup_crc = crc32_calc((uint8_t*)&backup->data, sizeof(backup->data)); if (main_crc == main->crc) { *valid_page = main; return FLASH_STATUS_OK; } else if (backup_crc == backup->crc) { *valid_page = backup; // 自动恢复:将备份页拷贝到主页 Flash_CopyPage((uint32_t)backup, (uint32_t)main); return FLASH_STATUS_OK; } else { return FLASH_STATUS_CRC_FAIL; } }

5.4 实际部署建议:参数分区与生命周期管理

不要把所有参数塞进一个结构体。按变更频率和重要性分区:

分区地址范围更新频率是否需双页示例
高频参数0x0800FC00~0x0800FCFF秒级✅ 双页电机实时PID参数
中频参数0x0800FD00~0x0800FDFF分钟级✅ 双页传感器校准偏移
低频参数0x0800FE00~0x0800FEFF上电一次❌ 单页设备唯一ID、出厂时间

这样既能控制Flash擦写次数(F103标称10K次),又能降低双页管理开销。本例程flash_partition.h已预定义这些宏:

#define FLASH_PARAM_HIGH_FREQ_ADDR 0x0800FC00 #define FLASH_PARAM_HIGH_FREQ_SIZE 0x00000100 #define FLASH_PARAM_MID_FREQ_ADDR 0x0800FD00 #define FLASH_PARAM_MID_FREQ_SIZE 0x00000100 #define FLASH_PARAM_LOW_FREQ_ADDR 0x0800FE00 #define FLASH_PARAM_LOW_FREQ_SIZE 0x00000100

从那以后我每次做参数存储需求,都强制走一遍“分区规划→电压校验→双页CRC→擦写计数统计”四步 checklist,哪怕项目紧急也绝不跳过。因为Flash写坏的芯片,没法像软件bug那样git revert——它就静静躺在PCB上,等着你拆焊返工。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询