bin文件这个东西,说简单也简单,就是一堆裸字节;说麻烦也麻烦,因为它没有任何自描述信息,没有地址、没有长度、没有校验,全靠你脑子里那张分区表。我在Linux上折腾固件这些年,被问得最多的三个问题几乎都一样:怎么把elf转成bin、怎么从一整片读回里切出指定分区、怎么在不重新编译的前提下把某个字段改掉。这三个问题分别对应标题里的制作、截取、合并、修改,它们其实是同一套工具链的四种用法——dd、cat、objcopy、xxd、printf、truncate,加上一点脚本。这篇内容我按"造出来、切下来、拼回去、改掉它、验证没改坏"的顺序讲,每一步都给出可直接复制的命令和参数推导过程。适合手里有板子、有固件、需要做量产打包或者现场救砖的人看,也适合刚接触裸二进制的新手,因为我会把那些"文档里不写但一定会踩"的细节一并说清楚。
1. bin文件不是"格式",是"责任"
1.1 裸二进制没有地址,加载地址是工具给的
很多人第一次接触bin文件会有个误解,觉得它跟hex、elf一样是一种"文件格式"。其实恰恰相反,bin是"没有格式"。elf里有段表、有虚拟地址、有入口点,链接器告诉你这段代码该放在0x08000000,那段数据该放在0x20000000;hex文件里每一行都带着地址,烧写工具按地址往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字节可写但按扇区擦除(常见4KB),NAND是页写入(常见2KB)加块擦除(常见128KB),eMMC是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,中间那一大段空隙会被实实在在写成0,bin文件会瞬间变成一个很大的文件。
如果你的链接脚本里段地址分布得很散,强烈建议加上-j指定只导出需要的段:
arm-none-eabi-objcopy -O binary -j .text -j .data -j .rodata firmware.elf firmware.bin另外,--gap-fill可以指定空隙填充值,默认是0:
arm-none-eabi-objcopy -O binary --gap-fill 0xff firmware.elf firmware.bin这里有个坑我踩过:用--gap-fill 0xff之后,.data段后面的空隙也变成0xFF了,如果引导程序靠"是否为0"来判断数据有效性,就会出问题。所以填充值要么统一约定,要么在生成之后单独处理尾部,别图省事一刀切。
2.2 多段固件的拼装顺序与分区偏移
典型的嵌入式固件是四段拼起来的:bootloader、设备树或者配置块、内核或主程序、文件系统。这四段在flash里的偏移通常在分区表里写死,拼接的时候必须严格对齐,不能简单地cat了事。
假设分区表是这样:
| 分区 | 起始偏移 | 大小 |
|---|---|---|
| boot | 0x000000 | 256 KiB |
| dtb | 0x040000 | 64 KiB |
| app | 0x050000 | 1 MiB |
| rootfs | 0x150000 | 剩余空间 |
拼装脚本我会这么写:
#!/bin/bash set -e OUT=image.bin rm -f "$OUT" # 预分配总大小,避免后续 seek 写出文件空洞 truncate -s $((0x150000)) "$OUT" write_at() { local file=$1 off=$2 dd if="$file" of="$OUT" bs=1 seek="$off" conv=notrunc status=none } 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先预分配是为了避免先写高偏移再写低偏移时产生大量稀疏空洞(虽然conv=notrunc会填回去,但过程不直观);bs=1在几MB级别还能忍,到了几百MB就该换成bs=4096并把偏移换算成块数加oflag=seek_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 = b'UPGD' 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(f'payload={length} crc=0x{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 if=fw.bin of=part.bin bs=4096 skip=64 count=256却不知道自己切的是 64×4096=262144(0x40000)开始、长度256×4096=1048576(0x100000)的一段。
这套算法有个致命弱点:当偏移量不是bs的整数倍时,skip就没法精确表达。比如偏移是0x1234,bs=4096时,skip=1会切到0x1234之后,剩下0x0DCC字节的偏差。解决办法有两个,一是用bs=1保证精确但速度慢,二是用iflag=skip_bytes和oflag=seek_bytes让dd把skip/seek当成字节数而不是块数:
dd if=fw.bin of=part.bin iflag=skip_bytes,count_bytes \ skip=$((0x1234)) count=$((0x8000)) bs=1048576 status=progressbs设大只影响读写缓冲区大小,不再参与偏移计算,这招在处理几十GB镜像的时候差别非常明显——bs=1切10GB可能要几十分钟,bs=1M加字节偏移几秒钟就完事。
3.2 head/tail 与 split:长度友好但偏移不友好
如果只想从文件头部取前N字节,head -c N是最省事的;如果只想从某个偏移取到文件末尾,tail -c +N就够用(注意+N是从第N字节开始,从1计数):
head -c $((0x40000)) fw.bin > boot.bin tail -c +$((0x150000+1)) image.bin > rootfs.bin但"从中间截一段"就需要管道组合:
head -c $((0x130000)) fw.bin | tail -c +$((0x50000+1)) > app.bin这行命令的意思是从文件开头取到0x130000,再从这个结果里保留从0x50000开始的部分,最终长度就是0x130000-0x50000=0xE0000。管道方式对偏移量不敏感(不需要对齐),缺点是数据被读了两遍,大文件上会多花时间,而且还容易被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 if=flash_dump.bin of=app.bin \ bs=4096 skip=$((0x050000/4096)) count=$((0x100000/4096)) \ status=progress这里我刻意用了bs=4096并且把偏移和长度都换算成块数,因为0x050000和0x100000都是4096的整数倍,换算无误差,而且速度比bs=1快得多。
第三步,去掉尾部填充。如果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(f'actual 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 bs=1024 count=64 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 + conv=notrunc 覆盖式合并
当合并的目标是"往一个已经存在的镜像里填内容"时,dd的seek加conv=notrunc才是正确姿势:
cp base_image.bin work.bin dd if=app_new.bin of=work.bin bs=4096 seek=$((0x050000/4096)) \ conv=notrunc status=progressconv=notrunc这个参数是必须的,不加的话dd在打开输出文件时会先把它截断成0长度,你的base_image就没了。我第一次犯这个错的时候,一份完整的出厂镜像被清成空文件,好在有备份。
seek指定的是从输出文件开头跳过的块数(除非用oflag=seek_bytes)。这里又回到bs的整数倍问题:如果偏移不是4096的整数倍,用oflag=seek_bytes避免换算误差。
还有一个变体是"部分覆盖",也就是新内容比原内容短,只覆盖前面一部分,后面保留原样。conv=notrunc天然支持这个行为,因为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 bs=1M count=32 of=work.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 of=fw.bin bs=1 seek=$((0x104)) conv=notrunc status=none这里往0x104写了一个小端的版本号0x00010002。printf的\x转义在bash的builtin里是支持的,如果你用的是sh或者dash,可能需要用printf '\002\000\001\000'(八进制)。这个兼容性问题我踩过,脚本在本地跑得好好的,一到精简的容器镜像里就写错了字节。
如果要写的是ASCII字符串,更简单:
printf 'SN20240101' | dd of=fw.bin bs=1 seek=$((0x200)) conv=notrunc status=none字符串长度必须跟原字段完全一致,多一个字节就会把后面的数据挤掉。如果新字符串短于原字段,可以用空格或者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(f'length 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:i+len(old)] = new idx = i + len(new) count += 1 open(path, 'wb').write(data) print(f'replaced {count} occurrence(s)')脚本里强制要求新旧长度相等,这是刻意的。等长替换不会移动任何后续字节,也就不会破坏任何偏移相关字段。如果确实需要变长替换,那就必须同步更新所有记录长度的字段和校验,工作量和风险都上一个大台阶,我一般会劝人重新编译而不是硬改。
另外,find是全字节匹配,如果某个短字符串恰好出现在别的数据里,也会被替换。比如替换'test'这个词,可能会误伤一段二进制数据里恰好等于0x74 0x65 0x73 0x74的字节。规避办法是先用脚本只统计不替换,确认出现次数符合预期再真正执行。
5.4 改长度、改校验必须同步,CRC32与长度字段的重算
只要你的bin文件里存在校验字段或者长度字段,改完内容之后就必须重算。以头部16字节(magic+version+length+crc32)、其余为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(f'length={new_len} crc=0x{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算错了,很可能整段数据被挪了位置。
这一步我在第一次改固件的时候就吃过亏:用了bs=1 seek=0x200,结果不小心写成了八进制的200(十进制128),差异偏移全都不对,cmp的输出一眼就看出来了。这也是我坚持每次改完都跑cmp的原因,代价只有几秒钟。
6.2 版本号、时间戳这类"每次都不一样"的字段怎么处理
有类字段天生就会变:编译时间戳、构建流水号、自增版本号。带着这些字段的固件做比对时,差异永远不会是零。处理办法是在比对前先把这些字段"屏蔽"掉,具体做法是生成一份"规范化"副本:把这些偏移处的字节统一改成固定的占位值,再对两份文件做哈希比对。
def normalize(path, offsets): data = bytearray(open(path, 'rb').read()) for off, length in offsets: data[off:off+length] = 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的页大小常见是2048+64字节(带OOB),NOR的扇区是4KiB,eMMC是512字节。如果你的固件长度不是这些数的整数倍,下一个分区就得填充到对齐边界。
我见过一次因为不对齐导致的诡异故障:bootloader结束在0x03FFF0,app从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 --sparse=always保持稀疏。tar默认会保留稀疏信息,但经过某些压缩管道之后就丢了。如果你把稀疏文件烧写到flash,读回来的是0而不是空洞——这时候拿"烧写前"和"烧写后读回"做哈希比对,结果必然不一致,但其实是正常的。
解决办法是在比对之前把两份文件都"实体化":
cp --sparse=never a.bin a_dense.bin cp --sparse=never b.bin b_dense.bin cmp a_dense.bin b_dense.bin && echo identical或者更彻底的,用fallocate在生成阶段就写出真实内容,不用空洞。在实际项目里,我的做法是要求所有交付的bin文件都不允许是稀疏的,生成脚本最后统一跑一次实体化,这样哈希结果就是稳定可靠的。
另外一个容易忽略的点是文件权限和扩展属性。有些产线工具会读取文件的mtime来判断版本新旧,改完bin之后mtime会变,可能触发非预期的行为。交付前统一touch -r到一个固定参考文件,能让产物完全确定。
我个人在实际操作中的体会是,bin文件的处理其实不难,难的是"别自作聪明"。绝大多数事故都不是因为命令不会写,而是因为参数随手改了一个、填充值随手换了一个、校验忘了重算。我现在固定用一套脚本模板,所有偏移和长度都从一份配置里读,改固件只需要改配置不改命令,这样出错概率能降一个数量级,而且半年后回来看也能立刻明白当初干了什么。