← 所有文章

二进制文件格式设计:从 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。读取不足时继续等待;连接提前结束时报告截断。消息内部的字段边界与消息之间的边界,是两个层次的问题。

从一张红色像素图走到网络消息,我们一直在回答同样的问题:这是什么,读多少,如何解释,遇到未知或损坏的数据怎么办。

下次设计二进制格式时,可以先写出一个最小样本,再亲手改坏长度、删掉最后几个字节、插入未知字段。看看解析器会停在哪里,往往比再补一页字段表更能暴露问题。

— 本文完 —

有不同的想法?欢迎在下方交流,或写封邮件。

继续翻阅其他笔记 ↗

交流与讨论