简介:嵌入式固件开发中,Bootloader与应用程序分离是IAP/OTA升级的基础,而量产阶段往往需要把两者合成一个DFU包进行统一烧录。但Keil MDK默认只输出HEX文件,无法直接得到BIN,更无法按地址合并多段固件。文章从HEX与BIN的格式差异切入,说明为何生产烧录与DFU升级都依赖BIN这种纯二进制流;接着讲解STM32 Flash地址布局、扇区对齐原则以及DfuSe文件结构,帮助理解固件合并的本质。随后给出在Keil中通过fromelf一键生成BIN的完整配置,并提供一个Python合并脚本,自动完成地址偏移检查、0xFF填充与合法性校验。最后演示使用DfuFileMgr将Boot和App封装为单个DFU文件,并指出VTOR设置、GAP填充、工具兼容性等常见翻车点。适合正在开发STM32 IAP、OTA或产线批量烧录方案的嵌入式工程师,让Boot+App合并与DFU生成从手动碰运气变成可靠可重复的自动化流程。
1. Keil 集成 bin 生成:为什么 boot+app 合并成 DFU 是量产前的最后一道坎
做 STM32 的 IAP 或 OTA 升级,绕不开一个需求:bootloader 和 app 要合成一个 DFU 包,给产线批量烧录或者远程升级用。Keil 默认只生成 hex 文件,不生成 bin,更不会把两个工程拼成一个 DFU。很多同事第一次拿到“keil集成bin文件生成_boot+app合并_dfu生成”这类交付要求时,第一反应是上网找现成脚本,结果要么 fromelf 命令写错路径,要么合并时地址对不上,要么生成的 .dfu 文件在升级工具里打不开。这篇文章把这套流程完整拆开:怎么在 Keil 里集成 bin 生成、怎么把 boot 和 app 按地址合并、怎么产出 DFU 文件,以及每一步最常见的翻车点。适合正在做 IAP、OTA 或产线烧录方案的嵌入式工程师,照着走能把三个工程串成一条自动化流水线。
2. 原理先立住:hex 到 bin、boot/app 地址布局与 DfuSe 文件结构
2.1 Keil 默认产物是 hex:为什么生产烧录和 DFU 升级都盯着 bin
Keil MDK 里默认勾选了 Create HEX File,所以大部分工程师见到的第一个产物是 .hex。hex 文件是 Intel HEX 格式的文本,每一行用 ASCII 字符记录地址、数据长度、记录类型和校验和,比如:10000000 00 C0B40020 49A00008 ... F7。这种格式的好处是地址信息跟着每行走,调试器、烧录器可以按地址逐条写入,哪怕程序在 Flash 里分了几段也能正确处理。但它的坏处也很明显:数据量比纯二进制大三倍左右,而且解析依赖行首的地址,不适合做简单的文件拼接和字节流传输。
bin 文件是纯二进制,从某个起始地址开始连续摆放,没有地址字段,长度就是数据本身。DFU 升级流程里,MCU 侧的 DFU 固件通过 USB 接收的是字节流,它自己决定把数据写到 Flash 的哪个地址,所以它不认 hex 的文本记录,只认 bin 这种“裸数据”。生产烧录也是同理,编程器的上位机软件一般会让你填一个起始地址,然后直接灌 bin。这就是为什么只要涉及 boot+app 合并、DFU 生成,第一步永远是先把 hex 转成 bin。
Keil 里生成 bin 不能靠勾选项,得用 fromelf 命令行工具。fromelf 读取编译链接后生成的 axf 文件(ARM 的 ELF 格式),把里面的可加载段提取出来输出成 bin。这里有个容易混淆的点:axf 和 .elf 的差别。如果你在工程里用了 GCC 工具链,生成的是 .elf;fromelf 是 ARM 编译器的工具,对 .elf 的兼容性很差,常见报错是认不出输入文件格式。所以用 Keil 的标准 AC5/AC6 编译器,生成的是 .axf,fromelf 才能正常处理。
2.2 boot 和 app 的地址段划分:合并前必须搞懂 Flash 布局
boot 和 app 合并不是简单把两个文件接在一起,而是按它们在 Flash 里的实际地址摆放。STM32 的 Flash 从 0x08000000 开始,boot 通常占用起始段,比如 0x08000000 到 0x08010000(64KB);app 的起始地址必须避开 boot,常见是 0x08010000,也有按扇区边界设成 0x08020000 甚至 0x08080000 的。这个地址不是随便定的,它由 boot 工程和 app 工程各自的分散加载文件(sct 文件)决定。
合并脚本要做的,就是把 boot.bin 放到偏移 0 的位置,把 app.bin 放到偏移APP_ADDR - BOOT_ADDR的位置。如果 boot 占 64KB,app 起始是 0x08010000,那偏移就是 0x10000,中间如果没有其他数据,就用 0xFF 填充。这里必须提扇区对齐:STM32F4 的 Flash 前 4 个扇区是 16KB,后面是 64KB;STM32F1 是 1KB/4KB 不等。如果 app 起始地址不是扇区边界,擦除时会把 boot 的一部分一并抹掉。所以合并脚本里最好加一个对齐检查,偏移值不能被扇区大小整除就直接报错。
另外要注意 boot 和 app 之间的 gap。如果 app 放在 0x08040000,而 boot 只有 64KB,中间空出约 192KB。合并成单个 bin 时这些 gap 会以 0xFF 填充,文件体积变大,DFU 下载时间也变长。如果产线用全片烧录,gap 填充是必须的;如果只做 DFU 升级,更优做法是让 DFU 文件里 boot 和 app 作为两个独立元素、各自带地址,跳过 gap。这一点在第 5 章会详细展开。
2.3 DfuSe 文件到底是什么:DFU 包里的地址与元素
DFU 是 USB Device Firmware Upgrade 协议的简称,ST 在此基础上扩展出了 DfuSe(Device Firmware Upgrade ST Extended),对应的文件后缀就是 .dfu。DfuSe 文件比 bin 多了一层包装:它除了固件数据,还记录了每个数据段的目标写入地址。这样 boot 和 app 可以放进同一个 .dfu 文件里,DFU 升级工具在烧录时按文件里记录的地地址逐个写入,不会乱套。
DfuSe 文件的结构可以简单理解为:文件头 + 镜像头 + 若干个 target + 数据区。文件头最前面 8 字节是固定签名,我用 Python 打开过很多次,实际内容就是00 01 44 46 53 45 08 00,其中44 46 53 45是 ASCII 码的 “DFSE”。镜像头里记录 target ID、alternate setting 编号和保留字段。每个 target 带一个目标地址和元素数量,元素再记录数据长度和起始地址。末尾一般还有 16 字节的 DFU suffix,包含 CRC32 校验值。
这里有个实际经验要提醒:不同版本的 ST DfuSe 工具对 target 结构的解析有细微差别,早期工具对某些字段的容错能力差,会导致“工具打不开文件”。所以我个人的做法是:生成 DFU 用官方 DfuFileMgr,检查文件只检查头部签名和数据长度,不自己手工构造 DfuSe 的字节流。手工构造看着很酷,但要兼容所有升级工具非常麻烦,量产场合不值得赌。
3. 在 Keil 里一键产出 bin:fromelf 命令与合并脚本落地
3.1 在 Keil User 页签挂 fromelf:一条命令让每次编译都出 bin
先把 fromelf 命令跑通。打开 Keil,在 Options for Target -> User 页签里,找到 After Build/Rebuild,勾选 Run #1,在输入框里填:
fromelf --bin --output=.\Build\app.bin .\Build\app.axf这条命令把 Build 目录下的 app.axf 转成 app.bin。要点是 axf 路径必须正确。工程输出路径可以在 Options for Target -> Output 页签的 Select Folder for Objects 里改,默认是工程目录下的 .\Objects。如果填错路径,build 后 fromelf 会直接报错“cannot open file”。
更稳妥的写法是用 Keil 的替换变量,把路径写活:
fromelf --bin --output=!L.bin !L!L会被 Keil 自动展开为当前工程 axf 文件的完整路径(不含扩展名),所以无论工程名怎么改,这行都不用动。输出文件会和 axf 在同一个目录,文件名也一致。
这里有个容易踩的坑:fromelf 在 Keil 安装目录下,AC5 和 AC6 的路径不同。AC5 编译器对应ARM\ARMCC\bin\fromelf.exe,AC6 编译器对应ARM\ARMCLANG\bin\fromelf.exe。Keil 在 User 页签里执行命令时会自动把工具链的 bin 目录加入 PATH,所以直接写fromelf一般能执行。如果你在命令行里手动跑 fromelf 却提示找不到命令,就是 PATH 里没有对应路径,把完整路径写进去即可。
3.2 合并 boot+app:Python 脚本按地址拼接并检查扇区对齐
生成 bin 之后,合并脚本是整条流水线的核心。我习惯用一个 Python 脚本,读入 boot.bin 和 app.bin,按照两个工程的分散加载文件地址做拼接,同时做几个关键检查。下面是一个可以直接用的版本:
#!/usr/bin/env python3 # merge_boot_app.py # 用法: # python merge_boot_app.py --boot boot.bin --app app.bin --out combined.bin # --boot-addr 0x08000000 --app-addr 0x08010000 import argparse import os DEFAULT_BOOT_ADDR = 0x08000000 DEFAULT_APP_ADDR = 0x08010000 # STM32F4 前 4 个扇区为 16KB,此处按最小擦除单元检查 FLASH_SECTOR_SIZE = 16 * 1024 def load_bin(path): with open(path, 'rb') as f: return bytearray(f.read()) def merge(boot_bin, app_bin, boot_addr, app_addr, out_path): offset = app_addr - boot_addr if offset < len(boot_bin): raise ValueError( f"地址重叠: app 起始偏移 {offset:#x} 小于 boot 长度 {len(boot_bin):#x}" ) if offset % FLASH_SECTOR_SIZE != 0: raise ValueError( f"app 偏移 {offset:#x} 未按扇区对齐,boot 会被擦除" ) total_size = offset + len(app_bin) image = bytearray([0xFF] * total_size) image[0:len(boot_bin)] = boot_bin image[offset:offset + len(app_bin)] = app_bin with open(out_path, 'wb') as f: f.write(image) print(f"boot: {len(boot_bin):#x} bytes @ {boot_addr:#010x}") print(f"app: {len(app_bin):#x} bytes @ {app_addr:#010x}") print(f"gap: {offset - len(boot_bin):#x} bytes (0xFF)") print(f"merged: {total_size:#x} bytes -> {out_path}") def main(): parser = argparse.ArgumentParser() parser.add_argument('--boot', required=True) parser.add_argument('--app', required=True) parser.add_argument('--out', required=True) parser.add_argument('--boot-addr', type=lambda x: int(x, 0), default=DEFAULT_BOOT_ADDR) parser.add_argument('--app-addr', type=lambda x: int(x, 0), default=DEFAULT_APP_ADDR) args = parser.parse_args() boot = load_bin(args.boot) app = load_bin(args.app) merge(boot, app, args.boot_addr, args.app_addr, args.out) if __name__ == '__main__': main()脚本逻辑分为三步。第一步通过offset = app_addr - boot_addr计算出 app 相对 flash 起始地址的偏移,这就是 app.bin 在合并文件里的摆放位置。第二步检查两个关键条件:地址重叠和扇区对齐。地址重叠意味着 app 起始位置还没超过 boot 的长度,合并产物会把 boot 尾巴覆盖掉,这在物理上就不可能运行;扇区对齐检查则是防止 app 起始地址落在某个 Flash 扇区中间,导致升级时擦除扇区会误伤 boot。
填充值用的是 0xFF,对应 Flash 擦除后的默认状态,这也是生产烧录全片时 Flash 里应有的值。bytearray 默认填 0x00,所以这里先bytearray([0xFF] * total_size)再赋值,是一开始写脚本最容易出错的地方。如果这里直接bytearray(total_size),最终合并文件里 gap 会全是 0x00,烧录后 Flash 的未用区域内容异常,虽然大部分程序跑起来不影响,但做完整镜像比对时会不一致。
3.3 把合并脚本串进 After Build:三个工程一条线
脚本单独跑没问题还不行,得把它串进 Keil 的编译流程,做到每次 build 完 app 自动生成合并固件。常见的做法是:boot 工程先编译出 boot.bin,然后 app 工程在 After Build 阶段调用合并脚本,把新编译的 app.bin 和已有的 boot.bin 合并。这样平时只需要 Rebuild app 工程,产物自动更新。
在 app 工程的 User 页签,After Build 的 Run #1 保留 fromelf 生成 bin,Run #2 填 Python 调用:
python D:\workspace\scripts\merge_boot_app.py --boot D:\workspace\boot\Build\boot.bin --app .\Build\app.bin --out .\Build\combined.binboot 路径建议写绝对路径,app 路径可以写相对路径,因为 Keil 执行命令时的工作目录就是工程文件所在目录。注意 Python 是否在系统 PATH 里,如果没加,就把python换成 Python 安装的完整路径。另一个细节:每次改动 boot 工程后,必须先 Rebuild boot 工程,再 Rebuild app 工程,否则合并用的 boot.bin 是旧版本。我习惯在脚本里加一个时间戳检查:如果 boot.bin 的修改时间比 app.bin 晚,就说明 boot 刚编译过,这是正常的;反过来如果 boot.bin 比 app.bin 老太多,打印一句警告提示。代码写起来不复杂,但能在流水线上省掉很多排查时间。
4. 生成 DFU 包:DfuFileMgr 操作步骤与产物验证
4.1 用 DfuSe 工具把 boot 和 app 装进一个 DFU 文件
合并出了 combined.bin 之后,DFU 生成我用的是 ST 官方 DfuSe demo 里的 DfuFileMgr 工具。它不是命令行工具,但胜在兼容性最好,生成的 DfuSe 文件在 ST 的 DfuSeDemo、dfu-util、以及各家量产工具的兼容性都是最高的。操作步骤很固定:
- 打开 DfuFileMgr.exe,菜单 File -> Generate DFU File。
- 在 Device 列表里选择目标芯片系列,比如 STM32F407VG,或者选 Global 让工具不绑定具体型号。
- 点击 Add 按钮,先加入 boot.bin,此时地址栏填 0x08000000。
- 再次点击 Add,加入 app.bin,地址填 0x08010000(和前面脚本里 addr 保持一致)。
- 保存生成 xxx.dfu。
生成过程中,工具会把每个文件作为一个元素写进 DfuSe 结构。boot 元素记录起始地址 0x08000000,app 元素记录起始地址 0x08010000,升级时 DFU 固件会按元素顺序逐个写入。这里要注意一个概念区别:DfuSe 里一个 target 可以包含多个元素,每个元素自带地址;也可以有多个 target,每个 target 一个地址。DfuFileMgr 添加多个文件时,默认是把它们作为同一个 target 下的多个元素,这对 STM32 的 DFU 升级流程是常见且正确的形态。
如果你只有一个 bin 文件需要生成 DFU,也可以不加第二个文件,直接保存。另一种替代路径是用 dfu-util 自带的一些脚本工具从 bin 生成标准 DFU 文件,但纯 bin 转出的 DFU 只支持单 target、单元素,放不了 boot+app。所以双固件场景,DfuFileMgr 是我实际项目中主要采用的方式。
4.2 生成 DFU 时要确认的三个参数:目标地址、alternate setting 和 CRC
DfuFileMgr 界面上看起来只是“添加文件、填地址、保存”,但背后有三个参数直接决定 DFU 能不能被目标设备正确识别。第一个是目标地址,它必须和 app 工程分散加载文件的 ROM 起始地址一致。曾见过有人把 app 的 DFU 地址填成 0x08000000,结果升级时把 boot 覆盖了,设备变砖。地址这块没有商量余地,必须以工程的分散加载文件为准,不能凭记忆填。
第二个是 alternate setting。DFU 协议里,设备可以暴露多个 alternates,每个对应一个存储区域,比如 TF-M 方案里 boot 和 app 可能放在不同 alternates。常规双固件方案里,alternate 数量是 1,boot 和 app 作为两个元素共用这个 alternate。如果你的 boot 工程在 DFU 固件里实现了多个 alternate,DfuFileMgr 生成时要相应地把不同文件挂到不同 alternate 下,否则下载时目标不匹配。
第三个是 CRC。DfuSe 文件末尾的 suffix 带 CRC32 校验,DfuFileMgr 在保存时会自动计算。但有些第三方工具或者手动修改过的 DFU 文件不更新 CRC,升级工具下载到最后一个包时校验不过,表现是“下载完成但校验失败”或者“设备跳出 DFU 模式但程序没写进去”。遇到这种问题别急着怀疑 Flash,先用 DfuFileMgr 重新生成一次再看。
4.3 校验脚本:用 Python 读取 DFU 头确认 segment 信息
DfuFileMgr 界面能直接看到每个元素的地址和大小,但生产中往往需要脚本化校验,尤其是把 DFU 文件交给产线之前。我写了下面这个简单的检查脚本,只做文件头签名校验和基本字段打印,不解析完整的 DfuSe 结构。它不能替代 DfuFileMgr 的打开检查,但能在自动化流程里快速筛掉损坏文件。
#!/usr/bin/env python3 # check_dfu.py # 用法: python check_dfu.py xxx.dfu import struct import sys def check_dfu(path): with open(path, 'rb') as f: data = f.read() if len(data) < 24: print("文件太小,不是有效的 DfuSe 文件") return False # DfuSe 头: 00 01 'D' 'F' 'S' 'E' 08 00 if data[0:2] != b'\x00\x01' or data[2:6] != b'DFSE': print("文件头签名错误") return False print(f"文件大小: {len(data)} bytes") print("DfuSe 签名: OK") # 打印前 32 字节 hex,方便人工核对 print("前 32 字节:", ' '.join(f'{b:02X}' for b in data[:32])) # 从文件尾部读 suffix 长度字段(如果有) suffix = data[-16:] length_field, bcd_dfu = struct.unpack('<IH', suffix[12:18]) if length_field == 16: print("DFU suffix: 存在 (16 bytes)") print(f"bcdDFU: {bcd_dfu:#06x}") else: print("DFU suffix: 未检测到,部分升级工具可能拒绝该文件") return True if __name__ == '__main__': if len(sys.argv) != 2: print("用法: python check_dfu.py <file.dfu>") sys.exit(1) ok = check_dfu(sys.argv[1]) sys.exit(0 if ok else 1)这段脚本没有深入解析每个 target,因为 DfuSe 的多 target 结构在不同工具和版本下字段顺序有差异,强行解析反而容易给出错误结论。它的价值在于:确认文件头签名和文件大小正常,确认 DFU suffix 存在,防止把损坏的或者被截断的文件发给产线。产线工具如果打不开 DFU 文件,先用这个脚本跑一遍,能快速判断是文件本身的问题还是产线工具的配置问题。
5. 避坑记录:fromelf、VTOR、gap 填充与工具打不开的四个现场
5.1 编译通过但烧录后首条指令就跑飞
现象:fromelf 成功生成了 bin,下载到 Flash 后程序不运行,硬件调试停在 HardFault,或者 PC 指针跑到随机地址。 原因:最常见的是 bin 的基地址填错了。fromelf --bin 生成的 bin 是纯数据,它的偏移 0 对应分散加载文件里第一个 region 的起始地址。如果 app 工程分散加载文件规定 ROM 起始是 0x08000000,而你在烧录工具里把下载地址填成了 0x08010000,那么第一条指令(栈顶指针)会被写到错误位置,MCU 上电直接跑飞。 解决:明确“哪个工程的 bin 按哪个基地址烧”。boot.bin 基地址是 0x08000000,app.bin 基地址是 APP_ADDR,合并后的 combined.bin 基地址是 0x08000000。烧录工具里填的地址不是“从哪里读文件”,而是“文件第一个字节写到哪”,这两个概念经常被混淆。我把这条写进了团队烧录规范:任何 bin 交付件必须附带烧录地址说明,否则默认按 0x08000000 处理。
5.2 app 跳转死机:VTOR 没跟着 APP_ADDR 走
现象:boot 正常启动,跳转指令执行后 app 不进 main,或者一进中断就 HardFault。 原因:Cortex-M3/M4 的中断向量表基地址由 VTOR 寄存器决定,app 工程如果没有在启动阶段把 VTOR 设置成 APP_ADDR,向量表就还留在 0x08000000。boot 跑在 0x08000000 时没问题,跳转到 app 后一旦发生任何中断,MCU 去 0x08000000 取中断向量,拿到的还是 boot 的向量,行为完全错乱。 解决:app 工程里加一行SCB->VTOR = APP_ADDR;,放在 system_init 或 main 最开头,必须在任何外设中断使能之前执行。同时注意跳转前关闭全局中断、复位外设状态,常见写法是__disable_irq()后跳转,app 初始化自己重新开启中断。这类问题合并脚本管不了,只能在代码层面解决,但每次做 boot+app 方案都应该把“跳转前关中断、app 里重设 VTOR”写成固定检查项。
5.3 合并文件比预想大几倍:gap 的 0xFF 与 DFU 多元素取舍
现象:boot 64KB,app 120KB,但 combined.bin 生成了 512KB,烧录时间明显变长。 原因:app 起始地址 0x08040000 和 boot 之间空着约 192KB 的 gap,合并脚本为了保持地址连续,用 0xFF 把这些空区域都填满了。从 Flash 镜像角度看这没问题,但 DFU 升级时会把这 192KB 的空数据也全量传输。 解决:分场景处理。产线全片烧录,combined.bin 是正确形态,烧录器按地址写全片,0xFF 区域擦除后本来也是 0xFF。DFU 远程升级,就不要用合并 bin 生成 DFU,而是直接把 boot.bin 和 app.bin 作为两个元素交给 DfuFileMgr,让 DFU 文件里跳过 gap。还有一个折中方案:如果 boot 固定不改,DFU 里只打包 app,boot 通过烧录器单独烧,升级流程也只升级 app。具体取舍看产品需求,但不要在没确认升级方式前就默认“合并 bin 越大越好”。
5.4 DfuSe 工具打不开生成的 .dfu:路径与格式细节
现象:生成的 .dfu 文件在 DfuSeDemo 里打开直接报 not valid DFU file,但在 DfuFileMgr 里正常。 原因:一是文件路径含中文或空格,ST 的部分工具对非 ASCII 路径支持很差,这一点在 Windows 下尤其明显;二是 DFU 文件本身缺 suffix 或者 CRC 不对,可能是别人手工改了文件内容后没重新生成。 解决:先把整个工作目录改成纯英文路径,这是玄学但成功率极高。其次用 check_dfu.py 看 suffix 是否存在,缺失就重新用 DfuFileMgr 生成。最后如果还打不开,换一个新版 DfuSe demo 包,老版本工具对 DfuSe 新字段的容错差,有时会直接把带保留位的文件拒掉。遇到工具兼容性问题,记住一个原则:以官方工具重新生成为准,不要去手工修文件字节。
6. 进阶:把 CRC、版本号和回读验证塞进自动化流程
6.1 合并时顺便算 CRC:给固件留一个自检后门
boot 跳转 app 之前,经常要做完整性校验。常见做法是在 app.bin 尾部预留一小块区域存放 CRC32,boot 在跳转前计算整个 app 区域的 CRC 和预留值比对。合并脚本可以顺手做这件事。下面是扩展思路的代码片段:
import zlib # 在 merge() 返回前调用 crc = zlib.crc32(app_bin) & 0xFFFFFFFF # 在 app.bin 末尾追加 8 字节: 4 字节 CRC + 4 字节长度 app_with_crc = app_bin + struct.pack('<II', crc, len(app_bin)) image[offset:offset + len(app_with_crc)] = app_with_crc注意 app 工程要在分散加载文件里预留这 8 字节的空间,并且 boot 端校验时要按“整个 app 区域大小”来算,不能只算到代码末尾。如果 app.bin 本身已经接近 Flash 分区上限,加这 8 字节可能溢出,所以预留位要从 app 区起始就规划好。这种 CRC 方案对生产烧录和一包到底的升级场景很实用,代码侧只需要在 app 链接脚本里加一个空段。
6.2 回读验证:dfu-util 把烧进去的固件读回来比对
生成 DFU 文件的最后一关,是真正下载到设备上再回读比对。设备进入 DFU 模式后,用 dfu-util 列出设备:
dfu-util -l确认设备列出后,回读到本地文件:
dfu-util -a 0 -U readback.bin回读文件是从设备 Flash 里按完整区域读出的,和原始 combined.bin 直接 cmp 一般对不上,因为 DFU 模式回读往往包含整个 Flash 区域,而原始文件里 0xFF 部分可能被设备擦除后保留 0xFF,也有一部分区域是配置字或选项字节。我会把回读文件用脚本截取 boot 和 app 两个地址区间,再和原始 boot.bin、app.bin 做逐字节比较,通过才算这个 DFU 文件真的能用于量产。
最初几次做这套流水线时,我在 fromelf 路径和 gap 填充上各翻过一次车,后来把检查点全部写进脚本里,编译、合并、校验一条命令走完。这个习惯一直留到现在。DFU 生成这件事,工具只是载体,真正可靠的是地址一致性和可重复验证的流程。希望这篇能帮你在自己的 boot+app 方案里少踩几个坑。
本文还有配套的精品资源,点击获取