深入理解 python 虛擬機器:令人拍案叫絕的位元組碼設計

一無是處的研究僧發表於2023-04-03

深入理解 python 虛擬機器:令人拍案叫絕的位元組碼設計

在本篇文章當中主要給大家介紹 cpython 虛擬機器對於位元組碼的設計以及在除錯過程當中一個比較重要的欄位 co_lnotab 的設計原理!

python 位元組碼設計

一條 python 位元組碼主要有兩部分組成,一部分是操作碼,一部分是這個操作碼的引數,在 cpython 當中只有部分位元組碼有引數,如果對應的位元組碼沒有引數,那麼 oparg 的值就等於 0 ,在 cpython 當中 opcode < 90 的指令是沒有引數的。

opcode 和 oparg 各佔一個位元組,cpython 虛擬機器使用小端方式儲存位元組碼。

我們使用下面的程式碼片段先了解一下位元組碼的設計:

import dis


def add(a, b):
    return a + b


if __name__ == '__main__':
    print(add.__code__.co_code)
    print("bytecode: ", list(bytearray(add.__code__.co_code)))
    dis.dis(add)

上面的程式碼在 python3.9 的輸出如下所示:

b'|\x00|\x01\x17\x00S\x00'
bytecode:  [124, 0, 124, 1, 23, 0, 83, 0]
  5           0 LOAD_FAST                0 (a)
              2 LOAD_FAST                1 (b)
              4 BINARY_ADD
              6 RETURN_VALUE

首先 需要了解的是 add.__code__.co_code 是函式 add 的位元組碼,是一個位元組序列,list(bytearray(add.__code__.co_code)) 是將和這個序列一個位元組一個位元組進行分開,並且將其變成 10 進位制形式。根據前面我們談到的每一條指令——位元組碼佔用 2 個位元組,因此上面的位元組碼有四條指令:

操作碼和對應的操作指令在文末有詳細的對應表。在上面的程式碼當中主要使用到了三個位元組碼指令分別是 124,23 和 83 ,他們對應的操作指令分別為 LOAD_FAST,BINARY_ADD,RETURN_VALUE。他們的含義如下:

  • LOAD_FAST:將 varnames[var_num] 壓入棧頂。
  • BINARY_ADD:從棧中彈出兩個物件並且將它們相加的結果壓入棧頂。
  • RETURN_VALUE:彈出棧頂的元素,將其作為函式的返回值。

首先我們需要知道的是 BINARY_ADD 和 RETURN_VALUE,這兩個操作指令是沒有引數的,因此在這兩個操作碼之後的引數都是 0 。

但是 LOAD_FAST 是有引數的,在上面我們已經知道 LOAD_FAST 是將 co-varnames[var_num] 壓入棧,var_num 就是指令 LOAD_FAST 的引數。在上面的程式碼當中一共有兩條 LOAD_FAST 指令,分別是將 a 和 b 壓入到棧中,他們在 varnames 當中的下標分別是 0 和 1,因此他們的運算元就是 0 和 1 。

位元組碼擴充套件引數

在上面我們談到的 python 位元組碼運算元和操作碼各佔一個位元組,但是如果 varnames 或者常量表的資料的個數大於 1 個位元組的表示範圍的話那麼改如何處理呢?

為了解決這個問題,cpython 為位元組碼設計的擴充套件引數,比如說我們要載入常量表當中的下標為 66113 的物件,那麼對應的位元組碼如下:

[144, 1, 144, 2, 100, 65]

其中 144 表示 EXTENDED_ARG,他本質上不是一個 python 虛擬機器需要執行的位元組碼,這個欄位設計出來主要是為了用與計算擴充套件引數的。

100 對應的操作指令是 LOAD_CONST ,其操作碼是 65,但是上面的指令並不會載入常量表當中下標為 65 物件,而是會載入下標為 66113 的物件,原因就是因為 EXTENDED_ARG 。

現在來模擬一下上面的分析過程:

  • 先讀取一條位元組碼指令,操作碼等於 144 ,說明是擴充套件引數,那麼此時的引數 arg 就等於 (1 x (1 << 8)) = 256 。
  • 讀取第二條位元組碼指令,操作碼等於 144 ,說明是擴充套件引數,因為前面 arg 已經存在切不等於 0 了,那麼此時 arg 的計算方式已經發生了改變,arg = arg << 8 + 2 << 8 ,也就是說原來的 arg 乘以 256 再加上新的運算元乘以 256 ,此時 arg = 66048 。
  • 讀取第三條位元組碼指令,操作碼等於 100,此時是 LOAD_CONST 這條指令,那麼此時的操作碼等於 arg += 65,因為操作碼不是 EXTENDED_ARG 因此運算元不需要在乘以 256 了。

上面的計算過程用程式程式碼表示如下,下面的程式碼當中 code 就是真正的位元組序列 HAVE_ARGUMENT = 90 。

def _unpack_opargs(code):
    extended_arg = 0
    for i in range(0, len(code), 2):
        op = code[i]
        if op >= HAVE_ARGUMENT:
            arg = code[i+1] | extended_arg
            extended_arg = (arg << 8) if op == EXTENDED_ARG else 0
        else:
            arg = None
        yield (i, op, arg)

我們可以使用程式碼來驗證我們前面的分析:

import dis


def num_to_byte(n):
    return n.to_bytes(1, "little")


def nums_to_bytes(data):
    ans = b"".join([num_to_byte(n) for n in data])
    return ans


if __name__ == '__main__':
    # extended_arg extended_num opcode oparg for python_version > 3.5
    bytecode = nums_to_bytes([144, 1, 144, 2, 100, 65])
    print(bytecode)
    dis.dis(bytecode)

上面的程式碼輸出結果如下所示:

b'\x90\x01\x90\x02dA'
          0 EXTENDED_ARG             1
          2 EXTENDED_ARG           258
          4 LOAD_CONST           66113 (66113)

根據上面程式的輸出結果可以看到我們的分析結果是正確的。

原始碼位元組碼對映表

在本小節主要分析一個 code object 物件當中的 co_lnotab 欄位,透過分析一個具體的欄位來學習這個欄位的設計。

import dis


def add(a, b):
    a += 1
    b += 2
    return a + b


if __name__ == '__main__':
    dis.dis(add.__code__)
    print(f"{list(bytearray(add.__code__.co_lnotab)) = }")
    print(f"{add.__code__.co_firstlineno = }")

首先 dis 的輸出第一列是位元組碼對應的原始碼的行號,第二列是位元組碼在位元組序列當中的位移。

上面的程式碼輸出結果如下所示:

  原始碼的行號  位元組碼的位移
  6           0 LOAD_FAST                0 (a)
              2 LOAD_CONST               1 (1)
              4 INPLACE_ADD
              6 STORE_FAST               0 (a)

  7           8 LOAD_FAST                1 (b)
             10 LOAD_CONST               2 (2)
             12 INPLACE_ADD
             14 STORE_FAST               1 (b)

  8          16 LOAD_FAST                0 (a)
             18 LOAD_FAST                1 (b)
             20 BINARY_ADD
             22 RETURN_VALUE
list(bytearray(add.__code__.co_lnotab)) = [0, 1, 8, 1, 8, 1]
add.__code__.co_firstlineno = 5

從上面程式碼的輸出結果可以看出位元組碼一共分成三段,每段表示一行程式碼的位元組碼。現在我們來分析一下 co_lnotab 這個欄位,這個欄位其實也是兩個位元組為一段的。比如上面的 [0, 1, 8, 1, 8, 1] 就可以分成三段 [0, 1], [8, 1], [8, 1] 。這其中的含義分別為:

  • 第一個數字表示距離上一行程式碼的位元組碼數目。
  • 第二個數字表示距離上一行有效程式碼的行數。

現在我們來模擬上面程式碼的位元組碼的位移和原始碼行數之間的關係:

  • [0, 1],說明這行程式碼離上一行程式碼的位元組位移是 0 ,因此我們可以看到使用 dis 輸出的位元組碼 LOAD_FAST ,前面的數字是 0,距離上一行程式碼的行數等於 1 ,程式碼的第一行的行號等於 5,因此 LOAD_FAST 對應的行號等於 5 + 1 = 6 。
  • [8, 1],說明這行程式碼距離上一行程式碼的位元組位移為 8 個位元組,因此第二塊的 LOAD_FAST 前面是 8 ,距離上一行程式碼的行數等於 1,因此這個位元組碼對應的原始碼的行號等於 6 + 1 = 7。
  • [8, 1],同理可以知道這塊位元組碼對應原始碼的行號是 8 。

現在有一個問題是當兩行程式碼之間相距的行數超過 一個位元組的表示範圍怎麼辦?在 python3.5 以後如果行數差距大於 127,那麼就使用 (0, 行數) 對下一個組合進行表示,(0, \(x_1\)), (0,$ x_2$) ... ,直到 \(x_1 + ... + x_n\) = 行數。

在後面的程式當中我們會使用 compile 這個 python 內嵌函式。當你使用Python編寫程式碼時,可以使用compile()函式將Python程式碼編譯成位元組程式碼物件。這個位元組碼物件可以被傳遞給Python的直譯器或虛擬機器,以執行程式碼。

compile()函式接受三個引數:

  • source: 要編譯的Python程式碼,可以是字串,位元組碼或AST物件。
  • filename: 程式碼來源的檔名(如果有),通常為字串。
  • mode: 編譯程式碼的模式。可以是 'exec'、'eval' 或 'single' 中的一個。'exec' 模式用於編譯多行程式碼,'eval' 用於編譯單個表示式,'single' 用於編譯單行程式碼。
import dis

code = """
x=1
y=2
""" \
+ "\n" * 500 + \
"""
z=x+y
"""

code = compile(code, '<string>', 'exec')
print(list(bytearray(code.co_lnotab)))
print(code.co_firstlineno)
dis.dis(code)

上面的程式碼輸出結果如下所示:

[0, 1, 4, 1, 4, 127, 0, 127, 0, 127, 0, 121]
1
  2           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (x)

  3           4 LOAD_CONST               1 (2)
              6 STORE_NAME               1 (y)

505           8 LOAD_NAME                0 (x)
             10 LOAD_NAME                1 (y)
             12 BINARY_ADD
             14 STORE_NAME               2 (z)
             16 LOAD_CONST               2 (None)
             18 RETURN_VALUE

根據我們前面的分析因為第三行和第二行之間的差距大於 127 ,因此後面的多個組合都是用於表示行數的。

505 = 3(前面已經有三行了) + (127 + 127 + 127 + 121)(這個是第二行和第三行之間的差距,這個值為 502,中間有 500 個換行但是因為字串相加的原因還增加了兩個換行,因此一共是 502 個換行)。

具體的演算法用程式碼表示如下所示,下面的引數就是我們傳遞給 dis 模組的 code,也就是一個 code object 物件。

def findlinestarts(code):
    """Find the offsets in a byte code which are start of lines in the source.

    Generate pairs (offset, lineno) as described in Python/compile.c.

    """
    byte_increments = code.co_lnotab[0::2]
    line_increments = code.co_lnotab[1::2]
    bytecode_len = len(code.co_code)

    lastlineno = None
    lineno = code.co_firstlineno
    addr = 0
    for byte_incr, line_incr in zip(byte_increments, line_increments):
        if byte_incr:
            if lineno != lastlineno:
                yield (addr, lineno)
                lastlineno = lineno
            addr += byte_incr
            if addr >= bytecode_len:
                # The rest of the lnotab byte offsets are past the end of
                # the bytecode, so the lines were optimized away.
                return
        if line_incr >= 0x80:
            # line_increments is an array of 8-bit signed integers
            line_incr -= 0x100
        lineno += line_incr
    if lineno != lastlineno:
        yield (addr, lineno)

python 位元組碼錶

操作 操作碼
POP_TOP 1
ROT_TWO 2
ROT_THREE 3
DUP_TOP 4
DUP_TOP_TWO 5
ROT_FOUR 6
NOP 9
UNARY_POSITIVE 10
UNARY_NEGATIVE 11
UNARY_NOT 12
UNARY_INVERT 15
BINARY_MATRIX_MULTIPLY 16
INPLACE_MATRIX_MULTIPLY 17
BINARY_POWER 19
BINARY_MULTIPLY 20
BINARY_MODULO 22
BINARY_ADD 23
BINARY_SUBTRACT 24
BINARY_SUBSCR 25
BINARY_FLOOR_DIVIDE 26
BINARY_TRUE_DIVIDE 27
INPLACE_FLOOR_DIVIDE 28
INPLACE_TRUE_DIVIDE 29
RERAISE 48
WITH_EXCEPT_START 49
GET_AITER 50
GET_ANEXT 51
BEFORE_ASYNC_WITH 52
END_ASYNC_FOR 54
INPLACE_ADD 55
INPLACE_SUBTRACT 56
INPLACE_MULTIPLY 57
INPLACE_MODULO 59
STORE_SUBSCR 60
DELETE_SUBSCR 61
BINARY_LSHIFT 62
BINARY_RSHIFT 63
BINARY_AND 64
BINARY_XOR 65
BINARY_OR 66
INPLACE_POWER 67
GET_ITER 68
GET_YIELD_FROM_ITER 69
PRINT_EXPR 70
LOAD_BUILD_CLASS 71
YIELD_FROM 72
GET_AWAITABLE 73
LOAD_ASSERTION_ERROR 74
INPLACE_LSHIFT 75
INPLACE_RSHIFT 76
INPLACE_AND 77
INPLACE_XOR 78
INPLACE_OR 79
LIST_TO_TUPLE 82
RETURN_VALUE 83
IMPORT_STAR 84
SETUP_ANNOTATIONS 85
YIELD_VALUE 86
POP_BLOCK 87
POP_EXCEPT 89
STORE_NAME 90
DELETE_NAME 91
UNPACK_SEQUENCE 92
FOR_ITER 93
UNPACK_EX 94
STORE_ATTR 95
DELETE_ATTR 96
STORE_GLOBAL 97
DELETE_GLOBAL 98
LOAD_CONST 100
LOAD_NAME 101
BUILD_TUPLE 102
BUILD_LIST 103
BUILD_SET 104
BUILD_MAP 105
LOAD_ATTR 106
COMPARE_OP 107
IMPORT_NAME 108
IMPORT_FROM 109
JUMP_FORWARD 110
JUMP_IF_FALSE_OR_POP 111
JUMP_IF_TRUE_OR_POP 112
JUMP_ABSOLUTE 113
POP_JUMP_IF_FALSE 114
POP_JUMP_IF_TRUE 115
LOAD_GLOBAL 116
IS_OP 117
CONTAINS_OP 118
JUMP_IF_NOT_EXC_MATCH 121
SETUP_FINALLY 122
LOAD_FAST 124
STORE_FAST 125
DELETE_FAST 126
RAISE_VARARGS 130
CALL_FUNCTION 131
MAKE_FUNCTION 132
BUILD_SLICE 133
LOAD_CLOSURE 135
LOAD_DEREF 136
STORE_DEREF 137
DELETE_DEREF 138
CALL_FUNCTION_KW 141
CALL_FUNCTION_EX 142
SETUP_WITH 143
LIST_APPEND 145
SET_ADD 146
MAP_ADD 147
LOAD_CLASSDEREF 148
EXTENDED_ARG 144
SETUP_ASYNC_WITH 154
FORMAT_VALUE 155
BUILD_CONST_KEY_MAP 156
BUILD_STRING 157
LOAD_METHOD 160
CALL_METHOD 161
LIST_EXTEND 162
SET_UPDATE 163
DICT_MERGE 164
DICT_UPDATE 165

總結

在本篇文章當中主要給大家介紹了 cpython 當中對於位元組碼和原始碼和位元組碼之間的對映關係的具體設計,這對於我們深入去理解 cpython 虛擬機器的設計非常有幫助!


本篇文章是深入理解 python 虛擬機器系列文章之一,文章地址:https://github.com/Chang-LeHung/dive-into-cpython

更多精彩內容合集可訪問專案:https://github.com/Chang-LeHung/CSCore

關注公眾號:一無是處的研究僧,瞭解更多計算機(Java、Python、計算機系統基礎、演算法與資料結構)知識。

相關文章