python反編譯之位元組碼

公眾號python學習開發發表於2019-05-19

如果你曾經寫過或者用過 Python,你可能已經習慣了看到 Python 原始碼檔案;它們的名稱以.Py 結尾。你可能還見過另一種型別的檔案是 .pyc 結尾的,它們就是 Python “位元組碼”檔案。(在 Python3 的時候這個 .pyc 字尾的檔案不太好找了,它在一個名為__pycache__的子目錄下面。).pyc檔案可以防止Python每次執行時都重新解析原始碼,該檔案大大節省了時間。

Python是如何工作的

Python 通常被描述為一種解釋語言,在這種語言中,你的原始碼在程式執行時被翻譯成CPU指令,但這只是說對了部分。和許多解釋型語言一樣,Python 實際上將原始碼編譯為虛擬機器的一組指令,Python 直譯器就是該虛擬機器的實現。其中這種中間格式稱為“位元組碼”。

因此,Python留下的這些.pyc檔案,是為了讓執行的速快變得 “更快”,或者是針對你的原始碼的”優化“的版本;它們是 Python 虛擬機器上執行的位元組碼指令。

Python 虛擬機器內幕

CPython使用基於堆疊的虛擬機器。也就是說,它完全圍繞堆疊資料結構(你可以將專案“推”到結構的“頂部”,或者將專案“彈出”到“頂部”)。
CPython 使用三種型別的棧:

1.呼叫堆疊。這是執行中的Python程式的主要結構。對於每個當前活動的函式呼叫,它都有一個專案一“幀”,堆疊的底部是程式的入口點。每次函式呼叫都會將新的幀推到呼叫堆疊上,每次函式呼叫返回時,它的幀都會彈出
2.在每一幀中,都有一個評估堆疊(也稱為資料堆疊)。這個堆疊是執行 Python 函式的地方,執行Python程式碼主要包括將東西推到這個堆疊上,操縱它們,然後將它們彈出。
3.同樣在每一幀中,都有一個塊堆疊。Python使用它來跟蹤某些型別的控制結構:迴圈、try /except塊,以及 with 塊都會導致條目被推送到塊堆疊上,每當退出這些結構之一時,塊堆疊就會彈出。這有助於Python知道在任何給定時刻哪些塊是活動的,例如,continue或break語句可以影響正確的塊。

大多數 Python 位元組碼指令操作的是當前呼叫棧幀的計算棧,雖然,還有一些指令可以做其它的事情(比如跳轉到指定指令,或者操作塊棧)。

為了更好地理解,假設我們有一些呼叫函式的程式碼,比如這個:

my_function(my_variable,2)。

Python 將轉換為一系列位元組碼指令:
1.一個LOAD_NAME指令,用於查詢函式物件 my_function,並將其推送到計算棧的頂部
2.另一個 LOAD_NAME 指令去查詢變數 my_variable,並將其推送到計算棧的頂部
3.一個 LOAD_CONST 指令將一個整數 2 推送到計算棧的頂部
4.一個 CALL_FUNCTION 指令
CALL_FUNCTION 指令有2個引數,它表示 Python 需要在堆疊頂部彈出兩個位置引數; 然後函式將在它上面進行呼叫,並且它也同時被彈出(關鍵字引數的函式,使用指令-CALL_FUNCTION_KW-類似的操作,並配合使用第三條指令CALL_FUNCTION_EX,它適用於函式呼叫涉及到引數使用 * 或 ** 操作符的情況)
一旦 Python 具備了這些,它將在呼叫堆疊上分配一個新的幀,填充到函式呼叫的本地變數,然後執行該幀內的 my_function 的位元組碼。一旦執行完成,幀將從呼叫堆疊中彈出,在原始幀中,my_function 的返回值將被推入到計算棧的頂部。

我們知道了這個東西了,也知道位元組碼了檔案了,但是如何去使用位元組碼呢?ok不知道也沒關係,接下來的時間我們所有的話題都將圍繞位元組碼,在python有一個模組可以通過反編譯Python程式碼來生成位元組碼這個模組就是今天要說的--dis模組。

dis模組的使用

dis模組包括一些用於處理 Python 位元組碼的函式,可以將位元組碼“反彙編”為更便於人閱讀的形式。檢視直譯器執行的位元組碼還有助於優化程式碼。這個模組對於查詢多執行緒中的競態條件也很有用,因為可以用它評估程式碼中哪一點執行緒控制可能切換。參考原始碼Include/opcode.h,可以找到位元組碼的正式列表。詳細可以看官方文件。注意不同版本的python生成的位元組碼內容可能不一樣,這裡我用的Python 3.8.

訪問和理解位元組碼

輸入如下內容,然後執行它:

def hello()
    print("Hello, World!")
import dis
dis.dis(hello)

函式 dis.dis() 將反彙編一個函式、方法、類、模組、編譯過的 Python 程式碼物件、或者字串包含的原始碼,以及顯示出一個人類可讀的版本。dis 模組中另一個方便的功能是 distb()。你可以給它傳遞一個 Python 追溯物件,或者在發生預期外情況時呼叫它,然後它將在發生預期外情況時反彙編呼叫棧上最頂端的函式,並顯示它的位元組碼,以及插入一個指向到引發意外情況的指令的指標。

它也可以用於檢視 Python 為每個函式構建的編譯後的程式碼物件,因為執行一個函式將會用到這些程式碼物件的屬性。這裡有一個檢視 hello() 函式的示例:

>>> hello.__code__
<code object hello at 0x104e46930, file "<stdin>", line 1>
>>> hello.__code__.co_consts
(None, 'Hello, World!')
>>> hello.__code__.co_varnames
()
>>> hello.__code__.co_names
('print',)

程式碼物件在函式中可以以屬性 __code__ 來訪問,並且攜帶了一些重要的屬性:

co_consts 是存在於函式體內的任意實數的元組
co_varnames 是函式體內使用的包含任意本地變數名字的元組
co_names 是在函式體內引用的任意非本地名字的元組
許多位元組碼指令--尤其是那些推入到棧中的載入值,或者在變數和屬性中的儲存值--在這些元組中的索引作為它們引數。

因此,現在我們能夠理解 hello() 函式中所列出的位元組碼:

1、LOAD_GLOBAL 0:告訴 Python 通過 co_names (它是 print 函式)的索引 0 上的名字去查詢它指向的全域性物件,然後將它推入到計算棧
2、LOAD_CONST 1:帶入 co_consts 在索引 1 上的字面值,並將它推入(索引 0 上的字面值是 None,它表示在 co_consts 中,因為 Python 函式呼叫有一個隱式的返回值 None,如果沒有顯式的返回表示式,就返回這個隱式的值 )。
3、CALL_FUNCTION 1:告訴 Python 去呼叫一個函式;它需要從棧中彈出一個位置引數,然後,新的棧頂將被函式呼叫。
“原始的” 位元組碼--是非人類可讀格式的位元組--也可以在程式碼物件上作為 co_code 屬性可用。如果你有興趣嘗試手工反彙編一個函式時,你可以從它們的十進位制位元組值中,使用列出 dis.opname 的方式去檢視位元組碼指令的名字。

基本反彙編

函式dis()可以列印 Python 原始碼(模組、類、方法、函式或程式碼物件)的反彙編表示。可以通過從命令列執行 dis 來反彙編 dis_simple.py 之類的模組。

dis_simple.py
#!/usr/bin/env python3
# encoding: utf-8
my_dict = {'a': 1}

輸出按列組織,包含原始原始碼行號,程式碼物件中的指令地址,操作碼名稱以及傳遞給操作碼的任何引數。
對於簡單的程式碼我們可以通過命令列的形式執行下面的命令:

python3 -m dis dis_simple.py

輸出

  1           0 LOAD_CONST               0 ('a')
              2 LOAD_CONST               1 (1)
              4 BUILD_MAP                1
              6 STORE_NAME               0 (my_dict)
              8 LOAD_CONST               2 (None)
             10 RETURN_VALUE

在這裡原始碼轉換為4個不同的操作來建立和填充字典,然後將結果儲存到一個區域性變數。
首先解釋每一行各列引數的含義:
以第一條指令為例:

第一列 數字(1)表示對應原始碼的行數。
第二列(可選)指示當前執行的指令(例如,當位元組碼來自幀物件時)【這個例子沒有】
第三列 一個標籤,表示從之前的指令到此可能的JUMP 【這個例子沒有】
第四列 數字是位元組碼中對應於位元組索引的地址(這些是2的倍數,因為Python 3.6每條指令使用2個位元組,而在以前的版本中可能會有所不同)指令LOAD_CONST在0位置。
第五列 指令本身對應的人類可讀的名字這裡是"LOAD_CONST"
第六列 Python內部用於獲取某些常量或變數,管理堆疊,跳轉到特定指令等的指令的引數(如果有的話)。
第七列 計算後的實際引數。

然後讓我們看看這個過程:
由於 Python 直譯器是基於棧的,所以前幾步是用LOAD_CONST將常量按正確順序放入到棧中,然後使用 BUILD_MAP 彈出要增加到字典的新鍵和值。用 STORE_NAME 將所得到的dict物件繫結名為my_dict.

反彙編函式

需要注意的是上面的命令列反編譯的形式,不能自動的遞迴反編譯函式,所以我們要使用在檔案中匯入dis的模式進行反編譯,就像下面這樣。

#dis_function.py
def f(*args):
    nargs = len(args)
    print(nargs, args)

if __name__ == '__main__':
    import dis
    dis.dis(f)

執行命令

python3 dis_function.py

然後得到以下結果

  2           0 LOAD_GLOBAL              0 (len)
              2 LOAD_FAST                0 (args)
              4 CALL_FUNCTION            1
              6 STORE_FAST               1 (nargs)

  3           8 LOAD_GLOBAL              1 (print)
             10 LOAD_FAST                1 (nargs)
             12 LOAD_FAST                0 (args)
             14 CALL_FUNCTION            2
             16 POP_TOP
             18 LOAD_CONST               0 (None)
             20 RETURN_VALUE

要檢視函式的內部,必須把函式傳遞到dis().因為這裡列印的是函式內部的東西,所以沒有顯示函式的在外層的行編號,而是從2開始的。

下面解析下每一行指令的含義:
1、LOAD_GLOBAL 用來載入全域性變數,包括指定函式名,類名,模組名等全域性符號,這裡是len函式,LOAD_FAST 一般載入區域性變數的值,也就是讀取值,用於計算或者函式呼叫傳參等,這裡就是傳入引數args。
2、一般是先指定要呼叫的函式,然後壓引數,最後通過 CALL_FUNCTION 呼叫。
3、STORE_FAST 儲存值到區域性變數。也就是把結果賦值給 STORE_FAST。
4、下面的print因為2個引數所以LOAD_FAST了2次,POP_TOP刪除堆疊頂部(TOS)項。LOAD_CONST載入const變數,比如數值、字串等等,這裡因為是print所以值為None。
5、最後通過RETURN_VALUE來確定函式結尾。

要列印一個函式的總結資訊我們可以使用dis的show_code的方法,它包含使用的引數和名的相關資訊,show_code的引數就是這個函式物件,程式碼如下:

def f(*args):
    nargs = len(args)
    print(nargs, args)

if __name__ == '__main__':
    import dis
    dis.show_code(f)

執行之後,結果如下

Name:              f
Filename:          dis_function_showcode.py
Argument count:    0
Kw-only arguments: 0
Number of locals:  2
Stack size:        3
Flags:             OPTIMIZED, NEWLOCALS, VARARGS, NOFREE
Constants:
   0: None
Names:
   0: len
   1: print
Variable names:
   0: args
   1: nargs

可以看到返回的內容有函式,方法,引數等資訊。

反彙編類

上面我們知道了如何反彙編一個函式的內部,同樣的我們也可以用類似的方法反彙編一個類。
我們看一個例子:

import dis

class MyObject:
    """Example for dis."""

    CLASS_ATTRIBUTE = 'some value'

    def __str__(self):
        return 'MyObject({})'.format(self.name)

    def __init__(self, name):
        self.name = name

if __name__ == '__main__':
    dis.dis(MyObject)

執行之和得到如下結果

Disassembly of __init__:
 12           0 LOAD_FAST                1 (name)
              2 LOAD_FAST                0 (self)
              4 STORE_ATTR               0 (name)
              6 LOAD_CONST               0 (None)
              8 RETURN_VALUE

Disassembly of __str__:
  9           0 LOAD_CONST               1 ('MyObject({})')
              2 LOAD_METHOD              0 (format)
              4 LOAD_FAST                0 (self)
              6 LOAD_ATTR                1 (name)
              8 CALL_METHOD              1
             10 RETURN_VALUE

從整體內容來看,結果分為了兩部分Disassembly of __init__和Disassembly of __str__,Disassembly就是反彙編的意思。
首先分析__init__部分:
1、然後需要注意的一點是,方法是按照字母的順序列出的,所以在部分,先看到name再看到self,但是他們都是 LOAD_FAST。
2、STORE_ATTR實現self.name = name。
3、然後LOAD_CONST一個None和RETURN_VALUE標誌著函式結束。
接下來分析__str__部分:
1、LOAD_CONST將'MyObject({})'載入到棧
2、然後通過 LOAD_METHOD 呼叫字串format方法。這個方法是Python3.7新加入的。
3、LOAD_FAST 也就是到了self了。
4、LOAD_ATTR 一般是呼叫某個物件的方法時。這裡就是self.name的.name操作
5、CALL_METHOD 是 python3.7 新增加的內容,這裡是執行方法。
6、RETURN_VALUE表示函式的結束。
上面字串的拼接我們用了format,之前我一直推薦用f-string,下面就讓我們通過位元組碼來分析,為什麼f-string比format要高快。

程式碼其他程式碼不變,把return改成以下內容:

return f'MyObject({self.name})'

再次執行,下面我們只看__str__函式的部分。
Disassembly of __str__: 9 0 LOAD_CONST 1 ('MyObject(') 2 LOAD_FAST 0 (self) 4 LOAD_ATTR 0 (name) 6 FORMAT_VALUE 0 8 LOAD_CONST 2 (')') 10 BUILD_STRING 3 12 RETURN_VALUE對比發現我們這裡沒有了呼叫方法的操作LOAD_METHOD,取而代之使用了用於實現fstring的FORMAT_VALUE指令。之後通過BUILD_STRING連線堆疊中的計數字符串並將結果字串推入堆疊.為什麼format慢呢, python中的函式呼叫具有相當大的開銷。 當使用str.format()時,CALL_METHOD 中花費的額外時間是導致str.format()比fstring慢得多。

使用反彙編除錯

除錯一個異常時,有時要檢視哪個位元組碼帶來了問題。這個時候就很有用了,要對一個錯誤周圍的程式碼反彙編,有多種方法。第一種策略是在互動直譯器中使用dis()報告最後一個異常。
如果沒有向dis()傳入任何引數,那麼它會查詢一個異常,並顯示導致這個異常的棧頂元素的反彙編效果。

命令列上使用

開啟我的命令列執行如下操作:

 chennan@chennandeMacBook-Pro-2  ~  python3
Python 3.8.0a3 (v3.8.0a3:9a448855b5, Mar 25 2019, 17:05:20)
[Clang 6.0 (clang-600.0.57)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dis
>>> j = 4
>>> i = i + 4
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'i' is not defined
>>> dis.dis()
  1 -->       0 LOAD_NAME                0 (i)
              2 LOAD_CONST               0 (4)
              4 BINARY_ADD
              6 STORE_NAME               0 (i)
              8 LOAD_CONST               1 (None)
             10 RETURN_VALUE
>>>

行號後面的-->就是導致錯誤的操作碼,一個LOAD_NAME指令,由於沒有定義變數i,所以無法將與這個名關聯的值載入到棧中。

程式碼中使用distb

程式還可以列印一個活動的traceback的有關資訊,將它傳遞到distb()方法。

下面的程式中有個DiviedByZero異常;但是這個公式有兩個除法,所以不清楚是哪一部分出錯,此時我們就可以使用下面的方法:
dis_traceback.py

i = 1
j = 0
k = 3

try:
    result = k * (i / j) + (i / k)
except Exception:
    import dis
    import sys
    exc_type, exc_value, exc_tb = sys.exc_info()
    dis.distb(exc_tb)

執行之後輸出

  1           0 LOAD_CONST               0 (1)
              2 STORE_NAME               0 (i)

  2           4 LOAD_CONST               1 (0)
              6 STORE_NAME               1 (j)

  3           8 LOAD_CONST               2 (3)
             10 STORE_NAME               2 (k)

  5          12 SETUP_FINALLY           24 (to 38)

  6          14 LOAD_NAME                2 (k)
             16 LOAD_NAME                0 (i)
             18 LOAD_NAME                1 (j)
    -->      20 BINARY_TRUE_DIVIDE
             22 BINARY_MULTIPLY
             24 LOAD_NAME                0 (i)
             26 LOAD_NAME                2 (k)
             28 BINARY_TRUE_DIVIDE
...
        >>   96 END_FINALLY
        >>   98 LOAD_CONST               3 (None)
            100 RETURN_VALUE

結果反映的位元組碼很長我們不用全看了,看最開始出現--> 就可以知道錯誤的位置了。
其中SETUP_FINALLY 位元組碼的含義是將try塊從try-except子句推入塊堆疊。
這裡可以看出將LOAD_NAME 將j壓入棧之後就報錯了。所以可以推斷出在(i/j)就出錯了。

今天的內容就到這吧,更多精彩內容請關注公眾號:python學習開發。

參考資料

https://docs.python.org/zh-cn/3.7/library/dis.html#opcode-STORE_FAST
https://opensource.com/article/18/4/introduction-python-bytecode
https://hackernoon.com/a-closer-look-at-how-python-f-strings-work-f197736b3bdb

相關文章