Python 怎么解析二进制数据?struct 模块的字节序和对齐是怎么回事?
简化版
struct 是「Python 值 ↔ 二进制字节」的翻译器:struct.pack(">IH", 1, 2) 把两个整数打成 6 字节,struct.unpack(">IH", data) 再解回一个元组。用它的场景是读写二进制文件头(PNG、WAV、ELF)、解析网络协议报文、处理定长记录的二进制数据文件。核心是格式字符串:一个可选的字节序前缀 + 若干类型码——> 大端(网络字节序)、< 小端(x86/ARM 常用)、= 本机字节序但标准大小、! 等同 >、不写前缀(或写 @)表示「本机字节序 + 本机大小 + 自动对齐填充」;类型码常用的有 b/B(1 字节)、h/H(2 字节)、i/I(4 字节)、q/Q(8 字节)、f/d(浮点)、s(字节串,写成 10s)、x(填充字节),大写表示无符号。最大的坑是对齐:默认(@)模式会像 C 编译器那样插入填充字节——struct.calcsize("@ci") 是 8 而 struct.calcsize("=ci") 是 5,解析别人给的协议时必须显式写字节序前缀(>/</=/!)来关闭对齐。其他必记点:unpack 要求数据长度和格式严格相等**(差一字节就报错,用 unpack_from 可以从偏移处只取需要的部分);重复解析同一格式要用 struct.Struct(fmt) 预编译对象(省掉每次解析格式字符串的开销)。核心记忆:永远显式写字节序前缀(否则有对齐填充);大端是网络序;Struct 预编译更快。
详细版
常用类型码:
| 码 | C 类型 | 标准大小 | 说明 |
|---|---|---|---|
x | 填充 | 1 | 占位不产生值 |
c | char | 1 | 长度为 1 的 bytes |
b / B | signed/unsigned char | 1 | 小写有符号、大写无符号 |
? | _Bool | 1 | Python bool |
h / H | short | 2 | |
i / I | int | 4 | |
l / L | long | 4(标准) | 本机模式下可能是 8 |
q / Q | long long | 8 | |
e / f / d | 半/单/双精度浮点 | 2/4/8 | |
Ns | char[N] | N | 10s = 10 字节定长串 |
Np | Pascal 串 | N | 首字节是长度 |
P | void* | — | 只在本机模式可用 |
字节序前缀(决定字节序、大小、对齐):
| 前缀 | 字节序 | 大小 | 对齐填充 |
|---|---|---|---|
@(默认) | 本机 | 本机 | ✅ 有填充 |
= | 本机 | 标准 | ❌ 无 |
< | 小端 | 标准 | ❌ 无 |
> / ! | 大端(网络序) | 标准 | ❌ 无 |
import struct
# ① 打包与解包
data = struct.pack(">IHB", 1, 2, 3) # 大端:4+2+1 = 7 字节
print(data) # b'\x00\x00\x00\x01\x00\x02\x03'
print(struct.unpack(">IHB", data)) # (1, 2, 3) ← ★永远返回元组★
# ② ★字节序的实际差别★
print(struct.pack(">I", 1)) # b'\x00\x00\x00\x01' 大端:高位在前
print(struct.pack("<I", 1)) # b'\x01\x00\x00\x00' 小端:低位在前
print(struct.pack("=I", 1)) # 取决于本机(x86 上同小端)
# ③ ★对齐陷阱:默认模式会插入填充字节★
print(struct.calcsize("@ci")) # 8 ← ★char(1) + 3 字节填充 + int(4)★
print(struct.calcsize("=ci")) # 5 ← 无填充
print(struct.calcsize(">ci")) # 5
# → 解析外部数据永远写前缀,否则在不同平台上长度都可能不同
# ④ 长度必须严格相等
try:
struct.unpack(">I", b"\x00\x00\x01") # 只有 3 字节
except struct.error as e:
print(e) # unpack requires a buffer of 4 bytes
# ⑤ unpack_from:从偏移处取,不要求长度相等(★解析大 buffer 时常用★)
buf = b"HEADER" + struct.pack(">IH", 100, 7) + b"...tail..."
print(struct.unpack_from(">IH", buf, 6)) # (100, 7) ← 从偏移 6 开始
# ⑥ iter_unpack:批量解定长记录(★流式,省内存★)
records = struct.pack(">IH", 1, 10) + struct.pack(">IH", 2, 20)
for uid, score in struct.iter_unpack(">IH", records):
print(uid, score) # 1 10 / 2 20
# ⑦ ★Struct 对象:预编译,重复使用时明显更快★
S = struct.Struct(">IHB") # 只解析一次格式字符串
print(S.size) # 7
for chunk in chunks:
S.unpack(chunk) # ★比 struct.unpack(fmt, ...) 快★
# ⑧ pack_into:写进已有的缓冲区(零拷贝,配合 memoryview)
buf = bytearray(16)
struct.pack_into(">IH", buf, 4, 100, 7) # 从偏移 4 开始写
print(buf.hex())
# ⑨ 字节串与填充
print(struct.pack("5s", b"ab")) # b'ab\x00\x00\x00' ← ★不足补 \x00★
print(struct.pack("2s", b"abcdef")) # b'ab' ← ★超出被截断★
print(struct.unpack("5s", b"ab\x00\x00\x00")) # (b'ab\x00\x00\x00',) ← ★补的 \0 不会自动去掉★
# → 定长字符串要自己 .rstrip(b"\x00").decode()
# ⑩ 解析真实文件头:PNG
with open("a.png", "rb") as f:
sig = f.read(8)
assert sig == b"\x89PNG\r\n\x1a\n" # ★魔数★
length, ctype = struct.unpack(">I4s", f.read(8)) # PNG 用★大端★
w, h, depth, color = struct.unpack(">IIBB", f.read(10))
print(w, h)
⚠️ 三个必须记住的点:① 不写字节序前缀 =
@模式 = 本机字节序 + 本机大小 + C 编译器式的对齐填充。struct.calcsize("@ci")在多数平台是 8(char后面填了 3 个字节,让int落在 4 字节边界上),而struct.calcsize("=ci")是 5。解析外部协议或文件格式时必须显式写>/</=/!,否则同一段代码在不同架构上行为不同,是极难排查的跨平台 bug。@模式只在「和本机 C 结构体交换数据」时才用。②unpack要求数据长度和calcsize(fmt)严格相等,多一字节少一字节都抛struct.error——从流里解析时应该用unpack_from(fmt, buf, offset)(只取需要的部分)或iter_unpack(批量解定长记录)。③ 定长字节串Ns不会帮你处理填充:打包时不足补\x00、超长直接截断(不报错,静默丢数据),解包时补的\x00也原样返回——需要自己.rstrip(b"\x00").decode(),而且要小心「数据本身就以\x00结尾」的情况。
完整版教学
一、struct 解决什么问题
Python 的值和二进制字节之间隔着一层:
整数 1 在内存里是什么?→ Python 的 int 是★对象★(带引用计数、类型指针、变长数字)
→ 而协议/文件格式要求的是"4 个字节,大端,值为 1" = b'\x00\x00\x00\x01'
→ ★struct 就是这两者之间的翻译器★
典型使用场景:
① 解析二进制文件头:PNG/JPEG/WAV/ELF/PDF/pyc/数据库页
② 网络协议:自定义 TCP 协议、DNS、TLS 记录层、MQTT
③ 定长记录文件:老式数据文件、传感器采集数据、金融行情快照
④ 和 C 程序/嵌入式设备交换数据
⑤ 自定义序列化(比 JSON 小很多,但可读性为零)
对比其他做法:
int.from_bytes(b, "big") ★只处理整数,简单场景更直观★
struct.unpack(">I", b) 要写格式串,但★能一次解多个不同类型的字段★
ctypes.Structure 直接映射 C 结构体(★带对齐,适合调 C 库★)
construct / kaitai 声明式描述复杂格式(★有条件分支/变长字段时★)
protobuf / msgpack ★有 schema 的现代序列化(能演进版本)★
★ 选择准则:
只取一两个整数 → int.from_bytes / int.to_bytes
固定布局的头部/记录 → ★struct★
格式复杂、有嵌套/变长 → construct 或手写解析器
自己设计新协议 → ★别用 struct 手搓,用 protobuf/msgpack★(能演进、跨语言)
struct 的定位是 Python 值和二进制字节之间的翻译器——Python 的 int 是个带引用计数和类型指针的对象,而协议要求的是「4 个字节、大端、值为 1」这样的裸字节,两者之间需要一层转换。典型场景是解析二进制文件头(PNG、WAV、ELF)、网络协议报文、定长记录的数据文件、以及和 C 程序交换数据。选择时要有分寸:只取一两个整数用 int.from_bytes 更直观;固定布局的头部和记录用 struct;格式复杂(有嵌套、条件分支、变长字段)时手写 struct 会变成一团乱麻,应该用 construct 这类声明式库;而自己设计新协议时不该用 struct 手搓——用 protobuf/msgpack 这类有 schema 的方案,它们能处理版本演进和跨语言,手搓的二进制格式加一个字段就是灾难。
二、格式字符串:字节序前缀决定三件事
格式串 = [字节序前缀] + 类型码序列(可带重复数字)
">IHB" 大端,4 字节无符号 + 2 字节无符号 + 1 字节无符号
"<3i" 小端,3 个 4 字节有符号整数(等价 "<iii")
">I4s" 大端,1 个 uint32 + 1 个 4 字节串
★ 前缀决定三件事(不只是字节序):
┌──────┬────────────┬──────────┬────────────┬──────────────────────┐
│ 前缀 │ 字节序 │ 类型大小 │ ★对齐填充★ │ 用在哪 │
├──────┼────────────┼──────────┼────────────┼──────────────────────┤
│ @默认 │ 本机 │ ★本机★ │ ★有★ │ 和本机 C 结构体交换 │
│ = │ 本机 │ 标准 │ 无 │ 本机字节序但要紧凑 │
│ < │ 小端 │ 标准 │ 无 │ x86 生态的格式(BMP等) │
│ > │ 大端 │ 标准 │ 无 │ ★网络协议、多数文件★ │
│ ! │ 大端(=网络序)│ 标准 │ 无 │ 语义更清晰的 > │
└──────┴────────────┴──────────┴────────────┴──────────────────────┘
★ "本机大小" vs "标准大小"的实际差别:
'l'(long)在 Linux x86-64 的本机模式是 ★8 字节★,标准模式是 ★4 字节★
'P'(指针)只在 @ 模式可用
→ 用 @ 解析外部数据 = 同一段代码在 32 位和 64 位机器上结果不同
大小端的直观理解(数字 0x12345678):
大端(Big-Endian,"高位在前",像人读数字):
内存地址: 低 ──────────────► 高
字节: 12 34 56 78
小端(Little-Endian,"低位在前"):
字节: 78 56 34 12
★ 网络字节序 = 大端★(TCP/IP 协议族的约定,htons/htonl 就是干这个的)
★ x86/x86-64/多数 ARM 运行在小端模式★
→ 所以 PC 上处理网络数据必须转换,这正是 struct 前缀存在的意义
用 sys.byteorder 查本机:
import sys; print(sys.byteorder) # 'little'
重复计数的两种语义(★易错★):
"3i" → 3 个 int(unpack 返回 3 个值)
"3s" → ★1 个 3 字节的串★(返回 1 个值) ← s 的数字是"长度"不是"个数"
"3x" → 3 个填充字节(★不返回任何值★)
格式字符串的关键是前缀决定三件事:字节序、类型大小、以及有没有对齐填充。不写前缀等于 @——本机字节序 + 本机大小 + 有填充,这在解析外部数据时是错误的选择:'l'(long)在 Linux x86-64 的本机模式下是 8 字节而标准模式是 4 字节,同一段代码在 32 位和 64 位机器上会得到不同结果。网络字节序就是大端(> 或语义更清晰的 !),而 x86/ARM 实际运行在小端,这正是前缀存在的意义。还有一个易错点是重复计数的两种语义:"3i" 是三个 int(返回 3 个值),而 "3s" 是一个 3 字节的串(返回 1 个值)——s 前面的数字表示「长度」而不是「个数」;"3x" 则是三个填充字节、不返回任何值。
三、对齐与填充:最容易踩的坑
为什么会有对齐:
CPU 读取内存时,如果一个 4 字节整数跨越了 4 字节边界,
可能需要★两次内存访问★(某些架构直接报总线错误)
→ C 编译器会在结构体成员之间★插入填充字节★,让每个成员落在
"地址是自身大小的整数倍"的位置上
C 里的结构体:
struct { char c; int i; };
内存布局: [c][pad][pad][pad][i i i i] ← ★sizeof = 8,不是 5★
Python 里的对应:
struct.calcsize("@ci") → 8 ★模拟了 C 的对齐规则★
struct.calcsize("=ci") → 5 紧凑,无填充
struct.calcsize(">ci") → 5
更复杂的例子:
struct.calcsize("@cid") → 16 (char + 3pad + int + 8字节 double 要 8 对齐)
struct.calcsize("=cid") → 13
★ 什么时候需要对齐(@ 模式):
✓ 用 ctypes 和本机 C 库交换结构体
✓ 读写本机程序写出的、带对齐的二进制文件
✗ ★解析网络协议★(协议都是紧凑的,且规定了字节序)
✗ ★解析标准文件格式★(PNG/WAV 等都有明确规范)
✗ 跨平台交换数据
★ 手动控制填充:用 'x'
">IxxH" uint32 + 2 个填充字节 + uint16
→ 解析"协议里有保留字段"时非常常用(保留字段直接用 x 跳过,不产生值)
真实例子(某协议头):
0-1 魔数(2字节) 2 版本(1) 3 保留(1) 4-7 长度(4)
fmt = ">2sBxI" # ★用 x 跳过保留字节★
magic, ver, length = struct.unpack(fmt, header)
★ 一个真实的排查故事(面试可讲):
代码 struct.unpack("ci", data) 在开发机(x86-64 Linux)跑得好好的,
部署到 32 位 ARM 设备上就解析错位
→ 因为没写前缀 = @ 模式,两个平台的对齐规则和 long 大小都不同
✓ 改成 "<ci" 或 ">ci" 立刻修复
→ ★教训:解析外部数据的格式串,第一个字符必须是 <、>、= 或 !★
对齐是 struct 最容易踩的坑,根源在 C 语言的结构体布局规则:CPU 读取跨边界的多字节整数可能需要两次内存访问(某些架构直接报错),所以编译器会在成员之间插入填充字节——struct { char c; int i; } 的 sizeof 是 8 而不是 5。Python 的 @ 模式忠实模拟了这套规则,所以 calcsize("@ci") 是 8、calcsize("=ci") 是 5。判断该不该用对齐很简单:只有「和本机 C 库/本机程序交换数据」时才用 @,而解析网络协议和标准文件格式一律要用显式前缀(它们都是紧凑布局且规定了字节序)。协议里常见的「保留字段」可以用 x 优雅地跳过(">2sBxI",不产生任何值)。最后那个排查故事值得记住:没写前缀的 struct.unpack("ci", data) 在 x86-64 上正常、换到 32 位 ARM 就错位——教训就是解析外部数据的格式串,第一个字符必须是 <、>、= 或 !。
四、API 全景:从 pack 到 Struct 对象
函数式 API:
struct.pack(fmt, v1, v2, ...) → bytes
struct.unpack(fmt, buffer) → tuple(★长度必须严格相等★)
struct.calcsize(fmt) → int(★算长度,读文件时必用★)
struct.pack_into(fmt, buf, offset, ...) 写进已有的可写缓冲区(bytearray/memoryview)
struct.unpack_from(fmt, buf, offset=0) ★从偏移处解,不要求长度相等★
struct.iter_unpack(fmt, buffer) ★迭代器,批量解定长记录★
Struct 对象(★重复使用时应该用它★):
S = struct.Struct(">IHB") # 格式串★只解析一次★
S.size / S.format
S.pack(...) / S.unpack(...) / S.pack_into(...) / S.unpack_from(...) / S.iter_unpack(...)
★ 性能差异(解析百万条记录):
struct.unpack(fmt, b) 每次都要解析格式字符串(有缓存,但仍有查找开销)
S.unpack(b) ★直接用预编译好的结构,实测快 20%~50%★
→ 循环里、热点路径上一律用 Struct 对象(模块级常量)
读二进制文件的标准姿势:
HEADER = struct.Struct(">4sIIH")
with open(p, "rb") as f:
head = f.read(HEADER.size) # ★用 .size,不要硬编码数字★
if len(head) < HEADER.size:
raise ValueError("文件太短") # ★read 可能读不满!★
magic, ver, count, flags = HEADER.unpack(head)
REC = struct.Struct("<IHf")
while chunk := f.read(REC.size * 1000): # 一次读 1000 条
for rec in REC.iter_unpack(chunk): # ★批量解★
handle(rec)
# ★注意:如果 chunk 长度不是 REC.size 的整数倍,iter_unpack 会报错
# → 严谨的做法是自己维护缓冲区、按 size 切分
配合 memoryview 做零拷贝:
mv = memoryview(big_buffer)
for off in range(0, len(mv), REC.size):
rec = REC.unpack_from(mv, off) # ★不复制切片★
✗ REC.unpack(big_buffer[off:off+REC.size]) # 每次都产生一个新 bytes(复制)
配合 mmap 处理超大文件:
import mmap
with open(p, "rb") as f:
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
for rec in REC.iter_unpack(mm): # ★按需分页,内存恒定★
handle(rec)
API 层面有三个提升实用性的要点。① 用 unpack_from 和 iter_unpack 而不是切片 + unpack:前者从指定偏移解析、不要求长度严格相等,后者能批量解定长记录。② 重复解析同一格式必须用 struct.Struct(fmt) 预编译对象——格式字符串只解析一次,实测在百万级记录上快 20%~50%,而且 .size 属性让你不用硬编码字节数。③ 配合 memoryview 实现零拷贝:REC.unpack_from(mv, off) 直接在原缓冲区上读,而 REC.unpack(buf[off:off+size]) 每次切片都会复制一份新的 bytes——处理几百 MB 数据时差别很明显;超大文件还可以配 mmap,按需分页、内存恒定。另外读文件时有个必查的细节:f.read(n) 可能读不满 n 字节(文件末尾、管道),直接把结果丢给 unpack 会报「长度不符」,应该先检查 len(head) < HEADER.size。
五、实战:解析真实格式
① PNG 文件头(★大端,经典例子★)
结构:8 字节签名 + 若干"块"(长度4 + 类型4 + 数据 + CRC4)
with open(p, "rb") as f:
assert f.read(8) == b"\x89PNG\r\n\x1a\n" # ★魔数★
length, ctype = struct.unpack(">I4s", f.read(8))
assert ctype == b"IHDR"
w, h, bit_depth, color_type, comp, filt, interlace = \
struct.unpack(">IIBBBBB", f.read(13))
print(f"{w}x{h}, 位深 {bit_depth}")
② WAV 文件头(★小端,RIFF 家族都是小端★)
riff, size, wave = struct.unpack("<4sI4s", f.read(12))
assert riff == b"RIFF" and wave == b"WAVE"
# fmt 块
cid, csize, fmt_tag, channels, rate, byte_rate, align, bits = \
struct.unpack("<4sIHHIIHH", f.read(24))
print(f"{channels} 声道 {rate}Hz {bits}bit")
★ 对比 PNG 用大端、WAV 用小端 —— ★没有统一规律,必须查格式规范★
③ 自定义 TCP 协议(★长度前缀 + 消息体,最常见的模式★)
HEADER = struct.Struct(">HI") # 2 字节类型 + 4 字节长度
def send(sock, msg_type: int, payload: bytes):
sock.sendall(HEADER.pack(msg_type, len(payload)) + payload)
def recv_exact(sock, n): # ★必须循环读满★
buf = b""
while len(buf) < n:
chunk = sock.recv(n - len(buf))
if not chunk:
raise ConnectionError("对端关闭")
buf += chunk
return buf
def recv(sock):
msg_type, length = HEADER.unpack(recv_exact(sock, HEADER.size))
if length > MAX_PAYLOAD: # ★必须限长,否则一个恶意的 4GB 长度就 OOM★
raise ValueError("消息过长")
return msg_type, recv_exact(sock, length)
★ 三个关键点:
- sock.recv(n) ★不保证返回 n 字节★(TCP 是流,没有消息边界)→ 必须循环
- 长度字段★必须校验上限★(防内存耗尽攻击)
- 用大端(网络序),和其他语言互通不出错
④ 定长记录的数据文件
REC = struct.Struct("<QfI") # 时间戳(8) + 值(4) + 标志(4) = 16 字节
with open("data.bin", "rb") as f:
f.seek(index * REC.size) # ★定长记录可以直接 seek 到第 N 条★
ts, value, flags = REC.unpack(f.read(REC.size))
★ 定长格式的优势:O(1) 随机访问、体积小;代价:字段无法演进
⑤ 位域(struct 不直接支持,要自己按位取)
flags, = struct.unpack(">H", data)
is_compressed = bool(flags & 0x0001)
version = (flags >> 4) & 0x0F # ★取第 4~7 位★
→ 复杂位域可以用 int.bit_count / 自己写辅助函数,或用 bitstring 库
真实格式的解析有几条通用经验。先看魔数(PNG 的 \x89PNG\r\n\x1a\n、WAV 的 RIFF),它既是格式校验也是解析起点。字节序没有统一规律——PNG 用大端、WAV 用小端,必须查格式规范而不能想当然。自定义 TCP 协议的标准模式是**「长度前缀 + 消息体」,这里有三个关键点:sock.recv(n) 不保证返回 n 字节**(TCP 是字节流、没有消息边界),必须循环读满;长度字段必须校验上限(否则一个恶意的 4GB 长度字段就能让你 OOM);用大端保证跨语言互通。定长记录格式的优势是能 f.seek(index * REC.size) 做 O(1) 随机访问、体积小,代价是字段无法演进(加一个字段就要重写所有历史数据)。最后,struct 不直接支持位域,要自己用 & 和 >> 按位取。
六、边界、陷阱与替代方案
★ 常见陷阱清单:
① 不写字节序前缀 → 默认 @(本机序 + 本机大小 + ★对齐填充★)
② unpack 的长度必须严格等于 calcsize → 用 unpack_from 避免
③ f.read(n) / sock.recv(n) ★可能返回不足 n 字节★ → 必须检查或循环
④ 'Ns' 打包时★超长静默截断★、不足补 \x00;解包时补的 \x00 ★原样返回★
→ data.rstrip(b"\x00").decode("utf-8", errors="replace")
→ ★但如果原始数据合法地以 \x00 结尾,rstrip 会误删★
⑤ 整数溢出:struct.error: 'I' format requires 0 <= number <= 4294967295
→ 打包前校验范围;有符号/无符号(小写/大写)★别搞反★
⑥ 浮点:'f' 是单精度,Python float 是双精度 → ★pack('f', x) 会丢精度★
struct.unpack("f", struct.pack("f", 0.1)) → 0.10000000149011612
⑦ 'l' 在 @ 模式下是 8 字节(64 位 Linux)、标准模式是 4 字节
⑧ 布尔 '?' 的大小不保证跨平台一致(本机模式下是 C 的 _Bool)
★ 什么时候不该用 struct:
✗ 自己设计★新★协议 → 用 protobuf / msgpack / cbor
理由:struct 手搓的格式★无法演进★(加字段就破坏兼容)、没有自描述、
跨语言要各写一套解析、调试全靠十六进制
✗ 格式有★变长字段/嵌套/条件分支★ → construct、kaitai struct
✗ 只取一两个整数 → int.from_bytes(b, "big") 更直观
✗ 大量同类型数值 → array 模块 或 ★numpy.frombuffer(快得多)★
np.frombuffer(data, dtype=">i4") # ★百万级数据比 struct 快一个数量级★
★ 相关工具对照:
int.from_bytes / to_bytes 单个整数,最简单
struct ★固定布局的多字段★
array 同类型数值序列(有 byteswap())
memoryview 零拷贝切片,配合 struct 用
numpy.frombuffer/dtype ★大批量数值,性能最好★
ctypes.Structure 映射 C 结构体(★带对齐★,调 C 库时用)
construct 声明式描述复杂格式
protobuf/msgpack/cbor ★有 schema、可演进、跨语言★
调试技巧:
print(data.hex(" ")) # ★带空格的十六进制,肉眼对照规范★
print(data.hex(" ", 4)) # 每 4 字节一组(3.8+)
binascii.hexlify(data)
★ 解析出错时先 hexdump 前 64 字节,和格式文档逐字节对照
最后是边界和选型。陷阱清单里最值得记的四条:f.read(n)/sock.recv(n) 可能返回不足 n 字节(必须检查或循环);Ns 打包超长会静默截断(丢数据不报错)而解包时补的 \x00 原样返回(rstrip(b"\x00") 又可能误删合法数据);'f' 是单精度,打包 Python 的 double 会丢精度(0.1 变成 0.10000000149011612);以及有符号/无符号(小写/大写)搞反导致的负数解析错误。选型上要有分寸:自己设计新协议不该用 struct 手搓(无法演进、没有自描述、跨语言要各写一套),应该用 protobuf/msgpack;格式有变长字段或条件分支时用 construct;大批量同类型数值用 numpy.frombuffer(比 struct 快一个数量级);只取一两个整数用 int.from_bytes 更直观。调试时记住 data.hex(" ", 4) 能打印出分组的十六进制,方便和格式文档逐字节对照。
记忆钩子:「struct 是『Python 值 ↔ 二进制字节』的翻译器,用于解析二进制文件头、网络协议、定长记录。格式串 = ★字节序前缀 + 类型码★,小写有符号、大写无符号(b/B=1、h/H=2、i/I=4、q/Q=8、f=4、d=8、Ns=定长串、x=填充不产生值)。★第一铁律:解析外部数据,格式串的第一个字符必须是 <、>、= 或 !★——因为不写前缀等于 @ 模式=本机字节序 + ★本机大小★ + ★C 式对齐填充★:calcsize(‘@ci’)=8(char 后填 3 字节)而 calcsize(‘=ci’)=5,‘l’ 在 64 位 Linux 的 @ 模式是 8 字节、标准模式是 4 字节,所以同一段代码换个架构就解析错位。★网络字节序=大端(>/!),而 x86/ARM 是小端★;PNG 用大端、WAV 用小端,★没有统一规律,必须查规范★。★第二铁律:unpack 要求长度严格等于 calcsize★(差一字节就报错),所以从流里解要用 unpack_from(fmt, buf, offset) 或 iter_unpack,而且 ★f.read(n)/sock.recv(n) 都可能返回不足 n 字节,必须检查或循环读满★。热点路径上用 ★struct.Struct(fmt) 预编译★(快 20%~50%,还能用 .size 避免硬编码),配 memoryview/mmap 做零拷贝。易错点:‘Ns’ ★超长静默截断、不足补 \0,解包时 \0 原样返回★;‘f’ 是单精度会丢精度;有符号/无符号别搞反;协议保留字段用 ‘x’ 跳过;‘3i’ 是三个 int 而 ★‘3s’ 是一个 3 字节的串★。选型:只取一两个整数用 int.from_bytes、大批量数值用 numpy.frombuffer(快一个数量级)、格式有变长/嵌套用 construct、★自己设计新协议别手搓 struct,用 protobuf/msgpack(能演进、跨语言)★。」
七、常见误区与追问
- 误区:
struct.unpack("ci", data)解析出来的长度就是 1 + 4 = 5 字节。 不写字节序前缀等于用@模式,它会像 C 编译器那样插入对齐填充——calcsize("@ci")在多数平台是 8(char之后填 3 个字节,让int落在 4 字节边界上)。更糟的是@模式还用本机类型大小:'l'(long)在 64 位 Linux 上是 8 字节、在 Windows 和 32 位系统上是 4 字节。结果就是同一段代码在开发机(x86-64)跑得好好的,部署到 32 位 ARM 设备上解析全部错位,而且不报错、只是数据不对,极难排查。解析任何外部数据(协议、文件格式)时,格式串的第一个字符必须是<、>、=或!——@只在「和本机 C 库交换结构体」时才用。 - 误区:
f.read(4)一定能读到 4 个字节,可以直接丢给unpack。 不一定:读到文件末尾时会返回不足的字节数(甚至b""),从管道、socket 读时更是常态。而struct.unpack要求 buffer 长度和calcsize(fmt)严格相等,少一个字节就抛struct.error: unpack requires a buffer of 4 bytes。正确写法是先检查:head = f.read(HEADER.size),if len(head) < HEADER.size: raise ValueError("文件截断")。socket 的问题更严重——sock.recv(n)只是「尽力返回最多 n 字节」,TCP 是字节流、没有消息边界,所以读固定长度的协议头必须写循环读满(recv_exact);直接struct.unpack(fmt, sock.recv(6))是新手最常见的协议解析 bug,在本地测试(数据小、一次就到)时完全发现不了。 - 误区:
struct.pack("10s", b"hello")和unpack回来能拿回原样的b"hello"。 拿回的是b"hello\x00\x00\x00\x00\x00"——Ns是定长字节串,打包时不足补\x00、解包时这些\x00原样返回,struct不会帮你去掉。所以要自己.rstrip(b"\x00")再.decode()。更危险的是反方向:打包时超长会被静默截断(struct.pack("2s", b"abcdef")得到b"ab",不报错),数据就这么丢了,应该在打包前自己校验长度。还有一个边界情况:如果原始数据本身合法地以\x00结尾(比如二进制内容而非文本),rstrip(b"\x00")会误删——这种场景应该在格式里额外放一个长度字段,而不是靠\x00填充推断。 - 误区:
struct.pack("f", 0.1)再 unpack 回来还是0.1。 得到的是0.10000000149011612——因为'f'是单精度(4 字节,约 7 位有效十进制数字),而 Python 的float是双精度(8 字节,约 15~16 位),打包时发生了精度截断。这在保存传感器数据、图形坐标时是可接受的(省一半空间),但用于金额、科学计算的中间结果就是 bug。要保持精度必须用'd'(双精度,8 字节)。相关的还有整数范围问题:struct.pack("I", -1)会抛struct.error: argument out of range(I是无符号),而struct.pack("i", 3000000000)同样超出有符号 32 位范围——打包前要校验取值范围,并且小写(有符号)和大写(无符号)绝对不能搞反,否则解析时一个大的正数会变成负数。 - 误区:
"3s"表示 3 个字节串,就像"3i"表示 3 个整数一样。 两者语义不同:"3i"是三个 int(unpack返回 3 个值),而"3s"是一个长度为 3 的字节串(返回 1 个值)——s前面的数字表示「这个串有多长」,不是「有几个串」。要表达「三个各自独立的 4 字节串」得写"4s4s4s"。同类的还有"3x":三个填充字节,不返回任何值(常用于跳过协议里的保留字段),以及"3p"(Pascal 串,首字节存长度)。这三个类型码的计数语义各不相同,是格式串里最容易读错的部分——写完之后用struct.calcsize(fmt)和len(struct.unpack(fmt, data))验证一遍是好习惯。 - 追问:什么是大端小端,为什么网络协议都用大端? 字节序是「多字节整数在内存里的排列顺序」:数字
0x12345678在大端下按12 34 56 78存放(高位字节在低地址,和人读数字的顺序一致),小端下按78 56 34 12存放(低位在前)。网络字节序被定义为大端,这是 TCP/IP 协议族早年的约定(htons/htonl就是做这个转换的)——选择大端一半是历史原因(早期主导的 IBM/Motorola 架构是大端),一半是因为大端在调试时更直观(hexdump 出来的字节顺序和数值的书写顺序一致)。而现实是 x86、x86-64 和绝大多数 ARM 都运行在小端模式,所以在 PC 上处理网络数据必然涉及转换——这正是struct需要字节序前缀的原因。实践上:网络协议一律用>或!;文件格式必须查规范(PNG、JPEG、Java class 是大端,WAV/BMP/ZIP 是小端);本机查询用sys.byteorder。 - 追问:
struct.Struct(fmt)对象比struct.pack(fmt, ...)快在哪? 快在省掉了「解析格式字符串」这一步。函数式的struct.pack(fmt, ...)每次调用都要拿格式串去查内部缓存、命中不了就重新编译成内部表示;而struct.Struct(">IHB")在构造时就完成了编译,之后每次S.pack()/S.unpack()直接用编译好的结构。单次调用差别微乎其微,但在热点循环里(解析百万条记录)实测能快 20%~50%。所以正确的用法是把Struct对象定义成模块级常量(REC = struct.Struct("<QfI")),循环里复用。它还有两个附带好处:.size属性让你不必硬编码字节数(f.read(REC.size),改格式时不会漏改),以及.format属性便于调试。配合unpack_from+memoryview还能避免切片产生的内存复制——处理几百 MB 的二进制数据时,这几项加起来是数倍的差距。 - 追问:什么时候不该用
struct,该用什么? 四种情况。① 自己设计新协议或新文件格式——struct手搓的二进制格式无法演进(加一个字段就破坏所有旧数据的兼容性)、没有自描述能力(拿到一段数据不知道怎么解)、跨语言要各写一套解析代码、调试全靠 hexdump;应该用 protobuf、msgpack、CBOR、FlatBuffers 这类有 schema、能演进、跨语言的方案。② 格式有变长字段、嵌套结构、条件分支(「如果 type == 3 则后面是这个结构」)——用struct会写成一堆偏移量计算和if,应该用construct这类声明式解析库或 Kaitai Struct。③ 大批量同类型数值——numpy.frombuffer(data, dtype=">i4")比逐条struct.unpack快一个数量级,array模块也比struct更合适。④ 只取一两个整数——int.from_bytes(b, "big")比记格式串更直观。struct的最佳定位是:解析别人定义好的、固定布局的二进制头部或定长记录。
八、加强记忆
struct 是「Python 值 ↔ 二进制字节」的翻译器,用于解析二进制文件头、网络协议报文和定长记录。格式串 = 字节序前缀 + 类型码,小写有符号、大写无符号(b/B=1、h/H=2、i/I=4、q/Q=8、f=4、d=8、Ns=定长串、x=填充且不产生值)。第一铁律:解析外部数据时,格式串的第一个字符必须是 <、>、= 或 !——因为不写前缀等于 @ 模式,即本机字节序 + 本机类型大小 + C 式对齐填充:calcsize("@ci") 是 8(char 后填 3 字节)而 calcsize("=ci") 是 5,'l' 在 64 位 Linux 的 @ 模式下是 8 字节、标准模式是 4 字节,所以同一段代码换个架构就静默解析错位。网络字节序就是大端(>/!),而 x86/ARM 是小端;PNG 用大端、WAV 用小端,没有统一规律,必须查格式规范。第二铁律:unpack 要求 buffer 长度严格等于 calcsize(fmt)(差一字节就报错),所以从流里解析要用 unpack_from(fmt, buf, offset) 或 iter_unpack,而且 f.read(n) 和 sock.recv(n) 都可能返回不足 n 字节,必须检查或循环读满(TCP 是字节流、没有消息边界,这是协议解析最常见的 bug)。热点路径上一律用 struct.Struct(fmt) 预编译对象(快 20%~50%,.size 属性还能避免硬编码),并配合 memoryview/mmap 做零拷贝。易错点:Ns 超长静默截断、不足补 \x00,解包时 \x00 原样返回(rstrip 又可能误删合法数据);'f' 是单精度会丢精度(0.1 → 0.10000000149011612);有符号/无符号搞反会把大正数读成负数;协议里的保留字段用 'x' 跳过;"3i" 是三个 int 而 "3s" 是一个 3 字节的串。选型:只取一两个整数用 int.from_bytes,大批量数值用 numpy.frombuffer(快一个数量级),格式有变长/嵌套/条件分支用 construct,而自己设计新协议千万别用 struct 手搓——用 protobuf/msgpack 这类能演进、跨语言的方案。