用 Python struct 解析二进制协议:pack/unpack、字节序与对齐踩坑
处理网络协议、读二进制文件格式(BMP、WAV、PNG 头)、跟单片机/设备通信时,拿到的都是一串裸字节 bytes,不是规规矩矩的 JSON。这时候满屏 data[0] << 8 | data[1] 的手工位运算又长又容易错。Python 标准库的 struct 模块就是干这个的:用一个格式字符串,把字节和 Python 值互相转换。这篇把它讲清楚,顺带把两个最坑人的地方------字节序和对齐------单独拎出来。
先看手工解析有多难受
假设有个设备返回 8 字节:1 字节消息类型、2 字节序号、4 字节时间戳、1 字节校验和。手工拆是这样:
python
data = b'\x01\x00\x2a\x00\x00\x18\x6f\xa5'
msg_type = data[0]
seq = (data[1] << 8) | data[2] # 大端拼 2 字节
ts = (data[3] << 24) | (data[4] << 16) | (data[5] << 8) | data[6] # 大端拼 4 字节
checksum = data[7]
print(msg_type, seq, ts, checksum) # 1 42 1601445 165
能跑,但字段一多就是灾难:偏移量要人肉数、字节序拼错了排查半天、加个字段所有偏移全得改。
用 struct 一行搞定
python
import struct
data = b'\x01\x00\x2a\x00\x00\x18\x6f\xa5'
# > = 大端;B = 1字节无符号;H = 2字节无符号;I = 4字节无符号
msg_type, seq, ts, checksum = struct.unpack('>BHIB', data)
print(msg_type, seq, ts, checksum) # 1 42 1601445 165
一个格式字符串 '>BHIB' 把整个协议描述清楚了:> 定字节序,后面每个字母是一个字段。反过来打包也一样:
python
packet = struct.pack('>BHIB', 1, 42, 1601445, 165)
print(packet) # b'\x01\x00*\x00\x18o\xa5' ------ 和原始字节一致
格式字符:记住常用这几个
格式字符串 = 一个可选的字节序符号 + 若干格式字符。常用格式字符:
| 字符 | C 类型 | Python 类型 | 标准大小(字节) |
|---|---|---|---|
b / B |
signed/unsigned char | int | 1 |
h / H |
short | int | 2 |
i / I |
int | int | 4 |
q / Q |
long long | int | 8 |
f |
float | float | 4 |
d |
double | float | 8 |
s |
char\[\] | bytes | 由前缀数字定长 |
x |
填充字节 | 无 | 1 |
数字前缀表示重复次数,但 s 例外------4s 是「一个 4 字节的字节串」,不是「4 个字节串」:
python
# 5H 表示 5 个 unsigned short
print(struct.unpack('>5H', b'\x00\x01\x00\x02\x00\x03\x00\x04\x00\x05'))
# (1, 2, 3, 4, 5)
# 4s 是一个定长 4 字节串,不会被拆成 4 个
print(struct.unpack('>4s', b'\x50\x4e\x47\x21')) # (b'PNG!',)
坑一:字节序(大端/小端)必须显式写
这是新手最容易栽的地方。同样 4 个字节,大端和小端解出来天差地别:
python
raw = b'\x00\x00\x01\x00'
print(struct.unpack('>I', raw)) # (256,) 大端:高位在前
print(struct.unpack('<I', raw)) # (65536,) 小端:低位在前
字节序符号放在格式字符串最前面:
| 符号 | 含义 | 对齐 |
|---|---|---|
< |
小端 | 无填充 |
> |
大端(网络字节序) | 无填充 |
! |
网络序(等价于 >) |
无填充 |
= |
本机字节序 | 无填充 |
@ |
本机字节序(默认) | 按本机对齐补填充 |
铁律:处理协议或文件时,永远显式写 <、> 或 !,别用默认。 默认是 @,它跟着运行机器的 CPU 走,你在 x86(小端)上开发跑得好好的,换到某些大端环境或者对端设备是大端,直接解析错乱。网络协议约定是大端,所以网络场景直接用 !(它就是「network」的意思),意图最清晰。
坑二:对齐填充,只在用 @ 时出现
@(以及不写字节序符号时的默认)会像 C 结构体一样按本机对齐规则插入填充字节,导致大小和你数的对不上:
python
# @ 模式:B 后面 I 需要 4 字节对齐,中间偷偷塞了 3 个填充字节
print(struct.calcsize('@BI')) # 8 (1 + 3填充 + 4)
# 显式字节序:无任何填充,老老实实 1+4
print(struct.calcsize('=BI')) # 5
print(struct.calcsize('>BI')) # 5
print(struct.calcsize('<BI')) # 5
如果你按 @BI 打包、对端按「紧凑 5 字节」解析,就会错位。所以解析固定协议时用 </>/!/= 而不是 @ ------它们保证「所见即所得」,格式字符串里写多少字节就是多少字节,不会自作主张补填充。真需要填充时用显式的 x 字符自己控制,别依赖 @ 的隐式对齐。
实战一:解析 PNG 文件头判断图片尺寸
PNG 文件前 8 字节是固定签名,紧接着是 IHDR 块,里面大端存着宽高。不用任何第三方库就能读出尺寸:
python
import struct
def png_size(path):
with open(path, 'rb') as f:
header = f.read(24) # 8字节签名 + 4长度 + 4"IHDR" + 4宽 + 4高
# PNG 签名固定,校验一下确保是 PNG
if header[:8] != b'\x89PNG\r\n\x1a\n':
raise ValueError('不是 PNG 文件')
# 宽高在偏移 16 开始,各 4 字节大端无符号整数
width, height = struct.unpack('>II', header[16:24])
return width, height
# print(png_size('logo.png')) # (1200, 630)
实战二:用 Struct 对象预编译,循环里复用
如果同一个格式要反复用(比如逐条解析一个二进制日志文件),别每次都调 struct.unpack('>BHIB', ...)------每次都要重新解析格式字符串。用 struct.Struct 预编译一次,循环里复用:
python
import struct
# 预编译格式,只解析一次格式字符串
record = struct.Struct('>BHIB')
print(record.size) # 8,提前知道每条记录多大
def parse_log(path):
results = []
with open(path, 'rb') as f:
while chunk := f.read(record.size):
if len(chunk) < record.size:
break # 末尾不完整的记录直接丢
msg_type, seq, ts, checksum = record.unpack(chunk)
results.append((msg_type, seq, ts, checksum))
return results
record.size 直接给出每条记录的字节数,读文件时正好按这个粒度切,不用手数偏移。
实战三:iter_unpack 流式拆一大块字节
如果整块数据已经在内存里,想按固定格式切成一条条记录,iter_unpack 更省事,它返回一个惰性迭代器:
python
import struct
blob = struct.pack('>3H', 10, 20, 30) + struct.pack('>3H', 40, 50, 60)
record = struct.Struct('>3H')
for tup in record.iter_unpack(blob):
print(tup)
# (10, 20, 30)
# (40, 50, 60)
一个容易忽略的细节:unpack 要求长度精确匹配
unpack 要求传入字节长度正好等于格式大小,多一个字节少一个字节都直接抛异常:
python
import struct
try:
struct.unpack('>I', b'\x00\x00\x01') # 只有 3 字节,I 要 4 字节
except struct.error as e:
print(e) # unpack requires a buffer of 4 bytes
如果你要从一个大 buffer 的某个偏移处只解析开头几个字段,用 unpack_from(fmt, buffer, offset),它不要求整个 buffer 长度匹配,只从 offset 读够格式需要的字节:
python
buf = b'\xff\xff\x00\x00\x01\x00\xff'
# 从偏移 2 处读一个大端 4 字节整数,后面多余的字节不管
print(struct.unpack_from('>I', buf, 2)) # (256,)
小结
- 拿到裸
bytes别手写位运算,用struct的格式字符串一次描述清整个结构。 - 字节序永远显式写 :协议/文件用
<(小端)、>或!(大端/网络序),不写默认是@,会跟着本机 CPU 走,埋跨平台的雷。 - 对齐填充只在
@下出现 ,用</>/!/=保证「所见即所得」无隐式填充,需要填充用显式的x。 - 反复用同一格式就
struct.Struct('...')预编译,循环里复用,还能用.size/.iter_unpack/.unpack_from。 unpack长度必须精确匹配,从偏移读部分字段用unpack_from。
一句话记忆:看到二进制协议,先写下格式字符串,第一个字符一定是 <、> 或 !。