深入理解 python 虛擬機器:pyc 檔案結構

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

深入理解 python 虛擬機器:pyc 檔案結構

在本篇文章當中主要給大家介紹一下 .py 檔案在被編譯之後對應的 pyc 檔案結構,pyc 檔案當中的一個核心內容就是 python 位元組碼。

pyc 檔案

pyc 檔案是 Python 在解釋執行原始碼時生成的一種位元組碼檔案,它包含了原始碼的編譯結果和相關的後設資料資訊,以便於 Python 可以更快地載入和執行程式碼。

Python 是一種解釋型語言,它不像編譯型語言那樣將原始碼直接編譯成機器碼執行。Python 的直譯器會在執行程式碼之前先將原始碼編譯成位元組碼,然後將位元組碼解釋執行。.pyc 檔案就是這個過程中生成的位元組碼檔案。

當 Python 直譯器首次執行一個 .py 檔案時,它會在同一目錄下生成一個對應的 .pyc 檔案,以便於下次載入該檔案時可以更快地執行。如果原始檔在修改之後被重新載入,直譯器會重新生成 .pyc 檔案以更新快取的位元組碼。

生成 pyc 檔案

正常的 python 檔案需要透過編譯器變成位元組碼,然後將位元組碼交給 python 虛擬機器,然後 python 虛擬機器會執行位元組碼。整體流程如下所示:

我們可以直接使用 compile all 模組生成對應檔案的 pyc 檔案。

➜  pvm ls
demo.py  hello.py
➜  pvm python -m compileall .
Listing '.'...
Listing './.idea'...
Listing './.idea/inspectionProfiles'...
Compiling './demo.py'...
Compiling './hello.py'...
➜  pvm ls
__pycache__ demo.py     hello.py
➜  pvm ls __pycache__ 
demo.cpython-310.pyc  hello.cpython-310.pyc

python -m compileall . 命令將遞迴掃描當前目錄下面的 py 檔案,並且生成對應檔案的 pyc 檔案。

pyc 檔案佈局

第一部分魔數由兩部分組成:

第一部分 魔術是由一個 2 位元組的整數和另外兩個字元回車換行組成的, "\r\n" 也佔用兩個位元組,一共是四個位元組。這個兩個位元組的整數在不同的 python 版本還不一樣,比如說在 python3.5 當中這個值為 3351 等值,在 python3.9 當中這個值為 3420,3421,3422,3423,3424等值(在 python 3.9 的小版本)。

第二部分 Bit Field 這個欄位的主要作用是為了將來能夠實現復現編譯結果,但是在 python3.9a2 時,這個欄位的值還全部是 0 。詳細內容可以參考 PEP552-Deterministic pycs 。這個欄位在 python2 和 python3 早期版本並沒有(python3.5 還沒有),在 python3 的後期版本這個欄位才出現的。

第三部分 就是整個 py 原始檔的大小了。

第四部分 也是整個 pyc 檔案當中最重要的一個部分,最後一個部分就是一個 CodeObject 物件序列化之後的資料,我們稍後再來仔細分析一下這個物件相關的資料。

我們現在來具體分析一個 pyc 檔案,對應的 python 程式碼為:

def f():
    x = 1
    return 2

pyc 檔案的十六進位制形式如下所示:

➜  __pycache__ hexdump -C hello.cpython-310.pyc
00000000  6f 0d 0d 0a 00 00 00 00  b9 48 21 64 20 00 00 00  |o........H!d ...|
00000010  e3 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  00 02 00 00 00 40 00 00  00 73 0c 00 00 00 64 00  |.....@...s....d.|
00000030  64 01 84 00 5a 00 64 02  53 00 29 03 63 00 00 00  |d...Z.d.S.).c...|
00000040  00 00 00 00 00 00 00 00  00 01 00 00 00 01 00 00  |................|
00000050  00 43 00 00 00 73 08 00  00 00 64 01 7d 00 64 02  |.C...s....d.}.d.|
00000060  53 00 29 03 4e e9 01 00  00 00 e9 02 00 00 00 a9  |S.).N...........|
00000070  00 29 01 da 01 78 72 03  00 00 00 72 03 00 00 00  |.)...xr....r....|
00000080  fa 0a 2e 2f 68 65 6c 6c  6f 2e 70 79 da 01 66 01  |.../hello.py..f.|
00000090  00 00 00 73 04 00 00 00  04 01 04 01 72 06 00 00  |...s........r...|
000000a0  00 4e 29 01 72 06 00 00  00 72 03 00 00 00 72 03  |.N).r....r....r.|
000000b0  00 00 00 72 03 00 00 00  72 05 00 00 00 da 08 3c  |...r....r......<|
000000c0  6d 6f 64 75 6c 65 3e 01  00 00 00 73 02 00 00 00  |module>....s....|
000000d0  0c 00                                             |..|
000000d2

因為資料使用小端表示方式,因此對於上面的資料來說:

  • 第一部分魔數為:0xa0d0d6f 。
  • 第二部分 Bit Field 為:0x0 。
  • 第三部分最後一次修改日期為:0x642148b9 。
  • 第四部分檔案大小為:0x20 位元組,也就是說 hello.py 這個檔案的大小是 32 位元組。

下面是一個小的程式碼片段用於讀取 pyc 檔案的頭部元資訊:

import struct
import time
import binascii


fname = "./__pycache__/hello.cpython-310.pyc"
f = open(fname, "rb")
magic = struct.unpack('<l', f.read(4))[0]
bit_filed = f.read(4)
print(f"bit field = {binascii.hexlify(bit_filed)}")
moddate = f.read(4)
filesz = f.read(4)
modtime = time.asctime(time.localtime(struct.unpack('<l', moddate)[0]))
filesz = struct.unpack('<L', filesz)
print("magic %s" % (hex(magic)))
print("moddate (%s)" % (modtime))
print("File Size %d" % filesz)
f.close()

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

bit field = b'00000000'
magic 0xa0d0d6f
moddate (Mon Mar 27 15:41:45 2023)
File Size 32

有關 pyc 檔案的詳細操作可以檢視 python 標準庫 importlib/_bootstrap_external.py 檔案原始碼。

CodeObject

在 CPython 中,CodeObject 是一個物件,它包含了 Python 程式碼的位元組碼、常量、變數、位置引數、關鍵字引數等資訊,以及一些用於執行程式碼的後設資料,如檔名、程式碼行號等。

在 CPython 中,當我們執行一個 Python 模組或函式時,直譯器會先將其程式碼編譯為 CodeObject,然後再執行。在編譯過程中,直譯器會將 Python 程式碼轉換為位元組碼,並將其儲存在 CodeObject 物件中。此後,每當我們呼叫該模組或函式時,直譯器都會使用 CodeObject 中的位元組碼來執行程式碼。

CodeObject 物件是不可變的,一旦建立就不能被修改。這是因為 Python 程式碼的位元組碼是不可變的,而 CodeObject 物件包含了這些位元組碼,所以也是不可變的。

在本篇文章當中主要介紹 code object 當中主要的內容,以及簡單介紹他們的作用,在後續的文章當中會仔細分析 code object 對應的原始碼以及對應的欄位的詳細作用。

現在舉一個例子來分析一下 pycdemo.py 的 pyc 檔案,pycdemo.py 的源程式如下所示:


if __name__ == '__main__':
    a = 100
    print(a)

下面的程式碼是一個用於載入 pycdemo01.cpython-39.pyc 檔案(也就是 hello.py 對應的 pyc 檔案)的程式碼,使用 marshal 讀取 pyc 檔案裡面的 code object 。

import marshal
import dis
import struct
import time
import types
import binascii


def print_metadata(fp):
    magic = struct.unpack('<l', fp.read(4))[0]
    print(f"magic number = {hex(magic)}")
    bit_field = struct.unpack('<l', fp.read(4))[0]
    print(f"bit filed = {bit_field}")
    t = struct.unpack('<l', fp.read(4))[0]
    print(f"time = {time.asctime(time.localtime(t))}")
    file_size = struct.unpack('<l', fp.read(4))[0]
    print(f"file size = {file_size}")


def show_code(code, indent=''):
    print ("%scode" % indent)
    indent += '   '
    print ("%sargcount %d" % (indent, code.co_argcount))
    print ("%snlocals %d" % (indent, code.co_nlocals))
    print ("%sstacksize %d" % (indent, code.co_stacksize))
    print ("%sflags %04x" % (indent, code.co_flags))
    show_hex("code", code.co_code, indent=indent)
    dis.disassemble(code)
    print ("%sconsts" % indent)
    for const in code.co_consts:
        if type(const) == types.CodeType:
            show_code(const, indent+'   ')
        else:
            print("   %s%r" % (indent, const))
    print("%snames %r" % (indent, code.co_names))
    print("%svarnames %r" % (indent, code.co_varnames))
    print("%sfreevars %r" % (indent, code.co_freevars))
    print("%scellvars %r" % (indent, code.co_cellvars))
    print("%sfilename %r" % (indent, code.co_filename))
    print("%sname %r" % (indent, code.co_name))
    print("%sfirstlineno %d" % (indent, code.co_firstlineno))
    show_hex("lnotab", code.co_lnotab, indent=indent)


def show_hex(label, h, indent):
    h = binascii.hexlify(h)
    if len(h) < 60:
        print("%s%s %s" % (indent, label, h))
    else:
        print("%s%s" % (indent, label))
        for i in range(0, len(h), 60):
            print("%s   %s" % (indent, h[i:i+60]))


if __name__ == '__main__':
    filename = "./__pycache__/pycdemo01.cpython-39.pyc"
    with open(filename, "rb") as fp:
        print_metadata(fp)
        code_object = marshal.load(fp)
        show_code(code_object)

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

magic number = 0xa0d0d61
bit filed = 0
time = Tue Mar 28 02:40:20 2023
file size = 54
code
   argcount 0
   nlocals 0
   stacksize 2
   flags 0040
   code b'650064006b02721464015a01650265018301010064025300'
  3           0 LOAD_NAME                0 (__name__)
              2 LOAD_CONST               0 ('__main__')
              4 COMPARE_OP               2 (==)
              6 POP_JUMP_IF_FALSE       20

  4           8 LOAD_CONST               1 (100)
             10 STORE_NAME               1 (a)

  5          12 LOAD_NAME                2 (print)
             14 LOAD_NAME                1 (a)
             16 CALL_FUNCTION            1
             18 POP_TOP
        >>   20 LOAD_CONST               2 (None)
             22 RETURN_VALUE
   consts
      '__main__'
      100
      None
   names ('__name__', 'a', 'print')
   varnames ()
   freevars ()
   cellvars ()
   filename './pycdemo01.py'
   name '<module>'
   firstlineno 3
   lnotab b'08010401'

下面是 code object 當中各個欄位的作用:

  • 首先需要了解一下程式碼塊這個概念,所謂程式碼塊就是一個小的 python 程式碼,被當做一個小的單元整體執行。在 python 當中常見的程式碼塊塊有:函式體、類的定義、一個模組。

  • argcount,這個表示一個程式碼塊的引數個數,這個引數只對函式體程式碼塊有用,因為函式可能會有引數,比如上面的 pycdemo.py 是一個模組而不是一個函式,因此這個引數對應的值為 0 。

  • co_code,這個物件的具體內容就是一個位元組序列,儲存真實的 python 位元組碼,主要是用於 python 虛擬機器執行的,在本篇文章當中暫時不詳細分析。

  • co_consts,這個欄位是一個列表型別的欄位,主要是包含一些字串常量和數值常量,比如上面的 "__main__" 和 100 。

  • co_filename,這個欄位的含義就是對應的原始檔的檔名。

  • co_firstlineno,這個欄位的含義為在 python 原始檔當中第一行程式碼出現的行數,這個欄位在進行除錯的時候非常重要。

  • co_flags,這個欄位的主要含義就是標識這個 code object 的型別。0x0080 表示這個 block 是一個協程,0x0010 表示這個 code object 是巢狀的等等。

  • co_lnotab,這個欄位的含義主要是用於計算每個位元組碼指令對應的原始碼行數。

  • co_varnames,這個欄位的主要含義是表示在一個 code object 本地定義的一個名字。

  • co_names,和 co_varnames 相反,表示非本地定義但是在 code object 當中使用的名字。

  • co_nlocals,這個欄位表示在一個 code object 當中本地使用的變數個數。

  • co_stackszie,因為 python 虛擬機器是一個棧式計算機,這個引數的值表示這個棧需要的最大的值。

  • co_cellvars,co_freevars,這兩個欄位主要和巢狀函式和函式閉包有關,我們在後續的文章當中將詳細解釋這個欄位。

總結

在本篇文章當中主要給大家介紹了 python 檔案被編譯之後的結果檔案 .pyc 檔案結構,在 pyc 檔案當中一個最重要的結構就是 code object 物件,在本篇文章當中主要是簡單介紹了 code object 各個欄位的作用。在後續的文章當中將會舉詳細的例子進行說明,正確理解這些這些欄位的含義,對於我們理解 python 虛擬機器大有裨益。


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

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

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

相關文章