Python 的 marshal 模块:CPython 内部对象序列化格式与 pyc 引擎详解
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 传输,应使用 pickle 与 shelve。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 对象都能写入。受支持的类型如下:
- 数值类型:
int、bool、float、complex; - 字符串与字节:
str、bytes;bytes-like 对象(如bytearray)会被当作bytes序列化(对应源码 Python/marshal.c 中PyObject_CheckBuffer分支); - 容器:
tuple、list、dict、frozendict(需格式版本 6,见下)、set、frozenset、slice(需格式版本 5);容器内部的元素也必须本身受支持;自版本 3 起支持递归容器; - 单例对象:
None、Ellipsis、StopIteration; - 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/dumps 的 version 参数正为此而设。
从源码可以印证各版本间的“门控”逻辑。在 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 格式)时,抛出
EOFError、ValueError或TypeError三者之一; - 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 对象转换回值对象。
- 找不到合法值时抛
EOFError、ValueError或TypeError; - 输入中多余的字节会被忽略;
- 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('?')标记并置错误标志——这是 dump 抛 ValueError 却仍把垃圾字节写进文件的根源,也解释了为何文档反复提醒:若对象含不可 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 开关即可,不要把它设计进任何跨版本或跨进程的长期存储链路。
延伸阅读
- 模块官方文档:Doc/library/marshal.rst
- 核心实现(类型编码、版本门控、引用机制):Python/marshal.c
- 参数解析与审计事件声明:Python/clinic/marshal.c.h
- C API 声明与
Py_MARSHAL_VERSION宏:Include/cpython/marshal.h - 单元测试(含
allow_code、递归容器、frozendict 引用、审计事件用例):Lib/test/test_marshal.py
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00