Python 源码编译过程

Python 源码到机器码的过程,以 CPython 为例,编译过程如下:

  • 将源代码解析为解析树(Parser Tree)
  • 将解析树转换为抽象语法树(Abstract Syntax Tree)
  • 将抽象语法树转换到控制流图(Control Flow Graph)
  • 根据流图将字节码(bytecode)发送给虚拟机(ceval)

可以使用以下模块进行操作:

  • ast 模块可以控制抽象语法树的生成和编译
  • py-compile 模块能够将源码换成字节码(编译),保存在 __pycache__ 文件夹,以 .pyc 结尾(不可读)
  • dis 模块通过反汇编支持对字节码的分析(可读)

ast 模块使用

ast 模块可以用于生成和编译 Python 代码的抽象语法树,许多静态分析工具都使用该模块生成抽象语法树。

ast.parse() 函数可以用来生成抽象语法树,ast.compile() 可以将抽象语法树编译为代码。

用下列代码作为测试样例:

1
2
3
4
5
6
7
8
def nums():
for i in range(2):
if i % 2 == 0:
print("even: ", i)
else:
print(" odd: ", i)

nums()

编译执行

代码对象是 CPython 实现的低级细节,涉及 code 模块,该模块是解释器基类,可用于自定义 Python 解释器。

1
2
3
4
5
6
7
# 读取源文件
with open("demo.py") as f:
data = f.read()

# 生成可以被 exec() 或 eval() 执行的代码对象
cm = compile(data, '<string>', 'exec')
exec(cm)

生成 AST

直接从源码生成,Python 3.9 支持 indent 参数,打印输出更为友好。

1
2
f_ast = ast.parse(data)
print(ast.dump(f_ast, indent=4))

得到如下结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
Module( # 第一级,模块
body=[
FunctionDef( # 第二级,函数定义
name='nums', # 函数名称
args=arguments( # 参数
posonlyargs=[],
args=[],
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[ # 函数体
For( # 循环
target=Name(id='i', ctx=Store()),
iter=Call( # 递归函数调用
func=Name(id='range', ctx=Load()),
args=[
Constant(value=2)], # 参数
keywords=[]),
body=[ # 循环体
If( # 条件判断
test=Compare(# 比较
left=BinOp( # 左操作数
left=Name(id='i', ctx=Load()), # 左操作数
op=Mod(), # 操作符
right=Constant(value=2)), # 右操作数
ops=[
Eq()],
comparators=[ # 右操作数
Constant(value=0)]),
body=[ # 为真
Expr(
value=Call( # 调用函数
func=Name(id='print', ctx=Load()),
args=[
Constant(value='even: '),
Name(id='i', ctx=Load())],
keywords=[]))],
orelse=[ # 为假
Expr(
value=Call( # 调用函数
func=Name(id='print', ctx=Load()),
args=[
Constant(value=' odd: '),
Name(id='i', ctx=Load())],
keywords=[]))])],
orelse=[])],
decorator_list=[]),
Expr( # 第二级,表达式语句
value=Call( # 调用函数
func=Name(id='nums', ctx=Load()),
args=[],
keywords=[]))],
type_ignores=[])

遍历 AST

修改 AST

有两种方式:①修改 AST 节点;②替换 AST 节点。ast 模块提供了 NodeVisitorNodeTransformer 实现这两种功能。

  1. i%2 == 0修改为 i+2==0

    • ast.NodeVisitor.visit 如果没有实现对象的 visit_classname 方法,则调用 generic_visit 方法
    • ast.NodeVisitor.generic_visit 在子节点上调用 visit 方法
    1
    2
    3
    4
    5
    6
    7
    8
    class NodeVisitor(ast.NodeVisitor):
    def visit_BinOp(self, node): # 修改操作符
    if isinstance(node.op, ast.Mod):
    node.op = ast.Add()
    self.generic_visit(node) # 遍历子节点

    visitor = NodeVisitor()
    visitor.visit(f_ast) # 遍历

    输出如下:

    1
    2
    odd:  0
    odd: 1
  2. 删除 else 节点

    • 调用 compile() 函数时缺失 linenocol_offset 属性,使用 ast.fix_missing_locations 函数添加
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class NodeTransformer(ast.NodeTransformer):
    def visit_If(self, node):
    return ast.If(
    test=node.test,
    body=node.body,
    orelse=[]
    )

    transformer = NodeTransformer()
    f_ast = transformer.visit(f_ast) # 返回新的 AST
    ast.fix_missing_locations(f_ast)

    输出如下:

    1
    even:  0

可视化 AST

使用 graphviz 绘制,遍历 AST 节点,将每个节点对象的类型名称作为点,父节点和每个子节点都连一条边。

  1. 安装 graphviz 二进制程序 👉 https://graphviz.org/download/

  2. pip 安装包

    1
    pip install graphviz
1
2
3
4
5
6
7
8
9
10
11
12
13
14
def visit(node, nodes, pindex, g):
name = str(type(node).__name__)
index = len(nodes)
nodes.append(index)
g.node(str(index), name)
if index != pindex:
g.edge(str(index), str(pindex))
for n in ast.iter_child_nodes(node):
visit(n, nodes, index, g)

graph = Digraph(format="png")
tree = ast.parse(data)
visit(tree, [], 0, graph)
graph.render("test")

得到的 test.png 如下:

参阅