理解 Python 位元組碼

Sheng Gordon發表於2014-12-24

我最近在參與Python位元組碼相關的工作,想與大家分享一些這方面的經驗。更準確的說,我正在參與2.6到2.7版本的CPython直譯器位元組碼的工作。

Python是一門動態語言,在命令列工具下執行時,本質上執行了下面的步驟:

  • 當第一次執行到一段程式碼時,這段程式碼會被編譯(如,作為一個模組載入,或者直接執行)。根據作業系統的不同,這一步生成字尾名是pyc或者pyo的二進位制檔案。
  • 直譯器讀取二進位制檔案,並依次執行指令(opcodes)。

Python直譯器是基於棧的。要理解資料流向,我們需要知道每條指令的棧效應(如,操作碼和引數)。

探索Python二進位制檔案

得到一個二進位制檔案位元組碼的最簡單方式,是對CodeType結構進行解碼:

code_object包含了一個CodeType物件,它代表被載入檔案的整個模組。為了檢視這個模組的類定義、方法等的所有巢狀編碼物件(編碼物件,原文為code object),我們需要遞迴地檢查CodeType的常量池。就像下面的程式碼:

這個案例中,我們列印出一顆編碼物件樹,每個編碼物件是其父物件的子節點。對下面的程式碼:

我們得到的樹形結果是:

為了測試,我們可以通過compile指令,編譯一個包含Python原始碼的字串,從而能夠得到一個編碼物件:

要獲取更多關於編碼物件的資訊,我們可以查閱Python文件的co_* fields 部分。

初見位元組碼

一旦我們得到了編碼物件,我們就可以開始對它進行拆解了(在co_code欄位)。從位元組碼中解析出它的含義:
• 解釋操作碼的含義
• 提取任意引數

dis模組的disassemble函式展示了是如何做到的。對我們前面例子,它輸出的結果是:

我們得到了:

  • 行號(當它改變時)
  • 指令的序號
  • 當前指令的操作碼
  • 操作引數(oparg),操作碼用它來計算實際的引數。例如,對於LOAD_NAME操作碼,操作引數指向tuple co_names的索引。
  • 計算後的實際引數(圓括號內)

對於序號為6的指令,操作碼LOAD_CONST的操作引數,指向需要從tuple co_consts載入的物件。這裡,它指向A的型別定義。同樣的,我們能夠繼續並反編譯所有的程式碼物件,得到模組的全部位元組碼。

位元組碼的第一部分(序號0到16),與A的型別定義有關;其他的部分是我們例項化A,並列印它的程式碼。

有趣的位元組碼構造

所有的操作碼都是相當直接易懂的,但是由於下面的原因,在個別情況下會顯得奇怪:

  • 編譯器優化
  • 直譯器優化(因此會導致加入額外的操作碼)

順序變數賦值

首先,我們看看順序地對多個元素賦值,會發生什麼:

這4中語句,會產生差別相當大的位元組碼。

第一種情況最簡單,因為賦值操作的右值(RHS)只包含常量。這種情況下,CPython會建立一個(1, ‘a’) 的t uple,使用UNPACK_SEQUENCE操作碼,把兩個元素壓到棧上,並對變數a和b分別執行STORE_FAST操作:

而第二種情況,則在右值引入了一個變數,因此一般情況下,會呼叫一條取值指令(這裡簡單地呼叫了LOAD_GLOBAL指令)。但是,編譯器不需要在棧上為這些值建立一個新的tuple,也不需要呼叫UNPACK_SEQUENCE(序號18);呼叫ROT_TWO就足夠了,它用來交換棧頂的兩個元素(雖然交換指令19和22也可以達到目的)。

第三種情況變得很奇怪。把表示式放到棧上與前一種情況的處理方式相同,但是在交換棧頂的3個元素後,它再次交換了棧頂的2個元素:

最後一種情況是通用的處理方式,ROT_*操作看起來行不通了,編譯器建立了一個tuple,然後呼叫UNPACK_SEQUENCE把元素放到棧上:

函式呼叫構造

最後一組有趣的例子是關於函式呼叫構造,以及建立呼叫的4個操作碼。我猜測這些操作碼的數量是為了優化直譯器程式碼,因為它不像Java,有invokedynamicinvokeinterfaceinvokespecialinvokestatic或者invokevirtual之一。

Java中,invokeinterfaceinvokespecialinvokevirtual都是從靜態型別語言中借鑑來的(invokespecial只被用來呼叫建構函式和父類AFAIK)。Invokestatic是自我描述的(不需要把接收方放在棧上),在Python中沒有類似的概念(在直譯器層面上,而不是裝飾者)。簡短的說,Python呼叫都能被轉換成invokedynamic

在Python中,不同的CALL_*操作碼確實不存在,原因是型別系統,靜態方法,或者特殊訪問構造器的需求。它們都指向了Python中一個函式呼叫是如何確定的。從語法來看:

呼叫結構允許程式碼這些寫:

關鍵字引數允許通過形式引數的名稱來傳遞引數,而不僅僅是通過位置。*符號從一個可迭代的容器中取出所有元素,作為引數傳入(逐個元素,不是以tuple的形式),而**符號處理一個包含關鍵字和值的字典。

這個例子用到了呼叫構造的幾乎所有特性:
• 傳遞變數引數列表(_VAR):CALL_FUNCTION_VAR, CALL_FUNCTION_VAR_KW
• 傳遞基於字典的關鍵字(_KW):CALL_FUNCTION_KW, CALL_FUNCTION_VAR_KW

位元組碼是這樣的:

通常,CALL_FUNCTION呼叫將oparg解析為引數個數。但是,更多的資訊被編碼。第一個位元組(0xff掩碼)儲存引數的個數,第二個位元組((value >> 8) & 0xff)儲存傳遞的關鍵字引數個數。為了要計算需要從棧頂彈出的元素個數,我們需要這麼做:

CALL_EXTRA_ARG_OFFSET包含了一個偏移量,由呼叫操作碼確定(對CALL_FUNCTION_VAR_KW來說,是2)。這裡,在訪問函式名稱前,我們需要彈出6個元素。

對於其他的CALL_*呼叫,完全依賴於程式碼是否使用列表或者字典傳遞引數。只需要簡單的組合即可。

構造一個極小的CFG

為了理解程式碼是如何執行的,我們可以構造一個控制流程圖(control-flow graph,CFG),這個過程非常有趣。我們通過它,檢視在什麼條件下,哪些無條件判斷的操作碼(基本單元)序列會被執行。

即使位元組碼是一門真正的小型語言,構造一個執行穩定的CFG需要大量的細節工作,遠超出本部落格的範圍。因此如果需要一個真實的CFG實現,你可以看看這裡equip

在這裡,我們只關注沒有迴圈和異常的程式碼,因此控制流程只依賴與if語句。

只有少數幾個操作碼能夠執行地址跳轉(對沒有迴圈和異常的情況);它們是:

  • JUMP_FORWARD:在位元組碼中跳轉到一個相對位置。引數是跳過的位元組數。
  • JUMP_IF_FALSE_OR_POPJUMP_IF_TRUE_OR_POPJUMP_ABSOLUTEPOP_JUMP_IF_FALSE,以及POP_JUMP_IF_TRUE:引數都是位元組碼中的絕對地址。

為一個函式夠造CFG,意味著要建立基本的單元(不包含條件判斷的操作碼序列——除非有異常發生),並且把它們與條件和分支連在一起,構成一個圖。在我們的例子中,我們只有True、False和無條件分支。

讓我們來考慮下面的程式碼示例(在實際中絕對不要這樣用):

如前所述,我們得到factorial方法的程式碼物件:

反彙編結果是這樣的(<<<後是我的註釋):

在這個位元組碼中,我們有5條改變CFG結構的指令(新增約束條件,或者允許快速退出):

  • POP_JUMP_IF_FALSE:跳轉到絕對地址16和32;
  • RETURN_VALUE:從棧頂彈出一個元素,並返回。

提取基本單元很簡單,因為我們只關心那些改變控制流程的指令。在我們的例子中,我們沒有遇到強制跳轉指令,如JUMP_FORWARDJUMP_ABSOLUTE

提取這類結構的程式碼示例:

我們得到了下面的基本單元:

以及單元的當前結構:

我們得到了控制流程圖(除了入口和隱式的退出單元),之後我們可以把它轉化成視覺化的圖形:

視覺化的流程控制圖:

為什麼有這篇文章?

需要訪問Python位元組碼的情況確實很少見,但是我已經遇到過幾次這種情形了。我希望,這篇文章能夠幫助那些開始研究Python逆向工程的人們。

然而現在,我正在研究Python程式碼,尤其是它的位元組碼。由於目前在Python中尚不存在這樣的工具(並且檢測原始碼通常會留下非常低效的裝飾器檢測程式碼),這就是為什麼equip會出現的原因。

相關文章