首页
/ Python 的 marshal 模块:CPython 内部对象序列化格式与 pyc 引擎详解

Python 的 marshal 模块:CPython 内部对象序列化格式与 pyc 引擎详解

2026-09-07 10:05:43作者:申梦珏Efrain

marshal 是 CPython 内置的二进制序列化模块,它把 Python 对象转换为紧凑的字节流并支持读回,且格式与机器架构无关。它并非通用的“持久化”工具,而是 CPython 导入系统编译 .pyc 缓存文件时序列化 code object 的底层引擎。阅读本文后,你将掌握 marshal.dump/load/dumps/loads 的完整用法、格式版本演进机制、allow_code 安全开关,以及从源码层面理解其内部类型编码与递归引用实现。

marshal 是什么:CPython 内部的字节级序列化器

marshal 模块(Doc/library/marshal.rst)提供的函数能够在二进制格式下读写 Python 值。这种格式有两个关键特性:

  • Python 专属:格式不是任何通用序列化标准(如 JSON、MessagePack),只服务于 CPython 自身;
  • 机器无关:在 PC 上写入的值可以传输到 Mac 并原样读回,不受字节序与字长影响(例如浮点数以二进制表示时使用固定的 IEEE 754 布局)。

模块名源于 Modula-3 等语言设计者使用的术语:marshalling 指把数据从内部形式转换为自包含的外部形式(例如放入 RPC 缓冲区),反向过程称为 unmarshalling。

值得反复强调的定位是:marshal 不是通用持久化模块。做通用的对象持久化或跨进程 RPC 传输,应使用 pickleshelve。marshal 存在的主要目的是支撑 CPython 读写 .pyc“伪编译”文件——即解释器把源码编译为 code object 后,用 marshal 将其落盘缓存、再次加载时直接反序列化跳过编译。

CPython 官方在文档中明确保留权利:一旦需要,可以以不兼容的方式修改 marshal 格式。也就是说 marshal 字节流不是稳定契约,且 code object 的格式在 Python 版本之间不兼容——即使格式版本号相同,在错误的 Python 版本中反序列化 code object 属于未定义行为。因此官方建议:若要序列化/反序列化常规对象,请改用 pickle——其性能与 marshal 相当、跨版本一致性有保证,且支持的对象范围远超 marshal。

一个至关重要的警告:marshal 绝不能用于不可信数据

warning: 本模块不保证能抵御错误或恶意构造的数据。绝不 unmarshal 来自不可信或未认证来源的数据。

这正是读取逻辑需要在 r_object 中处处防御、code.__new__ 曾被单独审计的原因。marshal 设计目标是性能而非安全,其反序列化器可以实例化任意 code object、构造递归容器等,喂入恶意字节流可能引发未定义行为甚至内存安全问题。

支持序列化的对象类型

marshal 只支持“值与某一次具体解释器调用无关”的对象类型,并非所有 Python 对象都能写入。受支持的类型如下:

  • 数值类型intboolfloatcomplex
  • 字符串与字节strbytesbytes-like 对象(如 bytearray)会被当作 bytes 序列化(对应源码 Python/marshal.cPyObject_CheckBuffer 分支);
  • 容器tuplelistdictfrozendict(需格式版本 6,见下)、setfrozensetslice(需格式版本 5);容器内部的元素也必须本身受支持;自版本 3 起支持递归容器
  • 单例对象NoneEllipsisStopIteration
  • code 对象:仅在 allow_code=True(默认开启)时才允许,且受上文版本不兼容性约束。

任何不支持的类型(或其内部嵌套了不支持的类型)都会在写入时抛出 ValueError,但垃圾数据仍会写入文件,且无法被 load 正确读回——读侧的行为在文档与实现中都很明确:load 会把不可 marshal 的类型替换为 None

格式版本(marshal.version)及其演进史

marshal.version 常量表示当前模块使用的格式版本。各版本特性由 Python/marshal.c 中的 Py_MARSHAL_VERSION 定义,当前仓库取值为 6(见 Include/cpython/marshal.h):

版本 自版本起可用 新增特性
1 Python 2.4 共享 interned 字符串(TYPE_INTERNED
2 Python 2.5 浮点数的二进制表示
3 Python 3.4 对象实例化与递归支持
4 Python 3.4 短字符串的高效表示
5 Python 3.14 支持 slice 对象
6 Python 3.15 支持 frozendict 对象

版本 0 是历史最早的格式;后续版本不断叠加新特性。通常一个新版本在引入之时即成为默认值。新版本引入后,旧版本仍可用于写出兼容旧解释器的数据——dump/dumpsversion 参数正为此而设。

从源码可以印证各版本间的“门控”逻辑。在 Python/marshal.c 中,写入 slice 时若 p->version < 5,直接写出 TYPE_UNKNOWN 并置 WFERR_UNMARSHALLABLE 错误;写入 frozendict 时若 p->version < 6 同理(Python/marshal.c)。反序列化端则是通过读取到 TYPE_SLICE/TYPE_FROZENDICT 类型标记来重建对象。

版本号与浮点/字符串优化的源码印证

  • 共享 interned 字符串(v1+):写出字符串时若 p->version > 1 且检测到字符串被 intern,会写 TYPE_INTERNED/TYPE_ASCII_INTERNED,读回后重新 intern,从而让多次出现的同一字符串只占一份内存;
  • 短字符串高效表示(v4+):当 p->version >= 4 且字符串长度 n < 256 时使用 TYPE_SHORT_ASCII/TYPE_SHORT_ASCII_INTERNED,把长度压缩为一个字节;
  • 二进制浮点(v2+):使用 TYPE_BINARY_FLOAT'g')与 TYPE_BINARY_COMPLEX'y');v0 只能写出旧式 TYPE_FLOAT'f')与 TYPE_COMPLEX'x'),见 Python/marshal.c

API 详解:四大核心函数

marshal 同时提供读写文件的函数和操作 bytes-like 对象的函数,且从 Python 3.13 起四个函数统一增加了 allow_code 关键字参数。

marshal.dumps(value, version=marshal.version, /, *, allow_code=True)

返回“假如调用 dump(value, file) 会写入文件”的那个 bytes 对象。

  • value 必须是受支持类型,否则抛 ValueError(内部含不支持类型同理);
  • version 指示要使用的数据格式;
  • allow_code=False 时若值中带有 code object 会抛 ValueError
  • 触发审计事件 marshal.dumps,事件参数为 (value, version),在 Python/marshal.c 通过 PySys_Audit("marshal.dumps", "Oi", x, version) 发出。
import marshal

data = {"name": "cpython", "nums": (1, 2, 3.14)}
blob = marshal.dumps(data)
print(blob)          # b'\xe3\x02\x00\x00\x00...'
print(marshal.loads(blob))  # {'name': 'cpython', 'nums': (1, 2, 3.14)}

marshal.dump(value, file, version=marshal.version, /, *, allow_code=True)

value 写入已打开的可写二进制文件(如 open(path, "wb"))。

  • 若值含不支持类型,抛 ValueError,但同时会把垃圾数据写入文件,导致后续 load 无法正确读回该对象(会被替换为 None);
  • code object 仅在 allow_code=True 时支持;
  • 审计事件同为 marshal.dumps(事件参数 value, version),说明 dump 与 dumps 走的是同一套写入核心。
import marshal

with open("/tmp/data.bin", "wb") as f:
    marshal.dump({"a": 1, "b": [2, 3]}, f)   # f 必须是二进制写模式

with open("/tmp/data.bin", "rb") as f:
    print(marshal.load(f))                    # {'a': 1, 'b': [2, 3]}

marshal.load(file, /, *, allow_code=True)

从打开的可读二进制文件中读取一个值并返回。

  • 读不到合法值(例如数据是另一个不兼容 Python 版本的 marshal 格式)时,抛出 EOFErrorValueErrorTypeError 三者之一;
  • code object 仅在 allow_code=True 时支持;
  • 若某对象在 dump 时含不支持类型,load 会以 None 替代无法反序列化的类型;
  • 触发审计事件 marshal.load

版本沿革:3.10 之前,load/loads 曾对每个 code object 单独触发一次 code.__new__ 审计事件;3.10 起改为整个加载操作只触发一次 marshal.load/marshal.loads 事件。3.13 新增 allow_code 参数。

marshal.loads(bytes, /, *, allow_code=True)

把 bytes-like 对象转换回值对象。

  • 找不到合法值时抛 EOFErrorValueErrorTypeError
  • 输入中多余的字节会被忽略
  • code object 仅在 allow_code=True 时支持;
  • 触发审计事件 marshal.loads(事件参数 bytes)。
import marshal

# 输入尾部有多余字节时不影响读取第一个值
blob = marshal.dumps(42) + b"trailing garbage"
print(marshal.loads(blob))  # 42

allow_code:3.13 加入的安全开关

四个函数共用一套规则:code object 只在 allow_code=True 时被允许。写入端在 Python/marshal.c 与读取端 Python/marshal.c 均有显式检查——例如读取到 TYPE_CODE 时若 allow_code 为假,直接抛 ValueError("unmarshalling code objects is disallowed")

这在“加载他人数据但不想实例化任意 code 对象”的场景下很有价值。测试 Lib/test/test_marshal.py 给出了对照用例:

data = marshal.dumps({"x": 1})            # 不含 code object 的普通数据
dump = marshal.dumps(data, allow_code=False)
assert marshal.loads(dump, allow_code=False) == data

def f(): ...                              # code object
with_exc = marshal.dumps(f.__code__, allow_code=True)  # 正常
marshal.dumps(f.__code__, allow_code=False)            # ValueError
marshal.loads(with_exc, allow_code=False)              # ValueError

二进制格式背后的实现机制

要理解 marshal 的格式与“版本不兼容”风险,读 Python/marshal.c 的源码比文档更直观。

类型标记是一字节 ASCII 字符Python/marshal.c),例如:

类型标记 含义
'N' 'F' 'T' 'S' '.' None / False / True / StopIteration / Ellipsis
'(' '[' '{' tuple / list / dict
'}' frozendict(v6 起)
':' slice(v5 起)
'c' code object
'g' 'y' 二进制 float / complex
'l' 大整数(任意精度)
's' 'u' bytes / unicode str
'z' 'Z' 'a' 'A' 't' 各类短/ASCII/interned 字符串优化形式
'r' 引用(对象重入)
'\x80'(FLAG_REF) 与类型标记按位或,表示“把该对象加入引用索引”

递归容器与共享引用依赖版本 3 引入的引用机制:写入侧用一个哈希表记录已写对象,首次写出时给类型标记加上 FLAG_REF'\x80');再次遇到同一对象时直接写 TYPE_REF 加索引号。读取侧维护 rf.refs 列表,用 r_ref_reserve 预留槽位后填充,从而让 a=[0]; a.append(a) 这样的自引用结构能无损往返。这也是为什么文档注明“递归容器自版本 3 起支持”。

值得一提的实现细节:写入未知/不支持类型时,写入器会写一个 TYPE_UNKNOWN'?')标记并置错误标志——这是 dumpValueError 却仍把垃圾字节写进文件的根源,也解释了为何文档反复提醒:若对象含不可 marshal 类型,即使异常被捕获,文件里也残留着无法读回的脏数据。

C 层 API:marshal 同时是解释器内部设施

marshal 并不只是 Lib 层的纯 Python 模块,它是编译进解释器的 C 模块,marshal 名称空间内的函数均由 Python/marshal.c 经 Argument Clinic 生成解析器(见 Python/clinic/marshal.c.h)。此外它还暴露一组面向 C 扩展编写者的 API(Include/cpython/marshal.h):

PyObject *PyMarshal_ReadObjectFromString(const char *, Py_ssize_t);
PyObject *PyMarshal_WriteObjectToString(PyObject *, int);
long      PyMarshal_ReadLongFromFile(FILE *);
int       PyMarshal_ReadShortFromFile(FILE *);
PyObject *PyMarshal_ReadObjectFromFile(FILE *);
PyObject *PyMarshal_ReadLastObjectFromFile(FILE *);
void      PyMarshal_WriteLongToFile(long, FILE *, int);
void      PyMarshal_WriteObjectToFile(PyObject *, FILE *, int);

其中 Py_MARSHAL_VERSION 宏定义为 6(Include/cpython/marshal.h),与 Python 层的 marshal.version 一致。PyMarshal_ReadLastObjectFromFile 专门服务于 .pyc 读取场景——先读 16 字节文件头(含 magic number)再读末尾的序列化 code object。

真实应用场景:.pyc 编译缓存的读写引擎

marshal 最大的“生产级”用户是 CPython 自身的导入系统。importlib 在编译模块后将代码对象的 marshal 字节流写入 .pyc 文件,下次导入时用同样的 magic 校验版本后直接反序列化,从而跳过重复编译。这就是文档中“marshal 主要用来支持 .pyc 文件中 Python 模块的伪编译代码读写”的落地形态,也解释了为什么:

  • marshal 必须能序列化 code object——但 code object 与具体解释器版本深度耦合,因此跨版本不兼容、反序列化到错误版本是未定义行为;
  • 版本格式以极慢节奏演进,且始终向后兼容早期数据,避免打破既有 .pyc 缓存;
  • loads 允许忽略尾部多余字节,恰好适配“.pyc 头部元数据 + 尾部 code 数据”的布局。

何时选择 marshal、何时坚决不用

选型结论可直接照搬文档与源码证据:

需求 推荐 原因
读写 .pyc 缓存、code object 搬运 marshal 唯一能序列化 code object 的内置手段
常规对象持久化 / RPC 传输 pickle / shelve 性能相当、跨版本一致、类型覆盖广
与外部系统交换数据 JSON 等文本格式 marshal 是 Python 专属私有格式
处理不可信或未认证数据 都不要用 文档与源码都未对恶意字节流做安全承诺

关键约束再强调一遍:marshal 格式可能随 Python 版本变化;code object 格式在版本间不兼容且反序列化错误版本属于未定义行为;dump 遇到不支持类型时抛异常但会留下脏数据。在你自己的代码中,最稳妥的用法是把它当作“仅供 CPython 自身使用”的黑盒——能理解、能调 version 参数、会开 allow_code 开关即可,不要把它设计进任何跨版本或跨进程的长期存储链路。

延伸阅读

登录后查看全文
热门项目推荐
相关项目推荐