简介:面向单片机开发者的 Keil 集成工程资源,专注解决 boot 与 app 固件合并及 DFU 升级场景中的文件生成与格式转换问题。资源将 Keil 编译输出的 bin 文件、Bootloader 与应用程序合并,并通过 xBin2Dfu 转换为 DFU 固件格式,覆盖从编译、自动合并到生成可烧录升级文件的完整流程。压缩包共 15 个文件,包含 Python 脚本与批处理脚本用于流程自动化,exe 可执行工具完成 bin 合并和 DFU 格式转换,另有 spec 配置文件与 Readme 说明文档,包体大小 17.89MB。已有 1264 人学习下载。对于需要实现固件远程升级、量产烧录或研究 DFU 协议的开发者,可从脚本中看到完整处理思路,并能基于现有工具快速改造集成到自身工程,减少重复开发,提升固件管理与发布效率。 做嵌入式这几年,凡是涉及OTA升级或者量产烧录的项目,几乎都逃不过固件格式处理这道坎。Keil编译完默认给的是hex,但产线工具、IAP方案、DFU升级流程偏偏只认bin;boot和app要分开管理,最终交付给产线或OTA服务器的却往往是一份合并后的完整固件。这个项目的核心就是把这堆手工操作固化成一个Keil一键完成的流程:编译完自动生成bin,再把boot和app按地址合并,最后输出DFU文件。这篇文章就围绕这套流程展开,讲清楚每个环节的原理、配置方法和实际踩过的坑,适合正在做STM32或其他ARM Cortex-M平台、被固件格式折腾得不轻的嵌入式工程师参考。
我在多个量产项目里反复用过这套做法,可以说,只要把“bin生成、boot+app合并、DFU生成”这三件事真正打通,后续无论是产品迭代还是售后返修,都能省下大量时间。
1. 项目本质与整体方案设计
1.1 为什么是bin、boot/app、DFU三件套
先说说为什么这套组合几乎绕不开。
bootloader和application分区是IAP升级的基本前提。boot放在Flash起始地址,负责上电校验、跳转和固件写入;app放在用户指定的偏移地址,通过中断向量表偏移和链接脚本与boot隔离。这种设计的好处是整个升级链路非常稳定,即使app写坏了,boot还在,还可以继续进入升级模式抢救。但代价也很直接:工程编译产物从一个变成两个,两个文件各自需要维护,最终烧录或升级时又需要把它们组合起来。
hex文件本身带了地址信息,调试器烧录很舒服。但很多场景并不接受hex——比如OTA升级时,固件包往往是纯bin配合固定地址下发;产线烧录工具、远程升级平台、部分DFU Bootloader也往往只认bin。所以生成bin几乎是必经之路。
DFU则是设备固件升级的统称,在STM32生态里最经典的就是USB DFU。设备通过USB枚举成一个DFU设备,上位机工具把固件按地址区间写入Flash。这里有个关键点:DFU文件不是裸数据,它必须携带目标地址信息。所以就算你已经有了合并好的合并bin,也不能直接扔给DFU工具,必须转成带地址段的DFU格式。
1.2 手动流程 vs 一键集成
如果完全手工来跑,流程大概是这样:编译出hex,用fromelf转成bin,再写脚本把boot.bin和app.bin按偏移拼起来,最后打开DfuSe工具手动导入、设置地址、导出DFU文件。这种做法在项目早期还凑合,一旦进入量产节奏,问题就会集中爆发:每次编译要重复操作,偶尔漏掉一个步骤,产线拿到的固件版本不对,升级现场才发现文件有问题。
所以我在设计这套方案时的思路很明确:所有重复劳动全部由脚本接管,Keil的编译后命令负责触发,整个流程从“手工倒腾”变成“编译完自动输出三个文件”——app.bin、合并后的完整bin、DFU文件。
整体架构如下:
- Keil工程配置After Build命令,编译成功后自动调用fromelf输出app.bin。
- 一个Python脚本读取boot.bin和app.bin,按照app在Flash中的偏移地址,合并输出完整固件bin。
- 同一套脚本或配套工具,把合并后的bin打包成DFU文件。
- 所有输出统一归档到一个目录,按版本号或编译时间命名。
只要把这条链路搭好,后面每次编译结束,产物自动就绪,连带着产线烧录文件和OTA升级包都不用额外准备。
2. Keil配置:编译后自动生成bin文件
2.1 fromelf命令参数与配置细节
Keil自带的fromelf工具能把axf文件转换成多种格式,bin只是其中一种。打开工程后进入Options for Target,切到User页签,在After Build/Rebuild区域找到Run #1,填入下面这条命令:
C:\Keil_v5\ARM\ARMCC\bin\fromelf.exe --bin --output=.\Output\app.bin .\Output\app.axf如果工程用的编译器是AC6(Arm Compiler 6),路径要换成ARMCLANG目录:
C:\Keil_v5\ARM\ARMCLANG\bin\fromelf.exe --bin --output=.\Output\app.bin .\Output\app.axf填完之后记得勾选Run #1前面的复选框,Keil才会真的执行。实际配置时有几个容易被坑的点:
--output后面先写目标文件,再写源文件,顺序不能反。很多人习惯性把源文件写在前面,命令直接报错。- fromelf不会自动创建输出目录。如果
.\Output\这个文件夹不存在,命令执行会失败,而且Keil编译本身又不报错,很容易忽略。 - 如果只想保留bin、不清理hex,完全不用管hex的生成逻辑,两个文件可以共存。
配置完成后,每次编译成功,Output目录下会同时出现app.axf、app.hex、app.bin三个文件。
2.2 多Target工程的路径与命名管理
一个工程里往往会建多个Target,比如Debug、Release、或者不同Flash容量的版本。不同Target的axf输出目录可能不一样,如果fromelf命令只写死一个路径,切换Target后很容易找不到文件。
我的做法是在Output页签里手动指定统一的Objects输出目录,比如固定为.\Output。这样多个Target共用同一个输出目录,虽然编译时文件互相覆盖,但对于单产品线、多配置的场景反而好管理。如果是多产品线,我建议把输出目录区分开,比如.\Output\ProductA、.\Output\ProductB。
还有一种情况是产线需要带版本号的固件。Keil内部的User命令直接写死文件名肯定不行,我一般会让fromelf生成一个固定的app.bin,然后额外跑一个批处理,把编译时间或者版本变量拼进去,复制出一个带版本号的文件。批处理里可以用%date%和%time%拼接,但要注意Windows命令行里特殊字符转义的问题,建议封装到脚本里处理,别在Keil里直接写太复杂的命令。
3. boot+app合并脚本:从原理到实现
3.1 Flash地址偏移与空洞填充逻辑
合并boot和app,听起来像“把两个文件拼在一起”,但实际不是这么简单。真正关键的是地址对齐和空洞填充。
以STM32F4系列为例,Flash起始地址是0x08000000,假设boot占满前32KB,即0x08000000到0x08007FFF,app从0x08008000开始。那合并后的bin文件,从文件头开始,先放boot的数据,长度为0x8000;到偏移0x8000的位置再放app的数据。
这时的文件总长度,不是“boot长度 + app长度”,而是“app起始偏移0x8000 + app长度”。如果app的bin长度只有90KB,合并文件长度就是0x8000 + 0x16800 = 0x1E800。中间从0x0000到0x8000是boot区,从0x8000到0x1E800是app区,这两个区间天然是连续的,不需要额外空洞填充。
但如果你把app.bin生成为“从0x08008000开始的纯数据”,而链接脚本里app的实际起始偏移和这个bin文件本身一致,那确实只用偏移拼接。另一种常见做法是,直接把app.bin当成相对app起始地址的偏移来导出,文件本身从偏移0开始,这样合并时就必须把0x8000部分用填充字节填满,再把app数据放进去。
填充值一般用0xFF。原因很简单:Flash擦除后所有位是1,也就是0xFF,填充0xFF可以模拟原始Flash空白区的状态,后续做差分升级、校验和计算时更符合实际。
还要注意重叠检查。如果app的偏移地址小于boot的实际长度,app会覆盖boot的尾部,合并出来的固件烧进去必翻车。这种问题不一定每次都能在烧录时立刻暴露,很隐蔽。所以脚本里必须加防护。
3.2 Python合并脚本完整代码与使用
下面这个脚本我用了很久,逻辑清晰、参数化,兼容Python 3,可以直接丢到命令行或者CI流水线里用。
import argparse def merge_firmware(boot_path, app_path, output_path, app_flash_offset): boot_data = bytearray(open(boot_path, 'rb').read()) app_data = bytearray(open(app_path, 'rb').read()) if app_flash_offset < len(boot_data): raise ValueError( f'app偏移(0x{app_flash_offset:X})小于boot长度(0x{len(boot_data):X}),' '固件区域重叠,请检查链接脚本配置!' ) total_size = app_flash_offset + len(app_data) merged = bytearray([0xFF] * total_size) merged[:len(boot_data)] = boot_data merged[app_flash_offset:app_flash_offset + len(app_data)] = app_data with open(output_path, 'wb') as f: f.write(merged) print(f'合并完成: {output_path}') print(f'总大小: {len(merged)} bytes (0x{len(merged):X})') print(f'Boot区: 0x00000000 - 0x{len(boot_data):X}') print(f'App区: 0x{app_flash_offset:X} - 0x{app_flash_offset + len(app_data):X}') if __name__ == '__main__': parser = argparse.ArgumentParser(description='Boot + App固件合并工具') parser.add_argument('--boot', required=True, help='bootloader的bin文件') parser.add_argument('--app', required=True, help='application的bin文件') parser.add_argument('--output', required=True, help='合并输出的bin文件') parser.add_argument('--offset', required=True, help='app在Flash中的偏移,十六进制,如0x8000') args = parser.parse_args() offset = int(args.offset, 16) merge_firmware(args.boot, args.app, args.output, offset)调用方式:
python merge_firmware.py --boot boot.bin --app app.bin --output merged.bin --offset 0x8000使用时有几个细节值得说:
app_flash_offset参数是app在Flash中的偏移,它应该和Keil工程里Target页签的IROM1起始地址保持一致。如果你把IROM1设置成0x08008000,那这个偏底就是0x8000。- 脚本里的重叠检查是必须的。我遇到过不止一次,做久了的人改Flash分区时把偏移改小,结果app和boot重叠,生产线上烧一批坏一批。有这行检查,至少能在生成固件的时候当场报错。
- 是否支持padding多段?脚本目前只处理boot和app两个文件。如果你的方案里还有类似wifi固件、字库等更多分区,可以在脚本基础上扩展成遍历多个文件、多个偏移,逻辑是一样的。
4. DFU文件生成与固件验证
4.1 DfuSe文件格式与生成思路
DFU文件格式在不同生态里定义不一样。STM32官方USB DFU Bootloader使用的DfuSe格式,核心结构是:文件头标识该文件由DfuSe规范定义,接着带一个或多个目标,每个目标里包含Flash起始地址、数据长度、实际数据。之所以必须带地址,是因为DFU下载过程中,上位机要根据地址决定把数据写到Flash的哪个位置。
理解了这一层,生成DFU文件的思路就清楚了:把bin文件切成和Flash地址区间对应的数据块,装上目标地址,封装成DfuSe规定的结构。
实际操作时,有两种常见的自动生成方式。
一种是用dfu-util工具包里的dfuse-pack命令。这条命令可以直接把bin文件打包成DFU格式,并指定目标地址。比如:
dfuse-pack -i merged.bin -T 0x08000000 merged.dfu这种方式简单直接,但dfuse-pack的版本和维护状态需要确认,不是所有平台都能方便安装。
另一种是我更常用的全Python方案,不依赖外部工具,也好集成到现有脚本里。下面给出一个参考实现,注意它做了一定简化,如果要严格对照ST官方AN3156文档,建议把生成后的文件用DfuSe工具打开确认一次。
4.2 Python生成DFU的参考实现
import struct def bin_to_dfu(bin_path, dfu_path, target_address): data = bytearray(open(bin_path, 'rb').read()) dfu_file = bytearray() # 文件前缀: DfuSe签名 + 版本 + 图像大小 + 目标数量 dfu_file += b'DfuSe' dfu_file += struct.pack('<I', 0x01000000) dfu_file += struct.pack('<I', len(data) + 20) dfu_file += struct.pack('<H', 1) # 目标前缀: Target签名 + 版本 + 目标大小 + 元素数量 dfu_file += b'Target' dfu_file += struct.pack('<I', 0x01000000) dfu_file += struct.pack('<I', len(data) + 18) dfu_file += struct.pack('<I', 1) # 元素段: 起始地址 + 数据长度 + 数据 dfu_file += struct.pack('<I', target_address) dfu_file += struct.pack('<I', len(data)) dfu_file += data with open(dfu_path, 'wb') as f: f.write(dfu_file) print(f'DFU文件已生成: {dfu_path}') print(f'目标地址: 0x{target_address:08X}') print(f'数据大小: 0x{len(data):X}')调用的地方很直接:
bin_to_dfu('merged.bin', 'merged.dfu', 0x08000000)这里要特别说明:DfuSe文件各字段长度我按最常用的结构填了,但不同版本的ST工具对头部字段的校验严格度略有差异。如果工具打开报错,先用官方DfuSe File Manager导出一个能正常用的dfu文件,用二进制对比工具和你的输出对照,逐个字段核对一遍,很快就能定位差异。
4.3 固件烧录验证流程
DFU文件生成之后,最后一步是验证。我一般在开发板上走一遍完整流程,不做任何跳步:
- 通过调试器把boot.bin烧到0x08000000,确保boot能正常启动。
- 通过调试器把app.bin烧到0x08008000,验证应用能跑起来,并能正常响应boot的跳转。
- 清空app区域或者整个Flash,用USB连接开发板,进入DFU模式。
- 用STM32CubeProgrammer等工具加载生成的DFU文件,执行下载。
- 下载完成后断电重启,确认应用能正常进入,且升级链路没有异常。
这一步很关键,因为DFU文件生成过程中如果地址写错、长度字段不对,往往得等到下载完跑起来才发现问题。提前在开发板上把完整链验一遍,后面量产才能放心。
5. 常见问题与排查技巧实录
5.1 Keil集成阶段的典型问题
| 现象 | 原因 | 解决 |
|---|---|---|
| fromelf命令执行后没有bin生成 | 输出目录不存在 | 提前创建Output目录,或者命令里改用绝对路径 |
| 编译成功但bin没更新 | 用户命令没勾选 | 确认After Build/Rebuild下的复选框已勾选 |
| 报错无法打开axf文件 | axf路径和实际输出不一致 | 在Output页签确认Objects路径,和命令里的路径保持一致 |
| AC6编译下fromelf路径错误 | 不同编译器工具链目录不同 | 用ARMCLANG\bin\fromelf.exe替换ARMCC\bin |
| 有多个Target时命令混乱 | 每个Target输出路径不一样 | 统一在Output页签指定Objects输出目录 |
其中“输出目录不存在”这个坑我印象最深。Keil编译本身一切正常,但那行fromelf命令在后台执行失败了,要是没打开命令行窗口,根本察觉不到。后来我干脆在批处理里加上自动创建目录的语句,一劳永逸。
5.2 合并与DFU阶段的典型问题
| 现象 | 原因 | 解决 |
|---|---|---|
| 合并后app跳转失败 | 合并偏移和App链接脚本不一致 | 核对Keil工程IROM1地址与--offset参数一致 |
| app覆盖了boot | boot占用的容量大于预留偏移 | 在脚本中加重叠检查,报错而非静默输出 |
| DFU下载后设备不停重启 | 目标地址错误或文件结构异常 | 用ST工具确认DFU里的地址信息,和链接脚本对照 |
| DFU工具报文件头部错误 | DfuSe字段长度或字节序不对 | 和官方工具导出的参考文件做二进制对比 |
| 升级失败后设备无法恢复 | 没有做app有效性校验 | 在boot里加上CRC/版本校验,升级失败自动回退旧版本 |
这里我想重点说下“合并后app跳转失败”的排查思路。问题大多数时候不是合并脚本,而是App工程本身的链接地址没有改彻底。在Keil的Target页签里,IROM1的起始地址必须和Bootloader约定的app加载地址一致,同时App的中断向量表也要设置偏移,否则即使固件文件合成对了,上电跳转后中断全部跑飞。检查时要两头看:一端是工程配置,另一端才是合并参数。
5.3 值得记下的几点实战心得
这套方案我迭代过好几轮,有些细节是踩过坑才真正理解的。
第一,合并脚本和链接脚本的偏移必须统一维护。我见过最混乱的情况是,boot分区规划在文档里写的是0x8000,但实际上有人把一个工程的IROM1改成0x4000,另一个还保留默认值,导致生产的固件和测试固件行为不一致。最好把偏移量提炼成一个单独参数,在脚本入口统一处理,并在编译输出里打印出来,方便核对。
第二,版本信息尽量写入固件。可以在Keil里用预定义宏把编译时间写进某个固定地址,也可以在脚本里把git commit哈希写进合并后binfile的头部。这样后续无论产线还是OTA侧出了问题,都能从设备固件反查源码版本。这个成本很低,收益非常大。
第三,自动化别一把梭。最开始的版本我把所有步骤全部串在一条Keil命令里,每次编译都要跑合并和DFU转换,因为生成了大量临时文件,导致编译速度变慢。后来改成“生成单一bin”作为编译后默认动作,把合并和DFU生成独立成可选脚本,开发调试时不必每次执行,只在需要出完整固件时手动跑一次,体验截然不同。
第四,DFU工具的选择要提前确认。不同MCU厂商甚至不同Bootloader版本,DFU文件格式都有差异。不要认为能生成一个.dfu后缀的文件就通用。做项目之前,先确认目标Bootloader接受的是标准DfuSe、还是自定义格式、还是带数字签名的安全固件,再决定用什么方式打包。否则等固件发到现场才发现格式不对,回退成本非常高。
我在项目里把这套流程固化下来之后,最大的感受是“省心”。以前每次出固件都要小心翼翼怕漏了哪一步,现在编译完文件自己就有了,产线、测试、售后拿到的都是同一套产物,少了太多扯皮。如果你也在为固件格式和升级流程发愁,不妨照上面这套思路把你的Keil工程配起来,先把bin跑通,再加合并,最后再挂DFU,一步一步来,比一次性追求大而全要稳得多。
本文还有配套的精品资源,点击获取