引言
Python 的 struct 模块使用描述类似于 C 结构体固定布局的格式字符串,在 Python 值和打包的二进制 bytes 之间进行转换。它解决了网络协议、二进制文件格式以及与 C 代码互操作时匹配精确字节布局的问题。每当线格式或磁盘记录必须逐字节符合规范时,二进制打包就变得重要。通过本教程的结束,你将能够打包和解包二进制数据、使用格式前缀控制字节序、通过 Struct 类重用已编译的格式、使用 pack_into 和 unpack_from 向缓冲区写入和从中读取,并在数据损坏之前发现常见错误。
关键要点
struct.pack()返回一个bytes对象,其中包含格式字符串描述的二进制表示。struct.unpack()从 bytes-like 缓冲区读取并返回 Python 值的tuple。struct.pack_into()写入现有的可写缓冲区,并使用显式的offset选择写入位置。- 字节序前缀(
@、=、<、>、!)会改变整数和浮点数的编码方式,不匹配可能悄无声息地损坏数据。 struct.Struct一次编译格式,当使用相同格式打包或解包多个记录时,可以节省工作。- 对于混合二进制格式,
struct.calcsize()会告诉你给定格式需要多少字节。
Python struct 模块是什么
struct 模块是 Python 标准库的一部分。它使用描述类似于 C 结构体的固定内存布局的格式字符串,将 Python 值转换为打包的二进制 bytes 并反向转换。当协议、文件格式或 C 接口需要精确的字节宽度和位置时,请使用它。
在生产代码中,当你的协议或文件格式以固定宽度的整数、浮点数和固定长度字节字段来描述,并且需要确定性地转换它们时,struct 是一个不错的选择。它并不适用于变长字段、可选字段或深度嵌套布局。对于这些情况,请参阅本教程后面的比较表中的 construct 库或 protobuf。
权威参考是 Python 文档中的 struct 部分。
理解格式字符串
格式字符串告诉 struct 为每个字段分配多少字节,以及将其映射到什么 Python 类型。它将可选的字节序前缀与一个或多个类型代码和可选的重复计数组合起来。代码的顺序定义了网络上的布局。
按类别分组的常见格式字符:
| 类别 | 代码 | C 类型 | Python 类型 | 标准大小 |
|---|---|---|---|---|
| 布尔型 | ? |
_Bool |
bool | 1 byte |
| 整数 | b |
signed char |
int | 1 byte |
| 整数 | B |
unsigned char |
int | 1 byte |
| 整数 | h |
short |
int | 2 bytes |
| 整数 | H |
unsigned short |
int | 2 bytes |
| 整数 | i |
int |
int | 4 bytes |
| 整数 | I |
unsigned int |
int | 4 bytes |
| 整数 | q |
long long |
int | 8 bytes |
| 整数 | Q |
unsigned long long |
int | 8 bytes |
| 浮点数 | f |
float |
float | 4 bytes |
| 浮点数 | d |
double |
float | 8 bytes |
| 字节 | s |
char[] |
bytes | 1 byte per char |
| 填充 | x |
pad byte | no value | 1 byte |
当使用字节序前缀(<, >, !, =)时,应用标准大小。没有前缀时,大小为平台原生大小,可能会有所不同。
完整列表请参阅 Python struct 格式字符。
读者经常使用的几个格式字符串规则:
- 重复计数会乘以后续类型,例如
3H表示三个 unsigned short。 s格式化具有明确长度的 bytes,例如8s存储精确的八个字节。x添加一个填充字节,在打包时跳过,在解包时忽略。calcsize(fmt)返回格式字符串将占用的字节数。
s 格式需要特别注意。与整数代码不同,8s
将精确的八个字节打包为单个 bytes 值,而不是八个单独的值。在打包前,必须将 Python str 编码为 bytes,并填充或截断到声明的长度。
import struct
label = b'hello'
padded = label.ljust(8, b'\x00') # 填充到精确 8 个字节
packed = struct.pack('>8sI', padded, 42)
print('packed_hex', packed.hex())
name_raw, number = struct.unpack('>8sI', packed)
print('name', name_raw.rstrip(b'\x00'))
print('number', number)
packed_hex 68656c6c6f0000000000002a
name b'hello'
number 42
注意,struct.unpack 返回完整的八个字节,包括空填充。要恢复原始值,请调用 .rstrip(b'\x00')。
如何使用 struct.pack
struct.pack(format, v1, v2, ...) 接受 Python 值并返回一个 bytes 对象,其长度始终等于 struct.calcsize(format)。值的数量必须与格式字符串中的字段数量匹配。
其签名如下:
struct.pack(format, v1, v2, ...) -> bytes
示例:使用 signed short 和 signed long 打包三个整数:
import struct
packed = struct.pack('>hhi', 5, 10, 15)
print(packed)
print(packed.hex())
print('size_bytes', struct.calcsize('>hhi'))
b'\x00\x05\x00\n\x00\x00\x00\x0f'
0005000a0000000f
size_bytes 8
在这个示例中,格式字符串 '>hhi' 使用 big-endian 字节序、两个 signed short 和一个 signed int。> 前缀确保在任何平台上输出都相同,这在与协议规范比较结果时非常重要。
如果你正在构建网络协议头部,通常会将 struct.pack 与 socket 发送和接收代码结合使用。有关完整的客户端/服务器示例,请参阅 Python socket 编程服务器-客户端。
当格式字符串混合整数和 bytes 字段时,值的数量仍然必须与格式代码的数量匹配,每个代码对应一个值,除了 s。s 代码始终消耗正好一个 bytes 参数,无论声明的长度如何。
import struct
# 格式:big-endian, uint16 version, 4-byte name, uint32 timestamp
fmt = '>H4sI'
print('expected_size', struct.calcsize(fmt))
packed = struct.pack(fmt, 1, b'node', 1700000000)
print('packed_hex', packed.hex())
expected_size 10
packed_hex 00016e6f6465655359c0
如果 calcsize 返回你未预期的数字,请检查你的格式是否使用了没有前缀的 native 类型。像 l 和 i 这样的 native 类型遵循平台对齐规则,这可能会在字段之间添加填充字节。切换到带前缀的格式如 > 或 <,可以在任何机器上获得固定、可预测的大小。
如何使用 struct.unpack
struct.unpack(format, buffer) 从 buffer 中读取正好 struct.calcsize(format) 个字节,并返回一个 Python 值的 tuple。buffer 的长度必须正好合适,既不能短也不能长。
其签名如下:
struct.unpack(format, buffer) -> tuple
示例:打包值,然后将它们解包回 Python 对象:
import struct
fmt = '>hhi'
data = (5, 10, 15)
wire = struct.pack(fmt, *data)
values = struct.unpack(fmt, wire)
print('wire_hex', wire.hex())
print('values', values)
wire_hex 0005000a0000000f
values (5, 10, 15)
struct.unpack 总是返回一个 tuple,即使格式只包含单个元素。期望单个标量的代码仍然可以使用元组解包来解包,例如 x, = struct.unpack('i', buf)。
在真实的流或文件中,你很少有一个 buffer 恰好包含一条记录。使用 struct.calcsize 在调用 struct.unpack 之前切片正确的字节数:
import struct
fmt = '>HH'
record_size = struct.calcsize(fmt) # 每条记录 4 字节
# 模拟一个包含三个连续记录的流。
stream = struct.pack(fmt, 1, 100) + struct.pack(fmt, 2, 200) + struct.pack(fmt, 3, 300)
offset = 0
while offset + record_size <= len(stream):
record = struct.unpack(fmt, stream[offset:offset + record_size])
print('record', record)
offset += record_size
record (1, 100)
record (2, 200)
record (3, 300)
这种模式也适用于二进制文件。以 'rb' 模式打开文件,每次读取 record_size 个字节,当 read() 返回的字节数少于 record_size 时停止。
控制字节序和字节序
格式字符串开头的字节序前缀控制多字节整数和浮点数的编码方式。没有前缀时,struct 使用原生字节序,该字节序因平台而异,会导致输出不可移植。
二进制协议通常为多字节字段定义单一字节序。例如,PNG 文件头使用 big-endian 整数,Windows BMP 文件头使用 little-endian 整数。
Python struct 支持五种前缀字符:
| 前缀 | 名称 | 字节序 | 大小/对齐 |
|---|---|---|---|
@ |
原生字节序并带对齐 | 原生 | 原生对齐可能添加填充 |
= |
标准大小,原生字节序 | 原生 | 标准大小,无对齐填充 |
< |
Little-endian | Little-endian | 标准大小,无对齐填充 |
> |
Big-endian | Big-endian | 标准大小,无对齐填充 |
! |
网络字节序 | Big-endian | 标准大小,无对齐填充 |
为了查看每个前缀如何改变字节,可以打包一个具体的整数:
import struct
value = 0x12345678
for prefix in ['@', '=', '<', '>', '!']:
packed = struct.pack(prefix + 'I', value)
print(prefix, packed.hex())
@ 78563412
= 78563412
< 78563412
> 12345678
! 12345678
在这个示例主机上,原生字节序是 little-endian。在 big-endian 主机上,@ 和 = 会切换为 big-endian,而 <、> 和 ! 则保持固定。
发送方和接收方之间的字节序不匹配不会引发异常,而是可能悄无声息地破坏值。
使用 Struct 类进行重复操作
struct.Struct(fmt) 会一次性编译格式字符串并存储结果。在实例上调用 st.pack(...) 或 st.unpack(...) 可以跳过每次调用的格式解析,这在循环和高吞吐量数据包处理中非常重要。
并排计时示例:
import struct
import timeit
fmt = '>Ih'
data = (1, 2)
N = 200000
module_time = timeit.timeit(
'struct.pack(fmt, *data)',
number=N,
globals={'struct': struct, 'fmt': fmt, 'data': data},
)
st = struct.Struct(fmt)
struct_time = timeit.timeit(
'st.pack(*data)',
number=N,
globals={'st': st, 'data': data},
)
speedup = module_time / struct_time
print('module_level_s', round(module_time, 6))
print('Struct_instance_s', round(struct_time, 6))
print('speedup_x', round(speedup, 2))
module_level_s 0.020752
Struct_instance_s 0.012069
speedup_x 1.72
确切计时因 CPU 和 Python 构建而异,但模式始终成立:Struct 避免了重复的格式解析和大小计算。
处理缓冲区:struct.pack_into 和 struct.unpack_from
struct.pack_into(fmt, buffer, offset, v1, ...) 将打包的字节写入现有可写缓冲区中指定的字节偏移量。struct.unpack_from 从相同类型的缓冲区中指定偏移量读取。这两者都适用于 bytearray 和 memoryview。
struct.pack_into 写入现有缓冲区并返回 None,因此通常在打包后检查缓冲区。
使用 bytearray 的示例:
import struct
fmt = '>Ih' # 无符号 int,有符号 short
st = struct.Struct(fmt)
buf = bytearray(st.size)
print('buf_len', len(buf))
st.pack_into(buf, 0, 0x12345678, -2)
print('buf_hex', buf.hex())
values = st.unpack_from(buf, 0)
print('unpacked', values)
buf_len 6
buf_hex 12345678fffe
unpacked (305419896, -2)
Python 的 bytes、bytearray 和 memoryview 类型分别表示不可变数据、可写缓冲区和零拷贝视图。背景知识请参阅 Python 数据类型教程。
使用 offset 在较大缓冲区的特定位置进行打包:
import struct
fmt = '>Ih'
st = struct.Struct(fmt)
big = bytearray(st.size + 4)
offset = 4
st.pack_into(big, offset, 0x01020304, 7)
print('big_hex', big.hex())
print('unpacked_at_offset', st.unpack_from(big, offset))
big_hex 00000000010203040007
unpacked_at_offset (16909060, 7)
如果已有切片视图,memoryview 可以避免额外的拷贝:
import struct
fmt = '<HH'
st = struct.Struct(fmt)
backing = bytearray(st.size + 6)
view = memoryview(backing)[3:] # 视图从偏移三个字节处开始
st.pack_into(view, 0, 0x1122, 0x3344)
out = st.unpack_from(view, 0)
print('backing_hex', backing.hex())
print('unpacked', out)
backing_hex 00000022114433000000
unpacked (4386, 13124)
旧代码有时使用 ctypes.create_string_buffer 为 pack_into 分配可写内存。对于纯 Python 缓冲区处理,除非已与 C ABI 内存区域集成,否则优先使用 bytearray 和 memoryview。
实际示例
下面的示例涵盖了三种常见的生产模式:构建二进制网络头部、写入和读取二进制文件,以及匹配 C struct 布局以实现互操作。
网络数据包头部示例
格式 '>BBHI' 以大端字节序打包一个四字段的数据包头部。在发送前打包它,在接收时解包以恢复原始字段值。
import struct
version = 1
packet_type = 2
payload = b'hello'
length = len(payload) # payload 的字节长度
checksum = sum(payload) & 0xFFFFFFFF
fmt = '>BBHI' # version (u8), type (u8), length (u16), checksum (u32)
header = struct.pack(fmt, version, packet_type, length, checksum)
print('packed_header_hex', header.hex())
# 发送模拟
sent = header
# 接收模拟
received = sent
v, t, l, c = struct.unpack(fmt, received)
print('unpacked', (v, t, l, c))
packed_header_hex 0102000500000214
unpacked (1, 2, 5, 532)
当你围绕这个构建真实的客户端/服务器代码时,将打包的头部与 socket 的发送和接收调用配对使用。有关模式和缓冲策略,请参阅 Python socket 编程的服务器-客户端示例。
如果你使用掩码和移位来计算校验和,Python 的位运算符就是你用于协议级算术的相同原语。
二进制文件解析示例
以二进制模式打开文件('wb' 和 'rb'),直接写入打包的字节,然后读取它们进行解包。上下文管理器即使发生错误也能处理文件关闭。
import struct
import tempfile
from pathlib import Path
values = (10, 20, 30, 40)
fmt = '>4I' # 四个无符号整数,大端序
with tempfile.TemporaryDirectory() as d:
path = Path(d) / 'data.bin'
packed = struct.pack(fmt, *values)
print('packed_hex', packed.hex())
print('size_bytes', struct.calcsize(fmt))
with path.open('wb') as f:
f.write(packed)
with path.open('rb') as f:
raw = f.read()
unpacked = struct.unpack(fmt, raw)
print('unpacked', unpacked)
packed_hex 0000000a000000140000001e00000028
size_bytes 16
unpacked (10, 20, 30, 40)
C Struct 互操作示例
格式 '<IHBB' 使用小端字节序匹配固定宽度的 C 记录布局。首先调用 struct.calcsize 来确认你的 Python 格式字符串生成的字节宽度与 C struct 相同。
假设有一个这样的 C struct:
// C (概念性)
// uint32_t magic; // 4 bytes
// uint16_t code; // 2 bytes
// uint8_t flags; // 1 byte
// uint8_t reserved; // 1 byte
//
// 在典型的 ABI 中,此字段顺序的总大小为 8 字节。
Python 格式字符串:
<IHBB 表示小端、uint32、uint16、uint8、uint8,使用标准大小且无额外的对齐填充。
import struct
fmt = '<IHBB'
print('calcsize', struct.calcsize(fmt))
# 从网络或二进制文件中接收的字节。
wire = bytes.fromhex('ddccbbaa34120100')
print('wire_hex', wire.hex())
decoded = struct.unpack(fmt, wire)
print('decoded', decoded)
calcsize 8
wire_hex ddccbbaa34120100
decoded (2864434397, 4660, 1, 0)
如果你需要匹配包含编译器插入填充的 C struct,请检查你的 C 编译器 ABI 规则,并考虑在 Python 中使用 @ 进行原生对齐,或者在 C 中定义显式打包。最安全的途径是双方确认大小,并添加解析真实 fixture 的测试。
常见错误及其修复方法
大多数 struct 错误分为三类:缓冲区长度不匹配、项目数量错误,以及无声的字节序损坏。第三类不会引发异常,这使其在生产环境中最为危险。
struct.error:Unpack 需要 X 字节的缓冲区
错误信息: struct.error: unpack requires a buffer of 4 bytes
import struct
try:
struct.unpack('>I', b'\x00\x01') # 2 字节,但 '>I' 需要 4 字节
except struct.error as e:
print(e)
unpack requires a buffer of 4 bytes
根本原因: 输入缓冲区比 struct.calcsize(format) 短。unpack 调用必须读取格式字符串所需的确切字节数。
修复方法: 切片或读取确切的 struct.calcsize(format) 字节,然后将该缓冲区传递给 struct.unpack。解析流数据时,缓冲直到有足够的字节。
struct.error:Pack 期望 X 个项目进行打包(提供了 Y 个)
错误信息: struct.error: pack expected 2 items for packing (got 1)
import struct
try:
struct.pack('>Ih', 123) # 格式需要两个值
except struct.error as e:
print(e)
pack expected 2 items for packing (got 1)
根本原因: 传递给 struct.pack 的 Python 值数量与格式字符串描述的字段数量不匹配。
修复方法: 为每个格式元素提供一个值,或将元组/列表展开为位置参数,例如 struct.pack(fmt, *values)。
发送方与接收方之间的字节序不匹配
症状: 不会引发异常。解码的值无声地错误,这使得字节序不匹配成为生产环境中三种错误中最难捕获的一种。
import struct
value = 0x1234
wire = struct.pack('>H', value) # 发送方使用大端序
# 接收方错误地使用小端序
decoded = struct.unpack('<H', wire)[0]
print('wire_hex', wire.hex())
print('decoded', decoded)
wire_hex 1234
decoded 13330
根本原因: 接收方使用与发送方不同的字节序前缀来解释多字节字段。
修复方法: 双方使用相同的字节序前缀,对于大多数网络协议,选择 > 或 !,对于主机间二进制文件,决定是否需要原生对齐或标准大小。
struct 与替代方法对比
struct 是固定宽度、与 C 兼容的二进制布局的正确工具。对于更复杂的需求,Python 提供了几种具有不同权衡的替代方案。
| 方法 | 使用场景 | 优点 | 缺点 |
|---|---|---|---|
struct |
固定布局,类似 C 的二进制打包和解包 | 标准库,显式格式字符串,使用 <>! 和 = 可预测大小 |
手动格式字符串,对于高度嵌套或可变长度格式支持有限 |
ctypes |
与 C API 互操作,映射外部内存 | 可镜像 C struct 并调用 C 函数 | 平台间 ABI 和对齐差异,内存处理更易出错 |
array 模块 |
用于 I/O 的同构数值数组 | 单一数值类型简单,在某些工作流中易转换为 bytes | 不适用于混合字段布局或填充规则 |
construct 库 |
复杂二进制格式的声明式解析和构建 | 丰富的模式语言,支持条件和嵌套解析 | 额外依赖,在紧凑循环中相比 struct 有解析开销 |
protobuf |
带 schema 的消息序列化 | 跨语言 schema 兼容性,支持版本控制 | 不是与 C struct 布局逐字节匹配,使用可变长度编码 |
常见问题解答
下面的问题涵盖了使用 struct.pack、struct.unpack、字节序前缀和缓冲区大小设置时最常见的困惑点。
问:Python 中的 struct.pack 返回什么?
答: struct.pack(format, ...) 返回一个 bytes 对象,其中包含 format 描述的二进制表示。返回的长度始终与 struct.calcsize(format) 匹配。
问:struct.pack 和 struct.pack_into 有什么区别?
答: struct.pack 创建并返回一个新的 bytes 对象。struct.pack_into 将打包的值写入现有的可写缓冲区,使用 offset 来选择写入位置。
问:为什么 struct.unpack 返回一个元组?
答: struct.unpack 返回 tuple,因为格式字符串可以描述多个字段。即使格式只有一个字段,返回元组也能保持 API 的一致性。
问:使用 Python struct 处理网络数据时如何处理字节序?
答: 网络协议通常使用固定的网络字节序,一般是大端序。在格式字符串中使用 > 或 ! 来处理多字节字段,并确保发送方和接收方使用相同的字节序前缀。
问:Python struct 中的原生字节序 @ 和标准字节序 = 有什么区别?
答: @ 使用原生字节序和原生对齐,这可能会在字段之间添加填充。= 使用原生字节序但采用标准大小,没有对齐填充,因此布局与文档中描述的大小匹配,而不是平台 ABI。
问:如何使用 Python struct 打包字符串或 bytes 对象?
答: 使用带有明确长度的 s 格式,例如 8s。对于 str,先编码为 bytes,然后将结果传递给 struct.pack,并确保其长度正好符合要求。
问:何时应该使用 Struct 类而不是模块级别的 struct.pack 和 struct.unpack?
答: 当你的代码反复使用相同的格式字符串进行打包或解包时,使用 struct.Struct(fmt)。它会一次编译格式,因此重复操作无需每次调用都重新解析格式。
问:什么原因导致 struct.error: unpack requires a buffer of X bytes,以及如何修复?
答: 当传递给 struct.unpack 的缓冲区短于 struct.calcsize(format) 时会发生此错误。修复方法是缓冲数据直到有足够的字节,或者在调用 struct.unpack 前切片出正确数量的字节。
结论
大多数二进制格式问题归结为三个决策:打包哪些字段、对端期望的字节序,以及是否经常调用相同的格式字符串以至于值得使用 Struct 实例。正确处理这三点,struct 很少会让你感到意外。
本教程中的格式字符表和字节序前缀表是你最常参考的两个参考资料。错误部分涵盖了即使是经验丰富的用户也会遇到的陷阱,特别是无声的字节序不匹配,它会产生错误的值却不抛出异常。
下一步的自然扩展是将打包的头部放到真实的 socket 上。要查看可扩展自己的头部格式的工作客户端和服务器,请参阅 Python socket 编程服务器-客户端。