用 Python struct 解析二进制协议:pack/unpack、字节序与对齐踩坑

用 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

一句话记忆:看到二进制协议,先写下格式字符串,第一个字符一定是 <>!

相关推荐
一知半解仙1 小时前
当机器人学会泛化,而我在写Java:一名后端开发者的2026年8月21日观察手记
java·开发语言·机器人
青 春 记 忆2 小时前
LeetCode 155. 最小栈|Python 解法详解
开发语言·python·leetcode
剩下了什么11 小时前
float32 与 float64 精度陷阱:如何在 Go 中避免错误使用
开发语言·后端·golang
指尖时光.11 小时前
Three.js 超全入门综合案例
开发语言·javascript·ecmascript
暴力求解11 小时前
Linux网络---传输层协议TCP(二)
linux·服务器·开发语言·网络·tcp/ip
程序员三藏11 小时前
Python+requests实现接口自动化测试
自动化测试·软件测试·python·测试工具·职场和发展·测试用例·接口测试
吴声子夜歌11 小时前
Java面试题——基础(一)
java·开发语言
小庞在加油12 小时前
WinDbg实战:QT/跨平台项目死锁与无响应问题排查指南
开发语言·qt·windbg·工具
Vae_Mars12 小时前
C#中的delegate委托
开发语言·c#