如果你曾經寫過或者用過 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