跳转到内容
新建笔记

Python struct:字节序、二进制记录与缓冲区边界

struct 按一个明确的格式,把 Python 数值打包为二进制字节,或从字节中解出数值。它适合固定布局的数据记录、设备协议和二进制文件;格式字符串描述字段顺序、宽度、字节序和填充,不会自动携带字段名称或协议版本。

本文使用 Python 3.11。跨设备的数据示例都显式指定字节序,避免把当前机器的 C 布局当成通用协议。

先规定布局,再打包数据

跳转到“先规定布局,再打包数据”

格式字符串的第一个字符决定整体规则。省略前缀相当于 @,因此原示例中的 If、Ihfd 会使用本机模式。字节序、大小与对齐规则

前缀字节序字段大小自动对齐填充
@ 或省略本机本机 C 类型大小按本机规则在字段之间填充
=本机标准大小无
<小端标准大小无
>大端标准大小无
!网络字节序,即大端标准大小无

= 固定了大小和填充,却仍使用本机字节序;可移植协议一般选明确的 <、> 或 !。@ 也不会自动补齐整个结构尾部的填充,不能不加核对就把 calcsize() 当作某个 C 编译器的结构体 sizeof。

常见代码的标准宽度如下。整数是否有符号、文本如何编码,都必须由协议规定。

格式Python 值标准字节数或含义
b / B整数1,有符号 / 无符号
h / H整数2,有符号 / 无符号
i / I整数4,有符号 / 无符号
q / Q整数8,有符号 / 无符号
f / d浮点数IEEE 754 binary32 / binary64,4 / 8 字节
c长度为 1 的 bytes1
5s一个字节串一个总长为 5 字节的字段
3c三个长度为 1 的字节串三个字符字段,共 3 字节
x不传入值一个填充字节

例如无符号 I 在标准模式中范围是 00 到 232−12^{32}-1,有符号 h 范围是 −215-2^{15} 到 215−12^{15}-1。超出范围会抛出 struct.error,不会替你按位截断。格式字符

假设协议规定:采样序号为小端 32 位无符号整数,电流为小端 16 位有符号整数,单位 mA;温度为小端 32 位浮点数,单位摄氏度。格式为 <Ihf,总长 4+2+4=104+2+4=10 字节。

Struct 把固定格式保存为可重复使用的对象。unpack 即使只有一个字段,也返回元组。这里先写后读,文件保存在临时目录中,示例无需预先准备文件。

import math
import struct
from pathlib import Path
from tempfile import TemporaryDirectory
SAMPLE = struct.Struct("<Ihf")
assert SAMPLE.size == 10
record = SAMPLE.pack(42, -500, 25.5)
assert record.hex() == "2a0000000cfe0000cc41"
sequence, current_ma, temperature_c = SAMPLE.unpack(record)
assert sequence == 42
assert current_ma == -500
assert math.isclose(temperature_c, 25.5)
with TemporaryDirectory() as directory:
path = Path(directory) / "sample.bin"
path.write_bytes(record)
decoded = SAMPLE.unpack(path.read_bytes())
assert decoded == (42, -500, 25.5)
# binary32 不能精确表示所有十进制小数。
rounded, = struct.unpack("<f", struct.pack("<f", 3.14))
assert rounded != 3.14
assert math.isclose(rounded, 3.14, rel_tol=1e-6)
# 原来的四字段示例在显式标准模式下是 18 字节。
assert struct.calcsize("<Ihfd") == 18

协议使用什么物理单位与 struct 无关。例如 -500 在上面代表 -500 mA,不是由 h 自动解释为电流。范围检查通过之后,还应检查业务范围、协议版本和必要的校验信息。

从缓冲区的指定位置读写

跳转到“从缓冲区的指定位置读写”
接口对缓冲区的要求常见用途
pack无输入缓冲区返回新 bytes
pack_into可写缓冲区,指定偏移后容量足够写入已有 bytearray
unpack长度必须恰好等于格式长度解一条完整记录
unpack_from从偏移开始至少有一条记录的长度从较大报文中读字段
iter_unpack总长度必须是非零记录长度的整数倍依次解同格式的多条记录

unpack_from 不要求报文只有这一条记录;因此它也不会因为后面还有额外字节而自动拒绝报文。是否允许尾随内容,应由外层协议判断。函数说明

import struct
SAMPLE = struct.Struct("<Ihf")
header = b"VL"
buffer = bytearray(len(header) + 2 * SAMPLE.size)
buffer[:2] = header
SAMPLE.pack_into(buffer, 2, 1, -20, 24.0)
SAMPLE.pack_into(buffer, 2 + SAMPLE.size, 2, 30, 25.0)
assert bytes(buffer[:2]) == b"VL"
assert SAMPLE.unpack_from(buffer, 2) == (1, -20, 24.0)
payload = memoryview(buffer)[2:]
assert list(SAMPLE.iter_unpack(payload)) == [
(1, -20, 24.0),
(2, 30, 25.0),
]
for invalid in (b"", bytes(buffer[2:-1]), bytes(buffer[2:]) + b"x"):
try:
SAMPLE.unpack(invalid)
except struct.error:
pass
else:
raise AssertionError("unpack accepted a wrong record length")
try:
SAMPLE.pack(1, 40_000, 25.0) # 不在有符号 16 位范围内
except struct.error:
pass
else:
raise AssertionError("out-of-range current accepted")

串口和网络接收可能分多次返回数据。应先完成外层长度、分帧和必要的校验,再交给 struct;一次 read 或 recv 不保证对应一条完整记录。

字节串不是 Unicode 字符串

跳转到“字节串不是 Unicode 字符串”

5s 表示固定的 5 个字节:短输入补零,长输入截断。它不会因为截断的是一个 UTF-8 多字节字符就自动报错。对于有编码约定的定长文本字段,应在打包前检查编码后的字节数。

import struct
assert struct.pack("3c", b"a", b"b", b"c") == b"abc"
assert struct.unpack("3c", b"abc") == (b"a", b"b", b"c")
assert struct.pack("5s", b"abc") == b"abc\x00\x00"
assert struct.unpack("5s", b"hello") == (b"hello",)
assert struct.pack("5s", b"abcdef") == b"abcde"
def pack_label(text):
encoded = text.encode("utf-8")
if len(encoded) > 5:
raise ValueError("label exceeds the 5-byte field")
if b"\x00" in encoded:
raise ValueError("label contains a reserved NUL byte")
return struct.pack("5s", encoded)
packed = pack_label("温度"[:1]) # “温”占 3 个 UTF-8 字节
raw, = struct.unpack("5s", packed)
assert raw.rstrip(b"\x00").decode("utf-8") == "温"

这里只因示例协议规定零字节是填充而使用 rstrip;对于任意二进制字段,尾部零字节也可能是有效数据。若格式需要灵活字段名、跨语言文本交换,可考虑 JSON;若要恢复 Python 对象关系,则是 pickle 的另一类任务。