Python从编译到执行
前言
读了读编译原理,用一句简单的 Python 代码来理一遍 Python 的编译执行过程。
因为 Python 不像 PHP 那样使用了 re2c 和 Bison 那些工具进行编译,所以看起来要麻烦很多。
调试环境
源码下载地址:https://github.com/python/cpython/tree/2.7
编译安装跟 PHP 差不多,就不说了。
测试脚本:
1 |
|
研究语法分析的时候可以将 print 改成 prin 来观察分析过程,加上 -d 参数运行可以看到它的语法分析过程。
入口
Py_Main(main.c) -> PyRun_AnyFileExFlags(pythonrun.c) -> PyRun_SimpleFileExFlags(pythonrun.c) -> PyRun_FileExFlags(pythonrun.c)
PyRun_FileExFlags 中将编译执行分为两个部分来进行:
- 调用 PyParser_ASTFromFile 词法分析、语法分析生成 AST 树,解析 AST 树生成中间数据
- 调用 run_mod 解析数据生成 PyCodeObject(里面包括字节码等执行数据),再执行字节码。
我们从 PyParser_ASTFromFile 往下看,经过 PyParser_ParseFileFlagsEx 会来到 parsetok 函数,这里就是语法分析的部分了。
词法分析
语法分析是一个死循环,不停地调用 PyTokenizer_Get 从源码中读取 token 进行语法分析,PyTokenizer_Get 函数 则会调用 tok_get 函数,这个函数里面的就是主要的词法分析逻辑,因为代码很长所以就不全贴了。
词法分析相关的数据(按行存放的源码、指向下一个要分析字符的指针等等)都存放在 tok 中,这是一个 tok_state 结构体:
1 |
|
成员的用途在注释里都标注得很清楚了,源码在 tok 中是按行存放的,tok_get 函数每次调用 tok_nextc 从中取出一个字符进行分析。而在第一次进入 tok_get 函数的时候,tok 里面是没有源码的,在调用 tok_nextc 的时候会调用 decoding_fgets 读入一行源码。tok_nextc 函数除了读入源码和返回字符还有一些其他的操作,不过与本文主题不太相关所以不提。
在处理每行开头的时候会跳过空白字符,还有交互模式下缩进量的相关处理,略。
缩进量设计自定义函数、递归和类等结构,跟本文主题无关,暂时也不是深入研究。
之后就是很普通的了,扫描字符串然后返回 token,匹配方式比 PHP 难看一些,没有使用正则表达式来表示,而类型跟 PHP 的词法分析差不多,比较大的区别就是 Python 把关键词和变量名/函数名/类名等合一叫做 NAME,所以定义中的终结符 token 类型就少了很多,具体的定义可以看 token.h,里面有终结符 token 的定义,都是小于 256 的整型数。
语法分析
语法部分可以看 Grammar 文件,里面有比较好看的语法规则。
parsetok 函数在调用 PyTokenizer_Get 获取 token 之后,会传入 token 和 value 调用 PyParser_AddToken 开始语法分析,跟入这个函数之后就会发现,因为Python 的语法分析器是自己实现的,所以这里的逻辑显得比较难懂。
我们一点点往下看,首先是获取 token 在标签数组里面的数组下标,获取到下标之后有什么用之后再说:
1 |
|
classify 函数的具体实现如下:
1 |
|
可以看到,如果 token 为 NAME,则尝试将 value 作为一个关键词进行搜索。而搜索失败,或者 value 不为关键词的情况下,则按照 token 进行搜索。
搜索的数组是 g->g_ll.ll_nlabels,这是个什么东西?我们先看看 g 是个什么东西,在调用 PyParser_ParseFileFlagsEx 函数的时候:
1 |
|
而 _PyParser_Grammar 则在 graminit.c 里面定义:
1 |
|
所以 g->g_ll.ll_nlabels 其实就是一个长度为 169 的 label 数组 labels,里面存放了所有的 token 和 关键词 NAME,大概是这样子的一个数组:
1 |
|
因为太多就不全放了,每个 label 里面前面是 token,后面则是 value。Python 的语法分析是个栈形/树形的过程,其中 0 这个下标表示其中一个 dfa 匹配结束,具体的分析匹配过程后面再说;而 {1, “def”} 代表的就是 def 这个关键词,而 {1, 0} 则代表其他不是关键词的 NAME。
仔细看这个数组会发现,除了 <256 的终结符 token 外,还有一些 >256 的非终结符 token,这些非终结符我们后面再看。
我们回到 PyParser_AddToken,在获取了数组下标之后就进入一个死循环,先看这两句:
1 |
|
ps->p_stack.s_top 很好理解,就是栈顶,栈的结构如下:
1 |
|
这两句就是从栈顶取出现在匹配状态和匹配中的 dfa,然后根据现在的状态获取 dfa 中的相应状态。dfa 中文名叫有限自动机,简单来说 1 个 dfa 就是语法分析中的一个匹配规则,dfa 的结构定义如下:
1 |
|
这些 dfa 也存放在 graminit.c 的一个数组里面:
1 |
|
看过 Gammar 文件里面语法之后就会发现,这里的 dfa 其实就是非终结符 token,第一个数字就是他们的 token,第二个则是他们易读的书面形式。至于后面四个成员则涉及匹配中的状态转换,分别代表该 dfa 的初始状态、状态数量、状态定义/转换以及可接受的首个 token(这里接受与否使用八进制表示的,比如 print_stmt 的 \000\000\000\000\000\000\000\004,表示的就是数组下标 58,即可接受的首个 token 就是关键词 print),详细的转换过程后面再说。
继续往下看:
1 |
|
s 就是前面获取的状态,结构如下:
1 |
|
其中 s_lower 代表能接受的最小数组下标,s_upper 则是最大数组下标,s_accel 下标代表可接受的首个 token、值代表接受后转换成的状态,s_accept 代表能否结束这个 dfa 的匹配。这些数据同样初始化在 graminit.c 中,比如 file_input 这个 dfa 的状态集及其转换规则就是:
1 |
|
每个状态中的两个数据分别代表转换规则数、转换规则集,而每条转换规则中的两个数据代表可以匹配的 labels 中的数组下标、匹配后要转换成的状态。而每个 dfa 的初试状态都是 0,也就是说接受 token 后会按照 arcs_1_0 中的规则进行状态转换,如果 token 下标为 2、6 则会转换为 0,7则会转换为 1。
这里就会发现,初始化定义里面是没有 s_lower 以及后面的数据的,而 dfa 和栈等分析所需的数据都来自 ps 这个 parser_state 结构体:
1 |
|
可以看到里面有栈、语法规则、AST 树等成员,我们回去看看它的初始化,回到 parsetok 函数中,这里有个 PyParser_New 函数调用:
1 |
|
在命令行运行 python 文件的情况下,这里的 start 为 257,即代表 file_input 的 token,可以看到最后会将 file_input 的 dfa 压入栈中作为匹配的开始,并让 ps->p_tree 指向 AST 树的根。我们关注 PyGrammar_AddAccelerators 函数,状态转换中重要的 s_accel 等成员都是在这里生成的:
1 |
|
遍历所有的 dfa,然后对每个 dfa 调用 fixdfa,继续看:
1 |
|
再遍历 dfa 中的状态集,对每个状态调用 fixstate:
1 |
|
总结一下过程,首先生成一个跟 labels 数组相同长度的 int 数组,全部赋值为 -1,代表不接受这个数组下标代表的 token。
然后遍历该状态的转换规则计算可以接受的首个 token,根据该规则可以匹配的数组下标从 labels 中取出对应的 token,然后判断 token 的类型,如果是 0,代表这次转换可以结束这个 dfa 的匹配,然后将 s_accept 赋值为 1,代表这个状态可以转换到匹配结束的接受状态。如果是其他终结符,则将转换后的状态放入 accel 中。而如果是非终结符,则要取出该非终结符对应的 dfa,根据其八进制 biset 来确定能接受的首个 token:
1 |
|
再将转换后的状态、代表非终结符的 1、代表非终结符 token 的数字 - 256 之后组合起来放入 accel:
1 |
|
后面就很简单了,从前遍历找到能接受的最小下标作为 s_lower,从后找到能接受的最大下标作为 s_upper,再将接受和转换的规则放入 s_accel 中。
这时候回过头来继续看 PyParser_AddToken 函数里面的状态转换就很好懂了,用测试脚本举例,语法分析过程如下:
1 |
|
后面的两个 NEWLINE 和 ENDMARKER 是 python 添加上去的,源码里没有这几个标记。
首先,词法分析返回一个 NAME,语法分析首先调用 classify 进行确认,发现这是个关键词,然后从栈上取出 file_input 的 dfa,查询 file_input 是否接受这个关键词:
1 |
|
然后判断接受这个关键词的下一个 dfa 是否属于非终结符:
1 |
|
如果属于非终结符则取出该非终结符的 token 和接受并转换后的状态,这里是 stmt 和 1,并将该非终结符的 dfa 作为下一次匹配的规则压入栈中。实际上 push 函数里面除了入栈,还对 AST 树进行了操作:
1 |
|
此时 s->s_top->s_parent 就是 AST 树的根节点,调用 PyNode_AddChild 会将非终结符 stmt 挂到根节点的子节点上面,s_push 则会将栈顶元素的 s_parent 修改为这个 stmt 的节点,也就是说下一次 push 形成的子节点就会挂在 stmt 节点的子节点上面,这样就构成了一棵 AST 树。
就这样一遍遍重复将下一个 dfa 压入栈中,直到遇到非终结符 print_stmt,此时接受关键词的是值为 print 的终结符 NAME,所以会不会进入前面的判断,而是开始尝试出栈:
1 |
|
shift 的操作跟 push 相同,只是少了入栈。阅读 Grammar 中的语法,可以发现对于非终结符而言,可能会有跟现在的 atom 一样的情况,即接受完一个 token 之后不能再继续接受第二个 token 了,这个时候就需要直接将其出栈,否则会影响到下一个 token 的分析,而这种直接出栈的情况在代码中的实现就是将状态转换成一个只接受 0 这个下标的状态,即:
1 |
|
到这里,关键词 print 的分析就完成了,我们根据转换规则看下 print_stmt 的转换过程:
1 |
|
初始状态 0,接受 print 转换为 1,此时可以接受非终结符 test 或者终结符 >>。接受 test 之后会转换成 2,可以接受终结符 , 并转换为 4,然后就是一个不断的循环 , test , test , test …。注意看会发现状态 2 的转换规则里面还有一条 {0, 2},这其实是这个状态可以作为结束状态的意思,因为这个状态的 s_accept 会是 1,后面的判断里面会依此判断该 dfa 是否匹配完全了,而放在现在这个情景中的意思就是 print 后面可以只有一个 test,而不需要后面更多的 , 和 test。这部分也就是 Grammar 里面写的规则:
1 |
|
接下来会读入一个 token NUMBER 进行分析,此时的栈顶 dfa 为 print_stmt,状态为 1,所以下一个接受的 dfa 为 test,同样一路入栈,最后到非终结符 atom,它接受终结符 NUMBER 并转换为状态 5:
1 |
|
而因为状态 5 的转换规则只有一个 0,所以会直接出栈,栈顶 dfa 变为 power,此时 power 的状态为 1。
接下来读入的 token 是 NEWLINE,而 power 状态 1 不接受这个 token,所以会直接来到最后的两部分处理:
1 |
|
如果该状态的 s_accept 为 1,代表这个状态已经匹配完全,可以结束该 dfa 的匹配了,则可以将该 dfa 出栈并返回继续上一个 dfa 的匹配。如果 s_accept 为 0 而 dfa 又不接受这个 token,则代表语法错误了。
而 power 状态 1 的规则如下:
1 |
|
所以可以出栈,继续 factor 的匹配,这样一路出栈入栈,最后一个 Direct pop 清空栈里面的 dfa 之后,语法分析就完成了。
解析 AST 树
AST 节点结构体:
1 |
|
调用 PyParser_ParseFileFlagsEx 构建完 AST 树之后,会调用 PyAST_FromNode 将其解析为 mod:
1 |
|
ast_for_stmt 是个 switch:
1 |
|
对各种语句调用专门的处理函数:
1 |
|
简单来说就是对接受的每个 test 调用 ast_for_expr,将解析出的表达式放入 seq,表达式结构体太复杂就不放了,最后返回一个 stmt_ty:
1 |
|
再将这些 stmt 集合起来放入 mod_ty:
1 |
|
调用 run_mod 将 mod 编译为 PyCodeObject 并执行:
1 |
|
PyCodeObject 实际上是 python 字节码类,除了要执行的字节码还要很多其他信息,比如变量常量等:
1 |
|
编译 mod 的过程在 compiler_body 函数中,之后会调用 VISIT,展开后则是 compiler_visit_stmt,然后 switch 找到 print 的变异函数 compiler_print:
1 |
|
可以看到这里构造了字节码指令,VISIT(c, expr, e) 访问的是要打印的数字 1,这里会添加一个指令 LOAD_CONST,之后就是 PRINT_ITEM 和 PRINT_NEWLINE 以及结束指令。我们可以通过 python 的 dis 模块进行查看:
1 |
|
后记
理了一遍 python 的编译分析,可以发现跟 PHP 大同小异。
Orz