- 游戏开发
- 后端
【免费下载链接】kbengine
A MMOG engine of server.
本文围绕 KBEngine 仓库中内嵌 CPython 的官方文档 pickletools.rst 展开。pickletools是 CPython 标准库中专门面向 pickle 协议研究者的小众模块,它能对 pickle 字节流进行符号化反汇编、逐指令解析和冗余优化。读完本文,你将理解 pickle 虚拟机的工作模型,掌握python -m pickletools命令行与dis/genops/optimize三个编程接口的完整用法,并了解 KBEngine 脚本层是如何基于同一个pickle模块完成 C++/Python 间数据序列化的。
一、模块定位:写给 pickle 协议研究者,而非普通用户
pickletools.rst 开篇即说明:该模块包含关于pickle模块内部细节的各种常量、大量实现注释,以及少数用于分析 pickle 数据的实用函数;其内容主要对从事pickle内核开发的 Python 核心开发者有用,普通pickle用户通常用不到它。模块源码为 pickletools.py,本仓库内嵌的 Python 版本为 3.7.3(见 patchlevel.h)。
它的核心价值在于把 pickle 字节流当成"虚拟机的字节码"来分析:
- 安全查看不受信任的 pickle:
pickle的反序列化过程本质上会执行构造对象所需的调用,直接loads来自不受信任来源的数据存在风险;而pickletools只做反汇编,不会执行任何 pickle 字节码,因此是查看可疑文件更安全的方式。 - 协议研究与格式调试:当两个组件因序列化格式不匹配出现异常时,逐条查看指令比看原始字节高效得多。
- 字节流瘦身:
optimize函数可删除无人引用的 memo 写入指令,得到更短、传输/存储/解包都更省的等价 pickle。
pickle 虚拟机的基本模型
pickletools.py 的头部大段注释是理解一切的前提,它解释了"一条 pickle 就是运行在一台虚拟解包机(unpickling machine,PM)上的程序":
- PM 没有循环、条件或算术指令,指令从第一条到最后一条各执行一次,直到
STOP指令结束; - PM 只有两块数据区:栈(stack)和memo(备忘表)。许多指令向栈推入对象,另一些从栈弹出对象;最终留在栈顶的对象就是解包结果;
- memo 是一张"整数索引 → 对象"的表,充当 PM 的长期记忆:
PUT/BINPUT类指令把栈对象存入 memo,GET/BINGET类指令按索引从 memo 取回入栈。它负责对象共享(如[a, a]中同一个a只序列化一次)与递归对象(如L = []; L.append(L))的正确重建; - 各 pickle 协议的演进规则:旧指令语义永不改变、只增不减,因此老 pickle 永远可读。protocol 0(旧称 text mode)全部为可打印 7 位 ASCII;protocol 1(旧称 binary mode)引入含任意字节的二进制参数(如
BININT用 4 字节表示一个 int,比INT的十进制字符串更省更快),并增加了APPENDS/SETITEMS这类批量指令和EMPTY_DICT/EMPTY_TUPLE等捷径指令;protocol 2(Python 2.3 起)则带来了PROTO协议自标识、LONG1/LONG4高效长整型、TUPLE1/2/3小元组捷径、NEWTRUE/NEWFALSE布尔专用指令以及扩展注册表EXT1/2/4。
正是这套"栈 + memo + 单向指令流"的极简设计,使得pickletools只需模拟一遍就能完整反汇编并做合法性校验。
二、命令行用法:python -m pickletools
从 Python 3.2 起,pickletools支持命令行直接反汇编一个或多个 pickle 文件。注意文档中的对比:如果你只关心 pickle 里存的对象本身,可用python -m pickle;但当 pickle 文件来自不受信任的来源时,-m pickletools是更安全的选择,因为它不执行 pickle 字节码。
文档给出的完整示例((1, 2)元组,protocol 3 序列化后存入x.pickle):
$ python -m pickle x.pickle (1, 2) $ python -m pickletools x.pickle 0: \x80 PROTO 3 2: K BININT1 1 4: K BININT1 2 6: \x86 TUPLE2 7: q BINPUT 0 9: . STOP highest protocol among opcodes = 2逐行解读:\x80 PROTO 3声明协议版本;两条BININT1(单字节整型)把 1 和 2 推上栈;TUPLE2弹出栈顶两个对象合成二元组;BINPUT 0把栈顶存入 memo 槽 0(不弹栈);.(STOP)结束。末行输出所有指令中出现过的最高协议号——这里TUPLE2属于 protocol 2 指令,故为 2,尽管文件头声明了 3。
命令行选项
文档列出 4 个选项,源码中的参数定义位于 pickletools.py 的__main__入口(使用argparse实现),与文档一一对应:
| 选项 | 作用 | 源码补充 |
|---|---|---|
-a, --annotate | 为每一行反汇编附加一段简短的指令说明 | 以store_true实现;启用时内部传annotate=30作为注释起始列的提示值 |
-o, --output=<file> | 把反汇编结果写入指定文件而非标准输出 | 默认sys.stdout |
-l, --indentlevel=<num> | 每遇到一个新的MARK层级时缩进的空白数 | 默认值为4 |
-m, --memo | 一次反汇编多个 pickle 时,在各次反汇编之间保留 memo | 内部用一个共享dict传给dis() |
-p, --preamble=<preamble> | 指定多个 pickle 文件时,在每个文件的反汇编前打印该前缀 | 默认格式串为==> {name} <==,文档未列出但源码中确实存在 |
此外源码还提供了文档未列出的-t, --test(运行 doctest 自检套件)与-v(自检时输出详细信息),见 pickletools.py。
三、编程接口:dis、genops、optimize
3.1dis(pickle, out=None, memo=None, indentlevel=4, annotate=0)
输出 pickle 的符号化反汇编。要点(文档 + 源码 pickletools.py 补充):
pickle可以是字符串,也可以是文件对象;反汇编从当前位置开始,到第一个STOP为止;out为类文件对象,默认sys.stdout;memo可以是字典,用作 pickle 的 memo。当同一个 pickler(共用同一 memo)连续写入了多个 pickle 时,把同一个 memo 对象反复传给dis()就能跨多个 pickle 正确续解——这正是命令行-m选项的底层机制。pickletools.py内置的 doctest_memo_test(pickletools.py)演示了这一点:同一个Pickler连续dump同一个对象两次,第二个 pickle 只剩BINGET 0;连续两次dis(f, memo=memo)时,第一次反汇编填充了 memo,第二次才能正确解释BINGET 0;indentlevel为每个MARK层级的缩进空格数;annotate为 0 时不加注释(annotate参数于 Python 3.2 加入);非 0 值既是开关也是注释列的位置提示,注释内容取自每个指令描述符的doc首行。
值得注意的是,dis()不只是打印——文档只说它输出反汇编,但源码中它同时做了一整套合法性校验(见 pickletools.py):
- 所有内嵌的指令参数必须"说得通";
- 显式/隐式 pop 操作的栈上对象数量必须足够(否则抛
ValueError: tries to pop N items ... with only M items); - 指令隐式引用 markobject 时,栈上必须真有 MARK;
- memo 项不能在定义之前被引用(
memo key N has never been stored into)、不能被重复定义、且 markobject 本身不得存入 memo; - 结束时栈必须为空(
stack not empty after STOP)。
这意味着dis()也是一个轻量级的 pickle 格式校验器。源码注释(pickletools.py)还列出了几个"未来想法":专门的 verifier、protocol 标识函数、以及"去掉从未被 GET 的 PUT"的优化器——最后一项实际上已经实现为下文optimize()。
3.2genops(pickle)
返回一个迭代器,逐个产出 pickle 中所有指令的(opcode, arg, pos)三元组:
opcode是OpcodeInfo实例,带有name、code(单字符助记符)、proto(引入该指令的最低协议)、doc(说明)以及stack_before/stack_after(栈影响描述)等属性;arg是该指令内嵌参数的解码后的 Python 对象,无参数时为None;pos是指令在字节流中的偏移;若pickle是文件对象则为其tell()值,若为 bytes 则内部包一层BytesIO,否则(无法定位)为None。
实现入口为 pickletools.py 的genops(),它只是对私有_genops()的封装;遇到STOP即停止。所有指令的描述数据以描述符实例的形式维护在模块前部——文件前 2000 余行几乎全是这些"可执行的文档",这也是文档中"extensive comments about the pickle protocols and pickle-machine opcodes"的由来。
3.3optimize(picklestring)
返回一个等价的、更短的新 pickle 字节串,方法是剔除那些从未被后续GET/BINGET引用的PUT指令。文档给出的收益是:体积更小、传输时间更短、占用存储更少、解包更快。
从实现(pickletools.py)可以看出它的处理细节:
- 第一遍
_genops扫描,收集所有PUT类指令的 id 集合oldids与所有GET类指令引用的 id 集合newids,同时跟踪最高协议proto,并保留位于偏移 0 的PROTO头部;MEMOIZE(protocol 4 的自动 memo 写入)也按隐式 PUT 记账; - 第二遍重建输出:对每个
PUT,若其 id 不在newids中则直接跳过,否则通过真正的pickle._Pickler重新写出put(idx)并分配新的连续索引;对应的GET改写为新索引;其余指令原样拷贝字节; - 若原 pickle 使用了 protocol 4,优化器还会维持其
FRAME框架(start_framing/commit_frame/end_framing),超长数据块则写为无框架块,保证输出仍可用原协议解包。
在 KBEngine 这样的网络引擎中,消息体里的序列化数据直接决定带宽,这类"先 dump 后 optimize"的做法是压缩序列化体积的廉价手段(注意它依赖pickle._Pickler等标准库内部实现,属于协议研究用途而非通用 API)。
四、KBEngine 的实际使用:同一个 pickle 模块的另一面
pickletools在 KBEngine 代码中并未被直接调用——它是 CPython 3.7.3 标准库文档(pickletools.rst)随内嵌源码一同入库的。但 KBEngine 的 C++ 网络脚本层确实大量依赖文档所说的"另一个"pickle模块:pickler.cpp 中的script::Pickler类在初始化时执行PyImport_ImportModule("pickle")并缓存dumps/loads两个函数,C++ 侧通过pickle(PyObject* pyobj)/pickle(PyObject* pyobj, int8 protocol)/unpickle(const std::string&)调用它们,把 Python 对象转成字节串用于跨进程消息与属性序列化(picklerMethod_以"(Oi)"格式传参,第二个参数即 protocol)。它还会注册一个_upf模块,供自定义类的 unpickle 构造函数查找(getUnpickleFunc)。
由此可以推断 KBEngine 中 pickle 的分工:
pickle模块(dumps/loads):运行期主力,承担 C++↔Python 边界上的对象序列化,性能路径上有AUTO_SCOPED_PROFILE("pickle")埋点;pickletools模块:调试与研究工具。当排查消息解析异常、怀疑某条消息体字节流畸形时,可以在内嵌 Python 环境下对捕获到的字节串调用dis()或genops()逐条查看指令——且如前所述,这不会执行其中的REDUCE/GLOBAL等可能触发构造的指令,适合检查来源不明的数据。
另外,entitydef组件(如 entity_call.h、fixedarray.h)中出现的Pickle/Unpickle相关声明走的是自定义的实体属性格式序列化,与标准库pickle并非同一条路径,阅读源码时不要混淆。
五、实践小结与注意事项
- 何时用:解析不受信任的 pickle(用
pickletools而非pickle)、对比不同 protocol 的字节布局、定位消息体中序列化格式错误、移除冗余PUT瘦身数据流。 - 典型流程:
import io, pickle, pickletools data = b'...捕获到的 pickle 字节流...' pickletools.dis(data, annotate=30) # 带注释反汇编 for op, arg, pos in pickletools.genops(io.BytesIO(data)): print(pos, op.name, arg) # 逐指令编程处理 slim = pickletools.optimize(data) # 剔除无用 PUT 后的等价字节串- 注意事项:
dis()会主动抛ValueError报告格式问题,它同时承担校验器角色;genops/dis从流当前位置读到第一个STOP为止,一个文件中连续写入的多个 pickle 需配合外部 memo(或命令行-m)才能连续正确反汇编;annotate参数自 Python 3.2 起可用;本仓库内嵌的 Python 为 3.7.3,上述全部接口均可用;optimize()依赖pickle._Pickler、framer等内部实现,只建议在深入理解协议后使用。
参考文件:pickletools.rst(官方文档)、pickletools.py(模块实现)、pickler.cpp(KBEngine 脚本层序列化入口)。
- 游戏开发
- 后端
【免费下载链接】kbengine
A MMOG engine of server.
相关推荐
深入 KBEngine 内嵌 Python 3.7 的 dis 模块:字节码反汇编 API 与指令集全解
深入 KBEngine 内嵌 Python 3.7 的 dis 模块:字节码反汇编 API 与指令集全解 本篇以 KBEngine 仓库内嵌的 CPython
游戏开发后端CPython 字节码反汇编完全指南:深入解析 `dis` 模块、命令行工具与字节码指令集
CPython 字节码反汇编完全指南:深入解析 dis 模块、命令行工具与字节码指令集 本指南以 CPython 仓库官方文档 Doc/library/dis.
编程语言语言运行时标准库解释器KBEngine 内嵌 CPython 的弱引用 C API 详解:PyWeakref_* 接口语义与源码实现剖析
KBEngine 内嵌 CPython 的弱引用 C API 详解:PyWeakref_ 接口语义与源码实现剖析 本篇技术指南基于 KBEngine 源码树中随
游戏开发后端
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考