最近在B站上刷到了UP主码农高天讲解Python的视频,UP主将Python的细节讲述得非常有意思。受此启发,我准备在自己的网站上记录一些我对Python的理解,暂定计划是写一些Python解释器的实现细节,用到的解释器版本是CPython v3.10.4,系统环境是Ubuntu 20.04。

这篇文章尝试编译一个debug版的Cpython解释器,写一个最简单的Python脚本,用GDB来调试解释器执行脚本的过程,分析解释器的函数栈。

准备工作

我们需要在Linux环境下编译并调试一个CPython解释器,所以GCC编译器、Make和GDB调试器、解释器源码都是必须的,解释器的一些依赖库是可选的。

下载CPython源码

CPython解释器的源码可以从Github下载,国内可以从Gitee镜像

# 从Github下载
git clone --depth 1 --branch v3.10.4 https://github.com/python/cpython.git
# 国内从Gitee镜像加速下载
git clone --depth 1 --branch v3.10.4 https://gitee.com/mirrors/cpython.git

下面列出了CPython源码中各个子目录的作用,更详细的介绍可以看官方文档:

Doc            文档
Grammar        EBNF语法定义
Include        解释器所用的头文件
Lib            标准库中用Python实现的部分
Mac            Mac系统特定的代码
Misc           杂项
Modules        标准库中用C实现的部分
Objects        所有内置类型的实现
PC             Windows系统特定的代码
PCbuild        MSVC构建文件
Parser         语法分析器
Programs       可执行文件的代码
Python         Python运行时,包含了main函数
Tools          维护Python使用的工具

安装编译工具

apt-get install gcc build-essential

安装debug工具

apt-get install gdb

先用上面的命令安装GDB,为了方便debug,我们还需要一款IDE或者编辑器,我这里选用VSCode和C/C++插件。

安装依赖

$ sudo apt-get update
$ sudo apt-get build-dep python3
$ sudo apt-get install pkg-config

以下一些依赖可以按需安装

$ sudo apt-get install build-essential gdb lcov pkg-config \
    libbz2-dev libffi-dev libgdbm-dev libgdbm-compat-dev liblzma-dev \
    libncurses5-dev libreadline6-dev libsqlite3-dev libssl-dev \
    lzma lzma-dev tk-dev uuid-dev zlib1g-dev

编译debug版的CPython解释器

在安装了上面的工具后,我们可以开始编译debug版的CPython解释器。

cd cpython
./configure --with-pydebug
make -j4 -s

用4线程并行编译,耗时大约在一分钟以内。编译完成后,会在当前目录下生成名为python的可执行文件,这个文件就是我们的解释器,可以用GDB来调试。

从Hello world开始

解释器主程序入口

我们可以用grep命令来搜索main函数,另外还有一个更简单的办法,用gdb调试python

gdb python

输入start开始运行解释器,可以看到程序暂停在了Programs/python.c文件的14行,这就是main函数。我们找到源码文件Programs/python.c中,可以看到main函数的内容只有一行:

return Py_BytesMain(argc, argv);

为了更加直观,接下来我们改用VSCode的图形化界面进行调试。

调试hello world脚本

首先写一个python文件debug/main.py,内容只有一行,用来输出字符串Hello world!

print("Hello world!")

我们想要在执行print函数的时候暂停解释器,来查看解释器的调用栈,所以我们需要在print函数的C代码中设置一个断点。print函数的实现是Python/bltinmodule.c文件中的builtin_print函数,我们在这个函数中设置一个断点,接下来就可以开始用VSCode调试了,下面是我用的launch.json配置:

{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "cppdbg",
            "request": "launch",
            "name": "python",
            "program": "${workspaceRoot}/python",
            "cwd": "${workspaceRoot}",
            "args": ["debug/main.py"]
        }
    ]
}

效果和在shell里执行下面这行命令一样:

gdb --args python debug/main.py

接下来开始debug,程序会在builtin_print函数中暂停,如下图:

debug_1 点击这里放大图片

第2001行的PyFile_WriteObject函数就是将字符串写到标准输出的函数,这里args中的唯一一个元素就是我们在Python脚本中写的Hello world!字符串,解释器层面的字符串是用PyUnicodeObject表示的,我们可以在debug窗口中输入PyUnicode_AsUnicode(args[i])PyUnicodeObject转为C字符串,证实了这个被打印的字符串就是我们在Pyhon代码中写的"Hello world!"字符串。

如果程序继续执行下去,可以看到在builtin_print函数中继续输出了一个换行符\n然后返回,解释器会接下去做一些收尾工作,例如刷新缓冲区、释放内存、最后结束运行。这就是我们通常在shell中执行python debug/main.py时CPython解释器所做的工作。

查看调用栈里的其他函数可以看到解释器执行脚本的主要过程,例如:

  • main.c文件中的pymain_main函数,首先用pymain_init函数初始化了运行时,然后开始运行Py_RunMain()

  • run_mod函数中,调用_PyAST_Compile函数将文件编译为PyCodeObject字节码对象,然后调用run_eval_code_obj执行字节码。

  • 解释器执行字节码的主循环都在ceval.c文件的_PyEval_EvalFrameDefault函数中。

参考资料