2.29. 结构体与二进制数据

struct 模块可以将 Python 值打包成固定的二进制布局,并将字节解包回 Python 值。当你处理二进制文件格式、网络协议,或与交换固定大小记录的设备通信时,可以使用它。

两个函数即可涵盖大多数情况:

  • struct.pack() —— 接收 Python 值和一个格式字符串,返回一个具有精确布局的 bytes 对象。

  • struct.unpack() —— 接收一个格式字符串和一个 bytes 对象,返回一个 Python 值组成的元组。

2.29.1. 格式字符串

格式字符串为记录中的每个字段列出一个代码。这些代码同时描述了每个字段的大小和解释方式。

Python 的 int 没有固定大小——它会自动增长以容纳你赋予的任何值。而二进制格式确实有固定大小:每个整数字段都使用约定数量的字节。struct 在无界的 Python 整数与这些固定大小的表示之间进行转换。

整数的位宽是它所使用的比特数。一个字节是八比特。小写代码是有符号变体;大写代码是无符号变体(仅非负值):

  • b / B —— 8 位(一个字节)。有符号为 -128..127,无符号为 0..255

  • h / H —— 16 位(两个字节)。有符号为 -32768..32767,无符号为 0..65535

  • i / I —— 32 位(四个字节)。有符号约为 ±二十亿,无符号约为四十亿。

  • q / Q —— 64 位(八个字节)。对于日常使用来说实际上是无界的。

选择一个能够轻松覆盖你预期范围的位宽。打包一个超出声明范围的值,根据构建版本的不同,要么会静默地回绕,要么会引发 struct.error

其余常用代码用于浮点数和字节串:

  • f —— 32 位浮点数(单精度;约七位十进制有效数字)。MicroPython 上的常规 float 本身就是这个大小,因此将其打包为 f 是无损的。

  • d —— 64 位浮点数(双精度;约十五位十进制有效数字)。将 32 位的 MicroPython float 打包为 d 会将其扩展为八个字节,但不会增加精度。

  • s —— 固定长度字节串,前面带有一个计数(8s 表示一个八字节字段)。

2.29.2. 字节序

一个多字节整数可以用两种方式存储在内存中。数字 0x12345678 在一个 32 位字段中的布局如下:

  • 小端序 —— 最低有效字节在前:78 56 34 12

  • 大端序 —— 最高有效字节在前:12 34 56 78

两者编码的是同一个值;它们只是在字段的哪一端是低字节这一点上有分歧。如果字节序不匹配,一个系统写入的文件在另一个系统读取时会变成乱码。

格式字符串的首字符决定了字节序:

  • < —— 小端序。在 x86 和 ARM 上常见。

  • > —— 大端序。在网络协议中常见。

  • ! —— 网络序,等同于 >

如果没有首字符,则使用本机字节序和本机对齐方式;显式设置 <> 可以消除这种歧义,在读取文件或与另一台机器通信时通常正是你想要的。

备注

OpenMV Cam 是小端序的——与其主机 PC 相同。对于摄像头本地文件以及往返于桌面的二进制数据,在格式字符串中使用 <。对于网络协议以及任何规范要求大端序的格式,使用 >(或 !)。

六个字节排成一行,前两个字节 组成一个 "H" 字段(16 位无符号),接下来 四个字节组成一个 "I" 字段(32 位无符号),每个字段都 标注了它们的小端序字节顺序。

"<HI" 将一个 16 位值后跟一个 32 位值打包成六个小端序字节。

2.29.3. 打包

import struct

blob = struct.pack("<HI", 320, 1000000)
print(blob, len(blob))

输出:

b'@\x01@B\x0f\x00' 6

<HI 格式生成六个字节:H 字段两个,I 字段四个,全部为小端序。传入的值数量必须与格式所期望的完全一致——不匹配会引发 struct.error

2.29.4. 解包

width, count = struct.unpack("<HI", blob)
print(width, count)

输出:

320 1000000

struct.unpack() 总是返回一个元组,即使格式只描述了一个字段。为了可读性,可以在同一行将其解包。

2.29.5. 固定长度字节串

s 代码原样读取或写入一段字节。计数放在 s 之前——4s 表示"作为单个字节串处理的四个字节"。这是在记录中嵌入魔数、固定大小标签或填充名称字段的常用方式:

header = struct.pack("<4sHI", b"OMV0", 320, 1000000)
print(header)

输出:

b'OMV0@\x01@B\x0f\x00'

前四个字节是字面魔数 b"OMV0";接下来的两个是 H 字段(320);最后四个是 I 字段(1000000)。解包会将字节作为 bytes 对象返回:

magic, width, count = struct.unpack("<4sHI", header)
print(magic, width, count)

输出:

b'OMV0' 320 1000000

如果源值比声明的计数短,结果会在右侧用 \x00 填充;如果更长,多余的字节会被静默丢弃:

struct.pack("4s", b"hi")        # b'hi\x00\x00'
struct.pack("4s", b"toolong")   # b'tool'

该计数是字节长度,而不是字符数——s 处理的是原始字节,因此一个含多字节字符的 UTF-8 字符串需要先 .encode() 并按字节计数。

2.29.6. 计算大小与部分读取

struct.calcsize() 返回一个格式字符串所消耗的字节数:

struct.calcsize("<HI")     # 6

当从文件中读取一连串记录时,每条记录恰好读取那么多字节:

record_size = struct.calcsize("<HI")
with open("data.bin", "rb") as f:
    while True:
        chunk = f.read(record_size)
        if len(chunk) < record_size:
            break
        width, count = struct.unpack("<HI", chunk)
        print(width, count)

文件末尾的短读取会产生一个小于 record_size 的数据块——应将其视为流结束条件,而不是试图解包一条不完整的记录。