很早以前就打算写一系列关于cpython源码解析的文章了,奈何水平不够迟迟没有动笔。正值新年伊始,我打算今年是时候实现我这个想法了。一方面能分享给大家自己的学习心得,另一方面能督促自己持续创造,这种好事何乐而不为呢?
很多时候,阅读大型项目源码就像打galgame,分支繁多,逻辑复杂,从main函数一头扎进去很容易迷失在代码中。所以在第一篇文章中我会串一下流程,从交互模式(interactive)直观的体会cpython是如何解析并执行Python原文件的。当遇到关键分支时,我会把它作为存档在后续的文章中详细展开解释。
本系列文章为CPython源码解析的一周目,集中研究交互模式(interactive)下cpython运行方式。在二周目会介绍在文件模式(file)下cpython是如何运作的。
因为是在Windows环境下进行,调试的工具很简单。
-
git:用来下载cpython依赖,比如sqlite、bzip、zlib等
-
低版本的Python:用来生成部分编译文件
-
Visual Studio 2022:编译运行cpython项目,调试也离不开它
本系列采用了GitHub上当前最新的cpython分支,Python 3.13.0 alpha2版本。可能仍然有部分代码无法和读者下载的保持一致,我会用截图展示。
另外,源码中用到了大量的C语言API。不熟悉的读者可以通过阅读我以前写的文章快速了解一个大概。
一、编译项目
Visual Studio 2022启动!
首先打开解决方案,双击PCBuild目录下的pcbuild.sln
工程文件。
然后运行方式选择Debug模式,编译到Win32或者x64平台,点击“本地Windows调试器”调试即可启动项目。第一次运行会下载依赖,请保持网络通畅。
二、交互模式主流程
常见的Python运行模式有两种,一种是以py文件的模式运行,另一种以交互的模式运行。交互的模式也叫REPL模式,我们首先介绍这种模式。
把断点打到Modules目录下main.c
文件的第731行,再运行程序。
程序会停留在Py_Main
这个函数内。这个函数是cpython的主入口。该函数就做一件事,调用真正的入口函数pymain_main
。该函数是Windows平台和Linux平台共用的入口函数。
pymain_main
函数核心逻辑有两个,一个是初始化解释器需要的参数pymain_init
函数,另一个是运行解释器Py_RunMain
函数。
在pymain_init
函数处先存档SAVE 1。直接看Py_RunMain
函数。
存档的意思是这里的逻辑太复杂,需要以后用大篇幅文章来介绍,所以暂时做个记号。记号用斜体加粗的SAVE+序号表示,后面会有相应的读档LOAD+序号,表示详细的介绍这块逻辑。
在main.c
文件553行打上断点,让程序停在这一行。
Py_RunMain
函数会调用pymain_run_python
函数。在这个函数内,解释器会根据前面的初始化信息,包括命令行传参、环境变量和配置文件,决定以何种模式运行。由于我们之前运行程序时没有提供参数,所以它会以REPL的模式运行。
让程序运行到576行。它会调用pymain_import_readline
函数,即加载两个python模块,readline和rlcompleterSAVE 2。
让程序运行到599行,调用pymain_header
函数。这个函数会像下面这样打印Python程序的头信息,不影响主业务。
接着是5个分支的判断语句,它决定了Python以何种模式运行SAVE 3。由于该程序运行的时候没有带任何参数,所以走最后一个分支,即交互模式(interactive)。
在Python目录下pythonrun.c
文件中的第95行打上断点,并让程序运行到这里。在经过一系列的初始化配置(包括执行初始脚本SAVE 4、交互钩子SAVE 5、异步通知SAVE 6、审计事件SAVE 7),程序开始进入执行命令行代码的步骤。
在短暂处理了一下文件名称后,马上调用_PyRun_AnyFileObject
函数执行真正的代码。
在pythonrun.c
的第114行打上断点,让程序运行到这一行。
该函数首先通过读取全局变量变量获得REPL模式下的prompt,默认的prompt分别是“>>>”和“...”。_Py_ID
是获取全局变量的宏,在整个项目中非常常见。全局变量在cpython编译前通过bat脚本根据配置写入SAVE 8。
让程序运行到138行,准备调用PyRun_InteractiveOneObjectEx
函数。这是REPL模式下最核心的函数。
进入该函数。该函数先申请了Python运行环境的内存SAVE 9。
然后调用pyrun_one_parse_ast
函数编译用户输入的Python代码。执行完这个函数程序会被挂起且控制台会出现prompt,表示等待用户输入。该函数会将Python代码解析成AST树SAVE 10,存储到mod变量内。
当用户输入完并按下回车后程序恢复执行。
在第280行,程序会先导入__main__
模块。由于是在命令行中输入程序,__main__
模块本身是个dummy,这里只是为了填充后面的run_mod
函数中的参数。同样,在第285行中,程序获取该模块的__dict__
对象也是为了填充参数。
在第287行,程序调用run_mod
函数解析AST树并生成字节码,然后根据字节码运行程序SAVE 11。执行完后命令行会输出运行结果。
然后程序释放了先前申请的Python运行内存,并处理了IO缓存。
当跳出这个函数后,会发现函数返回值ret
为0,不为EOF(11)。因此,该程序会循环调用PyRun_InteractiveOneObjectEx
函数处理用户的输入,直到出现EOF为止。
在命令行中输入exit()
退出程序。
ret
会返回为-1,程序判断后会直接调用PyErr_Print()
退出程序。
至此,整个交互模式下的cpython运行流程就介绍完了。整个流程简单清晰明了,没有多余的步骤。后续二周目文件模式也是如此,我会在以后的篇幅介绍。
回过来看,核心函数PyRun_InteractiveOneObjectEx
主要分为两个步骤,一个是编译Python源码生成AST树,另一个是解析AST树并生成字节码,然后执行。
在生成AST树的时候LOAD 10,程序会先将输入的字符串转化为Python的str对象,即PyUnicode。然后调用_PyParser_ASTFromFile
函数,将字符串转化为AST树并返回SAVE 12。
AST树指的是抽象语法树,一种通过树状图描绘代码结构的抽象表示。不止是Python,几乎所有的高级语言都会将程序抽象成AST树做进一步的解析。AST树由解释器(编译器)前端处理。
标准的解释器在处理源码时分为这几个阶段——词法分析、语法分析、IR生成与优化、IR执行,其中解释器前端指词法分析到IR生成与优化,而后端指IR执行。Python解释器的各个阶段没有明显的界限,词法分析和语法分析的逻辑全都在Parser/parser.c
和Parser/pegen.c
等几个文件中。Python的字节码可以看作是中间表示IR。
词法分析器和语法分析器可以手动编写,也可以通过代码自动生成,而cpython项目属于后者。cpython有一个子项目叫PEG,它可以根据给定的Grammar/python.gram
文件生成语法分析器。此外,cpython还需要Parser/Python.asdl
文件将Python源码转换成AST树结构。具体步骤可以查看这篇文章。
从代码角度来看,上图中的_PyParser_ASTFromFile
函数是语法分析器的入口函数,它最终会调用parser.c
中的函数生成AST树。
在AST树解析成字节码并运行的逻辑中LOAD 11,cpython会调用run_mod
函数作为入口函数。
把断点打到pythonrun.c
的1740行,让程序运行到这里。
程序会调用_PyAST_Compile
函数将刚刚生成好的AST树转换成Python字节码SAVE 13。
现在进入解释器后端部分,继续让程序运行到第1749行,它会调用run_eval_code_obj
函数执行Python字节码SAVE 14,并将结果输出到屏幕上。
将断点达到Python/compile.c
文件的第550行,让程序进入_PyAST_Compile
函数LOAD 13。
程序会先初始化一个compiler。它是整个cpython前端最核心的结构体,负责记录在编译过程中使用到的各种变量,也记录了最终生成的Python字节码SAVE 15。
然后程序执行compiler_mod
函数生成字节码。最后调用compiler_free
释放compiler。
进入compiler_mod
函数。
程序会先调用了compiler_codegen
函数,它将AST树转换成原始的Python字节码SAVE 16。然后程序调用了optimize_and_assemble
函数优化Python字节码SAVE 17。分别对应解释器的IR生成与IR优化两个阶段。
第一篇文章就到此结束了,本文还遗留了大量的存档,我在后续的文章会逐一介绍这些细节。