内部Flash,HAL库,bin文件——这三个词放在一起,就是很多单片机学习者从“能跑程序”走向“会做产品”的一道分水岭。我最近在整理HAL库系列学习笔记,第十一篇正好写到内部Flash的读写,顺手把怎么生成bin文件、估算程序占用Flash大小也聊透。这篇文章拿一个保存用户参数的场景把两部分串起来:第一部分先搞懂Flash为什么先擦后写、HAL库的读写API怎么用,第二部分讲清HEX、AXF、BIN的区别,然后在MDK里配置生成bin文件。
做嵌入式开发做到一定阶段,手里总有几个项目会出现这种尴尬:用户配置要保存,掉电不能丢;标定系数要调整,每台设备的数值还不一样;又或者产品要OTA升级,得先弄清楚现在编译出来的程序到底有多大。我以前一直把这些事拆开处理,后来发现它们都绕不开两个基础技能——操作内部Flash,以及通过bin文件评估固件体积。这篇笔记就专门把这两件事放在一起讲,适合刚学完HAL库基础外设、想继续深入存储和固件管理的开发者,也适合做BootLoader之前先补课的朋友。
1. 内部Flash到底能干什么?它和EEPROM、外部Flash不是一回事
1.1 你可能一直小看了Flash
很多朋友用单片机第一年,对Flash的印象就是“下载程序时它用来存代码”。确实,芯片上电后CPU从Flash取指令执行,但Flash并不只是“只读”的存在。只要芯片支持,在代码运行过程中同样可以解锁Flash、擦除扇区、写入数据,下次上电之后这些数据依然还在。这就是内部Flash除了跑程序之外的另一个价值:掉电不丢的存储空间。
我见过不少产品把用户参数、校准系数、设备序列号、运行日志甚至OTA标志直接放在内部Flash里。原因很简单:不加任何外部芯片,不增加PCB面积和BOM成本,读取的时候还特别快。而且现在很多MCU内部没有真正意义上的EEPROM,或者EEPROM容量小得可怜,这时候用内部Flash来模拟一段非易失存储,就是性价比最高的方案。
1.2 一张表看懂Flash和EEPROM的差异
我们常说“Flash模拟EEPROM”,那它和真正的外挂EEPROM有什么区别?我整理了一张表,方便新手建立概念:
| 对比项 | 内部Flash | 外挂I2C EEPROM(如24系列) | 外挂SPI Flash |
|---|---|---|---|
| 数据通路 | 内部总线直接访问 | I2C串行通信 | SPI串行通信 |
| 读取速度 | 最快,可当内存读 | 慢,按地址读 | 中等,页读为主 |
| 擦写粒度 | 扇区/页,通常几KB到几十KB | 字节级,灵活 | 扇区级,一般4KB |
| 擦写寿命 | 通常10万次左右 | 通常100万次 | 通常10万次 |
| 接线难度 | 无需外接 | 需要两根线 | 需要四根线 |
| 适合场景 | 程序存储+批量参数保存 | 小数据、频繁改写 | 字库、固件备份、大数据 |
这张表最关键的一点是擦写粒度。内部Flash的最小擦除单位是一个扇区,可能是1KB、4KB、8KB甚至128KB,而不像EEPROM那样可以单独改一个字节。这就意味着,哪怕你只想改掉一个参数,也得把整个扇区先擦掉再重新写入。这也是后面代码里最大的坑。
1.3 想用Flash存数据,先接受三条物理规则
我在带新人时经常说,操作Flash之前先背三条规则:
第一,写入只能把1变成0,不能把0变成1。也就是说,如果Flash某一位当前是0,你想直接写成1,硬件上是做不到的。必须先执行擦除操作,把整个扇区恢复成全1,即0xFF,然后才能写。这和RAM完全不同,很多人第一次写Flash失败就是因为没擦。
第二,擦除的最小单位是扇区,不是字节。哪怕你只想清掉一个字节,也必须把所在扇区全体擦一遍。所以设计数据存储结构时,要尽量让一个扇区只承载一类数据,避免频繁擦除大扇区。
第三,擦写寿命有限。内部Flash的擦写寿命通常在1万到10万次之间,不同芯片差异很大。你不可能像用RAM一样高频地写Flash,否则很快把扇区磨损。产品里如果用Flash保存频繁变化的数据,必须自己做磨损均衡,或者把“写Flash”这种操作设置得非常谨慎。
理解了这三条,后面的HAL库操作代码就顺理成章了:先解锁,再擦除,再编程,最后上锁。
2. Flash操作的底层逻辑:先擦后写,还有最小单位这个坑
2.1 从“橡皮擦作业”理解为什么必须先擦后写
我在课堂和群里都爱打一个比方:内部Flash就像一张写满了铅笔字的白纸,铅笔写上去容易,但想把某个字改成另一个字,尤其是把浓重的笔画擦没,你没法用笔尖直接刮掉,必须用橡皮擦。Flash里的“橡皮擦”就是擦除操作,而且这块橡皮很大,一擦就是一整块区域。
你可能会问:为什么Flash不设计成直接改写呢?这要从存储单元的物理结构说起。Flash的存储单元基于浮栅晶体管,写入是通过隧穿效应把电荷注入浮栅,而擦除是把电荷从浮栅中拉走。硬件上,“注入电荷”只能让存储状态从1变成0,而“拉走电荷”需要更高的电压和更复杂的机制,所以只能通过擦除实现。芯片设计时把大量单元连成片,共享擦除高压电路,于是最小擦除单位就成了一个扇区。
这个原理具体到代码上,就是HAL库里的HAL_FLASHEx_Erase和HAL_FLASH_Program两个核心函数。
2.2 翻开芯片手册,先确认扇区表和容量
不管是哪家芯片,做Flash读写之前,第一步永远都是看手册,查三个东西:Flash起始地址、总容量、扇区划分表。以常见的Cortex-M系列MCU为例,应用Flash通常从0x08000000开始,但容量从64KB到1MB不等,扇区划分也差异巨大。有些芯片前面几个扇区是1KB,中间4KB,后面32KB;有些则整个都是128KB的大扇区。
我一般会画一张地址草图,左侧程序区,右侧数据区。程序从首地址开始加载,按照链接脚本和编译结果连续占用,数据区则放到Flash末尾的最后一个扇区,或者至少放到程序当前结束地址之后的扇区。这样最不容易冲突,也符合程序向后增长的习惯。
如何确认程序区到哪结束?编译之后打开map文件,搜索Total RW Size之类的字段,或者直接看__initial_sp和代码段结束地址,就能估算。更省事的办法是把数据区固定在最后一个扇区,不管程序涨多大,只要还没超过末尾扇区起始地址,就不会撞车。这比把数据区放在中间要安全得多。
2.3 读Flash和写Flash的“待遇”完全不同
内部Flash的读取,在绝大多数情况下是和普通内存一样直接寻址的。比如你想读地址0x08080000处的数据,在C语言里写*(uint32_t *)0x08080000就行。不需要解锁,不需要调用任何HAL库函数,CPU可以直接从Flash读取内容。这也是前面说“读取方便”的原因。
但写Flash就不一样了。为了防止程序跑飞后乱改存储,芯片默认会把Flash置于写保护状态。在调用擦除或编程操作前,必须通过HAL_FLASH_Unlock()解锁,操作完成后再用HAL_FLASH_Lock()锁上。HAL库的这两个函数不仅仅是在寄存器里写一个标志位,它还会检查解锁键序列、等待Flash状态机就绪,内部逻辑比较多,所以一旦调用失败,返回值会直接反映问题。我习惯在调试阶段把每个HAL函数的返回值打串口或者用调试器看,而不是忽略返回值。
解锁之后,真正干活的是HAL_FLASHEx_Erase和HAL_FLASH_Program,下面一章直接上代码。
3. 用HAL库把用户参数写进内部Flash:一封可直接抄的代码
3.1 先规划一个“用户参数区”的地址
我建议在工程头文件里把地址写清楚,比如:
#define APP_FLASH_START 0x08000000U /* 芯片Flash起始地址 */ #define USER_DATA_SECTOR 10U /* 数据区所在扇区,按芯片实际情况修改 */ #define USER_DATA_ADDR 0x08080000U /* 数据区起始地址 */ #define USER_DATA_MAGIC 0xA5A5A5A5U /* 校验魔数,用于判断数据是否有效 */这个0x08080000不是所有芯片都有效,我只是拿一个中等容量芯片的末尾地址举例。你拿到自己的板子后,一定要先看数据手册确认。方法很简单:用调试器打开Memory窗口,输入你怀疑的Flash末尾地址,看看读回来是不是全是0xFF。如果全是0xFF,说明这块区域还没有被程序占用,可以用来放数据。
还要注意扇区号。HAL库为每个扇区定义了常量,比如FLASH_SECTOR_10,你在擦除代码里必须填对。这里最容易出的问题是,不同型号芯片的扇区编号和地址映射不一样,同样的FLASH_SECTOR_10在不同芯片上可能对应完全不同的地址。所以定义地址和扇区号的时候,一定要跟芯片手册对照。
3.2 擦除扇区:不只是调一个函数
下面是一段完整的扇区擦除代码,直接可以抄进工程改一改:
static void UserData_EraseSector(void) { FLASH_EraseInitTypeDef erase_cfg = {0}; uint32_t sector_error = 0; HAL_FLASH_Unlock(); erase_cfg.TypeErase = FLASH_TYPEERASE_SECTORS; erase_cfg.Sector = USER_DATA_SECTOR; erase_cfg.NbSectors = 1; erase_cfg.VoltageRange = FLASH_VOLTAGE_RANGE_3; if (HAL_FLASHEx_Erase(&erase_cfg, §or_error) != HAL_OK) { /* 擦除失败,sector_error会指示出错的扇区号 */ } HAL_FLASH_Lock(); }这段代码看起来简单,但有两个细节值得说明。第一,VoltageRange这个字段不是选电压,而是选择芯片内核电压范围,不同系列填法不同,但常见的Cortex-M系列支持范围3,照抄通常没问题。第二,HAL_FLASHEx_Erase内部会阻塞等待Flash操作完成,如果你担心看门狗或者长中断,可以在函数里加一些标志位,但别把写Flash放在时间极其苛刻的地方。
擦除不是越快越好。擦除期间CPU会等着,如果你在擦除期间发生复位,很可能留下一个擦了一半的扇区,导致后读出来是一堆半FF半00的无意义数据。所以产品级的代码一般会先记录状态标志,再执行擦除,擦完再把标志置为成功。
3.3 用HAL_FLASH_Program写入结构体数据
擦完之后,扇区全变成0xFF,这时就可以写了。HAL库提供了HAL_FLASH_Program函数,支持按字节、半字、字、双字编程。我的习惯是尽量按字或双字写,速度和可靠性都比按字节好。
假设我们要保存这样一个结构体:
typedef struct { uint32_t magic; /* 魔数 */ uint16_t version; /* 版本号 */ uint16_t length; /* 数据长度 */ uint32_t baudrate; /* 串口波特率 */ float kp; /* 某控制参数 */ } UserParams;那么写Flash的代码可以这样写:
HAL_StatusTypeDef UserData_Write(const UserParams *params) { HAL_StatusTypeDef status = HAL_OK; uint32_t addr = USER_DATA_ADDR; uint32_t *src = (uint32_t *)params; uint32_t word_count = sizeof(UserParams) / 4; uint32_t i; HAL_FLASH_Unlock(); for (i = 0; i < word_count; i++) { status = HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr, *src++); if (status != HAL_OK) { break; } addr += 4; } HAL_FLASH_Lock(); return status; }这里的word_count必须保证结构体大小是4的整数倍。如果结构体里有奇数个uint16_t或者有char,编译器可能插入填充字节,sizeof结果还是4的倍数,但你在别的平台解析时可能会有对齐问题。我一般会在结构体定义后加一句_Static_assert(sizeof(UserParams) == 16, "size mismatch");,编译期验证大小。
为什么用字写而不是双字?因为双字要求8字节对齐,而结构体里的数据往往不能保证8字节对齐。字对齐只要4字节对齐就行,Cortex-M的Flash编程硬件通常天然支持。如果你确定地址是8字节对齐,也可以用FLASH_TYPEPROGRAM_DOUBLEWORD一次写64位,速度更快。
3.4 读取与校验:Flash里到底写对了没有
读取比写入简单太多,直接指针解引用即可:
const UserParams *p = (const UserParams *)USER_DATA_ADDR; if (p->magic == USER_DATA_MAGIC) { printf("params valid, baud=%lu, kp=%.2f\r\n", (unsigned long)p->baudrate, p->kp); }这就是读Flash的数据。因为HAL库没有提供Flash的“读函数”,Flash区域就像普通内存一样映射在地址空间上。但要注意,魔数只是很粗糙的校验,实际产品还会加CRC或者校验和。我见过不少项目只判断magic,结果flash写了一半或者被干扰,魔数凑巧对了,参数却乱套。如果你保存的数据很重要,建议至少加一个简单的CRC8/CRC16,写入结构体时把校验值填进去,读取时重新计算对比。
这里也推荐一个辅助手段:调试器Memory窗口。你可以在仿真暂停时,输入0x08080000,直接看到Flash里的字节。小端模式下,0xA5A5A5A5在内存窗口里显示为A5 A5 A5 A5,如果结构体里的baudrate是0x0001C200,会显示00 C2 01 00。这样能直观验证写入结果,也方便排查对齐问题。
4. 编译产物里的大学问:HEX、BIN、AXF到底谁代表程序真实大小
4.1 每个嵌入式工程师都应该关心“固件有多大”
我刚开始写单片机时,从来不看编译产出的大小,反正能下载能跑就行。直到有一次做产品选型,芯片Flash就64KB,程序却越写越大,一编译发现爆了,才开始回头研究固件体积。后来又做OTA升级,上位机要把固件分包发给设备,这时候必须知道BIN文件的确切大小,因为分包数量、CRC算法、Flash写入策略全都要根据它来计算。
如果你不做OTA,只看bin大小也能帮你判断Flash剩余空间。比如芯片Flash 128KB,bin文件15KB,你心里就有数,剩下的空间还能放字库、放升级缓存区,或者继续加功能。这个“看大小”的行为应该成为日常习惯。
4.2 AXF、HEX、BIN三者的区别
很多新手在工程目录里看到三个后缀名,不知道谁是谁。我列个表:
| 格式 | 内容 | 文件体积 | 能否直接看程序大小 | 常见用途 |
|---|---|---|---|---|
| AXF | ELF格式,包含调试信息、符号表、重定位 | 最大 | 不能,体积虚胖 | 调试器加载,断点、变量查看 |
| HEX | Intel HEX文本格式,每行有地址和数据 | 中,约为BIN的2~3倍 | 文件大小不可直接代表固件大小 | 下载器烧录,大部分J-Link/ST-Link支持 |
| BIN | 纯二进制镜像,从起始地址开始连续存放 | 最小,接近真实固件数据量 | 可以,文件字节数就是需要写入Flash的字节数 | 量产烧录、OTA升级、体积评估 |
有人习惯用HEX文件大小乘一个系数去估计固件大小,这非常不准。HEX是ASCII文本,每行有冒号、长度、地址、数据类型和校验,哪怕真实数据只有1KB,HEX也可能有3KB以上。直接用BIN文件看大小,才是最简单可靠的做法。
AXF文件更不能拿来当大小参考,它里面有完整的调试符号和字符串表,体积比实际固件大得多。调试器能用AXF加载符号,但烧录到Flash的是AXF解析出来的加载段,不等于AXF文件本身。所以选型时别看AXF体积吓人。
4.3 MDK里配置命令,让每次编译自动产出bin
在MDK(Keil)里生成BIN文件,最常用的方法是设置一条“编译后命令”。我用的步骤是:
- 打开Options for Target。
- 切到User标签页。
- 在After Build/Rebuild下面的Run #1输入框里填命令。
- 重新编译,看Build Output窗口有没有报错。
- 去工程输出目录找生成的bin文件。
关键就是这条命令,两种工具链写法不完全一样:
AC5编译器:
fromelf.exe --bin -o "$L@L.bin" "#L"AC6编译器:
"$K\ARM\ARMCLANG\bin\fromelf.exe" --bin -o "$L@L.bin" "#L"如果你安装了ARMCC,也可以直接用绝对路径:
C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin -o "$L@L.bin" "#L"这里$L@L.bin的意思是:生成一个与链接器输出文件同名的bin文件,放在同一个目录。#L代表链接器输出的axf文件完整路径。如果路径里带空格,必须用双引号包住。
我实际用下来的感觉是,优先用fromelf.exe不带路径,MDK会自动去当前工具链目录找。如果命令没生效,再换成绝对路径。有时候你装了AC5和AC6共存,MDK默认的fromelf不一定是你想要的那一个,这时候显式写路径最稳。
注意:如果你用的是AC6,工具链目录是ARMCLANG,不是ARMCC,千万别照抄老教程里的路径。还有,文件名里不要带空格,有些SDK工程名喜欢带“-”或者中文,生成的bin文件名也可能有这些字符,产线脚本处理起来容易出问题。
4.4 bin文件大小怎么换算成Flash占用
生成bin之后,右键属性就能看到“大小”和“占用空间”。我一般只看“大小”,单位是字节。比如显示15284字节,那这个程序大约需要14.9KB的Flash空间。
但这里有个容易误解的点:bin文件大小是程序数据本身需要的字节数,不等于Flash擦除后占用的扇区总和。比如程序15284字节,芯片Flash最小扇区是4KB,那它至少会占4个扇区,也就是16KB。中间有几百字节的余量是扇区对齐造成的,不是“漏洞”,而是Flash物理结构决定的。这个差额在OTA差分包设计时必须注意。
所以在设计方案时,我会把“Flash可用空间”理解为:总容量减去向上取整到扇区边界的程序占用。比如128KB Flash,程序15284字节,按4KB扇区算,程序占4×4=16KB,剩余112KB。如果按bin大小算会误以为剩了113KB,多估那么一点看似无所谓,但数据区擦写容易越界。
5. 串起来跑一遍:读写Flash到bin文件大小实测
5.1 实验目标
这一章我完整演示一个实验:上电后读取Flash里的用户参数,如果发现magic无效,就先擦除数据区扇区,再写入一组默认参数,然后串口打印“default params saved”;如果magic有效,就直接读取并打印参数,同时把LED闪烁频率改掉。编译结束后生成bin文件,看看固件大小。
这个实验能验证三个点:Flash写入是否成功、掉电后数据是否保留、bin文件生成是否正常。
5.2 主循环和初始化代码
我整理了一个简化版main函数:
int main(void) { HAL_Init(); SystemClock_Config(); MX_UART4_Init(); MX_GPIO_Init(); const UserParams *p = (const UserParams *)USER_DATA_ADDR; if (p->magic != USER_DATA_MAGIC) { UserParams defaultParams; memset(&defaultParams, 0, sizeof(defaultParams)); defaultParams.magic = USER_DATA_MAGIC; defaultParams.version = 1; defaultParams.length = sizeof(UserParams); defaultParams.baudrate = 115200; defaultParams.kp = 1.5f; UserData_EraseSector(); if (UserData_Write(&defaultParams) == HAL_OK) { printf("default params saved\r\n"); } } else { printf("baud=%lu, kp=%.2f\r\n", (unsigned long)p->baudrate, p->kp); /* 根据参数调整LED闪烁 */ } while (1) { HAL_Delay(500); HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } }第一次烧录时,Flash数据区全是0xFF,magic不可能是0xA5A5A5A5,所以程序会走写入分支。等你复位或者重新上电,Flash里的数据还在,magic有效,就会走读取分支。这就是最直观的“掉电保存”验证。
注意我在写参数之前先擦除了扇区,如果扇区本来就是干净的0xFF,擦除会影响性能,但单次操作无所谓。产品代码里通常会记录“是否已初始化”标志,已经初始化过的就不再擦除了。
5.3 编译生成bin,并对照芯片Flash容量
代码编译通过后,去工程输出目录找到project.bin,右键属性改一下单位,或者直接用文件管理器查看。假设我看到的字节数是19872,除以1024约19.4KB。如果芯片Flash是128KB,那程序只占了约15%,用来做BootLoader和App分区还是很宽裕的。
你可以做一个有意思的对比:把编译器优化等级从-O0改成-O2,重新编译,再看bin文件大小,通常能缩小30%甚至更多。这直观展示了编译优化对固件体积的影响。我在团队里带新人时,会让他们分别编译三次,把bin大小记录下来,理解-O0、-O1、-Os的区别。这不只是性能问题,直接关系到Flash占用。
5.4 顺带用调试器验证Flash区域内容
继续往下验证,可以进入Debugger,暂停运行,在Memory窗口输入0x08080000。如果刚才写成功了,应该能看到类似这样的字节序列:
A5 A5 A5 A5 01 00 10 00 00 C2 01 00 00 00 C0 3F其中A5 A5 A5 A5是magic,01 00是version,10 00是length,00 C2 01 00是115200的十六进制,最后00 00 C0 3F是1.5f的IEEE 754表示。看到这些,说明HAL库的编程函数真的把数据写进了Flash,不是只在RAM里操作。
如果数据不对,优先检查USER_DATA_ADDR是否在有效Flash范围内,再检查结构体大小是否对齐。还有一个很常见的错误是写的时候忘了擦除,直接HAL_FLASH_Program,返回HAL_ERROR。遇到这种情况,第一反应就是看看扇区是不是干净的0xFF。
6. 我的踩坑清单:Flash读写和bin文件体积相关的血泪经验
6.1 擦错扇区导致程序区全军覆没
我最早踩过一个大坑:想把数据放到Flash中后段,结果把扇区号填成了前段的一个扇区。程序下载进去,一运行,咔一下,把正在跑的代码给擦空了,芯片当场变“砖”,重新下载程序才恢复。那次之后我才真正理解为什么Flash数据区要放到程序区后面。
防错方法其实很简单:每次擦除前,先看map文件里程序区的结束地址。通常map文件的Memory Map部分会列出所有加载区的地址范围,程序主体从0x08000000往后排。数据区放在最后一个扇区,然后写一个编译检查:
#if (USER_DATA_ADDR < 0x08000000 + PROGRAM_FLASH_SIZE) #error "USER_DATA_ADDR overlaps the program area" #endif这个宏里的PROGRAM_FLASH_SIZE是你预估的程序大小,可能不够精确,但能挡住低级错误。更严谨的做法是根据编译产物计算,但那要动构建脚本,一般项目没精力搞,我通常就选最后一个扇区,天然规避冲突。
6.2 写Flash期间被中断打断,导致写入超时或数据错误
HAL库的HAL_FLASH_Program是阻塞调用,会等待Flash操作完成,但它在等待过程中CPU依然可以响应中断。如果中断里也有访问Flash的指令,或者DMA搬运的数据正好要经过Flash控制器,可能会和正在进行的编程操作冲突。轻则延长写入时间,重则导致写入失败。
我在一个使用实时操作系统的项目里,有一个周期性任务会写Flash保存运行统计,结果抓包发现偶尔写入超时,就是因为在写Flash过程中被高优先级任务抢占。后来我干脆在写Flash的临界区关掉调度器,或者用taskENTER_CRITICAL()包起来。你如果不用RTOS,至少要把Flash写入放到主循环的串行上下文里,别放在运行频繁的中断回调中。
6.3 掉电写坏数据,没有备份机制
这是产品级问题,学习阶段可以不管,但做量产必须想。Flash擦除和编程期间如果突然断电,扇区里的数据可能处于未知状态。设计上,我建议至少划分两个槽位:A槽和B槽。写入时先写B槽,确认成功后更新一个“当前槽索引”,下次读时优先读索引指向的槽。这样即使掉电发生在写入中间,另一个槽的数据还是完好的。
你也可以在数据区加一个简单的标志序列:写入前先写一个“准备中”标志,写完数据后再写“已完成”标志。上电时如果看到“准备中”而没有“已完成”,就认为上次写入异常,自动回退到备份数据。这些机制在BootLoader和参数存储里几乎是标配。
6.4 fromelf命令生成bin失败:路径、变量、工具链版本
MDK里配fromelf命令,最常见的问题是报“无法打开输入文件”。我一开始也遇到过,后来发现是命令里的路径没有加双引号,导致包含空格的目录被拆开了。用"#L"包一下就好。
第二个坑是AC5和AC6的fromelf路径不同。有的工程默认用了AC6,但你网上搜到的是AC5的命令,直接复制过来就找不到fromelf.exe。我的建议是,不用写完整路径,直接用fromelf.exe --bin -o "$L@L.bin" "#L",让MDK自己找。如果确实要写路径,AC6是ARMCLANG,AC5是ARMCC。
第三个坑是变量$L和$L@L的区别。$L是axf文件的完整路径,$L@L是不带扩展名的路径变量。我第一次用的时候写成"$L.bin",结果生成的是project.axf.bin,虽然也能用,但看着难受。用$L@L.bin就干净了。
还有一个隐藏坑:如果你的构建脚本依赖生成bin的路径,尽量设置成固定输出路径,不要用$L@L这种相对路径。比如:
"$K\ARM\ARMCLANG\bin\fromelf.exe" --bin -o ".\Output\app.bin" ".\Output\app.axf"路径写死后,产线脚本、CI集成更省事,只是每次改工程名要同步改。
6.5 只看bin大小就以为Flash占用,忽略了扇区对齐
我见过有同事用bin大小除以总容量,算出占用率后说“还剩98KB可以随便用”,结果数据区放多了,超出实际剩余容量。原因就是bin的大小没按扇区向上取整。比如芯片Flash 128KB,最小扇区4KB,程序bin是20KB,实际占用是5个扇区正好20KB,这没问题;但如果是21KB,占用就是6个扇区共24KB,实际可用是104KB而不是107KB。差的这3KB,在数据存储规划时可能就是压垮骆驼的稻草。
我的习惯是在注释里写清楚:
/* 程序bin约21KB,占用4KB扇区6个,共24KB,剩余104KB */这样后面改代码的人心里有数。如果你不想手动算,可以在生成bin后用一个小脚本获取大小,再打印到构建日志,但这属于进阶玩法,这里不展开。
6.6 读取非法Flash地址导致HardFault
内部Flash不是所有地址都能随便读。系统存储区、选项字节区、以及某些保留区域,可能在读的时候直接触发总线错误,进了HardFault中断。所以用指针读Flash时,务必确认地址落在用户Flash范围内。我调试时有一次把地址写到0x08000000之前,结果芯片直接HardFault,查了半天才发现是地址越界。后来我封装了一个统一的读取函数,每次访问前做边界判断:
static int UserData_IsValidAddr(uint32_t addr) { return (addr >= APP_FLASH_START && addr < APP_FLASH_START + APP_FLASH_SIZE); }地址校验看着多写几行,但能帮你少调一晚上的HardFault。
最后再分享一点体会
内部Flash的读写和bin文件的生成,单独看都不算复杂,但把它们组合起来,就是一个完整的“自举能力”:你会存参数了,就能做带记忆的设置;你会看bin大小了,就能规划BootLoader分区和OTA升级。我现在每编译完一个工程,都会顺手看一眼bin文件大小,再想想当前Flash容量余额。这个习惯帮我提前发现了不少“快超了”的风险。
如果你正在做批量产品,建议把bin文件生成放到自动构建流程里,产线上只烧bin,不烧HEX,这样既可以严格控制固件版本,也能在发布前用脚本自动检查固件大小是否符合分区限制。如果对Flash读写还想继续深入,下一步可以研究Flash模拟EEPROM的磨损均衡算法,或者基于内部Flash的BootLoader跳转,那都是非常实用的方向。