cpython解释器源码剖析(一)——字节码

下文中的Python指的是在官方cpython解释器上实现的Python 3.8

1. Python也有编译器

一般的观点中常将编程语言分为编译执行解释执行两类,C/C++是最典型的编译执行,而Python被视为最典型的解释执行。

典型以Java为例,二者并非泾渭分明,

graph LR
source(*.java) --javac--> bytecode(*.class)
bytecode --java--> result(程序输出)
  • 第一步中,编译器javac把源文件编译成字节码文件,这一步像是编译型语言,只不过输出不是CPU指令,而是虚拟机指令(所谓跨平台的原因再此)。
  • 第二部中,解释器java(或者说虚拟机JVM)负责执行字节码,产生实际程序效果。

Python其实也和Java一样,存在编译字节码和对字节码进行解释执行两步。只不过,Java的编译部分通常在开发者机器上完成,解释部分在用户机器上运行;而Python中这两步统统运行在用户机器上,编译发生在解释执行的前一刻(即Just In Time编译),编译器嵌入在解释器内部,对开发者和用户透明。通常唯一让使用者察觉到Python字节码存在的现象便是__pycache__文件夹,这个文件夹中缓存了被import的模块的字节码,以便加快执行速度。

2. 字节码指令和字节码对象

在Python交互式中创建一个函数,

def cube(x): 
return x ** 3

函数的内容会立即被编译,通过cube.__code__可以获取函数对应的code object,即字节码对象;在字节码对象中,又包含了属性co_code,即字节码指令,其类型为bytes,即实质上是一个字节数组。

通内置dis模块可以反汇编字节码指令:

print(tuple(cube.__code__.co_code))
from dis import dis
dis(cube.__code__.co_code)
(124, 0, 100, 1, 19, 0, 83, 0)
          0 LOAD_FAST                0 (0)
          2 LOAD_CONST               1 (1)
          4 BINARY_POWER
          6 RETURN_VALUE

可以发现,指令以两个字节为一组,每两个字节中,第一个字节是指令号,第二个字节是指令是指令参数。

例如,查询cpython源码的Include/opcode.h文件,其中定义了指令号的宏:

#define LOAD_FAST               124
#define LOAD_CONST 100
#define BINARY_POWER 19
#define RETURN_VALUE 83

这与方面反汇编的结果是一一对应的。

但问题出在了参数上,上面的LOAD_FAST的参数是0,而LOAD_CONST的参数是1,似乎没有和变量x以及常数3对应起来,其实这好理解,这个0和1其实只表示变量和常数的访问索引,并不是对象本身,真正的的对象内容保留在code object的其他属性中。

print(cube.__code__.co_varnames)
print(cube.__code__.co_consts)
dis(cube.__code__)
('x',)
(None, 3)
  2           0 LOAD_FAST                0 (x)
              2 LOAD_CONST               1 (3)
              4 BINARY_POWER
              6 RETURN_VALUE

可以看到变量名表中索引0的位置对应了x,而常数表中索引1的位置对应了常数3,通过直接对字节码对象反汇编(而不是像之前只对字节码指令反汇编)可以看到参数所对应的真实对象名称或者值,同时反汇编结果还多出了行号信息(以函数def语句作为第1行,这里便是第2行)。

从中我们可以知道字节码指令和字节码对象的关系:

  • 数据结构上,字节码指令是一个字节数组,而字节码对象是一个复杂结构体
  • 逻辑关系上,前者只是后者的一个属性
  • 内容上,前者只包含了解释器所执行的指令和参数,后者包含了更加具体的索引内容值、执行环境信息以及调试信息

做一个类比的话,字节码对象就像是整个exe或者elf可执行文件,而字节码指令就是其中的.text段而已。

3. Python字节码的特点

3.1. 定长编码

如前文所言,Python用1字节指令好加1字节参数表达一个指令。

但这产生了一个问题,就是指令的长度,用1字节的指令号加上1字节的参数足够表达所有指令吗?

对于指令号,Python目前只使用了1-161,其中甚至还有很多占位符以及空缺号,显然1字节是够的。

但对于指令参数就有些问题了,如果只固定了一个字节,那么当一个函数中有超过256个不同常数时,岂不是LOAD_CONST就要失效了?Python的解决方案是通过一个名为EXTENDED_ARG(指令号144)的特殊指令。例如,指令序列(144, 1, 124, 5, 144, 1, 90, 6),扩展参数指令以前缀的形式存在于既有指令的前面,表示将其携带的的一个字节扩展到后面指令的高位上,所以方才的指令序列可以立即为(124, 0x0105, 90, 0x0106),也就是把索引262的常数存储到索引261的变量中,突破了1字节的256上限。(如果2字节还不够,害可以继续加前缀。)

另外对于某些其实压根不需要参数的指令,如RETURN_VALUE,它直接讲栈顶的一个值弹出作为返回值,形式上还是带了一个参数,以维持字节码指令的格式一致。

讲指令设计为定长,当然其流水执行和指令定位的效率也就更高了。

3.2. 栈式计算

所有计算都是在栈上完成。

相加便是将加数和被加数压入栈,调用加法指令后消耗两个数并在栈上产生压入其求和结果。而函数调用便是将函数对象和参数依次入栈然后执行函数调用指令。

3.3. 无类型

Python所产生的字节码指令中不具有类型信息,如前面的BINARY_POWER指令表示两个操作数的指数操作,至于这是数值的指数操作,还是某个重载了指数运算的自定义类,更甚至是非法操作(如对list类型取指数),这都无关紧要,他们统统都会生成一样的压栈指令,一样的指数运算指令,至于在运行期其是结果是什么,全部依赖于在运行时动态判断操作数的类型信息,然后调用类型中定义的实际运算过程。

毕竟Python的语言设计也是动态类型的,所以指望能纯粹依靠编译器给所有指令中添加类型信息当然是不切实际的。

3.4. 动态名字搜索

譬如考虑这样一段代码:

globals()[input()] = 1234
print(abc)

它会产生错误吗?答案是不一定,这取决于运行时输入是不是恰好就是字符串'abc',是的话,Python会在全局名字空间中新建一个变量abc并赋值1234,这段代码便没有任何错误;而反之,就会有一个NameError: name 'abc' is not defined类型的错误。

这一点和C/C++则是完全相反,在C/C++中,变量编译后其实就是位于某个地址中的数据,只有位置信息,没有名称信息,根本就不可能通过变量名进行动态搜索。

Python的这种动态搜索特效位程序编写提供了极大的灵活性,当然也导致了不小的隐患(至少静态分析没法做了),但更加严重的是,因为每次加载变量都要从名字空间(实质上是一个哈希字典)搜索,效率当然也受到很大限制。Python为了缓解这个效率问题,对于函数中的局部变量,采取固定存储位置的手段,直接通过列表偏移值索引,这也就是前面所出现的LOAD_FAST指令只由来。