Python 从源码到执行

0.介绍一下常见的编译模型: Java, Python, C

在今天的主题以前,先来了解下几个典型的编译模型。
松本行弘先生,在讲解语言处理器构成时列举了一个通用架构。java

source code
    |
    |
   \./
----------                 ---------
|Compiler| ---mid code---> |Runtime|
----------                 ---------
                              /.\
                               |
                               |
                           ---------    
                           |  Lib  |
                           ---------

处理器主要由三部分组成: 编译器(Compiler),运行时(Runtime),库(Lib)。python

  • 编译器(Compiler): 顾名思义,就是编译源码的程序。一般状况下,它会将源码编译成运行时(Runtime)识别的中间码,可是在极端状况下,如 C 中,由于没有运行时(Runtime),就直接输出机器码了。编译器(Compiler)在这过程当中可能还会本身对源码进行优化,并剔除一些运行没必要要的信息,好比注释等。
  • 运行时程序(Runtime): 这个程序用于代码的具体执行,最被熟知的是 JVM,因此也能够把它叫作虚拟机好了
  • 库(Lib): 库很好理解,就比如一个词典,运行这个程序所须要的一些额外支持。最基础的,标准库应该包含基本的 IO 库,如 stdio.h,还有平台所提供的系统调用等等。

Java 在这里就颇有表明性,中规中矩的按照这个流程走。
首先 Java 的编译器(Javac)会将源码 .java 的文件编译为字节码形式的 .class 文件。
而后将文件中的字节码引入虚拟机中(JVM),这里的 JVM 承担的就是运行时(Runtime)的任务。
运行过程当中从 JDK 中引入须要的库(Lib)。git

那么 Python 会有什么不同呢 ?
Python 也是按照这个流程走的~~
可是 Python 中编译器(Compiler)承担的工做比重相对较少,由于没有了复杂的语法检查还有类型校验等工做,大部分工做都在运行时完成,大部分错误也只有在运行过程当中才能发现。
像这种运行时(Runtime)部分承担大部分工做的语言,外观上给人一种像是直接从源代码执行的错觉,因此被叫作“解释型”。
也是由于如此,Python 的执行效率远不及 Java ,还有 C 这些静态编译的语言。github

最后 C 呢?
C 又是另外一个极端,C 语言的 GCC 编译器(compiler)异常强大,几乎包办了大部分工做。它能根据须要执行相应的编译优化,如转化机器不须要的变量名,加入混淆等等,几乎跳过了运行时(Runtime)直接输出包含机器码,输出文件通过连接器能够转换成平台可执行的文件。
若是有了解过反编译的同窗应该看过反编译回来的 C 源码可读性大打折扣,有时只能转到汇编了解程序的运行逻辑,相比较而言 Java 的 .class 文件中的字节码保留了更多信息,反编译回来的代码可读性更好一点。
在我很是喜欢的美剧《硅谷》中也有这样一个桥段,Hooli 专门组织了一个团队反编译 Richard 的音乐程序,来获取他的数据压缩技术。
这是一个很是有意思的过程,有不少书花了长篇大论专门介绍这个,这里再也不展开了。编程

1.CPython 编译流程

咱们先从编译器开始,Python 的编译器大体分为下面四步流程:windows

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

这是在最新的 CPython3.8.4 中的 python 源码编译及执行过程架构

PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename_str, int start, PyObject *globals,
                  PyObject *locals, int closeit, PyCompilerFlags *flags)
{
    PyObject *ret = NULL;
    mod_ty mod;
    PyArena *arena = NULL;
    PyObject *filename;

    filename = PyUnicode_DecodeFSDefault(filename_str);
    if (filename == NULL)
        goto exit;

    arena = PyArena_New();
    if (arena == NULL)
        goto exit;

    /* PyParser 包含了前述中的 step1 和 2 */
    mod = PyParser_ASTFromFileObject(fp, filename, NULL, start, 0, 0,
                                     flags, NULL, arena);
    if (closeit)
        fclose(fp);
    if (mod == NULL) {
        goto exit;
    }

    /* run_mod 包含 step3, 4 以及虚拟机的运做过程 */
    ret = run_mod(mod, filename, globals, locals, flags, arena);

exit:
    Py_XDECREF(filename);
    if (arena != NULL)
        PyArena_Free(arena);
    return ret;
}

大部分人对这一块应该没多大兴趣,我就简单带过,编译器主要会进行词法分析以及语法分析,遍历语法树生成流,最后生成虚拟机的机器码,也就是字节码。这里的每一点都包含很大的信息量,就再也不详细叙述编译的细节了。下面解析一下 Python 的执行,那就要牵扯到虚拟机和字节码了。编程语言

2.虚拟机和字节码

虚拟机(vm): 这里所说的虚拟机不是 KVM,VMware 虚拟机。指的是在软件层面模拟了 CPU 执行逻辑的程序,其中最有名的应该是 JVM 了。在解释型语言 Python, Ruby 中一样包含了解析程序指令的虚拟机。ide

字节码(bytecode): 字节码是相对于机器码的存在。机器码是 CPU 能读懂的机器指令,全部指令都包含在一个指令集里面,那字节码就是虚拟机能理解的指令。函数

那么在编程语言中,它们基于 CPU 的原理在软件层实现了一个指令集,相应的将程序翻译成虚拟机理解的字节码再加载到虚拟机中运行。

这也给代码的移植性带来好处,只要相应的平台(Intel, ARM)上的操做系统(*nux, windows)安装有相应的虚拟机,就能够直接运行程序。

一个简单的例子:

import dis

def hello(): 
    print("Hello World")

print(dis.dis(hello))

# 0 LOAD_GLOBAL              0 (print)
# 2 LOAD_CONST               1 ('Hello World')
# 4 CALL_FUNCTION            1
# 6 POP_TOP
# 8 LOAD_CONST               0 (None)
# 10 RETURN_VALUE

上面的注释部分就是虚拟机要运行的指令部分,和在学校的时候学习的 x86 汇编很是类似,目前在 CPython3.8.4 中包含了163条指令(opcode),不一样版本之间指令有一些指令差别,在本身尝试的时候可能会看到不一样的指令是正常的,感兴趣的话能够在源码(Include/opcode.h)中查看所有指令。

另外须要提一点,CPython 中使用的是栈式虚拟机架构,相对的还有寄存器式虚拟机。

这是一个简单的打印 Hello World 的程序。
咱们来逐一解释如下:

  • LOAD_GLOBAL: 将全局变量 print 压入(push)栈
  • LOAD_CONST: 将常量 Hello World压入(push)栈
  • CALL_FUNCTION: 执行 print 方法,弹出常量 'Hello World' 以及 print 变量,将结果压入(push)栈中。
  • POP_TOP: 弹出(pop)栈顶元素,就是刚刚的 print 的返回值
  • LOAD_CONST: 将常量 None 压入(push)栈
  • RETURN_VALUE: 弹出(pop)栈顶元素做为最终返回值

就是这样看起来复杂的六条指令拼凑出了 hello 函数。

我以为还有些疑问: CALL_FUNCTION 是如何 CALL 到这个函数的? 指令(opcode)旁边出现的数字有什么含义?

咱们得从字节码中找到答案。

首先观察指令前面的数字 0,2,4,..,10,不难看出这是字节码的长度,0-2表示字节码的长度是 2 bytes,也就是说一条完整的代码指令实际上是以字(word, 1 word= 2 bytes)的形式出现的,这里其实更适合叫作“字码”。

而后是字码的构成,一个 word 有 16 个bit,前面说过 CPython 目前支持的指令(opcode)是163个,要怎么存呢?

opcode 占用 8 bit,也就是说目前最多能够扩展到 256 个 opcode,另外 8 bit 存参数长度,这么说来一次函数调用最多只能压入(Push) 255 个参数(这个还真没试过,感兴趣能够试一下)。

因此你看到的指令(opcode)后面的 0 和 1 实际上是目前的参数长度,当 CALL 的时候,虚拟机会经过参数长度从栈顶向下检索调用位置运行函数。

这样差很少对字节码有了必定的认识了。

问题

经过上面逐一对指令进行了解析,相信你们对 python 又有了新了解了吧。可是还会有其余疑问。

  • print 这个全局变量是从哪里来的 ?
  • 'Hello Word' 字符串,还有 None 为何应该是常量 ?
  • 这里只揭示了栈式虚拟机,那寄存器式虚拟机是什么样子的呢 ?
  • 另一个老生长谈的问题,除了虚拟机负担了更多工做,还有哪些因素致使了 Python 的慢 ?

这些问题一下也说不完,下次必定 :)

参考

我的博客连接: https://00kai0.github.io/cpy-compile-and-runtime/

相关文章
相关标签/搜索