做一個位元組碼追蹤器,從內部理解 Python 的執行過程

jasper發表於2015-06-22

最近我在研究 Python 的執行模型。我對 Python 內部的東西挺好奇,比如:類似 YIELDVALUE 和 YIELDFROM 此類操作碼的實現;列表表示式、生成器表示式以及一些有趣的Python 特性是怎麼編譯的;異常觸發之時,位元組碼層面發生了什麼。

閱讀 CPython 程式碼是相當有益的,但是我覺得要完全理解位元組碼的執行和堆疊的變化,光讀原始碼是遠遠不夠的。GDB 是個好選擇,但我很懶,只想寫一些高階的介面和 Python 程式碼。

因此我想做一個位元組碼級別的追蹤 API,就像 sys.settrace 所提供的那樣,但顆粒度更出色。這種練習完美地鍛鍊了我將 C 轉化為 Python 的能力。我們所需的有以下幾點:

  • 一個新的CPython直譯器操作碼
  • 一種將操作碼注入Python位元組碼的方法
  • 一些Python程式碼,用於在Python的角度處理操作碼

注:在這篇文章中,Python版本是3.5

一種新的CPython操作碼

我們的新操作碼:DEBUG_OP

這個新的操作碼DEBUG_OP是我第一次嘗試用C程式碼來實現CPython。我會盡量使之保持簡潔。

我想要達到的目的是,無論我的操作碼何時執行,都有一種方式呼叫一些Python程式碼,與此同時,我們也想能夠追蹤一些與執行上下文有關的資料。我們的操作碼會把這些資訊當作引數傳遞給我們的回撥函式。我能辨識出的有用資訊如下:

  • 堆疊的內容
  • 執行DEBUG_OP的幀物件資訊

因此我們 DEBUG_OP 所需做的所有事情是:

  • 找到回撥函式
  • 建立堆疊內容的列表
  • 呼叫回撥函式,並將堆疊列表和當前幀作為引數傳給它

聽起來挺簡單啊,讓我們開始吧!

宣告:以下的解釋和程式碼都是經過大量段錯誤得到的。首先要做的事情,就是給我們的操作碼命名並賦值,因此我們需要在Include/opcode.h中新增

這簡單的部分是完成了,現在我們必須真正去編寫我們的操作碼。

實現 DEBUG_OP

在考慮實現DEBUG_OP之前,我們需要問我們自己的第一個問題是:“我的介面應該是什麼樣的?”

擁有一個可以呼叫其他程式碼的新操作碼是很酷的,但是它實際上會呼叫哪些程式碼呢?這個操作碼怎麼找到回撥函式呢?我選擇了一種看起來最簡單的解決方案,在幀的全域性區域寫死函式名。

現在問題就變成了:“我怎麼從一個字典中找到一個不變的C字串?”

為了回答這個問題,我們可以尋找一些用在Python的main迴圈中的用到的和上下文管理相關的識別符號**enter**和**exit**。

我們可以看到識別符號被用在 SETUP_WITH 操作碼中。

現在,看一下_Py_IDENTIFIER 的巨集定義:

很好,至少註釋部分已經說明得很清楚了。通過一番查詢,我們發現了可以用來從字典找固定字串的函式 _PyDict_GetItemId,所以我們操作碼的查詢部分的程式碼就是這樣的:

為了方便理解,我來解釋一下這段程式碼:

  • f 是當前的幀,f->f_globals 是它的全域性區域
  • 如果我們沒有找到 op_target,我們需要檢查這個異常是不是 KeyError
  • goto error; 是一種在 main-loop 中丟擲異常的方法
  • PyErr_Clear() 抑制了當前異常,DISPATCH() 觸發了下一個操作碼的執行下一步是收集我們想要的堆疊資訊。

    最後一步是呼叫回撥函式,我們需要使用 call_function,通過研究操作碼 CALL_FUNCTION 來學習怎麼使用 call_function。

有了這些資訊,我們就能夠精心地完成 DEBUG_OP:

因為我在編寫 CPython 實現 C 程式碼方面沒有太多的經驗,,所以我可能漏掉了一些(我期待你的反饋)

編譯通過!完成了!

看起來一切順利,但是當我們嘗試去執行 DEBUG_OP 時卻失敗了。自 2008 年以來,Python 使用事先完成的 GOTO(你可以從這裡讀取更多資訊),因此我們需要更新下 goto jump table,我們僅需要在 Python/opcode_targets.h 中做如下修改:

搞定了,現在我們擁有一個全新的可以工作的操作碼,唯一的問題是,我們的操作碼永遠不會被呼叫,因為不存在於編譯好的位元組碼中。現在我們需要在一些函式的位元組碼中注入 DEBUG_OP。

將操作碼 DEBUG_OP 注入到 Python 位元組碼中

下面是一些把新的操作碼插入 Python 位元組碼中的方法。

  • 我們可以像 Quarkslab 那樣用 peephole optimizer
  • 我們可以在生成位元組碼時做些改變
  • 我們可以僅僅修改一些執行時的函式的位元組碼(這其實就是我們將要做的)

為了編寫出新的操作碼,有了上面的C程式碼就足夠了,讓我們回到起點,理解奇怪而神奇的Python!

So, what we are going to do is:

因此,我們將要做下面這些事兒:

  • 得到我們想要追蹤的code object
  • 重寫位元組碼來注入DEBUG_OP
  • 將新的code object替換回去

關於 code object 的提示

如果你聽說過 code object,在我第一篇文章裡有一點介紹。在網上也有一些相關文件,可以直接用 Ctrl+F 查詢“code objects”

在這篇文章中,還有一件需要注意的事情是,code objects不能改變:

但是不用擔心,我們會找到方法繞過這個問題。

所用工具

為了修改這些位元組碼,我們將需要一些工具:

  • dist模組用來反編譯和分析位元組碼
  • dis.Bytecode是Python3.4的新特性,對於反編譯和分析位元組碼特別有用
  • 簡單修改code object的工具dis.

dis.Bytecode反編譯一個code object,可以給我們一些關於操作碼,引數和上下文有用的資訊。

為了能夠修改code objects,我建立了一個class,用來複制code object,並允許根據我們的需要修改相應的值,然後生成新的code object。

很容易使用,並解決了上面說的 code object 不可變的問題

測試新的操作碼

現在我們有了注入DEBUG_OP的基本工具,我們來驗證實現是否可用。

將操作碼加入到一個最簡單的函式中:

好像成功了!有一行程式碼需要解釋一下:new_nop_code.co_stacksize += 3:

  • Co_stacksize表示code object所需的堆疊大小
  • DEBUG_OP增加了3個值到堆疊中,因此我們需要增加預留空間

現在我們可以將我們的操作碼注入到每一個Python函式中了!

重寫位元組碼

就像我們在上一個例子中看到的,重寫Python位元組碼聽起來很簡單!為了在每一操作碼之間注入DEBUG _OP,所有我們必須獲取每一個操作碼的偏移量(把我們操作碼注入到引數上是有問題的),然後將操作碼注入到這些偏移量中。偏移量很容易獲取,使用dis.Bytecode就行。

如下所示:

基於上面的例子,有人可能會認為我們的insert_op_debug會在指定的偏移量增加一個”x00″,這是個坑啊!在第一個 DEBUG_OP 注入的例子中,被注入的函式是沒有任何分支的,為了使 insert_op_debug 有完美的功能,我們需要考慮到存在分支操作碼的情況。

Python 的分支一共有兩種:

  • 絕對分支:看起來是這樣的 Instruction_Pointer = argument(instruction)
  • 相對分支:看起來是這樣的 Instruction_Pointer += argument(instruction)
  • 相對分支總是向前的

我們希望這些分支在插入操作碼之後仍然能夠正常工作,為此我們需要修改一些指令引數。以下是我用的邏輯:

對於每一個在插入偏移量之前的相對分支而言:

  • 如果目標地址是嚴格大於我們的插入偏移量,將指令引數增加 1
  • 如果相等,則不需要增加 1 就能夠在跳轉操作和目標地址之間執行DEBUG_OP
  • 如果小於,插入DEBUG_OP並不會影響到跳轉操作和目標地址之間的距離

對於 code object 中的每一個絕對分支而言

  • 如果目標地址是嚴格大於我們的插入偏移量的話,將指令引數增加 1
  • 如果相等,那麼不需要任何修改,理由和相對分支部分是一樣的
  • 如果小於,插入DEBUG_OP並不會影響到跳轉操作和目標地址之間的距離

下面是實現:

我們可以看到結果如下:

太棒啦!現在我們知道了如何獲取堆疊資訊和 Python 中每一個操作對應的幀資訊。上面所展示的結果目前而言並不是很實用。在最後一部分中讓我們對注入做進一步的封裝。

增加 Python 封裝

正如您所看到的,所有的底層介面工作正常。我們最後要做的一件事是讓 op_target 更加有用(這部分相對而言比較空泛一些,畢竟在我看來這不是整個專案中最有趣的部分)。

首先我們來看一下幀的引數所能提供的資訊,如果我們看到幀中儲存的資訊,我們將會看到下面這些:

  • f_code當前幀將執行的 code object
  • f_lasti當前的操作(code object 中的位元組碼字串的索引)

經過我們的處理我們可以得知DEBUG_OP之後要被執行的操作碼,這對我們聚合資料並展示是相當有用的。

我們可以新建一個用於追蹤函式內部機制的class:

  • 改變函式自身的co_code
  • 設定回撥函式作為op_debug的目標函式

一旦我們知道下一個操作,我們就可以分析它並修改它的引數。例如,我們可以增加一個auto-follow-called-functions的特性。

現在我們必須要做的是,用方法callback和do_report實現一個的子類,其中callback 方法將在每一個操作之後被呼叫。doreport 方法將我們收集到的資訊列印出來。

這是一個偽函式追蹤器實現:

這裡有一些實現的例子和使用方法。格式有些不方便觀看,畢竟我並不擅長寫這種對使用者友好的報告。

例1:自動追蹤堆疊資訊和已經執行的指令

例2:上下文管理

最後是列表表示式的工作示例

例3:偽追蹤器的輸出

例4:輸出收集的堆疊資訊

總結

瞭解 Python 底層的好方法,直譯器的 main 迴圈,Python 實現的 C 程式碼程式設計、Python 位元組碼,這個專案是一個好方法。同時,也讓我們看到了 Python 的一些有趣的建構函式(諸如生成器、上下文管理器和列表表示式)的位元組碼行為。

這裡有完整程式碼

另外,我們還可以修改所追蹤的函式的堆疊。雖然不確定這個是否有用,但一定會充滿樂趣。

相關文章