Python 编译/解释过程

54 投票
2 回答
18582 浏览
提问于 2025-04-16 01:37

我想更清楚地理解Python的编译器/解释器过程。可惜我没有上过相关的课程,也没读过太多这方面的内容。

基本上,我现在理解的是,Python代码来自.py文件,首先会被编译成Python字节码(我猜这就是我偶尔看到的.pyc文件?)。接下来,这些字节码会被进一步编译成机器代码,也就是处理器能理解的语言。大致上,我读过这个帖子 为什么Python在解释之前要将源代码编译成字节码?

有没有人能给我一个简单明了的解释,讲讲整个过程,考虑到我对编译器/解释器几乎没有了解?或者,如果不行的话,能不能给我一些资源,让我快速了解编译器/解释器的概念?

谢谢

2 个回答

7

为了补充Marcelo Cantos的精彩回答,这里有一个简单的逐列总结,帮助大家理解反汇编字节码的输出。

比如,给定这个函数:

def f(num):
    if num == 42:
        return True
    return False

这个函数可以被反汇编成(Python 3.6):

(1)|(2)|(3)|(4)|          (5)         |(6)|  (7)
---|---|---|---|----------------------|---|-------
  2|   |   |  0|LOAD_FAST             |  0|(num)
   |-->|   |  2|LOAD_CONST            |  1|(42)
   |   |   |  4|COMPARE_OP            |  2|(==)
   |   |   |  6|POP_JUMP_IF_FALSE     | 12|
   |   |   |   |                      |   |
  3|   |   |  8|LOAD_CONST            |  2|(True)
   |   |   | 10|RETURN_VALUE          |   |
   |   |   |   |                      |   |
  4|   |>> | 12|LOAD_CONST            |  3|(False)
   |   |   | 14|RETURN_VALUE          |   |

每一列都有特定的用途:

  1. 源代码中的行号
  2. 可选地指示当前执行的指令(例如,当字节码来自一个帧对象时)
  3. 一个标签,表示可能的JUMP指令从之前的指令跳转到这一条
  4. 字节码中的地址,对应字节索引(这些地址是2的倍数,因为Python 3.6每条指令使用2个字节,而之前的版本可能会有所不同)
  5. 指令名称(也叫opname),每个指令在dis模块中都有简要说明,它们的实现可以在ceval.c中找到(这是CPython的核心循环)
  6. 指令的参数(如果有的话),Python内部用来获取一些常量或变量,管理堆栈,跳转到特定指令等
  7. 指令参数的人类友好解释
67

字节码其实并不是直接转化为机器码,除非你使用一些特别的实现,比如pypy。

除此之外,你的描述是正确的。字节码会被加载到Python的运行环境中,由一个虚拟机来解释。这个虚拟机就像一段代码,它会逐条读取字节码中的指令,并执行指令所表示的操作。你可以通过dis模块查看这些字节码,方法如下:

>>> def fib(n): return n if n < 2 else fib(n - 2) + fib(n - 1)
... 
>>> fib(10)
55
>>> import dis
>>> dis.dis(fib)
  1           0 LOAD_FAST                0 (n)
              3 LOAD_CONST               1 (2)
              6 COMPARE_OP               0 (<)
              9 JUMP_IF_FALSE            5 (to 17)
             12 POP_TOP             
             13 LOAD_FAST                0 (n)
             16 RETURN_VALUE        
        >>   17 POP_TOP             
             18 LOAD_GLOBAL              0 (fib)
             21 LOAD_FAST                0 (n)
             24 LOAD_CONST               1 (2)
             27 BINARY_SUBTRACT     
             28 CALL_FUNCTION            1
             31 LOAD_GLOBAL              0 (fib)
             34 LOAD_FAST                0 (n)
             37 LOAD_CONST               2 (1)
             40 BINARY_SUBTRACT     
             41 CALL_FUNCTION            1
             44 BINARY_ADD          
             45 RETURN_VALUE        
>>> 

详细解释

理解上面的代码是非常重要的,因为这些代码从来不会被你的CPU执行;也不会被转化成CPU能理解的东西(至少在官方的Python C实现中是这样的)。CPU执行的是虚拟机的代码,这些代码完成字节码指令所指示的工作。当解释器想要执行fib函数时,它会一条一条地读取指令,并按照指令的要求去做。比如,它首先看到的指令是LOAD_FAST 0,这意味着它会从存放参数的地方取出参数0(也就是传给fibn),然后把这个值放到解释器的栈上(Python的解释器是一个栈机器)。接下来,读取到的指令是LOAD_CONST 1,这时它会从函数拥有的常量集合中取出常量1,这里恰好是数字2,然后把它也放到栈上。你实际上可以看到这些常量:

>>> fib.func_code.co_consts
(None, 2, 1)

接下来的指令COMPARE_OP 0,指示解释器将栈顶的两个元素弹出,并进行不等式比较,然后把比较结果(布尔值)再放回栈上。第四条指令根据这个布尔值来决定是跳过五条指令还是继续执行下一条指令。这些内容解释了fib中的条件表达式if n < 2。你可以尝试理解fib字节码的其余部分,这将是一个非常有意义的练习。唯一我不太确定的是POP_TOP;我猜JUMP_IF_FALSE的定义是将布尔参数留在栈上,而不是弹出,所以需要显式地弹出。

更有意思的是,你可以这样查看fib的原始字节码:

>>> code = fib.func_code.co_code
>>> code
'|\x00\x00d\x01\x00j\x00\x00o\x05\x00\x01|\x00\x00S\x01t\x00\x00|\x00\x00d\x01\x00\x18\x83\x01\x00t\x00\x00|\x00\x00d\x02\x00\x18\x83\x01\x00\x17S'
>>> import opcode
>>> op = code[0]
>>> op
'|'
>>> op = ord(op)
>>> op
124
>>> opcode.opname[op]
'LOAD_FAST'
>>> 

这样你就可以看到字节码的第一个字节是LOAD_FAST指令。接下来的两个字节'\x00\x00'(16位的数字0)是LOAD_FAST的参数,告诉字节码解释器将参数0加载到栈上。

撰写回答