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 的 bytes | 1 |
5s | 一个字节串 | 一个总长为 5 字节的字段 |
3c | 三个长度为 1 的字节串 | 三个字符字段,共 3 字节 |
x | 不传入值 | 一个填充字节 |
例如无符号 I 在标准模式中范围是 到 ,有符号 h 范围是 到 。超出范围会抛出 struct.error,不会替你按位截断。格式字符
一个 10 字节采样记录
跳转到“一个 10 字节采样记录”假设协议规定:采样序号为小端 32 位无符号整数,电流为小端 16 位有符号整数,单位 mA;温度为小端 32 位浮点数,单位摄氏度。格式为 <Ihf,总长 字节。
Struct 把固定格式保存为可重复使用的对象。unpack 即使只有一个字段,也返回元组。这里先写后读,文件保存在临时目录中,示例无需预先准备文件。
import mathimport structfrom pathlib import Pathfrom 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 == 42assert current_ma == -500assert 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.14assert 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] = headerSAMPLE.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: passelse: 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 的另一类任务。