二进制文件格式设计:从 PNG 学习
二进制文件格式设计的核心是识别、边界、扩展和校验。本文以 PNG 为主线,重点拆解签名与 Chunk(IHDR/IDAT/IEND),最后简要迁移到网络帧与 Protobuf。
1. 文件格式先要解决的四个问题
无论是图片、日志归档,还是自定义二进制文档,核心都绕不开这四件事:
- 识别:怎么快速确认“这是我的格式”?
- 边界:怎么知道每段数据从哪开始、到哪结束?
- 扩展:未来加字段时,旧解析器会不会直接崩?
- 校验:数据损坏时,能不能尽早发现并定位?
对应到工程手段,就是:
- magic number(识别)
- length / framing(边界)
- chunk / TLV(扩展)
- CRC / hash / signature(校验)
2. 先定全局语义,再谈字段
在设计具体字段之前,先把这些“地基规则”写死:
- 字节序统一:全大端或全小端,不混用。
- 长度单位统一:长度字段一律表示“字节数”。
- 上限明确:单块最大长度、总大小、嵌套深度都给硬上限。
- 错误处理策略:遇到未知块是跳过还是报错,必须有一致规则。
这些规则看起来不起眼,但它们决定了解析器是否稳定、可审计、可维护。
3. PNG:文件格式设计的经典样板
PNG 非常适合用来学习“可演进的二进制文件结构”。
如果只抓主干,最关键就是三个 Chunk:IHDR(头)、IDAT(数据)、IEND(结束)。
3.1 文件签名(Magic Number)
PNG 固定 8 字节签名:
89 50 4E 47 0D 0A 1A 0A
解析器可以在第一时间做“快速失败”:
- 不是 PNG,立即退出;
- 偏移读错,立即退出;
- 文本模式传输导致污染,也更容易识别。
3.2 Chunk 通用结构
PNG 后续内容由多个 Chunk 组成,每个 Chunk 都是同一结构:
Length(4) | Type(4) | Data(N) | CRC(4)
关键点:
Length是 Data 长度(4 字节,大端)。Type是 4 字节 ASCII(如IHDR、IDAT、IEND)。CRC校验范围是Type + Data(不含 Length)。
这种结构的工程价值很高:按长度可跳过未知块,按类型可增量扩展语义。
3.3 IHDR:必须第一个
IHDR 是第一个 Chunk,Data 固定 13 字节:
- Width(4)
- Height(4)
- Bit depth(1)
- Color type(1)
- Compression method(1)
- Filter method(1)
- Interlace method(1)
它相当于“全文件解码前提”。IHDR 不合法,后面基本都没有继续解析的意义。
3.4 IDAT:图像数据主体(可多个)
IDAT 承载压缩后的图像数据,注意两点:
- 可以有多个 IDAT,解码时按顺序拼接为一个连续压缩流;
- 解压后不是“直接像素”,而是“每行一个过滤器字节 + 行数据”。
也就是说,解码路径通常是:
- 拼接 IDAT 数据;
- zlib/deflate 解压;
- 按行执行 unfilter(None/Sub/Up/Average/Paeth);
- 得到最终像素数据。
3.5 IEND:必须最后一个
IEND 表示文件结束:
- Length 必须为 0;
- Data 为空;
- 仍然有 CRC。
它让解析器可以明确“到此为止”,避免靠 EOF 猜测结构完整性。
3.6 为什么这种结构特别耐用
Chunk 机制的核心收益:
- 新增 Chunk 不影响旧解析器;
- 旧实现不认识新类型时,可按 Length 跳过;
- 校验可做到块级定位,排障成本低。
这就是“格式可演进”的基本盘。
4. 一个很实用的小技巧:TL 融合(Tag + Length 合并)
标准 TLV 是三段:Tag | Length | Value(Tag 在不少文档里也会写作 Type)。
但在短数据场景里,可以把 Tag 和 Length 融到 1 个字节里。例如:
0x20 <= b < 0x40统一表示“字符串”;- 字符串长度
len = b - 0x20(等价len = b & 0x1F)。
这样短字符串(0~31 字节)只用 1 字节头,省空间且解析快。
uint8_t b = read_u8();
if ((b & 0xE0) == 0x20) { // 001xxxxx
size_t len = b & 0x1F; // 0..31
read_bytes(len);
}
实践建议:
- 保留“长字符串扩展码”(如
0x40),后续再跟 1/2/4 字节长度; - 区间定义用半开区间
[0x20, 0x40),避免边界歧义。
这种“短头内联”的思路,在 MessagePack 的 fixstr 里也能看到。
5. 文件视角下的校验与版本
5.1 校验分层
不要用一种手段试图覆盖所有风险:
- CRC32:适合块级误码检测,快且便于定位坏块;
- SHA-256:适合整体完整性校验;
- 签名(Ed25519/RSA):适合“谁发布的”可信验证。
常见组合:
- Header CRC(快速失败)
- Block CRC(局部定位)
- Whole-file Hash/Signature(发布保障)
5.2 版本策略
major:允许不兼容变更;minor:默认向后兼容;- 新增优先,少做“改写旧语义”;
- 预留位写清语义(例如“必须为 0”)。
配合 Chunk/TLV,版本演进会稳定很多。
6. 迁移到网络协议:Framing + Protobuf
上面这套思想可以迁移到网络,但网络侧重点更偏“流式切包”。
6.1 先做帧边界,再谈 Payload 编码
TCP 是字节流,不是消息流,所以通常要先定义帧头:
Magic | Version | MsgType | Flags | Length | Payload | CRC
有了 Length 才能稳定切出完整消息;否则即使用了 Protobuf,也会卡在“包边界不明确”。
6.2 Protobuf wire format(最小必要细节)
Protobuf 每个字段是 key + value:
key = (field_number << 3) | wire_typewire_type决定 value 的读法(varint / 64-bit / length-delimited / 32-bit)
其中字符串、bytes、子消息都走 length-delimited:
[key(varint)][len(varint)][payload]
举个最小例子:
message User {
uint32 id = 1;
string name = 2;
}
当 id=150, name="A" 时,字节可写成:
08 96 01 12 01 41
这和文件里的 TLV/Chunk 在思想上是同构的:
- 先告诉解析器“这段是什么”;
- 再告诉解析器“这段有多长”;
- 旧实现遇到未知字段可跳过。
结语
把 PNG 读透之后,再看 TLV、网络帧和 Protobuf,会发现它们共享同一组底层原则:
- 识别靠 magic;
- 边界靠 length;
- 扩展靠可跳过结构;
- 稳定靠版本纪律;
- 可靠靠分层校验。
掌握这组原则,才能从“会写字段”走到“会做长期可演进的格式设计”。