之前看了 a-python-interpreter-written-in-python 和 byterun,就想试试用 JAVA 解析 Python 生成的 pyc 文件,读取 bytecode 后在 JAVA 中实现解释执行。
要解析 pyc 文件,就需要知道其来龙去脉,以及是如何生成的。
pyc
根据平时编写 Python 代码的经验,pyc 文件是在我们 import 一个模块后生成的。
imp module
而官方文档中提到了 imp 模块是用来和 import 语句的具体实现机制交互的。其中:
find_module 函数负责到 sys.path 中寻找对应的 module
若存在需要的 module,则调用 load_module 加载对应 module
根据之前分析 CPython 源码的经验, 标准库模块中和运行逻辑相关的函数一般对应着一个 CPython 解释器中的 C 代码实现。
import.c
如 load_module 就位于 https://github.com/python/cpython/blob/2.7/Python/import.c#L1929
可以看到 load_module 会检查找到的 module 是还是 ,而这两个宏分别对应着 和 文件。
我们跟入在没有 文件时加载 源文件的 函数(只摘录了一部分)。
https://github.com/python/cpython/blob/2.7/Python/import.c#L1076
可以看到 同样会去找一次 文件,再找不到的情况下,会先解析源文件, 得到 codeobject 后调用 生成 文件,再执行 import 逻辑。
write_compiled_module
所以, 函数中应该就对应着我们的 pyc 文件生成逻辑了。
https://github.com/python/cpython/blob/2.7/Python/import.c#L951
可以看到, 文件的生成大致分下面几步:
1.创建目标 pyc 文件
2.首先调用 序列化 magic number 到文件中
3.然后序列化一个空的时间戳到文件中
4. 将 PyCodeObject 序列化到文件
5.写完 CodeObject 后,fseek 到时间戳的位置,填充真实的时间戳
其中,magic number 定义于 import.c 头部
而 和 定义于 marshal.c 中。
marshal.c
PyMarshal_WriteLongToFile
我们先来看 https://github.com/python/cpython/blob/2.7/Python/marshal.c#L462
创建了 WFILE,将打开的文件描述符赋值给 WFILE,并调用 w_long。
用于表示写入的 pyc 文件的 WFILE 结构如下。
跟入
可以看到 只是调用了四次 将一个 type 为 long ,长度为4字节的数写入到文件中。
宏简单的将传入的一字节内容写入到 WFILE->fp ,即对应的 pyc 文件中。 中的序列化写入操作都是基于 封装的。
PyMarshal_WriteObjectToFile
相对之前的 更加的复杂了,用于将 Python 对象序列化到文件中。
可以看到 调用的是 ,是 marshal 最复杂的一个函数。
https://github.com/python/cpython/blob/2.7/Python/marshal.c#L212
的主要逻辑为读取传入的 的具体类型,调用 写入一个字节的类型数据,然后调用不同的 系列函数序列化对应类型的数据。
这里我们省略其他类型的代码,重点看下 类型的处理。可以看到, 只是简单的讲 中每个类变量依次序列化到文件中,我们只需要按照 的顺序去反序列化即可得到对应的内容。
TYPE 相关的宏定义于marshal.c#L27
使用 JAVA 反序列化 文件参考 PycFile.java 。
pyc 文件结构(Struct of pyc)
根据上面的分析,我们可以得出 pyc 文件的格式如下,其中 部分为变长,需要参考 进行反序列化。
PyCodeObject
根据上面的分析,我们知道了 pyc 文件中最主要的内容为序列化的 ,接下来我们就分析一下 的结构,以及如何生成及如何被解释执行。
定义于 Include/code.h#L10
上面 中可以看到 文件的 是调用 生成的。
parse_source_module
我们在第一篇CPython源码阅读笔记(1) 中曾经分析从 开始的代码生成流程,这里的逻辑和之前一致。
即在 阶段划分好了 CFG ,然后按照 CFG 遍历生成 。其中最外层为一个入口的 Block,嵌套的生成多个 code object。
代码生成测试
创建 test.py 如下
在同级目录启动一个 Python 终端。
可以看到 test 函数的生成了单独的一个 code object。
查看最外层 code object 的 后找到了对应的 test 函数的 code object 。
接着我们可以根据 的各个属性的名字猜测并查看其内容。
调试
按照第一篇文章中的方法,我们可以试着调试一下 test.py 的编译过程。
compiler_mod
在编译的入口函数 处下断点,运行 。
compiler_body
单步跟入 函数,可以看到传入的 mod 为 ,所以接下来跟入。
在 处下断点,然后跟入该函数。
可以看到, 只是简单的讲 stmts 中的元素取出,通过 宏进行代码生成。
展开其实就是。
单步跟入循环中的 VISIT 调用,查看传入的 stmt 参数,为 中定义的 ,即 stmt AST Node(stmt 的语法树节点)。
中定义了 Python 中 stmt 的类型。 https://github.com/python/cpython/blob/2.7/Include/Python-ast.h#L62
compiler_visit_stmt
https://github.com/python/cpython/blob/2.7/Python/compile.c#L2074
因为这里第一次传入的 stmt 的类型为 ,这里会调用 。
compiler_function
跟入 , 这里即是真正的代码生成逻辑。
https://github.com/python/cpython/blob/2.7/Python/compile.c#L1351
这里逻辑比较复杂,就不贴调试的过程了。大致的流程为
将 FuncDef AST Node 中的一些 metadata 存储到 compiler 对象中。
调用 assemble 将函数体生成单独的 code object。
调用 生成 和 两个opcode。
调用 生成 opcode。
至此生成了下面的字节码
对应源码中的
Py_OPCODE
字节码对应的数字定于于 opcode.h 。
其中 宏定义了字节码是否带有参数(通过判断字节码对应的数字是否大于指定的值)。
在 Python2.7 中这个值为 90
领取专属 10元无门槛券
私享最新 技术干货