用 Python struct 读写二进制文件:字节序、对齐与截断校验

设备采样、索引文件和离线数据交换,常把整数紧凑地保存为字节。读取时最危险的情况,是程序没有报错,却因字节序或字段宽度理解不同,得到一组看似合理的错误值。用 Python 标准库 struct 可以明确描述布局,但可靠读取还需要一份格式约定和严格的边界校验。

先写格式,再写解析器

假设我们设计一个温度采样文件:文件头保存标识和记录数,后面紧跟固定长度记录,所有整数都使用大端序。

区域 字段 字节数 约定
文件头 标识 4 固定为 TEMP
文件头 记录数 4 无符号整数,最多一万条
每条记录 采样编号 4 无符号整数
每条记录 温度 2 有符号整数,单位为 0.1℃

单位和符号必须写进约定。235 表示 23.5℃,-12 表示 -1.2℃;二进制布局只保存整数,不会替业务解释单位。文件头为 8 字节,每条记录为 6 字节,因此两条记录的文件应恰好占 20 字节。

字节序与对齐要显式选择

不要让机器替文件决定布局

struct 的格式前缀控制字节序、大小和对齐。省略前缀等同于 @,会采用本机规则;它可能插入对齐填充,不适合作为跨平台文件约定。

前缀 字节序 字段大小与填充
@ 本机 本机大小,可能填充
= 本机 标准大小,无自动填充
< 小端 标准大小,无自动填充
> 大端 标准大小,无自动填充

= 虽然消除了填充差异,仍保留本机字节序。要固定文件格式,应明确用 < 或 >。例如 0x01020304 按大端编码为 01 02 03 04,按小端则反过来。

把布局声明为对象

下面用 Struct 对象集中定义格式:4s 是四个字节,I 是标准格式下的四字节无符号整数,h 是两字节有符号整数。通过 .size 计算偏移,避免散落的数字常量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from struct import Struct

HEADER = Struct(">4sI")
RECORD = Struct(">Ih")
MAX_RECORDS = 10_000

def decode(raw):
if len(raw) < HEADER.size:
raise ValueError("truncated header")
magic, count = HEADER.unpack_from(raw)
if magic != b"TEMP":
raise ValueError("unknown format")
if count > MAX_RECORDS:
raise ValueError("too many records")
expected = HEADER.size + count * RECORD.size
if len(raw) != expected:
raise ValueError("file size mismatch")
return list(RECORD.iter_unpack(raw[HEADER.size:]))

raw = (
HEADER.pack(b"TEMP", 2)
+ RECORD.pack(1, 235)
+ RECORD.pack(2, -12)
)
assert len(raw) == 20
assert raw[8:14].hex() == "0000000100eb"
assert decode(raw) == [(1, 235), (2, -12)]

长度正确只是第一道校验

unpack() 要求输入长度恰好匹配格式;unpack_from() 可以从更大的缓冲区指定偏移读取,剩余字节不会自动报错。因此,读完文件头不代表整份文件有效。

示例先检查记录数上限,再核对文件总长度,最后用 iter_unpack() 逐条解码。后者要求输入长度是记录大小的整数倍,但无法发现“头部声明两条、正文实际三条”这种业务矛盾,仍需显式比较。

这里选择拒绝尾随字节。若格式需要扩展,应该约定版本与扩展区长度,而不是悄悄忽略剩余内容。标识只能帮助识别格式,也不能证明文件未被篡改。

用独立样例守住格式边界

只验证“自己写、自己读”容易让同一个错误互相抵消。示例额外断言了第一条记录的十六进制字节,作为独立的布局依据。还应覆盖空文件、半个文件头、记录截断、额外尾字节、错误标识和超限记录数。

这个函数接收已读入内存的内容。实际读取外部文件时,还需在读取阶段设置字节上限;若先无限制 read(),函数中的记录数检查无法阻止先前的内存消耗。小型格式先把布局与失败条件写清楚,再考虑流式读取等优化。

参考:Python 官方 struct 文档。