bin文件制作、截取、合并与修改:嵌入式固件处理实战

发布时间:2026/10/2 9:50:33
bin文件制作、截取、合并与修改:嵌入式固件处理实战
bin文件这个东西说简单也简单就是一堆裸字节说麻烦也麻烦因为它没有任何自描述信息没有地址、没有长度、没有校验全靠你脑子里那张分区表。我在Linux上折腾固件这些年被问得最多的三个问题几乎都一样怎么把elf转成bin、怎么从一整片读回里切出指定分区、怎么在不重新编译的前提下把某个字段改掉。这三个问题分别对应标题里的制作、截取、合并、修改它们其实是同一套工具链的四种用法——dd、cat、objcopy、xxd、printf、truncate加上一点脚本。这篇内容我按造出来、切下来、拼回去、改掉它、验证没改坏的顺序讲每一步都给出可直接复制的命令和参数推导过程。适合手里有板子、有固件、需要做量产打包或者现场救砖的人看也适合刚接触裸二进制的新手因为我会把那些文档里不写但一定会踩的细节一并说清楚。1. bin文件不是格式是责任1.1 裸二进制没有地址加载地址是工具给的很多人第一次接触bin文件会有个误解觉得它跟hex、elf一样是一种文件格式。其实恰恰相反bin是没有格式。elf里有段表、有虚拟地址、有入口点链接器告诉你这段代码该放在0x08000000那段数据该放在0x20000000hex文件里每一行都带着地址烧写工具按地址往flash里写。bin文件里什么都没有它就是内存映像的一段连续拷贝从第一个字节到最后一个字节。这就意味着同一个bin文件烧到0x08000000和烧到0x08004000结果是完全不同的两个系统。所以你在做bin的时候脑子里必须有一张明确的映射图这个文件的第0个字节对应flash的哪个物理地址长度是多少后面跟的是谁。我在项目里经常见到有人把两份bin混在一起分不清其实就是因为他们没有在文件命名里体现基地址和长度比如app_0x08008000_256k.bin这种命名方式虽然啰嗦但在救砖现场能救你一命。另一个常见误区是拿bin文件的文件大小去反推固件版本这个也不可靠。因为编译优化等级变了、多了几条打印字符串大小就变了反过来如果两个版本恰好都做了4字节对齐填充大小也可能完全一样。bin文件的大小只能告诉你占了多少空间不能告诉你是什么东西。1.2 什么时候必须用bin、什么时候死守elf烧写阶段几乎都用bin因为烧写器只需要一段字节流加一个起始偏移实现最简单。但调试阶段一定要保留elf符号表、行号信息、函数边界都在elf里用gdb或者addr2line定位崩溃点的时候没有elf你只能对着一串地址干瞪眼。我的习惯是同一份产物同时输出三个文件elf留档、bin用于烧写、map用于查符号边界和段大小。这三个文件必须来自同一次编译不能分两次编译再拼否则地址对不上。生产打包环节我会再额外计算一份sha256写进产线测试脚本烧完之后读回比对这一步能把烧写工具报成功但实际写坏这类问题挡在出厂之前。还有一类场景是必须用bin的加密固件、OTA差分包、带自定义头的升级包。这些东西的生成逻辑通常是在bin基础上再包一层所以bin是原料不是成品。理解了这一层后面截取和合并才有意义。1.3 动手之前先问三个问题每次我准备在bin文件上做手术之前都会先问自己三个问题这三个问题决定了后面所有参数怎么填。第一个问题是对齐。目标存储介质的最小写入单位是多少NOR flash通常是1字节可写但按扇区擦除常见4KBNAND是页写入常见2KB加块擦除常见128KBeMMC是512字节扇区。如果你的固件长度是0x1A3F而下一个分区从0x2000开始那你必须填充0x5C1个字节填充值还得按介质要求选。第二个问题是填充值。擦除后的空白区域NOR/NAND通常是0xFF某些eMMC区域可能是0x00。填充值搞错了校验环节会报一堆莫名其妙的错。我一般直接用0xFF填充因为绝大多数flash的空白就是0xFF这更接近真实烧写后的状态。第三个问题是长度字段的口径。你的头部里如果记录了固件长度它指的是含头的总长还是纯内容的长度这个字段在不同项目里定义完全不一样改坏它比改坏代码后果更严重因为引导程序可能直接用一个越界的长度去校验或者搬移数据。2. 造一个bin文件objcopy转换、多段拼装与校验头写入2.1 objcopy -O binary 到底扔掉了什么从elf生成bin最标准的命令是这样arm-none-eabi-objcopy -O binary firmware.elf firmware.bin看着简单但它做的事一点都不简单。objcopy会把elf里所有LOAD属性的段按照物理地址顺序排列取最低地址作为bin文件的第0字节然后把段之间的空隙用0填充补齐一直补到最高地址。也就是说如果.text在0x08000000、.data的加载地址在0x08020000中间那一大段空隙会被实实在在写成0bin文件会瞬间变成一个很大的文件。如果你的链接脚本里段地址分布得很散强烈建议加上-j指定只导出需要的段arm-none-eabi-objcopy -O binary -j .text -j .data -j .rodata firmware.elf firmware.bin另外--gap-fill可以指定空隙填充值默认是0arm-none-eabi-objcopy -O binary --gap-fill 0xff firmware.elf firmware.bin这里有个坑我踩过用--gap-fill 0xff之后.data段后面的空隙也变成0xFF了如果引导程序靠是否为0来判断数据有效性就会出问题。所以填充值要么统一约定要么在生成之后单独处理尾部别图省事一刀切。2.2 多段固件的拼装顺序与分区偏移典型的嵌入式固件是四段拼起来的bootloader、设备树或者配置块、内核或主程序、文件系统。这四段在flash里的偏移通常在分区表里写死拼接的时候必须严格对齐不能简单地cat了事。假设分区表是这样分区起始偏移大小boot0x000000256 KiBdtb0x04000064 KiBapp0x0500001 MiBrootfs0x150000剩余空间拼装脚本我会这么写#!/bin/bash set -e OUTimage.bin rm -f $OUT # 预分配总大小避免后续 seek 写出文件空洞 truncate -s $((0x150000)) $OUT write_at() { local file$1 off$2 dd if$file of$OUT bs1 seek$off convnotrunc statusnone } write_at boot.bin 0x000000 write_at board.dtb 0x040000 write_at app.bin 0x050000 write_at rootfs.bin 0x150000 echo image size: $(stat -c %s $OUT)注意几个点truncate先预分配是为了避免先写高偏移再写低偏移时产生大量稀疏空洞虽然convnotrunc会填回去但过程不直观bs1在几MB级别还能忍到了几百MB就该换成bs4096并把偏移换算成块数加oflagseek_bytes。2.3 写一个几十行的生成脚本把magic、长度、CRC一次性补齐很多升级包的结构是[magic 4B][version 4B][length 4B][crc32 4B][payload]头部共16字节。这种包我从来不用手工命令拼一律写脚本import zlib, struct, sys payload open(sys.argv[1], rb).read() magic bUPGD version 0x00010002 length len(payload) crc zlib.crc32(payload) 0xffffffff header struct.pack(4sIII, magic, version, length, crc) open(upgrade.bin, wb).write(header payload) print(fpayload{length} crc0x{crc:08x} total{length 16})struct.pack的4sIII里表示小端4s是4字节字符串三个I是三个32位无符号整数。大小端一定要跟接收端对齐ARM默认小端但有些协议规范为了跨平台会写大端这时候把换成。我把顺序搞反过一次烧录器一直报magic错误查了两个小时才反应过来。关于length字段的口径我个人的做法是明确定义成payload长度不含头16字节并且在脚本里顺手打印出来。头部一旦定死后续所有截取和修改操作都按这个口径推导不要中途改定义。3. 截取bin文件从偏移量算法到三种截取姿势的取舍3.1 bs、skip、count 的乘法关系算错一次就差之毫厘dd的参数关系可以用一句话概括实际跳过的字节数 bs × skip实际拷贝的字节数 bs × count。很多人写dd iffw.bin ofpart.bin bs4096 skip64 count256却不知道自己切的是 64×40962621440x40000开始、长度256×409610485760x100000的一段。这套算法有个致命弱点当偏移量不是bs的整数倍时skip就没法精确表达。比如偏移是0x1234bs4096时skip1会切到0x1234之后剩下0x0DCC字节的偏差。解决办法有两个一是用bs1保证精确但速度慢二是用iflagskip_bytes和oflagseek_bytes让dd把skip/seek当成字节数而不是块数dd iffw.bin ofpart.bin iflagskip_bytes,count_bytes \ skip$((0x1234)) count$((0x8000)) bs1048576 statusprogressbs设大只影响读写缓冲区大小不再参与偏移计算这招在处理几十GB镜像的时候差别非常明显——bs1切10GB可能要几十分钟bs1M加字节偏移几秒钟就完事。3.2 head/tail 与 split长度友好但偏移不友好如果只想从文件头部取前N字节head -c N是最省事的如果只想从某个偏移取到文件末尾tail -c N就够用注意N是从第N字节开始从1计数head -c $((0x40000)) fw.bin boot.bin tail -c $((0x1500001)) image.bin rootfs.bin但从中间截一段就需要管道组合head -c $((0x130000)) fw.bin | tail -c $((0x500001)) app.bin这行命令的意思是从文件开头取到0x130000再从这个结果里保留从0x50000开始的部分最终长度就是0x130000-0x500000xE0000。管道方式对偏移量不敏感不需要对齐缺点是数据被读了两遍大文件上会多花时间而且还容易被shell的整数运算精度限制坑到。split更适合整体分段的场景比如把一个16MiB的整片读回按4MiB切成四份split -b 4M -d -a 2 flash_dump.bin chunk_ # 产出 chunk_00 chunk_01 chunk_02 chunk_03split是按固定长度切不能指定偏移所以它只能解决从头开始等分的需求。真要按分区表切还是dd更直接。3.3 实战从16MiB整片读回里抠出应用分区场景是这样用烧写工具把整片16MiB flash读回来存成flash_dump.bin现在要从里面把app分区抠出来做校验和反汇编。第一步先确认读回的完整性和基地址。整片读回的offset 0就是flash物理地址0这个前提要跟烧写工具确认有的工具会跳过前面的保护区。第二步用分区表里的偏移和长度切dd ifflash_dump.bin ofapp.bin \ bs4096 skip$((0x050000/4096)) count$((0x100000/4096)) \ statusprogress这里我刻意用了bs4096并且把偏移和长度都换算成块数因为0x050000和0x100000都是4096的整数倍换算无误差而且速度比bs1快得多。第三步去掉尾部填充。如果app的实际长度小于1MiB尾部会是一大段0xFF。可以用一个简单的方法找到最后一个非0xFF字节python3 - EOF data open(app.bin,rb).read() end len(data) while end 0 and data[end-1] 0xFF: end - 1 print(factual size: {end} (0x{end:x})) EOF第四步和编译产物做比对cmp -l app.bin app_from_build.bin | head -20cmp -l会输出第几个字节 文件1的值 文件2的值全是八进制。如果只有尾部填充差异那就说明抠出来的内容是对的如果中间有差异那就要考虑是不是读回的时候有坏块或者烧写时做了加密。4. 合并bin文件顺序拼接与按偏移覆盖式写入4.1 cat 顺序拼接的适用边界cat a.bin b.bin c.bin是最直觉的合并方式但它只适用于一种情况a和b在目标介质里就是紧挨着的a的末尾后面第一个字节就是b的开头。只要中间需要填充、需要对齐、需要留空cat就不够用了。我在做双区固件A/B双备份升级的时候两个区之间通常要留一段元数据区记录当前生效的是A还是B、升级计数器是多少。这时候如果直接cat元数据区就没地方放了。一个折中办法是先把填充文件造出来再cat# 造一个64KiB的全0xFF填充块 dd if/dev/zero bs1024 count64 2/dev/null | tr \0 \377 pad_ff.bin cat boot.bin pad_ff.bin app.bin pad_ff.bin rootfs.bin image.bintr \0 \377把全0转换成全0xFF\377是八进制的255。这个写法比printf \xff循环快得多也不用依赖hexdump之类的工具。不过要注意tr处理大文件时的性能一般几百MB以上我更倾向于用Python一次性生成。4.2 dd seek convnotrunc 覆盖式合并当合并的目标是往一个已经存在的镜像里填内容时dd的seek加convnotrunc才是正确姿势cp base_image.bin work.bin dd ifapp_new.bin ofwork.bin bs4096 seek$((0x050000/4096)) \ convnotrunc statusprogressconvnotrunc这个参数是必须的不加的话dd在打开输出文件时会先把它截断成0长度你的base_image就没了。我第一次犯这个错的时候一份完整的出厂镜像被清成空文件好在有备份。seek指定的是从输出文件开头跳过的块数除非用oflagseek_bytes。这里又回到bs的整数倍问题如果偏移不是4096的整数倍用oflagseek_bytes避免换算误差。还有一个变体是部分覆盖也就是新内容比原内容短只覆盖前面一部分后面保留原样。convnotrunc天然支持这个行为因为dd只写它读到的那么多字节不会去动文件剩余部分。这个特性在做补丁的时候非常有用——比如只替换一个几百字节的配置块剩下的几百MB镜像原封不动。4.3 空洞、预分配与填充值0x00 和 0xFF 不能随便选用seek往文件后面写的时候如果输出文件原本不够长中间会形成空洞。Linux下dd写空洞的区域默认是0读出来是0但这是文件系统的行为跟真实flash上的0xFF不一样。处理方式有两种。第一种是先把文件预分配到目标大小truncate -s $((0x2000000)) work.bintruncate也是产生空洞逻辑上文件长度变了但磁盘占用没变。第二种是实打实地写出填充字节fallocate -l $((0x2000000)) work.bin # 真分配速度极快或者dd if/dev/zero bs1M count32 ofwork.binfallocate和dd的区别是前者只分配空间不写内容内容仍是0后者真的写了一遍。如果目标介质空白是0xFF那你必须用tr \0 \377之类的方式生成真正的0xFF填充不能指望空洞。我在量产脚本里统一的做法是先用Python生成一个全0xFF的模板文件再用dd按偏移往里填各分区。这样不管目标介质是什么填充值都是显式可控的。5. 修改bin文件定位字段、回写字节与重算校验5.1 先建表再动手xxd/od 建立地址-内容对照改bin文件最忌讳的是凭感觉找位置。正确做法是先建一张表和一份dump看清楚每个字段在哪个偏移。xxd -g 1 -l 256 -s 0x100 fw.bin-g 1表示每字节一组-l 256表示显示256字节-s 0x100表示从偏移0x100开始。输出会带地址列和ASCII对照列找字符串特别方便。如果要全局搜索某个magic或者字符串用grep -abo $\x55\x50\x47\x44 fw.bin-a把二进制当文本处理-b输出字节偏移-o只输出匹配部分。这条命令能直接告诉你magic在文件里的绝对偏移。对于纯ASCII字符串grep -abo VERSION就够了。od的优势是可以自定义输出格式od -A x -t x1z -j $((0x1000)) -N 64 fw.bin-A x地址用十六进制-t x1z每字节十六进制加ASCII显示-j起始偏移-N读取长度。我一般用xxd看结构用od对照特定格式校验。建表这一步的关键是把字段名-偏移-长度-字节序-含义写下来不要只在终端里看一眼就动手。改完再回来看的时候有表就能立刻确认改对了没有。5.2 单点字段回写printf 管道给 dd确认偏移之后回写就一行命令printf \x02\x00\x01\x00 | dd offw.bin bs1 seek$((0x104)) convnotrunc statusnone这里往0x104写了一个小端的版本号0x00010002。printf的\x转义在bash的builtin里是支持的如果你用的是sh或者dash可能需要用printf \002\000\001\000八进制。这个兼容性问题我踩过脚本在本地跑得好好的一到精简的容器镜像里就写错了字节。如果要写的是ASCII字符串更简单printf SN20240101 | dd offw.bin bs1 seek$((0x200)) convnotrunc statusnone字符串长度必须跟原字段完全一致多一个字节就会把后面的数据挤掉。如果新字符串短于原字段可以用空格或者0补齐但要注意有些固件是用\0判断字符串结尾的用空格补会读出多余内容。5.3 等长字符串批量替换的Python脚本有些场景要改的不是一个字段而是散落在文件各处的一批字符串比如把默认SSID前缀统一换掉、把测试用的URL换成生产环境。这种活交给Python最省事import sys path, old_s, new_s sys.argv[1], sys.argv[2], sys.argv[3] old, new old_s.encode(), new_s.encode() if len(old) ! len(new): sys.exit(flength mismatch: {len(old)} vs {len(new)}) data bytearray(open(path, rb).read()) count, idx 0, 0 while True: i data.find(old, idx) if i 0: break data[i:ilen(old)] new idx i len(new) count 1 open(path, wb).write(data) print(freplaced {count} occurrence(s))脚本里强制要求新旧长度相等这是刻意的。等长替换不会移动任何后续字节也就不会破坏任何偏移相关字段。如果确实需要变长替换那就必须同步更新所有记录长度的字段和校验工作量和风险都上一个大台阶我一般会劝人重新编译而不是硬改。另外find是全字节匹配如果某个短字符串恰好出现在别的数据里也会被替换。比如替换test这个词可能会误伤一段二进制数据里恰好等于0x74 0x65 0x73 0x74的字节。规避办法是先用脚本只统计不替换确认出现次数符合预期再真正执行。5.4 改长度、改校验必须同步CRC32与长度字段的重算只要你的bin文件里存在校验字段或者长度字段改完内容之后就必须重算。以头部16字节magicversionlengthcrc32、其余为payload的结构为例import zlib, struct data bytearray(open(upgrade.bin, rb).read()) payload data[16:] new_len len(payload) new_crc zlib.crc32(payload) 0xffffffff struct.pack_into(I, data, 8, new_len) # length 字段在偏移8 struct.pack_into(I, data, 12, new_crc) # crc32 字段在偏移12 open(upgrade_fixed.bin, wb).write(data) print(flength{new_len} crc0x{new_crc:08x})struct.pack_into比切片再拼接更直观它直接往bytearray的指定偏移写打包后的字节不会改变数组长度。用切片赋值也可以但偏移算错的时候pack_into会直接抛异常提醒你切片则可能悄无声息地截断。校验算法本身也要跟接收端一致。CRC32有很多变体Python的zlib.crc32是标准的多项式0x04C11DB7、反射输入输出、初值0xFFFFFFFF、结果异或0xFFFFFFFF。如果你的固件用的是查表法实现的另一种变体算出来对不上那就得照抄固件里的算法。我的习惯是先在固件源码里找到校验函数把它翻译成Python跑一遍已知正确的固件验证再用来改文件。这一步验证不能省否则你会陷入改了之后设备不启动但不知道是校验错了还是别的地方错了的泥潭。6. 改完怎么证明没改坏验证链路与我对参数的核查习惯6.1 md5sum cmp 的组合拳改完文件第一件事是算哈希md5sum fw.bin fw_backup.bin sha256sum fw.bin fw.bin.sha256md5足够用来做版本比对但如果文件要发布或者要走产线我会用sha256避免碰撞带来的误判风险。sha256的另一个用途是给烧录器或者产线测试脚本提供期望指纹烧完读回后重新算一遍比对。第二件事是逐字节比对差异cmp -l fw_backup.bin fw.bin | wc -l cmp -l fw_backup.bin fw.bin | head -20第一条看差异字节总数第二条看具体差在哪。理想情况是差异数量刚好等于你修改的字节数且偏移全部落在你预期的位置。如果差异数量远超预期说明写入的时候seek或者bs算错了很可能整段数据被挪了位置。这一步我在第一次改固件的时候就吃过亏用了bs1 seek0x200结果不小心写成了八进制的200十进制128差异偏移全都不对cmp的输出一眼就看出来了。这也是我坚持每次改完都跑cmp的原因代价只有几秒钟。6.2 版本号、时间戳这类每次都不一样的字段怎么处理有类字段天生就会变编译时间戳、构建流水号、自增版本号。带着这些字段的固件做比对时差异永远不会是零。处理办法是在比对前先把这些字段屏蔽掉具体做法是生成一份规范化副本把这些偏移处的字节统一改成固定的占位值再对两份文件做哈希比对。def normalize(path, offsets): data bytearray(open(path, rb).read()) for off, length in offsets: data[off:offlength] b\x00 * length return bytes(data) a normalize(fw_backup.bin, [(0x100, 4), (0x110, 8)]) b normalize(fw.bin, [(0x100, 4), (0x110, 8)]) print(same if a b else differs)offsets列表里放的就是时间戳和版本号的位置。这段逻辑我一般直接塞进持续集成的比对脚本里每次构建完自动跑一遍确认除了预期变化的字段之外其它字节一个都没动。另外一个更省事的思路是把可变字段放在文件的最后或者干脆不入镜像、由引导程序在首次启动时写入。这样编译产物就是完全确定的任何两次构建都能做出哈希一致的bin文件非常适合做供应链校验。7. 几个我反复踩到的坑对齐、字节序、填充值与文件空洞7.1 对齐、擦除块与flash硬件约束所有偏移和长度最好都按目标介质的最小写入粒度对齐。NAND的页大小常见是204864字节带OOBNOR的扇区是4KiBeMMC是512字节。如果你的固件长度不是这些数的整数倍下一个分区就得填充到对齐边界。我见过一次因为不对齐导致的诡异故障bootloader结束在0x03FFF0app从0x040000开始看着没问题但bootloader的实际有效数据只到0x03FF80中间那0x80是随机数据链接器没清干净。烧写器按4KiB整块写入把随机数据也写进去了。结果引导程序校验时把随机数据当成了合法的扩展头读出一个离谱的长度直接跳过app去执行0地址。排查了整整一天。从那以后我养成一个习惯在所有bin生成脚本末尾加一段尾部清理把最后一个有效字节之后的内容统一填成介质默认值。python3 - EOF data bytearray(open(app.bin,rb).read()) end len(data) while end 0 and data[end-1] 0xFF: end - 1 for i in range(end, len(data)): data[i] 0xFF open(app_clean.bin,wb).write(data) EOF这段代码看着有点绕先找末尾再回填实际意图是显式保证尾部全是0xFF不受链接器行为影响。7.2 字节序和位域改一个bit却动了半个字节多字节字段的字节序必须和固件里的读取代码一致。ARM默认小端所以0x00010002在文件里是02 00 01 00。如果协议规定大端就得写成00 01 00 02。位域更麻烦。有些字段是一个字节里高4位是A低4位是B你要改A却用整字节写就把B也改了。正确做法是先读原字节、按位与清掉目标位、再按位或写入新值data bytearray(open(cfg.bin,rb).read()) off 0x300 data[off] (data[off] 0x0F) | (0x05 4) open(cfg.bin,wb).write(data)这行代码把偏移0x300处字节的高4位设为5低4位保持原样。如果直接data[off] 0x50低4位就被清零了而低4位可能是另一个开关。为了确认位域含义最好的办法是去翻固件源码里的结构体定义而不是猜。__attribute__((packed))的结构体在内存里的布局可以严格对应文件里的字节只要知道基地址和字节序就能精确算出每个位在哪。7.3 大文件、空洞与看起来一样实际不一样最后一个坑是关于文件空洞的。用truncate或者dd seek造出来的区域在文件系统层面是稀疏的ls -l显示的大小跟du显示的实际占用可能差好几倍。这两者在读取时表现一致都是0但在做哈希和拷贝时行为不同。cp默认会把空洞读出来再写一遍结果是文件占用膨胀。要用cp --sparsealways保持稀疏。tar默认会保留稀疏信息但经过某些压缩管道之后就丢了。如果你把稀疏文件烧写到flash读回来的是0而不是空洞——这时候拿烧写前和烧写后读回做哈希比对结果必然不一致但其实是正常的。解决办法是在比对之前把两份文件都实体化cp --sparsenever a.bin a_dense.bin cp --sparsenever b.bin b_dense.bin cmp a_dense.bin b_dense.bin echo identical或者更彻底的用fallocate在生成阶段就写出真实内容不用空洞。在实际项目里我的做法是要求所有交付的bin文件都不允许是稀疏的生成脚本最后统一跑一次实体化这样哈希结果就是稳定可靠的。另外一个容易忽略的点是文件权限和扩展属性。有些产线工具会读取文件的mtime来判断版本新旧改完bin之后mtime会变可能触发非预期的行为。交付前统一touch -r到一个固定参考文件能让产物完全确定。我个人在实际操作中的体会是bin文件的处理其实不难难的是别自作聪明。绝大多数事故都不是因为命令不会写而是因为参数随手改了一个、填充值随手换了一个、校验忘了重算。我现在固定用一套脚本模板所有偏移和长度都从一份配置里读改固件只需要改配置不改命令这样出错概率能降一个数量级而且半年后回来看也能立刻明白当初干了什么。