CPython编译器设计

CPython编译器设计

1. 概述

2.4版本以前,从源码编译到字节码的过程主要分为两步:

1)分析源码生成分析树(Parse/pgen.c)

2)从分析树生成字节码(Python/compile.c)。

这是以往使用的编译过程,因而这不是一个标准的编译器工作流程。通常标准的编译流程是这样:

1)分析源码生成分析树(Parser/pgen.c)。

2)转换分析树为抽象语法树(Python/ast.c)。

3)转换抽象语法树为控制流图(Python/compile.c)。

4)从控制流图生成字节码(Python/compile.c)。

2.5版本开始,上面标准编译流程开始使用。在这个流程里主要把以前编译过程最后一步改变为三个步骤,这样更加简单明了。本文档主要用来描述了最后三步是怎么样工作的过程。因而本文档不会有分析阶段相关的内容,但也没有完全地描述了整个编译系统的工作过程,并且你需要辅助读取一些源码来了解这里说到的一些细节内容,否则理解起来比较吃力。

 

2. 分析树(Parse Trees

Python的词法和语法分析过程采用LL(1)分析方法,它的实现与龙书中实现的方式是基本相同的,如果想了解这个过程可以参考龙书([Aho86]Python语法分析相关的文件主要在目录Grammar/Grammar,规则定义的数值类型在头文件include/graminit.h里。词法分析里使用的标记数值(比如If等于200)定义在include/token.h里。分析树的节点node *结构定义在文件include/node.h里。

对于分析树的节点结构进行操作是比较常见的,因此定义了宏定义,在文件include/node.h里:

CHILD(node*, int)

通过以0为开始的索引参数返回子孩子节点。

RCHILD(node*, int)

通过以负数为索引参数返回从右边数起的子孩子节点。

NCH(node*)

返回这个节点有多少个子孩子节点。

STR(node*)

返回字符串表示的节点。比如返回冒号分隔的单词。

TYPE(node*)

返回节点的类型,类型定义在include/graminit.h里。

REQ(node*, TYPE)

确认节点是指定的类型节点。

LINENO(node*)

返回分析源码时创建这个节点的源码行号,分析规则在Python/ast.c里。

为了理解上面这些函数的使用,考虑下面这条语法规则:

while_stmt: 'while' test ':' suite ['else' ':' suite]

按这条语法规则生成的节点,就可以有这样的操作:

TYPE(node) == while_stmt  表示节点类型为while_stmt

根据源码中是否出现else语句,就可以得知这个节点子孩子节点的个数应在47,通过NCH(node*)函数就可以返回这个子孩子节点的个数。为了判断这个节点是否有第二个子节点为冒号,就可以这样判断:REQ(CHILD(node, 2), COLON)

 

3. 抽象语法树(AST

抽象语法树是程序源码结构的高级表示方式,它不需要包括源码,它是源码抽象表示。在这里AST的节点的定义是使用Zephyr的抽象语法定义语言来描述(ASDL[Wang97])。

PythonAST节点定义在文件Parser/Python.asdl里。

每个AST节点都被ASDL来定义,比如节点语句、表达式和其它几种类型:列表解析和异常处理。大多数AST节点与源码是相对应的,比如if语句或者属性查找,但也有一些与源码不一致的定义。

下面来介绍一段Python里用ASDL定义抽象语法树的方式和语法形式:

module Python

{

      stmt = FunctionDef(identifier name, arguments args, stmt* body,

                          expr* decorators)

            | Return(expr? value) | Yield(expr value)

            attributes (int lineno)

}

在前面这个ASDL例子里描述了三种不同类型的语句:函数定义语句、返回语句和产生式语句。这三个语句是通过符号|来分隔,并且都有不同的参数类型和个数。

在参数里面发现使用了一些修饰语:问号?是表示参数可选;星号*是表示0个或者多个,如果没有修饰语在参数里,表示这个参数是一定需要。比如函数定义FunctionDef有一个标识符字符串作为名称,参数args0或多个语句参数保存在body里,0个或多个表达式参数作为decorators

 

要注意的是像参数arguments,它是一个节点类型,单独地表示了一个AST节点,它不会放到语句stmt里面。

 

所有三个类型的语句都有一个属性参数attributes。从前面定义来看,在attributes前面是没有|分隔的,因此三个语句都有这个属性。

 

前面ASDL编写的规则可以生成下面对应的C语言结构的代码:

typedef struct _stmt *stmt_ty;

 

struct _stmt {

      enum { FunctionDef_kind=1, Return_kind=2, Yield_kind=3 } kind;

      union {

              struct {

                      identifier name;

                      arguments_ty args;

                      asdl_seq *body;

              } FunctionDef;

 

              struct {

                      expr_ty value;

              } Return;

 

              struct {

                      expr_ty value;

              } Yield;

      } v;

      int lineno;

 }

 

同时也会生成一系列的构造函数来创建语句stmt_ty结构,以便合理地初始化。在语句类型kind是通过枚举类型来定义,比如函数定义FunctionDef()是通过类型FunctionDef_kind来设置kind类型,在构造函数同时初始化nameargsattributes字段。

 

4. 内存管理

在讨论编译器内存管理实现之前,先来了解一下内存怎么样管理的方式。为了使用内存管理变得简单,使用了一个大块内存作为缓冲区的方式,这意味着所有内存分配和删除都在这一块内存缓冲区上实现。这样就可以让我们不要明指地分配和删除内存了,因为所有需要使用的内存都在这块内存缓冲区上,当需要删除时,只要一次调用释放内存的动作就可以删除所有编译器使用的内存。

 

不过,内存管理相关过程是可以不用了解,除非你是作为Python编译器的核心实现的开发人员。如果你真的是编译器底层前端或后端开发人员,你需要了解整个缓冲区机制的内存管理。所有相关代码在文件include/pyarena.hpython/pyarena.c里。

 

PyArena_New()创建一块内存池,返回PyArena结构声明的这块内存池的指针。当编译器完成内存的使用时,它会根据内存的标签来回收相应的内存。这个回收过程是通过调用函数PyArena_Free()来实现,这个调用只会出现在编译器完成编译退出时调用。

 

根据上面的描述来看,一般情况下你是不需要担心编译器的内存管理。所有技术的细节都已经隐藏在接口后面,你仅是需要使用即可。

 

不过有一种情况需要小心的,就是管理PyObject对象时。因为Python采用引用计数来管理这些对象生命周期,当创建一个PyObject对象时它就已经初始化。不过使用这些对象的情况是非常少的,如果你需要创建一个PyObject对象,一定要通过函数PyArena_AddPyObject()函数来创建。

 

5. 分析树转为抽象语法树(AST

从分析树转生成抽象语法树是通过函数PyAST_FromNode()来实现的,可以查看(Python/ast.c)文件。这个函数遍历分析树节点,然后创建需要的AST节点,这过程中会调用不同AST节点创建函数,并且把这些AST节点连接到一起,就生成AST树。

 

要认识到这个过程不是自动化的,也没有符号表的在这些节点之间作关联。所有这些过程就像使用YACC一样,没有直接提供分析树。

 

比如,想跟踪一个分析树的处理过程,需要跟踪节点的提示(例如查看if语句处理过程,需要查看冒号之后结束条件)。

 

所有处理从分析树生成AST节点都使用这样命名的函数ast_for_xx,其中xx是函数定义处理语法规则的名称(alias_for_import_name是一种例外)。那些通过ASDL规则定义的构造函数都包含在文件Python/Python-ast.c(这个文件通过Parser/asdl_c.py生成)里,通过那些构造函数可以创建AST节点,并且所有AST节点按顺序保存在asdl_seq的序列结构里。

在文件Python/asdl.cinclude/asdl.h里包含所有操作创建和使用asdl_seq*类型的函数或宏定义,具体如下:

asdl_seq_new()

为指定asdl_seq个数分配内存。

asdl_seq_GET()

从指定位置获取asdl_seq对象。

asdl_seq_SET()

指定索引位置设置asdl_seq值。

asdl_seq_LEN(asdl_seq *)

返回asdl_seq的个数。

如果你想使用这些语句,当然想知道这些语句是那行源码生成的,当前是通过每个stmt_ty函数里传递最后一个参数来实现源码行号保存的。

 

6. 控制流图

控制流图是一种有向图,常常使用control flow graph的首字母缩写成CFG,它经常用来对程序运行的流向作建模,用包含中间表示代码的节点来表示基本块,在Python里中间代码表示使用字节码来表示,这些基本块之间通过边来表示程序控制流向。那些包括中间表示的基本块都只有一个入口,但可能有多个出口。单一的入口限制是基本块的关键点,所有其它想跳转到基本块运行,都需要从这里进入。入口基本上都是由函数调用或者跳转这些改变控制流的指令来生成;而出口基本上都是由跳转或返回语句来生成的控制流的目标。基本块就是一段代码,它只有一个入口运行,然后执行到一个出口或者这段代码运行到结束。

 

例如一个带elseif语句构造成的基本块,if语句和条件表达式会构造一个基本块,if语句的基本块包括一个跳转出口,当if语句判断为true时跳转到真部分基本块,当if语句判断为false时跳转到假部分基本块,也许假部分的基本块为空。所有这些基本块都连接在if语句的基本块入口后面。

 

CFG通常都是从最后一个基础块离开,因此从CFG生成代码只需要采用后序遍历和深度优先的搜索,沿着所有基础块的边进行就可以,只有那些需要跳转出去的基本块才需要调整目标顺序。

 

7. 从ASTCFG,再到字节码

已经创建好AST之后,接着下来就是创建CFG。第一步在不考虑跳转目标位置之下把AST转换为Python字节码(跳转目标的偏移位置在最后字节输出之前进行计算)。基本上这步就是沿着CFG的边,把控制流图里所有相关的AST转换为字节码。

 

AST转换为字节码分为两遍进行,第一遍创建命名空间(局部变量、全局变量、闭包相关单元);第二遍把CFG变成一个线性顺序列表,然后为输出字节码计算最终输出偏移位置。

 

转换过程的初始化是通过在文件Python/compile.cPyAST_Compile()函数调用,在这个函数里先把AST转换CFG,接着把CFG生成最后的字节码输出。在函数PyAST_Compile()里实现AST转换为CFG,主要通过调用这两个函数PySymtable_Build()compiler_mod()。函数PySymtable_Build()在文件Python/symtable.c里,函数compiler_mod()在文件Python/compile.c里。

 

函数PySymtable_Build()在开始分配AST使用整个内存块,接着调用symtable_visit_xx函数(xx表示AST节点类型)遍历所有AST的节点,把所有代码块的局部变量保存到内存块里,符号表内存块进入时调用函数symtable_enter_block(),退出时调用函数symtable_exit_block()

 

一旦符号表创建完成,紧接下来就是创建CFG,这个过程的代码在文件Python/compile.c里。这个过程把不同的AST节点类型分成多个函数任务来处理。这些函数命名都是这样:compiler_vist_xx,其中xx就是表示节点类型(比如stmtexpr等等),每个函数都接收两个参数:一个结构struct compiler*xx_ty,其中xx也是AST节点的类型。基本上这些函数实现过程就是把它们放到一个大的switch循环语句里,然后根据不同类型节点来选择不同的函数执行。在这个switch大循环里,简单处理就是调用其它一大堆命名为compiler_xx的函数,其中xx就是节点类型的名称。

 

合适时候使用宏VISIT()来转换AST节点属性,就会导致函数compiler_visit_xx被调用,主要根据传入的类型<node type>(比如 VISIT(c, expr, node)就会调用compiler_visit_expr(c, node))。宏VISIT_SEQ处理过程也与前面介绍的一样,但它是用来处理AST节点序列(参数采用*修饰传递入来)。宏VISIT_SLICE()处理切片的情况。

 

下面的宏是用来生成字节码:

ADDOP()

添加指定的字节码。

ADDOP_I()

添加指定的字节码,并带一个参数。

ADDOP_O(struct compiler *c, int op, PyObject *type, PyObject *obj)

添加指定的字节码,并带一个通过在PyObject对象序列里指定位置PyObject的参数,但不处理命名相关的参数,主要用来从全局量、常量或已经知道名称的参数。

ADDOP_NAME()

添加指定的字码,处理与ADDOP_O()一样,不过处理命名相关的变量。主要用来属性加载或导入基于名称的模块。

ADDOP_JABS()

创建一个绝对跳转的基本块。

ADDOP_JREL()

创建一个相对跳转的基本块。

 

还有一些辅助函数来处理输出字节码,主要以compiler_xx()命名,其中xx表示功能名称(listboolop等等)。最常用的函数比如 compiler_nameop(),这个函数查找变量的作用,并且基于表达式的上下环境关系,生成合适的操作码来加载、保存或删除变量。

 

在关于源码语句行号处理方面,主要通过函数compiler_visit_stmt()处理,在这个函数调用之后就使用了。

 

AST节点转换为字节码过程中,需要创建基本块,下面的宏和函数就是管理基本块的功能:

NEW_BLOCK()

创建一个基本块,并设置为当前操作的基本块。

NEXT_BLOCK()

除上面的功能之外,还需要添加跳转换操作码到当前块里。

compiler_new_block()

创建一个基本块,基本上不使用它,都使用前面两个宏。

 

所有CFG都创建好之后,并且把每个基本块内的代码生成,最后就可以输出字节码了。线性模式处理,就是使用深度优先的后序遍历方式。当线性处理之后,就可以计算出跳转偏移位置,最后就可以生成一个PyCodeObject对象。整个过程都是在函数assemble()处理。

 

8. 添加新的字节码

有时增加新的功能就需要添加新的操作码。但是添加一个新的操作码不是简单地修改AST的字节码生成阶段就可以的,需要整个Python编译过程进行正确的修改,涉及比较多的地方需要修改。

 

第一步,你必须选择一个名称和唯一数字作为操作码标识。已经使用的字节码的操作码在文件Include/opcode.h里,如果想操作码带有参数,要注意操作码的大小要大于HAVE_ARGUMENT(这个宏定义在文件Include/opcode.h里可以看到)。

 

第二步,当你选择好名称和唯一的操作码之后,就需要添加到文件Include/opcode.h里,同时添加到文件Lib/opcode.pyDoc/library/dis.rst。由于操作码添加,会与以前版本的.pyc文件不兼容,因而需要修改.pyc文件里的版本号,这个版本号在文件Python/import.c文件定义的宏MAGIC。当修改这个版本号之后,会导致所有已经编译的.pyc文件无效,需要在导入之时重新对这些文件进行编译。

 

最后,你需要添加生成新操作码的字节码的代码。主要修改文件Python/compile.cPython/ceval.c,也许还需要修改编译包,关键的文件是Lib/compiler/pyassem.pyLib/comiler/pycodegen.py

 

当然如果你添加新的功能,不需要新的操作码,就不需要修改.pyc的版本号,只需要确认你使用的.py(c|o)旧文件删除即可。如果你是在调试时修改.pyc的版本号,但这时并没有重新生成.pyc文件,因此需要把所有.pyc文件删除,强制解释器重新生成所有的文件。

 

9. 代码对象

在编译函数PyAST_Compile()里最终生成的代码对象PyCodeObject,它是定义在文件Include/code.h里。当生成这个代码对象之后,就可以生成Python运行的字节码了。代码对象是在文件Python/ceval.c文件里运行,因此在这个文件里也需要添加新的操作码,主要在switch大循环里添加处理相应的代码,具体就是在函数PyEval_EvalFrameEx()里添加。

 

10. 相关重要文件

Parser/

Python.asdl

ASDL语法文件。

asdl.py

实现Zephyr抽象语法定义语言解释处理,主要使用SPARK来分析ASDL文件。

asdl_c.py

ASDL文件生成C代码,主要生成文件Python/Pythn-ast.cInclude/Python-ast.h文件。

spark.py

SPARK分析器生成器。

 

Python/

Python-ast.c

ASDL定义类型对应生成C构造函数。

asdl.c

包含处理ASDL序列类型。

ast.c

转换Python的分析树为抽象语法树。

ceval.c

执行字节码,所谓运行的虚拟机。

compile.c

基本AST生成字节码。

symtable.c

AST生成符号表。

pyarena.c

实现内存池管理。

import.c

字节码版本管理,比如MAGIC

Include/

Python-ast.h

包含ASTC结构定义。通过Parser/asdl_c.py自动生成。

asdl.h

Python/ast.c对应的头文件。

ast.h

声明PyAST_FromNode()外部需要使用。

code.h

Objects/codeobject.c头文件,包括PyCodeObject定义。

symtable.h

符号表的头文件定义。

pyarena.h

内存池管理的头文件定义。

opcode.h

主要包括所有操作码定义。

Object/

codeobject.c

包括所有PyCodeObject相关操作的代码。

 

Lib/

opcode.py

Include/opcode.h文件修改之后需要修改的文件。

compiler/

pyassem.py

Include/opcode.h文件修改之后需要修改的文件。

pycodegen.py

Include/opcode.h文件修改之后需要修改的文件。

 

11. 跟编译器相关的实验性项目

在本节里主要描述一些与编译器相关的实验性项目:

Skip Montanaro presented a paper at a Python workshop on a peephole optimizer [1].

Michael Hudson has a non-active SourceForge project named Bytecodehacks [2] that provides functionality for playing with bytecode directly.

An opcode to combine the functionality of LOAD_ATTR/CALL_FUNCTION was created named CALL_ATTR [3]. Currently only works for classic classes and for new-style classes rough benchmarking showed an actual slowdown thanks to having to support both classic and new-style classes.

12. 参考引用

 

[Aho86]

Alfred V. Aho, Ravi Sethi, Jeffrey D. Ullman. Compilers: Principles, Techniques, and Tools, http://www.amazon.com/exec/obidos/tg/detail/-/0201100886/104-0162389-6419108

 

[Wang97]

Daniel C. Wang, Andrew W. Appel, Jeff L. Korn, and Chris S. Serra. The Zephyr Abstract Syntax Description Language. In Proceedings of the Conference on Domain-Specific Languages, pp. 213–227, 1997.

 

[1]

Skip Montanaro’s Peephole Optimizer Paper (http://www.smontanaro.net/python/spam7/optimizer.html)

 

[2]

Bytecodehacks Project (http://bytecodehacks.sourceforge.net/bch-docs/bch/index.html)

 

[3]

CALL_ATTR opcode (http://bugs.python.org/issue709744)

 

翻译网址:https://docs.python.org/devguide/compiler.html  

翻译作者:蔡军生  QQ: 9073204 微信号:shenzhencai   深圳

你可能感兴趣的:(python,编译器,milang)