二进制文件格式设计:从 PNG 学习
从一张 1×1 PNG 的前 33 字节读出宽高,亲手修改数据验证 CRC,再把长度、类型与扩展规则迁移到网络协议和 Protobuf。
本文目录
不打开图片,也不解压像素,只看一个 PNG 文件的前 33 字节,能知道它有多大吗?
可以。因为文件格式已经约定好了:哪个位置是签名,哪个位置是宽高,以及如何检查这一段数据有没有损坏。
我们先造一张只有一个红色像素的图片,再从它的字节中把这些信息读回来。
一、先准备一张能拆开的图片
下面的代码只用 Python 标准库,在当前目录生成 demo.png。后续 Python 片段都接在它后面运行。
import struct
import zlib
from pathlib import Path
def make_chunk(chunk_type, payload):
checksum = zlib.crc32(chunk_type + payload)
return (
struct.pack('>I', len(payload))
+ chunk_type
+ payload
+ struct.pack('>I', checksum)
)
signature = bytes.fromhex('89 50 4e 47 0d 0a 1a 0a')
header = struct.pack('>IIBBBBB', 1, 1, 8, 2, 0, 0, 0)
scanline = bytes([0, 255, 0, 0])
png = (
signature
+ make_chunk(b'IHDR', header)
+ make_chunk(b'IDAT', zlib.compress(scanline))
+ make_chunk(b'IEND', b'')
)
Path('demo.png').write_bytes(png)
print(png[:33].hex(' '))
先不用记住每个参数。看打印出来的前 33 字节,按结构分行:
89 50 4e 47 0d 0a 1a 0a ← 文件签名,8 字节
00 00 00 0d ← 数据长度:13
49 48 44 52 ← ASCII 字符:IHDR
00 00 00 01 ← 宽度:1
00 00 00 01 ← 高度:1
08 02 00 00 00 ← 位深、颜色类型及三种方法
90 77 53 de ← CRC
这已经回答了开头的问题。前 8 字节识别格式,再往后 8 字节读出块的长度和类型,接着就能按约定找到宽高。
二、读字段之前,先约定怎么读
00 00 00 01 为什么是 1,而不是 16777216?因为 PNG 的这些多字节整数使用大端序:高位字节在前。
print(int.from_bytes(bytes.fromhex('00 00 00 01'), 'big'))
print(int.from_bytes(bytes.fromhex('00 00 00 01'), 'little'))
输出分别是 1 和 16777216。四个字节没变,解释规则变了。
这就是设计格式之前必须交代的事情:字段宽度、字节序、长度单位,以及哪些值合法。只写“这里存一个整数”,还不足以让两个独立实现互相理解。
PNG 的签名也不是只为拼出三个字母。开头的非 ASCII 字节以及后面的换行、控制字节,让某些文本传输造成的损坏更容易被发现。签名适合快速识别,但不能证明整个文件合法。
三、Chunk:知道是什么,也知道在哪里结束
PNG 的数据块有统一外壳:
Length(4) | Type(4) | Data(N) | CRC(4)
Length 只计算 Data,因此一个块的总长度是 N + 12 字节。CRC 覆盖 Type + Data,不包含 Length。
对于示例中的 IHDR:
8 字节文件签名 + 4 + 4 + 13 + 4 = 33 字节
于是下一块从偏移 33 开始。解析器不需要在内容里搜索字符串 IDAT,而是沿着长度计算边界。
写一个只读取 IHDR 的检查器
下面的函数只检查前 33 字节中的签名、IHDR 长度和 CRC,并提取宽高。它不是完整的 PNG 合法性验证器。
def read_dimensions(prefix):
if len(prefix) < 33:
raise ValueError('文件头被截断')
if prefix[:8] != signature:
raise ValueError('PNG 签名不匹配')
length, chunk_type = struct.unpack('>I4s', prefix[8:16])
if length != 13 or chunk_type != b'IHDR':
raise ValueError('首块必须是长度为 13 的 IHDR')
payload = prefix[16:29]
expected_crc = struct.unpack('>I', prefix[29:33])[0]
if zlib.crc32(chunk_type + payload) != expected_crc:
raise ValueError('IHDR CRC 不匹配')
width, height = struct.unpack('>II', payload[:8])
if not (0 < width < 2**31 and 0 < height < 2**31):
raise ValueError('宽高超出 PNG 允许的范围')
return width, height
print(read_dimensions(png[:33]))
输出 (1, 1)。真实解析器还要校验位深与颜色类型的组合、压缩与过滤方法、后续块结构等。
故意改坏一个字节
宽度的最后一个字节在偏移 19,把它从 1 改成 2:
broken = bytearray(png[:33])
broken[19] = 2
try:
read_dimensions(broken)
except ValueError as error:
print(error)
结果是 IHDR CRC 不匹配。CRC 让我们发现了数据改变,但如果修改者也重新计算 CRC,这个检查就会通过。
CRC 用于检测意外损坏,不能证明内容来自可信发布者。 如果还要防止主动篡改,需要来自可信渠道的哈希、消息认证码或数字签名。把数据和普通哈希一起交给同一个不可信来源,并不能解决这个问题。
四、从文件头走到那个红色像素
IHDR 中的 08 02 00 00 00 分别指定 8 位采样、真彩色、压缩方法 0、过滤方法 0,以及不交错。
示例中的原始扫描行是:
00 | FF 00 00
↑ ↑ ↑ ↑
过滤 红 绿 蓝
第一个 00 表示这一行使用 None 过滤器,后面三个字节是一个红色像素。注意 IHDR 中的“过滤方法 0”规定整套过滤机制,行首的 0 才选择本行的具体过滤器,两者不是同一个字段。
IDAT 保存的是经过 zlib 压缩的扫描行数据。多个 IDAT 的数据要按顺序连接,再作为一个压缩流处理,不能把每个 IDAT 都当成独立的压缩包。
对于本文这种不交错的图片,解压后按行读取过滤器字节,执行对应的反过滤,才能得到像素。交错 PNG 还需要处理多个 pass;索引色图片则需要 PLTE 等信息。
最后,IEND 的数据长度为 0,但仍然保留类型和 CRC。这表示 PNG 数据流结束。关键块顺序、IDAT 连续性和各字段取值都由 PNG 规范 约束。
五、不认识的块,到底能不能跳过?
有了长度,我们在结构上知道怎样越过一块数据,但能跳过不代表允许忽略它的含义。
PNG 用块类型第一个字母的大小写区分 critical 与 ancillary。遇到不认识的辅助块,可以忽略;遇到不认识的关键块,则不能继续假装自己能正确解码。编辑器是否可以原样保留未知块,还有额外的复制规则。PNG 块命名规则
设计自定义格式时,也应该明确:哪些新字段是可选信息,哪些改变了解码前提。扩展能力来自可定位的边界加上明确的兼容规则。
解析器还必须检查长度是否超过剩余输入、整数运算是否溢出、解压后是否超过资源上限。CRC 不覆盖 Length,尤其不能用“稍后会检查 CRC”来代替这些边界检查。
六、把同一套思路用到小消息里
PNG 每块有 12 字节开销,对图片很合理;如果消息本身只有几个字节,就可能太重了。
一种自定义方案是把类型和短长度放进同一个字节:
001xxxxx → 字符串,低 5 位表示 UTF-8 字节数
那么字符串 A 可以编码成:
21 41
↑ ↑
头 内容
0x21 & 0x1F = 1,所以后面读 1 字节。这里计数的是字节,不是字符;“中”用 UTF-8 保存时需要 3 字节。
还应预留长字符串的表示方法,并拒绝长度超出输入的记录。这种短头设计节省空间,代价是类型空间和长度空间要一起规划。
Protobuf 的六个字节
对于下面的消息:
message User {
uint32 id = 1;
string name = 2;
}
id=150, name="A" 可以编码为:
08 96 01 12 01 41
逐段拆开:
| 字节 | 解释 |
|---|---|
08 |
字段 1,wire type 0,即 varint |
96 01 |
低 7 位依次是 22 和 1,得到 22 + 1×128 = 150 |
12 |
字段 2,wire type 2,即长度限定字段 |
01 |
后续数据长 1 字节 |
41 |
UTF-8 字符 A |
字段头按 (field_number << 3) | wire_type 组合。与 PNG 不同,Protobuf 并非每个字段都显式存一个长度:varint 用最高位指示是否还有后续字节,固定宽度类型则直接由 wire type 决定边界。Protobuf 编码文档
七、文件边界到了 TCP 上还在吗?
把两条 Protobuf 消息连续写进 TCP,并不会自动保留两次发送的边界。接收方拿到的是字节流,一次读取可能只得到半条,也可能得到几条合在一起的数据。
应用层可以在每条序列化消息前加一个明确的长度:
消息长度(4 字节大端) | 一条 Protobuf 消息
接收方先累计够 4 字节,检查长度上限,再累计够 payload。读取不足时继续等待;连接提前结束时报告截断。消息内部的字段边界与消息之间的边界,是两个层次的问题。
从一张红色像素图走到网络消息,我们一直在回答同样的问题:这是什么,读多少,如何解释,遇到未知或损坏的数据怎么办。
下次设计二进制格式时,可以先写出一个最小样本,再亲手改坏长度、删掉最后几个字节、插入未知字段。看看解析器会停在哪里,往往比再补一页字段表更能暴露问题。
交流与讨论