news 2026/10/16 1:05:16

STM32内部Flash擦写与bin文件生成:HAL库实战与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32内部Flash擦写与bin文件生成:HAL库实战与避坑指南

1. 为什么要掌握内部Flash操作和bin文件

做嵌入式开发越久,越会碰到这样的时刻:程序编译通过、下载到板子上也能跑,但你想知道这颗芯片到底被烧录了多少程序,或者想在掉电后保存一些配置参数,又或者想把OTA升级的固件缓存到某个位置——这时候就绕不开两个东西:内部Flash和bin文件。

先说Flash吧。STM32内部的Flash不像外挂的SPI Flash/W25Q系列那样需要用命令序列去操作,HAL库已经把底层寄存器操作封装成了几个看起来很简单、但实际上颇多讲究的函数。很多人只知道往死里调HAL_FLASH_Program(),结果动不动就HardFault、写不进去、或者把程序代码给擦掉了。说到底,是没搞明白Flash的物理特性:写之前必须先擦、擦的粒度是扇区/页而不是字节、写入是有位宽限制的。这三点你绕不开,理解了它们,Flash操作就成功了一半。

再说bin文件。Keil MDK默认编译输出的是axf和hex,hex文件是带地址信息的文本格式,里面有校验、有起始地址记录,能方便烧录器下载,但你要直观地看出"我这个程序实际占用多少Flash空间",hex帮不上什么忙。bin文件是纯粹的二进制镜像,从烧录起始地址开始,一个字节一个字节地"铺"下来,它的文件大小就约等于烧录到Flash里的程序实际占用空间。所以很多做OTA、做固件升级、做量产下载的工程师,都习惯用bin文件来确认程序体积、计算剩余空间、做固件传输。

这篇文章就把这两件事放在一起讲透:先带你走一遍HAL库操作内部Flash的完整流程(解锁、擦除、写入、读取、锁定),再教你在Keil MDK里一键生成bin文件的方法,最后分享一些我在实际项目中踩过的坑和排查思路。无论是刚入门HAL库的初学者,还是想在项目里灵活用Flash存数据的开发者,这篇应该都有值得你收藏的东西。

2. 内部Flash的物理结构与操作前提

2.1 为什么Flash"写前必须先擦除"

要是之前只用过外部EEPROM(比如AT24C02),你可能觉得存储器嘛,写个地址、传个数据就完了。但内部Flash不一样,它在物理上是由浮栅晶体管阵列组成的,已经编程过的位如果要从0变回1,必须通过擦除操作将电荷放掉。所以Flash的写入逻辑是:你要把某个地址的数据从0x00改成0xFF,先擦除(变成全0xFF),再编程(把需要为0的位写成0)。

这个特性直接导致两个结论:其一,擦除的最小单位是扇区(或页),不是字节,你没法单独擦一个字节,一擦就是一片;其二,如果你要修改某个扇区里的几个字节,必须先把整个扇区的内容读到RAM缓冲里暂存,修改后再整体擦除、重新写回去。很多新手不知道这个,直接往同一个扇区反复写数据,写一次擦一次,结果把自己宝贵的数据都擦没了。

2.2 不同型号的Flash布局差异

在做Flash操作规划之前,我强烈建议你先去翻一下对应芯片的参考手册,确认三件事:Flash总容量多大、扇区是怎么划分的、主存储器的起始地址是多少。

以常见的F1系列为例,比如STM32F103VET6,512KB Flash,划分成256页,每页2KB。地址从0x08000000开始,依次排下去,最后一页起始地址是0x0807F800。而F4系列就不一样了,比如STM32F407ZGT6,1MB Flash,前面4个扇区是16KB,接着1个扇区64KB,后面7个扇区都是128KB。写代码的时候如果直接把F1加2KB页的做法套到F4上,擦除参数必然出错。

再强调一点:芯片内部的Flash地址空间,就是从0x08000000开始的,这部分是程序代码存放的地方,也是我们数据存储可以使用的区域。还有一个单独的"系统存储器"和"选项字节区",一般我们用不到,但要知道它们的存在,避免操作地址越界。

2.3 规划存储区域:别把程序跑飞了

这是我最想提醒的一点。很多教程写Flash Demo,上来就选最后一个扇区或者页来存数据,这是有道理的——程序编译出来一般从0x08000000开始往上占,如果数据区选得太靠前,一旦程序体积增长到和数据区重叠,等待你的就是"上电白屏""程序莫名跑飞"之类的诡异问题。所以工程上默认的做法是:

  • 数据存储区选在Flash最高地址附近的扇区/页
  • 编译完成后查看map文件或bin文件大小,确认程序占用没有跨越到数据区
  • 数据区尽量只固定用一个扇区(页),避免频繁擦写影响整个Flash寿命

不要觉得用一个扇区浪费,普通项目存设备编号、校准参数、运行状态,一页几KB绰绰有余。数据存得稀疏一点,反而避开了和程序区的冲突。

3. HAL库操作Flash的API详解与参数选择

3.1 解锁与锁定:这不是走形式

STM32把Flash操作接口设计成"默认锁死"状态,目的是防止程序跑飞时误写Flash,把固件给搞坏。所以任何擦除和写入操作之前,必须先调用HAL_FLASH_Unlock()解锁;操作完毕,再调用HAL_FLASH_Lock()重新上锁。

/* 解锁Flash */ HAL_FLASH_Unlock(); /* ... 执行擦除、编程操作 ... */ /* 锁定Flash */ HAL_FLASH_Lock();

这两个函数的实现其实就是操作FLASH->KEYR寄存器,往里面写入固定的两个键值0x45670123和0xCDEF89AB,硬件比对通过后,Flash的CR寄存器才允许被写入控制位。你要是不解锁就直接调擦除或者编程,HAL库的接口函数内部会检测到FLASH_CR_LOCK这个位还置着,直接返回HAL_ERROR。

实际项目里我习惯把解锁放在一个独立函数里,操作完马上锁定。有些方案为了省事,前面解锁一次后整个程序都不锁了,这是有风险的——万一某个指针越界写到了Flash地址映射区,而Flash又处于解锁状态,固件可能瞬间被毁。所以我个人的经验是:擦写数据这套操作,全部封装在一个接口函数里,函数内部完成"解锁→擦除→写→锁定"的全流程,出去之后Flash永远是锁着的。

3.2 擦除操作:选对粒度和数量

擦除函数有两个参数需要重点理解:

HAL_StatusTypeDef HAL_FLASH_Erase(FLASH_EraseInitTypeDef *pEraseInit, uint32_t *PageError);

第一个参数是结构体指针,里面定义了擦除方式、起始地址、擦除的页/扇区数量;第二个参数是一个返回错误页码的指针。

FLASH_EraseInitTypeDef eraseInit; uint32_t pageError = 0; eraseInit.TypeErase = FLASH_TYPEERASE_PAGES; // F1系列按页擦除 eraseInit.PageAddress = 0x0807F800; // 从最后一页起始地址开始擦(以F103VET6为例) eraseInit.NbPages = 1; // 擦除1页 if (HAL_FLASH_Erase(&eraseInit, &pageError) != HAL_OK) { /* 擦除失败,pageError会指向出错的页地址 */ }

注意几点:

  • F1系列用FLASH_TYPEERASE_PAGES按页擦,F2/F4/L4系列用FLASH_TYPEERASE_SECTORS按扇区擦,类型名都不一样,别照搬。
  • PageAddress必须和你的擦除粒度对齐,也就是必须是页/扇区起点地址,不能随便填一个中间地址。
  • NbPages填几就把连续几页一起擦掉,我一般只擦1页,够用就行。擦大片区域虽然快,但没必要,风险也大。

3.3 编程写入:位宽是硬约束

HAL库的编程函数长这样:

HAL_StatusTypeDef HAL_FLASH_Program(uint32_t TypeProgram, uint32_t Address, uint64_t Data);

这个TypeProgram决定了一次写入的数据位宽,有三个选项:

类型宏位宽数据参数类型适用场景
FLASH_TYPEPROGRAM_BYTE8位uint8_t逐字节保存小数据
FLASH_TYPEPROGRAM_HALFWORD16位uint16_t存半字长度数据
FLASH_TYPEPROGRAM_WORD32位uint32_t最常用,存32位整数或连续数据
FLASH_TYPEPROGRAM_DOUBLEWORD64位uint64_tF4等系列支持64位编程

我看到有一些初学教程特别喜欢用FLASH_TYPEPROGRAM_WORD,写32位整数还好,要是想写一个uint8_t的数组,每次只取出一个字节就拿来编程,这种用法在个别系列上会触发对齐异常。从实际经验来看,低于位宽约束的写入会硬错误,你应付不了。所以我给个稳妥的写法:需要写入连续数据时,把整块数据都当做uint32_t数组处理,逐字写入;最后不足4字节的部分,再单独用BYTE方式补写。

举个例子,要写一串传感器校准参数结构体到Flash:

typedef struct { float kp; float ki; float kd; uint32_t crc; } CalibParam_t; CalibParam_t params = {1.5f, 0.02f, 0.8f, 0x5A5A5A5A}; uint32_t flashAddr = 0x0807F800; uint32_t *pData = (uint32_t *)&params; uint16_t wordCount = sizeof(CalibParam_t) / 4; HAL_FLASH_Unlock(); for (uint16_t i = 0; i < wordCount; i++) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, flashAddr + i * 4, pData[i]) != HAL_OK) { /* 写入失败,务必记录日志 */ break; } } HAL_FLASH_Lock();

这里有个很重要但容易被忽略的点:Flash写入地址必须是和位宽匹配的。比如按32位编程,地址必须4字节对齐;按8位编程,地址随意。你写float数组时如果结构体成员顺序排得不对,编译器在结构体里插入填充字节,sizeof算出来不是4的整数倍,按4字节搬运就会导致越界。所以准备存Flash的结构体,我建议用__attribute__((packed))或者把成员设计成全部4字节对齐,省去这些麻烦。

3.4 读取操作:直接指针访问

读取是Flash中最普通的操作,不需要任何解锁,也不需要HAL库函数,直接拿指针读就行了:

const CalibParam_t *pStored = (const CalibParam_t *)0x0807F800; float kp = pStored->kp;

很多人问"HAL库怎么没有Flash读取函数",因为Flash本质上就是内存映射的存储介质,0x08000000这一段CPU可以直接寻址。读取就像读const数组一样快。只有外部Flash才需要spi读写命令。这一点理解之后,你就会发现Flash读取逻辑其实非常简单。

4. 完整实操:在STM32上实现Flash擦写读Demo

4.1 目标与硬件准备

为了把前面的API串起来,我带大家做一个完整的Demo。目标如下:

  • 程序启动后,检查Flash存储区是否已有有效数据(用一个magic标志判断)
  • 如果没有,初始化一组默认校准参数,写入Flash
  • 如果有,直接读取显示
  • 提供一个按键手动触发"重新写入"功能,验证多次擦写

硬件方面,我用的是STM32F103VET6核心板(512KB Flash)+一个按键,串口1输出调试信息。没有按键也无所谓,你可以通过串口命令触发写入。F103VET6的最后一页起始地址是0x0807F800,页大小2KB,这个区域的2500个字左右的空间,足够存几个结构体了。

4.2 代码结构与初始化

我习惯把Flash的擦写逻辑独立成一个模块,不跟业务代码混着写。新建flash_storage.c和flash_storage.h,对外只暴露两个接口:

uint8_t FlashStorage_Init(CalibParam_t *outParams); void FlashStorage_Save(const CalibParam_t *inParams);

设计思路非常简单:Init函数初始化Flash,读出数据;如果magic不对,就用默认值填充并调用Save写一次。Save负责"查扇区偏移量 → 擦除整页 → 写数据 → 再读回校验"全套流程。

4.3 关键代码实现

先用一个宏定义存储区地址和magic值:

#define FLASH_STORAGE_ADDR 0x0807F800u #define STORAGE_MAGIC 0xA5A5A001u typedef struct { uint32_t magic; uint32_t deviceSN; float calibKp; float calibKi; float calibKd; } StorageData_t;

注意我把magic放在结构体第一个字段,这样读取时先判断它:

uint8_t FlashStorage_Init(StorageData_t *outParams) { StorageData_t *p = (StorageData_t *)FLASH_STORAGE_ADDR; if (p->magic != STORAGE_MAGIC) { /* 首次上电,写入一份默认数据 */ StorageData_t defaults; defaults.magic = STORAGE_MAGIC; defaults.deviceSN = 0xDEADBEEF; defaults.calibKp = 1.0f; defaults.calibKi = 0.1f; defaults.calibKd = 0.0f; FlashStorage_Save(&defaults); memcpy(outParams, &defaults, sizeof(defaults)); return 0; } memcpy(outParams, p, sizeof(*p)); return 1; }

FlashStorage_Save实现擦写:

void FlashStorage_Save(const StorageData_t *inParams) { /* 先把整页数据读出来 */ StorageData_t tmp; memcpy(&tmp, (void *)FLASH_STORAGE_ADDR, sizeof(tmp)); /* 更新要修改的内容 */ tmp = *inParams; /* 擦除整页 */ FLASH_EraseInitTypeDef eraseInit = {0}; eraseInit.TypeErase = FLASH_TYPEERASE_PAGES; eraseInit.PageAddress = FLASH_STORAGE_ADDR; eraseInit.NbPages = 1; uint32_t pageErr = 0; HAL_FLASH_Unlock(); if (HAL_FLASH_Erase(&eraseInit, &pageErr) != HAL_OK) { HAL_FLASH_Lock(); return; } /* 按32位写入 */ uint32_t *words = (uint32_t *)&tmp; uint16_t count = sizeof(StorageData_t) / 4; for (uint16_t i = 0; i < count; i++) { if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, FLASH_STORAGE_ADDR + i * 4, words[i]) != HAL_OK) { HAL_FLASH_Lock(); return; } } HAL_FLASH_Lock(); /* 读回校验 */ StorageData_t *verify = (StorageData_t *)FLASH_STORAGE_ADDR; if (verify->magic != tmp.magic || verify->deviceSN != tmp.deviceSN) { /* 校验失败,说明Flash可能存在问题 */ } }

注意我在Save过程中,先把待写数据放到了局部变量tmp里,确保整个结构体的内容是连续的。因为擦除后Flash是全0xFF,假如你是从原Flash里直接改数据再写回去,可能会出现"旧残留数据 + 新写入数据"混合的脏数据。先拷贝再擦除再写入这个顺序是我自己吃了亏才改过来的习惯。

4.4 主程序调用与验证

主逻辑简单直接:

int main(void) { HAL_Init(); SystemClock_Config(); MX_USART1_UART_Init(); MX_GPIO_Init(); StorageData_t params; uint8_t isStored = FlashStorage_Init(&params); char msg[64]; sprintf(msg, "Flash init: stored=%d SN=0x%08lX KP=%.2f\r\n", isStored, params.deviceSN, params.calibKp); printf("%s", msg); while (1) { if (按键按下) { params.deviceSN++; FlashStorage_Save(&params); printf("Data saved, SN=%ld\r\n", params.deviceSN); } HAL_Delay(20); } }

上电第一次运行,串口会打印stored=0,说明是初始状态;按键之后SN自增,再次上电会打印stored=1且SN保持上次的值,就说明数据成功保存在Flash里了。我跑通这个Demo后,最直观的感受是:数据掉电不丢失的效果立竿见影,而且Flash读写速度极快,跟外部EEPROM相比简直是"瞬间保存"。

5. 生成bin文件:一键搞定程序大小统计

5.1 为什么看hex不直观

很多初学者习惯每次编译完看"Program Size: Code=xxx RO-data=xxx RW-data=xxx",或者直接把hex文件拖进烧录工具里下载。但实际工作中,尤其是做量产、做OTA的时候,大家关心的是"完整固件镜像文件多大",而这个文件就是bin。

hex文件是文本格式,里面记录了从哪个地址开始写多长一段数据。它包含了类似:020000040800F2这种行格式,有起始地址、长度、类型、数据和校验。烧录器能解析它,但文件本身的大小跟实际程序大小没有直接对应关系。bin文件就纯粹多了,它就是Flash里从起始地址开始的内容原样导出,bin文件体积 = 从起始地址到最后一个有效字节的程序占用空间,直观得不能再直观。

举个例子:你烧录一个20KB的程序到0x08000000,bin文件就是20KB大小;hex文件可能是50KB、80KB甚至更大,因为地址信息、校验、换行符全算进去了。

5.2 Keil MDK生成bin文件的完整配置

Keil MDK没有界面上的"输出bin"打勾选项,要在编译后自动调用fromelf.exe工具来转换。配置步骤如下:

  1. 打开工程,点击菜单栏的Options for Target
  2. 切换到User选项卡
  3. 在After Build/Rebuild区域,勾选Run #1
  4. 填写命令行(关键):
C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin -o .\Output\demo.bin .\Output\demo.axf

注意两个要点:

  • fromelf.exe的完整路径根据你的Keil安装目录调整。如果用AC5编译器,就在ARM\ARMCC\bin\;如果用AC6编译器,路径类似C:\Keil_v5\ARM\ARMCLANG\bin\fromelf.exe
  • -o后面是输出bin文件的路径,.\Output\demo.bin表示输出到Output目录
  • 最后一个参数是axf文件的路径,一般在工程的Output目录里

配置好之后,每次编译或者Rebuild,编译结束MDK都会自动执行这个命令。打开Output目录,就能看到生成的bin文件了。

如果你在用命令行构建,也完全可以把这个命令写进构建脚本里,效果是一样的:

fromelf.exe --bin -o build/app.bin build/app.axf

5.3 用bin文件核对程序大小

生成bin之后,在Windows资源管理器里右键查看属性,就能看到文件大小。也可以把bin文件拖到任意十六进制编辑器里,看它的最后有效数据是什么。

这里有一个非常有用的换算关系:bin文件大小四舍五入到KB,就是你的程序占用的Flash空间。比如bin文件是18324字节,说明程序占用了约18KB的Flash。对照你芯片的Flash总容量,立刻知道剩余空间还够不够用。

配合第4节的存储区规划,你还能做一个更精确的验证:程序bin大小 < 数据区起始地址相对于0x08000000的偏移,这样数据区就不会跟程序区打架。比如F103VET6的数据区在0x0807F800,这个地址偏移是0x7F800= 522240字节 ≈ 510KB。如果bin文件大于510KB,说明程序已经顶到数据区了,这是极其危险的信号。我自己在一个项目里就遇过类似情况,当时程序没做边界检查,编译也一直正常,结果某次加了新功能后程序莫名跑飞,查了三天发现是Flash数据区被代码覆盖了一部分——那种痛,经历过一次就再也不想有了。

5.4 其他获取bin文件的方法

除了fromelf转换,还有几种方式能拿到bin文件,按场景选用:

  • STM32CubeProgrammer:连接芯片后,可以读取整个Flash内容并导出为bin文件。这个方式适合做固件备份、分析已烧录产品的实际程序内容。
  • 烧录工具导出:有些第三方的下载算法支持"读取然后另存为bin",本质和上面一样。
  • 编译脚本/GCC工具链:如果工程已经切到GCC工具链,用objcopy -O binary app.elf app.bin就可以得到bin文件,一行命令的事。

这里补一个技巧:用STM32CubeProgrammer读取芯片内容时,最好把起始地址设置成0x08000000,长度设置成你要读取的范围。如果只读程序区,bin大小会比你实际程序大很多,因为它把中间空洞(比如Flash中全0xFF的部分)也一起读出来了。此时要拿bin大小来评估程序体积,就得靠fromelf这种从axf直接生成的方式,而不是读全片。

6. 实用技巧与避坑经验汇总

6.1 Flash操作失败的排查思路

实际开发中,Flash操作出问题是很常见的。我整理了一份排查清单,按出现频率排个序,碰到问题先对着查:

症状可能的根因解决办法
写入返回HAL_ERRORFlash未解锁检查有没有先调用HAL_FLASH_Unlock()
写入返回HAL_ERROR操作前没有先擦除必须先调用HAL_FLASH_Erase(),且确认擦除返回HAL_OK
写入返回HAL_ERROR地址超过Flash容量核对芯片Flash边界地址,别超界
擦除返回HAL_ERROR擦除粒度类型不对F1用FLASH_TYPEERASE_PAGES,F4用FLASH_TYPEERASE_SECTORS,确认清楚
HardFault写入地址没有对齐32位写入地址必须4字节对齐;16位写入地址必须是2字节对齐
HardFault从Flash读取时地址非法确认读取地址在有效Flash映射范围内
上电数据丢失存储区被程序覆盖编译后看bin大小,确认数据区偏移大于bin大小
数据写不全结构体有填充字节用packed或保证结构体size是4的整数倍

这些都是我踩过或者帮别人排查过的坑,最典型的就是"写入地址没对齐"和"忘了擦除"。时钟上还有个细节:有些系列(比如F0/G0)的Flash擦写期间必须保证CPU不能从Flash取指令,要用RWW或者分扇区设计才能边擦边执行。前期学习阶段只要不在Flash擦写中断里放耗时中断回调,基本不会遇到。

6.2 数据对齐与校验设计

前面多次提到对齐,这里我再展开讲一下。Flash编程的最小粒度是字节,但很多芯片限制单次编程的最小位宽。STM32F1系列单次编程可以是8位、16位、32位(取决于寄存器位宽和硬件支持);F4系列还有64位编程模式。当你用32位模式写4字节时,这4字节所在的地址必须是4的倍数。地址0x0807F800是4的倍数,但0x0807F801不是,你往这个地址写32位数据,控制器根本不会响应,直接总线错误。

如果你的结构体里有char类型字段,那么结构体成员之间可能被编译成插入填充字节。比如:

typedef struct { uint8_t flag; uint32_t value; } BadStruct_t; // sizeof=8,但flag后面有3个填充字节

这个value字段在结构体内部的偏移是4,sizeof结果是8,看起来似乎没问题。但如果你把结构体按4字节搬运到Flash,写入是从起始地址开始的连续4字节和接着4字节——这里包含的填充字节是你没有显式初始化的,很可能随机值。所以要么用__attribute__((packed))来消除填充,要么读回校验的时候忽略填充区。我个人的风格是存储内容全部设计成4字节对齐类型。

校验方面,用magic字段判断Flash是否已初始化是一个通用办法。在线升级场景里,我还会在固件末尾放crc32,下载完先校验crc再决定要不要跳转。Flash里存重要参数也是同样的思路:结构体最后放一个CRC,读取时算一遍,不匹配就说明数据被破坏或者写入不完整,可以回退到默认参数。

6.3 擦写寿命与缓冲区设计

内部Flash的擦写寿命一般标称1万次(数据手册上通常写10K次,部分系列能到100K次)。1万次听起来不少,但如果你的程序每次上电都调用FlashStorage_Init,而Init逻辑里一旦发现magic不匹配就写入一次——这种操作如果因为其他原因反复触发,很快就把寿命耗掉。

所以我有个习惯:写入频率严格受控。比如环境监测设备,每5分钟存一次采样点,一天288次,一年不到10万次,相当危险。这时候方案就不能是每五分钟擦一次Flash,而应该改成:用户配置类数据只在参数变化时保存;日志类数据选外部Flash或小容量的EEPROM;如果必须频繁存Flash,可以考虑多备份+轮流写入的策略,让每个扇区的磨损平均化。

有经验的工程师还会在Save函数里加一个"数据变化检测":要写入的新数据和Flash里现有数据完全相同,就直接跳过擦写过程。这个优化逻辑简单,却能大幅减少无意义的擦写次数。

7. 实操中的进阶思考

讲到这里,Flash基本操作和bin生成方法就说完了。不过我还要啰嗦几句进阶的思路,因为实际产品开发和简单Demo差距很大。

第一,把Flash驱动封装成"配置存储中间层"。不要在业务代码里到处散落HAL_FLASH_Program,建议统一封装。一个产品至少要有两种配置:出厂默认参数和用户运行时参数。设计时可以让出厂参数放在只读区域,用户参数放在可擦写区域,通过版本号区分。这样即便用户参数区被写坏了,还能从出厂区恢复。我最近一个项目就是这么做的,后期排查问题省了大力气。

第二,明白bin文件在OTA流程中的作用。OTA的本质就是从外部介质下载一个新固件bin,校验通过后,把它写入到内部Flash的应用程序区,然后跳转执行。这过程里,你要擦除的扇区数、要写入的地址范围,全部依赖bin文件大小。如果你不会生成bin,OTA几乎无从谈起。所以学Flash的时候顺便学bin,不是浪费时间,而是为后面做固件升级打基础。

第三,用bin文件做量产一致性检查。同样的代码在不同批次硬件上出现一个能跑一个不能跑的情况,我会将两片芯片的Flash内容分别导出成bin,hex比较后往往能发现差异。尤其是数据区,如果一个芯片的数据区已经写过但另一个是出厂空白,行为就会不同。把bin当作"固件指纹",排查问题会快很多。

这里再分享一个实用的终端思路——用fromelf生成bin文件后,在Post-build里顺便生成一个大小记录文件:

fromelf.exe --bin -o .\Output\demo.bin .\Output\demo.axf powershell -Command "(Get-Item .\Output\demo.bin).Length" > .\Output\size.txt

这样每次编译自动把程序大小写进size.txt里。做CI构建或者记录版本信息的时候,把这个值嵌入到生成的说明文档里,整个团队就能一眼看出固件体积变化趋势,非常方便。

8. 常用问题速查与我的心得体会

8.1 常见问题速查表

我见过太多人卡在Flash操作上,这里把最容易踩的几个点再集中整理一下,方便你工作中遇到问题时快速定位:

场景具体操作易错点提示
先解锁再操作HAL_FLASH_Unlock()每次上电后Flash默认锁定,忘解锁是最常见的低级错误
擦除按扇区/页HAL_FLASH_Erase()擦除粒度和类型宏必须匹配;地址必须是页/扇区起始地址
写入按位宽HAL_FLASH_Program()32位写地址必须4字节对齐,数据位宽、地址、函数参数三者要一致
读取直接访问指针强转地址要在有效Flash映射区间内,否则HardFault
生成binfromelf --bin注意路径与编译器版本;输出目录要存在
核对程序大小查看bin字节数结合芯片Flash容量判断剩余空间;数据区地址必须大于bin大小

8.2 我踩过的一些具体坑

写这一节,想想还是把自己实操中的经验写出来,多数是常规文档不会提的。

坑一:写入数据之前忘了把缓冲区清干净。我在一个项目里要从Flash读出一块老数据,修改其中一个字段后再写回去。直接拿老数据指针改了其中一个值就调用擦写,结果写回去的数据里包含了老数据里残留的脏字节,导致新结构体里某个字段出现了乱码。后来改成"先拷贝到RAM的零初始化数组,再修改,再擦写",再也没出过问题。

坑二:分扇区编程模式搞混。我在F407上写代码,沿用F1的FLASH_TYPEERASE_PAGES,结果编译没错,运行直接HardFault。查了半天手册才反应过来F4只能用FLASH_TYPEERASE_SECTORS。所以在换芯片平台时,Flash驱动代码务必重写,不能只改个芯片型号就完事。

坑三:从axf转bin时路径写错。这个纯粹是手滑。fromelf命令里如果-o后面路径不存在,编译虽然提示命令执行失败,但Keil有时候不会标红,只是Output窗口里刷了一行错误。建议把bin输出路径跟axf输出路径放在同一个目录,少一层子目录就少一次出错机会。

坑四:用hex文件大小评估程序空间。这个错法是很多教程误导出来的。hex文件每行都有地址、长度、校验,实际文件体积远大于程序真实大小,拿它估算Flash余量极不靠谱。要评估就生成bin,或者看编译输出窗口里的Code + RO-data + RW-data。

8.3 几句实在话

内部Flash在HAL库的加持下,操作其实不复杂,但正因为API简单,很多人忽略了背后那套"擦除粒度、位宽对齐、地址规划"的约束。我个人的习惯是:改动任何涉及Flash的代码时,先翻对应型号的参考手册,确认Flash结构图,再动手写代码。宁可多花十分钟读手册,也比写完跑不通再排查半天省时间。

bin文件的生成,说到底就是编译链路上的一件小事,但它能带来的工程收益是实打实的——你能直接看到程序体积涨了多少,知道Flash剩余空间还够不够,做OTA能精确算出下载包大小。建议所有用MDK做开发的同学,都把生成bin这个步骤配上,形成习惯。

后面如果你自己去做Bootloader、做IAP升级、做参数掉电保存,会发现今天学的这套东西全都用得上。先把手头的Flash操作Demo跑通,再顺手生成一份bin文件看看你的程序到底多大,成就感会比想象中来的快很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/16 1:04:46

哈夫曼树与哈夫曼编码详解:从手算建树到编码解码全流程

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:03:26

FFmpeg编译实战:从音视频编码基础到依赖库集成

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:02:55

24岁全款买车:从攒钱到养车的实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:01:52

H5样本工厂:轻量级深度学习训练样本生产方法论

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:01:29

矩阵连乘问题:动态规划解法与区间DP思想详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/16 1:01:29

Proteus仿真51单片机定时器计数器实验:初值计算与中断调试详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华