深度解密 Python 的字節碼

楔子

當我們想要執行一個 py 文件的時候,只需要在命令行中輸入 python xxx.py 即可,但你有沒有想過這背後的流程是怎樣的呢?

首先 py 文件不是一上來就直接執行的,而是會先有一個編譯的過程,整個步驟如下:

這裏我們看到了 Python 編譯器、Python 虛擬機,而且我們平常還會說 Python 解釋器,那麼三者之間有什麼區別呢?

Python 編譯器負責將 Python 源代碼編譯成 PyCodeObject 對象,然後交給 Python 虛擬機來執行。

那麼 Python 編譯器和 Python 虛擬機都在什麼地方呢?如果打開 Python 的安裝目錄,會發現有一個 python.exe,點擊的時候會通過它來啓動一個終端。

但問題是這個文件大小還不到 100K,不可能容納一個編譯器加一個虛擬機,所以下面還有一個 python38.dll。沒錯,編譯器、虛擬機都藏身於 python38.dll 當中。

因此 Python 雖然是解釋型語言,但也有編譯的過程。源代碼會被編譯器編譯成 PyCodeObject 對象,然後再交給虛擬機來執行。而之所以要存在編譯,是爲了讓虛擬機能更快速地執行,比如在編譯階段常量都會提前分配好,而且還可以儘早檢測出語法上的錯誤。

pyc 文件是什麼

在 Python 開發時,我們肯定都見過這個 pyc 文件,它一般位於 pycache 目錄中,那麼 pyc 文件和 PyCodeObject 之間有什麼關係呢?

首先我們都知道字節碼,虛擬機的執行實際上就是對字節碼不斷解析的一個過程。然而除了字節碼之外,還應該包含一些其它的信息,這些信息也是 Python 運行的時候所必需的,比如常量、變量名等等。

我們常聽到 py 文件被編譯成字節碼,這句話其實不太嚴謹,因爲字節碼只是一個 PyBytesObject 對象、或者說一段字節序列。但很明顯,光有字節碼是不夠的,還有很多的靜態信息也需要被收集起來,它們整體被稱爲 PyCodeObject。

而 PyCodeObject 對象中有一個成員 co_code,它是一個指針,指向了這段字節序列。但是這個對象除了有 co_code 指向的字節碼之外,還有很多其它成員,負責保存代碼涉及到的常量、變量(名字、符號)等等。

但是問題來了,難道每一次執行都要將源文件編譯一遍嗎?如果沒有對源文件進行修改的話,那麼完全可以使用上一次的編譯結果。相信此時你能猜到 pyc 文件是幹什麼的了,它就是負責保存編譯之後的 PyCodeObject 對象。

所以我們知道了,pyc 文件裏面的內容是 PyCodeObject 對象。對於 Python 編譯器來說,PyCodeObject 對象是對源代碼編譯之後的結果,而 pyc 文件則是這個對象在硬盤上的表現形式。

當下一次運行的時候,Python 會根據 pyc 文件中記錄的編譯結果直接建立內存中的 PyCodeObject 對象,而不需要再重新編譯了,當然前提是沒有對源文件進行修改。

PyCodeObject 底層結構

我們來看一下這個結構體長什麼樣子,它的定義位於 Include/code.h 中。

typedef struct {
    //頭部信息,我們看到真的一切皆對象,字節碼也是個對象
    PyObject_HEAD    
    //可以通過位置參數傳遞的參數個數
    int co_argcount;            
    //只能通過位置參數傳遞的參數個數,Python3.8新增
    int co_posonlyargcount;     
    //只能通過關鍵字參數傳遞的參數個數
    int co_kwonlyargcount;     
    //代碼塊中局部變量的個數,也包括參數
    int co_nlocals;             
    //執行該段代碼塊需要的棧空間
    int co_stacksize;      
    //參數類型標識    
    int co_flags;           
    //代碼塊在文件中的行號  
    int co_firstlineno;         
    //指令集,也就是字節碼,它是一個bytes對象 
    PyObject *co_code;         
    //常量池,一個元組,保存代碼塊中的所有常量 
    PyObject *co_consts;       
    //一個元組,保存代碼塊中引用的其它作用域的變量
    PyObject *co_names;      
    //一個元組,保存當前作用域中的變量   
    PyObject *co_varnames;    
    //內層函數引用的外層函數的作用域中的變量
    PyObject *co_freevars;      
    //外層函數的作用域中被內層函數引用的變量
    //本質上和co_freevars是一樣的
    PyObject *co_cellvars;      
    //無需關注
    Py_ssize_t *co_cell2arg;    
    //代碼塊所在的文件名
    PyObject *co_filename;    
    //代碼塊的名字,通常是函數名、類名,或者文件名
    PyObject *co_name;         
    //字節碼指令與python源代碼的行號之間的對應關係
    //以PyByteObject的形式存在 
    PyObject *co_lnotab;        
} PyCodeObject;

這裏面的每一個成員,我們一會兒都會逐一演示進行說明。總之 Python 編譯器在對源代碼進行編譯的時候,對於代碼中的每一個 block,都會創建一個 PyCodeObject 與之對應。

但多少代碼纔算得上是一個 block 呢?事實上,Python 有一個簡單而清晰的規則:當進入一個新的名字空間,或者說作用域時,就算是進入了一個新的 block 了。舉個例子:

class A:
    a = 123

def foo():
    a = []

我們仔細觀察一下上面這個文件,它在編譯完之後會有三個 PyCodeObject 對象,一個是對應整個 py 文件(模塊)的,一個是對應 class A 的,一個是對應 def foo 的。因爲這是三個不同的作用域,所以會有三個 PyCodeObject 對象。

所以一個 code block 對應一個作用域、同時也對應一個 PyCodeObject 對象。Python 的類、函數、模塊都有自己獨立的作用域,因此在編譯時也都會有一個 PyCodeObject 對象與之對應。

PyCodeObject 代碼演示

PyCodeObject 我們知道它是幹什麼的了,那如何才能拿到這個對象呢?首先該對象在 Python 裏面的類型是 <class 'code'>,但是底層沒有將這個類暴露給我們,因此 code 這個名字在 Python 裏面只是一個沒有定義的變量罷了。

但是我們可以通過其它的方式進行獲取,比如函數。

def func():
    pass

print(func.__code__)  # <code object ......
print(type(func.__code__))  # <class 'code'>

我們可以通過函數的 code 屬性拿到底層對應的 PyCodeObject 對象,當然也可以獲取裏面的成員,我們來演示一下。

co_argcount:可以通過位置參數傳遞的參數個數

def foo(a, b, c=3):
    pass
print(foo.__code__.co_argcount)  # 3

def bar(a, b, *args):
    pass
print(bar.__code__.co_argcount)  # 2

def func(a, b, *args, c):
    pass
print(func.__code__.co_argcount)  # 2

foo 中的參數 a、b、c 都可以通過位置參數傳遞,所以結果是 3;對於 bar,則是兩個,這裏不包括 *args;而函數 func,顯然也是兩個,因爲參數 c 只能通過關鍵字參數傳遞。

co_posonlyargcount:只能通過位置參數傳遞的參數個數,Python3.8 新增

def foo(a, b, c):
    pass

print(foo.__code__.co_posonlyargcount)  # 0

def bar(a, b, /, c):
    pass

print(bar.__code__.co_posonlyargcount)  # 2

注意:這裏是只能通過位置參數傳遞的參數個數。對於 foo 而言,裏面的三個參數既可以通過位置參數、也可以通過關鍵字參數傳遞;而函數 bar,裏面的 a、b 只能通過位置參數傳遞。

co_kwonlyargcount:只能通過關鍵字參數傳遞的參數個數

def foo(a, b=1, c=2, *, d, e):
    pass
print(foo.__code__.co_kwonlyargcount)  # 2

這裏是 d 和 e,它們必須通過關鍵字參數傳遞。

co_nlocals:代碼塊中局部變量的個數,也包括參數

def foo(a, b, *, c):
    name = "xxx"
    age = 16
    gender = "f"
    c = 33

print(foo.__code__.co_nlocals)  # 6

局部變量有 a、b、c、name、age、gender,所以我們看到在編譯之後,函數的局部變量就已經確定了,因爲它們是靜態存儲的。

co_stacksize:執行該段代碼塊需要的棧空間

def foo(a, b, *, c):
    name = "xxx"
    age = 16
    gender = "f"
    c = 33

print(foo.__code__.co_stacksize)  # 1

這個暫時不需要太關注。

co_flags:參數類型標識

標識函數的參數類型,如果一個函數的參數出現了 *args,那麼 co_flags & 0x04 爲真;如果一個函數的參數出現了 **kwargs,那麼 co_flags & 0x08 爲真;

def foo1():
    pass
# 結果全部爲假
print(foo1.__code__.co_flags & 0x04)  # 0
print(foo1.__code__.co_flags & 0x08)  # 0

def foo2(*args):
    pass
# co_flags & 0x04 爲真,因爲出現了 *args
print(foo2.__code__.co_flags & 0x04)  # 4
print(foo2.__code__.co_flags & 0x08)  # 0

def foo3(*args, **kwargs):
    pass
# 顯然 co_flags & 0x04 和 co_flags & 0x08 均爲真
print(foo3.__code__.co_flags & 0x04)  # 4
print(foo3.__code__.co_flags & 0x08)  # 8

當然啦,co_flags 可以做的事情並不止這麼簡單,它還能檢測一個函數的類型。比如函數內部出現了 yield,那麼它就是一個生成器函數,調用之後可以得到一個生成器;使用 async def 定義,那麼它就是一個協程函數,調用之後可以得到一個協程。

這些在詞法分析的時候就可以檢測出來,編譯之後會體現在 co_flags 這個成員中。

# 如果是生成器函數
# 那麼 co_flags & 0x20 爲真
def foo1():
    yield
print(foo1.__code__.co_flags & 0x20)  # 32

# 如果是協程函數
# 那麼 co_flags & 0x80 爲真
async def foo2():
    pass
print(foo2.__code__.co_flags & 0x80)  # 128
# 顯然 foo2 不是生成器函數
# 所以 co_flags & 0x20 爲假
print(foo2.__code__.co_flags & 0x20)  # 0

# 如果是異步生成器函數
# 那麼 co_flags & 0x200 爲真
async def foo3():
    yield
print(foo3.__code__.co_flags & 0x200)  # 512
# 顯然它不是生成器函數、也不是協程函數
# 因此和 0x20、0x80 按位與之後,結果都爲假
print(foo3.__code__.co_flags & 0x20)  # 0
print(foo3.__code__.co_flags & 0x80)  # 0

在判斷函數種類時,這種方式是最優雅的。

co_firstlineno:代碼塊在對應文件的起始行

def foo(a, b, *, c):
    pass

# 顯然是文件的第一行
# 或者理解爲 def 所在的行
print(foo.__code__.co_firstlineno)  # 1

如果函數出現了調用呢?

def foo():
    return bar

def bar():
    pass

print(foo().__code__.co_firstlineno)  # 4

如果執行 foo,那麼會返回函數 bar,最終得到的就是 bar 的字節碼,因此最終結果是 def bar(): 所在的行數。所以每個函數都有自己的作用域,以及 PyCodeObject 對象。

co_names:符號表,一個元組,保存代碼塊中引用的其它作用域的變量

c = 1

def foo(a, b):
    print(a, b, c)
    d = (list, int, str)

print(
    foo.__code__.co_names
)  # ('print', 'c', 'list', 'int', 'str')

一切皆對象,但看到的都是指向對象的變量,所以 print, c, list, int, str 都是變量,它們都不在當前 foo 函數的作用域中。

co_varnames:符號表,一個元組,保存在當前作用域中的變量

c = 1

def foo(a, b):
    print(a, b, c)
    d = (list, int, str)
print(foo.__code__.co_varnames)  # ('a', 'b', 'd')

a、b、d 是位於當前 foo 函數的作用域當中的,所以編譯階段便確定了局部變量是什麼。

co_consts:常量池,一個元組,保存代碼塊中的所有常量

x = 123

def foo(a, b):
    c = "abc"
    print(x)
    print(True, False, list, [1, 2, 3]{"a": 1})
    return ">>>"

print(
    foo.__code__.co_consts
)  # (None, 'abc', True, False, 1, 2, 3, 'a', '>>>')

co_consts 裏面出現的都是常量,但 [1, 2, 3] 和 {"a": 1} 卻沒有出現,由此我們可以得出,列表和字典絕不是在編譯階段構建的。編譯時,只是收集了裏面的元素,然後等到運行時再去動態構建。

不過問題來了,在構建的時候解釋器怎麼知道是要構建列表、還是字典、亦或是其它的什麼對象呢?所以這就依賴於字節碼了,解釋字節碼的時候,會判斷到底要構建什麼樣的對象。

因此解釋器執行的是字節碼,核心邏輯都體現在字節碼中。但是光有字節碼還不夠,它包含的只是程序的主幹邏輯,至於變量、常量,則從符號表和常量池裏面獲取。

co_name:代碼塊的名字

def foo():
    pass
# 這裏就是函數名
print(foo.__code__.co_name)  # foo

co_code:字節碼

def foo(a, b, /, c, *, d, e):
    f = 123
    g = list()
    g.extend([tuple, getattr, print])

print(foo.__code__.co_code)
#b'd\x01}\x05t\x00\x83\x00}\x06|\x06......'

這便是字節碼,它只保存了要操作的指令,因此光有字節碼是肯定不夠的,還需要其它的靜態信息。顯然這些信息連同字節碼一樣,都位於 PyCodeObject 中。

字節碼與反編譯

Python 執行源代碼之前會先編譯得到 PyCodeObject 對象,裏面的 co_code 指向了字節碼序列。

虛擬機會根據這些字節碼序列來進行一系列的操作(當然也依賴其它的靜態信息),從而完成對程序的執行。

每個操作都對應一個操作指令、也叫操作碼,總共有 120 多種,定義在 Include/opcode.h 中。

#define POP_TOP                   1
#define ROT_TWO                   2
#define ROT_THREE                 3
#define DUP_TOP                   4
#define DUP_TOP_TWO               5
#define NOP                       9
#define UNARY_POSITIVE           10
#define UNARY_NEGATIVE           11
#define UNARY_NOT                12
#define UNARY_INVERT             15
#define BINARY_MATRIX_MULTIPLY   16
#define INPLACE_MATRIX_MULTIPLY  17
#define BINARY_POWER             19
#define BINARY_MULTIPLY          20
#define BINARY_MODULO            22
#define BINARY_ADD               23
#define BINARY_SUBTRACT          24
#define BINARY_SUBSCR            25
#define BINARY_FLOOR_DIVIDE      26
#define BINARY_TRUE_DIVIDE       27
#define INPLACE_FLOOR_DIVIDE     28
// ...
// ...

操作指令只是一個整數,然後我們可以通過反編譯的方式查看每行 Python 代碼都對應哪些操作指令:

# Python中的dis模塊專門負責幹這件事情
import dis

def foo(a, b):
    c = a + b
    return c

# 裏面接收一個字節碼
# 當然函數也是可以的,會自動獲取co_code
dis.dis(foo)
"""
  5           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 STORE_FAST               2 (c)

  6           8 LOAD_FAST                2 (c)
             10 RETURN_VALUE
"""

字節碼反編譯後的結果多麼像彙編語言,其中第一列是源代碼行號,第二列是字節碼偏移量,第三列是操作指令(也叫操作碼),第四列是指令參數(也叫操作數)。Python 的字節碼指令都是成對出現的,每個指令都會帶有一個指令參數。

查看字節碼也可以使用 opcode 模塊:

from opcode import opmap

opmap = {v: k for k, v in opmap.items()}

def foo(a, b):
    c = a + b
    return c

code = foo.__code__.co_code
for i in range(0, len(code), 2):
    print("操作碼: {:<12} 操作數: {}".format(
        opmap[code[i]], code[i+1]
    ))
"""
操作碼: LOAD_FAST    操作數: 0
操作碼: LOAD_FAST    操作數: 1
操作碼: BINARY_ADD   操作數: 0
操作碼: STORE_FAST   操作數: 2
操作碼: LOAD_FAST    操作數: 2
操作碼: RETURN_VALUE 操作數: 0
"""

總之字節碼就是一段字節序列,轉成列表之後就是一堆數字。偶數位置表示指令本身,而每個指令後面都會跟一個指令參數,也就是奇數位置表示指令參數。

所以指令本質上只是一個整數:

虛擬機會根據不同的指令執行不同的邏輯,說白了 Python 虛擬機執行字節碼的邏輯就是把自己想象成一顆 CPU,並內置了一個巨型的 switch case 語句,其中每個指令都對應一個 case 分支。

然後遍歷整條字節碼,拿到每一個指令和指令參數。然後對指令進行判斷,不同的指令進入不同的 case 分支,執行不同的處理邏輯,直到字節碼全部執行完畢或者程序出錯。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/AygMub1DOe0PNxF_gxzHUw