← 返回题目列表

Python 怎么解析二进制数据?struct 模块的字节序和对齐是怎么回事?

困难 第 27 / 27 题 更新于 2026/07/31
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占位不产生值
cchar1长度为 1 的 bytes
b / Bsigned/unsigned char1小写有符号、大写无符号
?_Bool1Python bool
h / Hshort2
i / Iint4
l / Llong4(标准)本机模式下可能是 8
q / Qlong long8
e / f / d半/单/双精度浮点2/4/8
Nschar[N]N10s = 10 字节定长串
NpPascal 串N首字节是长度
Pvoid*只在本机模式可用

字节序前缀(决定字节序、大小、对齐)

前缀字节序大小对齐填充
@默认本机本机有填充
=本机标准❌ 无
<小端标准❌ 无
> / !大端(网络序)标准❌ 无
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") 在多数平台是 8char 后面填了 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; }sizeof8 而不是 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_fromiter_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") 在多数平台是 8char 之后填 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 rangeI 是无符号),而 struct.pack("i", 3000000000) 同样超出有符号 32 位范围——打包前要校验取值范围,并且小写(有符号)和大写(无符号)绝对不能搞反,否则解析时一个大的正数会变成负数。
  • 误区:"3s" 表示 3 个字节串,就像 "3i" 表示 3 个整数一样。 两者语义不同"3i"三个 intunpack 返回 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.10.10000000149011612);有符号/无符号搞反会把大正数读成负数;协议里的保留字段用 'x' 跳过;"3i" 是三个 int 而 "3s" 是一个 3 字节的串选型:只取一两个整数用 int.from_bytes,大批量数值用 numpy.frombuffer(快一个数量级),格式有变长/嵌套/条件分支用 construct,而自己设计新协议千万别用 struct 手搓——用 protobuf/msgpack 这类能演进、跨语言的方案。