ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

嵌入式固件BIN与HEX互转合并:Python实现与CI集成指南

嵌入式固件BIN与HEX互转合并:Python实现与CI集成指南

简介:这是一款面向嵌入式开发者的BIN与HEX文件互转合并工具,基于MFC对话框程序实现,适合需要处理单片机固件格式转换的工程师与学习者使用。工具支持BIN转HEX、HEX转BIN以及HEX文件合并三类核心操作,并针对实际使用中的常见问题持续修复:转HEX时A~F字符统一为大写以避免Keil使用出错,解决了BIN文件超过64K时数据错乱的问题,还修复了C8051所用Keil编译的HEX文件转BIN时的错误。资源包共22个文件,包含5个h头文件、4个cpp源文件、2个exe可执行程序,以及rc、dsp、dsw等工程配置与资源文件,压缩包约654KB,可直接运行或参考源码二次开发。目前已有3661人学习下载,适合需要快速完成固件格式转换、排查转换异常或研究MFC工程结构的开发者参考使用。

1. BIN 和 HEX 互转合并:嵌入式固件交付里最容易被低估的一环

做嵌入式交付的同行大概率都遇到过这种场景:产线烧录工装只认.bin,但芯片原厂给的参考固件是.hex;或者 Bootloader 升级包要求把 Boot 区、App 区、参数区三段合并成一个镜像,而手头拿到的三段文件格式还不统一。这时候如果只会用 IDE 点一下「下载」,就会卡在「文件格式对不上」这种看起来很小、实际很要命的问题上。BIN 文件和 HEX 文件可以互转合并,说的就是这件事:把裸二进制镜像和带地址信息的文本记录格式互相翻译,并且按地址把多段内容拼成一个完整镜像。它解决的是固件在编译、烧录、OTA 升级、产线工装之间的格式鸿沟,适合做 MCU 固件、Bootloader、量产烧录脚本、CI 自动打包的工程师。核心不是「会不会用工具」,而是搞清楚地址、段、填充这三件事,否则转出来的文件烧进去就是一块砖。

2. 先把 BIN 和 HEX 的地址模型讲透,再谈转换

2.1 BIN 是纯字节流,HEX 是带地址的记录集合

BIN 文件本质就是一段连续的字节序列,它自己不知道这段数据应该放在 Flash 的哪个地址。你把它烧到0x08000000还是0x08004000,完全由烧录器或下载算法决定。这也是为什么同一个 BIN 文件,用不同地址烧进去,运行结果可能完全不同。

HEX 文件(常见的是 Intel HEX 格式)则不一样,它是文本格式,每一行是一条记录,记录里包含数据长度、目标地址、记录类型、数据和校验和。常见记录类型有:

记录类型含义是否携带数据
00数据记录是
01文件结束否
02扩展段地址否
04扩展线性地址否
05起始线性地址否

关键点在于:HEX 文件里的地址是显式写死的,所以它天然支持「不连续的多段数据」。而 BIN 文件要表达多段,就必须靠外部约定,比如「前 16KB 是 Boot,后面是 App」,或者干脆在合并时用填充字节把空洞补上。

2.2 转换的本质是「地址 + 数据」和「纯数据」之间的映射

从 HEX 转 BIN,核心动作是:解析每一条数据记录,取出地址和数据,写入输出缓冲区对应偏移。偏移的计算方式是记录地址 - 起始地址。如果记录地址不连续,中间的空洞要么用0xFF填充,要么直接报错,取决于你的策略。

从 BIN 转 HEX,核心动作是:给定一个起始地址,把 BIN 的每个字节按顺序生成数据记录,每行最多 16 或 32 字节,最后补一条结束记录。这里最容易翻车的地方是地址进位和校验和计算,尤其是文件超过 64KB 时,必须用扩展线性地址记录(类型 04)来切换高 16 位地址。

2.3 合并多段固件时,地址冲突和空洞怎么处理

合并的本质是把多个 HEX 或 BIN 按各自地址写进同一张「地址-数据」表。常见做法是:

  • 统一转成带地址的中间结构,比如{address: byte}的映射或按段存储的列表。
  • 按地址排序后检查重叠。如果两段地址范围有交集,必须报错,不能默默覆盖。
  • 对于空洞,量产镜像通常填0xFF,因为 Flash 擦除后的状态就是全 1。如果填0x00,烧进去可能改变未使用区域的行为。

我一般会先定义一个段结构,再写合并逻辑,这样后面加段、改地址都方便。

# 段结构:每段包含起始地址和字节数据 class FirmwareSegment: def __init__(self, base_addr, data): self.base_addr = base_addr self.data = bytearray(data) @property def end_addr(self): return self.base_addr + len(self.data) - 1 # 合并多个段,检查重叠,空洞填 0xFF def merge_segments(segments, fill=0xFF): segments = sorted(segments, key=lambda s: s.base_addr) for i in range(1, len(segments)): if segments[i].base_addr <= segments[i-1].end_addr: raise ValueError( f"地址重叠: 段{i} 起始 0x{segments[i].base_addr:08X} " f"与上一段结束 0x{segments[i-1].end_addr:08X}" ) base = segments[0].base_addr end = segments[-1].end_addr buf = bytearray([fill]) * (end - base + 1) for seg in segments: offset = seg.base_addr - base buf[offset:offset+len(seg.data)] = seg.data return base, buf

这段代码里fill参数控制空洞填充值,默认0xFF。merge_segments先排序再检查重叠,重叠直接抛异常,避免静默覆盖。最后按最小地址到最大地址分配缓冲区,逐段拷贝。注意end_addr是闭区间,所以缓冲区长度是end - base + 1,这个+1漏掉就会少一个字节,烧录时最后一个字节丢失,排查起来非常费劲。

3. 用 Python 实现 HEX 与 BIN 互转的完整脚本

3.1 解析 Intel HEX:逐行读取与校验和验证

Intel HEX 每行格式是:LLAAAATT[DD...]CC,其中LL是数据长度,AAAA是 16 位地址,TT是记录类型,DD是数据,CC是校验和。校验和算法是:除冒号外所有字节求和,取低 8 位,再取补码。

def parse_hex_line(line): line = line.strip() if not line or not line.startswith(':'): raise ValueError(f"非法 HEX 行: {line}") raw = bytes.fromhex(line[1:]) length = raw[0] addr = (raw[1] << 8) | raw[2] rectype = raw[3] data = raw[4:4+length] checksum = raw[4+length] # 校验和验证 total = sum(raw[:-1]) & 0xFF if ((total + checksum) & 0xFF) != 0: raise ValueError(f"校验和错误: {line}") return rectype, addr, data

raw[0]是长度,raw[1:3]是地址,raw[3]是类型,raw[4:4+length]是数据,最后一个字节是校验和。校验通过的条件是「所有字节之和的低 8 位为 0」。如果校验失败,说明文件在传输或编辑过程中被破坏,必须停下来查,不能跳过。

3.2 HEX 转 BIN:处理扩展线性地址和空洞填充

解析完整 HEX 文件时,遇到类型 04 要更新高 16 位地址基址,遇到类型 00 才真正写数据。

def hex_to_bin(hex_path, bin_path, fill=0xFF): base_high = 0 segments = {} with open(hex_path, 'r') as f: for line in f: rectype, addr, data = parse_hex_line(line) if rectype == 0x04: base_high = ((data[0] << 8) | data[1]) << 16 elif rectype == 0x00: full_addr = base_high + addr for i, b in enumerate(data): segments[full_addr + i] = b elif rectype == 0x01: break if not segments: raise ValueError("HEX 文件中没有数据记录") min_addr = min(segments) max_addr = max(segments) buf = bytearray([fill]) * (max_addr - min_addr + 1) for a, b in segments.items(): buf[a - min_addr] = b with open(bin_path, 'wb') as f: f.write(buf) return min_addr, len(buf)

这里用字典segments存「地址-字节」,好处是天然去重且能处理乱序记录。最后按最小地址到最大地址生成缓冲区。fill默认0xFF,和 Flash 擦除态一致。返回的min_addr很重要,烧录 BIN 时必须告诉烧录器这个起始地址,否则数据会错位。

3.3 BIN 转 HEX:地址进位与扩展线性地址记录生成

BIN 转 HEX 时,如果起始地址加数据长度超过0xFFFF,就必须插入类型 04 记录。

def bin_to_hex(bin_path, hex_path, base_addr=0x08000000, line_size=16): with open(bin_path, 'rb') as f: data = f.read() lines = [] upper = None for offset in range(0, len(data), line_size): chunk = data[offset:offset+line_size] addr = base_addr + offset high = (addr >> 16) & 0xFFFF low = addr & 0xFFFF if high != upper: # 生成扩展线性地址记录 rec = bytes([0x02, 0x00, 0x00, 0x04, (high >> 8) & 0xFF, high & 0xFF]) checksum = (-sum(rec)) & 0xFF lines.append(':' + (rec + bytes([checksum])).hex().upper()) upper = high rec = bytes([len(chunk), (low >> 8) & 0xFF, low & 0xFF, 0x00]) + chunk checksum = (-sum(rec)) & 0xFF lines.append(':' + (rec + bytes([checksum])).hex().upper()) lines.append(':00000001FF') with open(hex_path, 'w') as f: f.write('\n'.join(lines) + '\n')

line_size默认 16,和大多数工具一致。upper变量记录当前高 16 位地址,只有变化时才插入类型 04 记录,避免冗余。最后一行:00000001FF是标准结束记录。注意校验和计算用(-sum(rec)) & 0xFF,这是补码写法,等价于「取反加一」。

3.4 多段合并:把 Boot、App、参数区拼成一个镜像

有了上面的基础,合并就是先分别解析成段,再调用merge_segments。

def merge_firmware(inputs, output_bin, output_hex=None): segments = [] for path, base in inputs: if path.lower().endswith('.hex'): # 解析 HEX 得到段 base_high = 0 seg_data = {} with open(path) as f: for line in f: rectype, addr, data = parse_hex_line(line) if rectype == 0x04: base_high = ((data[0] << 8) | data[1]) << 16 elif rectype == 0x00: for i, b in enumerate(data): seg_data[base_high + addr + i] = b elif rectype == 0x01: break if seg_data: min_a = min(seg_data) max_a = max(seg_data) buf = bytearray([0xFF]) * (max_a - min_a + 1) for a, b in seg_data.items(): buf[a - min_a] = b segments.append(FirmwareSegment(min_a, buf)) else: with open(path, 'rb') as f: segments.append(FirmwareSegment(base, f.read())) base, merged = merge_segments(segments) with open(output_bin, 'wb') as f: f.write(merged) if output_hex: bin_to_hex(output_bin, output_hex, base_addr=base) return base, len(merged)

inputs是(文件路径, 基地址)列表,HEX 文件会忽略传入的基地址,以文件内地址为准;BIN 文件必须传基地址。合并后输出 BIN,可选再转回 HEX。返回合并后的起始地址和总长度,方便写烧录脚本时直接引用。

4. 避坑与排查:格式转换里那些让人返工的细节

4.1 现象:转出来的 BIN 烧进去不运行,读回来发现开头少了一段

原因通常是 HEX 文件里第一条数据记录的地址不是最小地址,而转换脚本默认从 0 开始写。比如 HEX 从0x08004000开始,脚本却把数据写到了 BIN 偏移 0,烧录器又从0x08000000烧,整体错位 16KB。

解决:转换时记录最小地址并返回,烧录时用这个地址作为起始地址。或者转换时就按最小地址对齐,并在日志里打印出来。

4.2 现象:合并后文件变大很多,烧录时间明显变长

原因是两段固件之间地址跨度很大,中间空洞被0xFF填满。比如 Boot 在0x08000000,参数区在0x08040000,中间 256KB 全是填充。

解决:如果烧录器支持分段烧录,就不要合并成一个 BIN,而是分别烧录。如果必须合并,确认空洞是否真的需要填充,有些场景可以用稀疏文件或让烧录算法跳过全0xFF区域。

4.3 现象:HEX 文件用文本编辑器改过之后,转换报校验和错误

原因是手动改数据没重算校验和。Intel HEX 的校验和是强校验,改一个字节就会失败。

解决:不要手动改 HEX,改 BIN 再转 HEX。如果必须改 HEX,用脚本重新计算校验和,或者用支持自动校验的工具。

4.4 现象:超过 64KB 的 BIN 转 HEX 后,烧录到高地址区域数据错乱

原因是转换脚本没有正确插入类型 04 扩展线性地址记录,或者高地址计算用了 16 位截断。

解决:检查high = (addr >> 16) & 0xFFFF这一行,确保地址是 32 位计算。另外确认烧录器支持扩展线性地址记录,有些老工具只认类型 02。

4.5 现象:两段固件地址有重叠,合并脚本没报错,运行异常

原因是合并逻辑用了字典或直接覆盖,后写入的段把前面的覆盖了。

解决:合并前显式检查每段的地址范围,有交集就报错。不要依赖「后写覆盖」这种隐式行为,量产固件里这是事故。

5. 把转换脚本接进 CI:自动打包与校验的实用技巧

5.1 用 Makefile 或脚本封装,让每次编译都产出 BIN 和 HEX

我一般会在项目里放一个tools/fwpack.py,然后在 Makefile 里加两个目标:pack-bin和pack-hex。编译完自动调用,产出物统一放到build/目录。这样产线和 CI 拿到的永远是同源文件,不会出现「本地能跑、产线烧错」的问题。

build/firmware.hex: build/firmware.elf arm-none-eabi-objcopy -O ihex $< $@ build/firmware.bin: build/firmware.elf arm-none-eabi-objcopy -O binary $< $@ pack: build/firmware.hex build/firmware.bin python tools/fwpack.py merge \ --input build/boot.hex \ --input build/app.bin@0x08004000 \ --output build/full.bin \ --output-hex build/full.hex

objcopy负责从 ELF 生成标准 HEX 和 BIN,fwpack.py负责合并。@0x08004000这种写法表示 BIN 的基地址,解析起来直观。

5.2 合并后做一次回读校验,比信任转换脚本更靠谱

转换脚本再小心也可能有边界问题。我的习惯是合并后立刻做一次反向解析:把输出的 HEX 再转回 BIN,和原始合并结果逐字节比较。不一致就报错,CI 直接失败。

def verify_roundtrip(bin_path, hex_path): import tempfile, os with tempfile.NamedTemporaryFile(delete=False, suffix='.bin') as tmp: tmp_path = tmp.name try: base, length = hex_to_bin(hex_path, tmp_path) with open(bin_path, 'rb') as f1, open(tmp_path, 'rb') as f2: a = f1.read() b = f2.read() if a != b: raise ValueError("回读校验失败:BIN 与 HEX 内容不一致") return True finally: os.unlink(tmp_path)

这个校验能抓住绝大多数地址计算和填充错误。代价是几秒钟,换来的是产线不返工。

5.3 版本号和构建信息写进固定地址,方便追溯

量产固件最怕拿到一个 BIN 不知道是哪个版本。我的做法是在链接脚本里留一个固定地址的版本区,比如0x0803F000,编译时把 git commit、构建时间、版本号写进去。合并时这个区域自然被包含进去,烧录后可以通过调试器或 Bootloader 读出来。

# 生成版本信息二进制,追加到指定地址 import struct, time def make_version_blob(version, git_hash): blob = struct.pack('<16sI', version.encode()[:16], int(time.time())) blob += git_hash.encode()[:8].ljust(8, b'\x00') return blob

这个 blob 在合并时作为一个独立段传入,地址固定。后面排查现场问题时,读一下这个区域就能确认固件身份,比翻烧录记录快得多。

5.4 一个我踩过的坑:别在转换脚本里用默认参数猜地址

早期我写转换脚本时,base_addr默认给了0x08000000,结果有一次处理一个从0x00000000开始的镜像,转出来的 HEX 地址全错,烧进去直接不启动。后来我把所有地址参数改成必填,没有默认值,调用时必须显式写清楚。这个改动让脚本难用了一点,但再也没出过地址错位的事。固件格式转换这件事,宁可多打几个字,也不要让脚本替你做假设。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进