一文帶你深度解密Python的字節(jié)碼
楔子
當我們想要執(zhí)行一個 py 文件的時候,只需要在命令行中輸入 python xxx.py 即可,但你有沒有想過這背后的流程是怎樣的呢?
首先 py 文件不是一上來就直接執(zhí)行的,而是會先有一個編譯的過程,整個步驟如下:
這里我們看到了 Python 編譯器、Python 虛擬機,而且我們平常還會說 Python 解釋器,那么三者之間有什么區(qū)別呢?
Python 編譯器負責將 Python 源代碼編譯成 PyCodeObject 對象,然后交給 Python 虛擬機來執(zhí)行。
那么 Python 編譯器和 Python 虛擬機都在什么地方呢?如果打開 Python 的安裝目錄,會發(fā)現(xiàn)有一個 python.exe,點擊的時候會通過它來啟動一個終端。
但問題是這個文件大小還不到 100K,不可能容納一個編譯器加一個虛擬機,所以下面還有一個 python38.dll。沒錯,編譯器、虛擬機都藏身于 python38.dll 當中。
因此 Python 雖然是解釋型語言,但也有編譯的過程。源代碼會被編譯器編譯成 PyCodeObject 對象,然后再交給虛擬機來執(zhí)行。而之所以要存在編譯,是為了讓虛擬機能更快速地執(zhí)行,比如在編譯階段常量都會提前分配好,而且還可以盡早檢測出語法上的錯誤。
pyc 文件是什么
在 Python 開發(fā)時,我們肯定都見過這個 pyc 文件,它一般位于 __pycache__ 目錄中,那么 pyc 文件和 PyCodeObject 之間有什么關系呢?
首先我們都知道字節(jié)碼,虛擬機的執(zhí)行實際上就是對字節(jié)碼不斷解析的一個過程。然而除了字節(jié)碼之外,還應該包含一些其它的信息,這些信息也是 Python 運行的時候所必需的,比如常量、變量名等等。
我們常聽到 py 文件被編譯成字節(jié)碼,這句話其實不太嚴謹,因為字節(jié)碼只是一個 PyBytesObject 對象、或者說一段字節(jié)序列。但很明顯,光有字節(jié)碼是不夠的,還有很多的靜態(tài)信息也需要被收集起來,它們整體被稱為 PyCodeObject。
而 PyCodeObject 對象中有一個成員 co_code,它是一個指針,指向了這段字節(jié)序列。但是這個對象除了有 co_code 指向的字節(jié)碼之外,還有很多其它成員,負責保存代碼涉及到的常量、變量(名字、符號)等等。
但是問題來了,難道每一次執(zhí)行都要將源文件編譯一遍嗎?如果沒有對源文件進行修改的話,那么完全可以使用上一次的編譯結果。相信此時你能猜到 pyc 文件是干什么的了,它就是負責保存編譯之后的 PyCodeObject 對象。
所以我們知道了,pyc 文件里面的內(nèi)容是 PyCodeObject 對象。對于 Python 編譯器來說,PyCodeObject 對象是對源代碼編譯之后的結果,而 pyc 文件則是這個對象在硬盤上的表現(xiàn)形式。
當下一次運行的時候,Python 會根據(jù) pyc 文件中記錄的編譯結果直接建立內(nèi)存中的 PyCodeObject 對象,而不需要再重新編譯了,當然前提是沒有對源文件進行修改。
PyCodeObject 底層結構
我們來看一下這個結構體長什么樣子,它的定義位于 Include/code.h 中。
typedef?struct?{ ????//頭部信息,我們看到真的一切皆對象,字節(jié)碼也是個對象 ????PyObject_HEAD???? ????//可以通過位置參數(shù)傳遞的參數(shù)個數(shù) ????int?co_argcount;???????????? ????//只能通過位置參數(shù)傳遞的參數(shù)個數(shù),Python3.8新增 ????int?co_posonlyargcount;????? ????//只能通過關鍵字參數(shù)傳遞的參數(shù)個數(shù) ????int?co_kwonlyargcount;????? ????//代碼塊中局部變量的個數(shù),也包括參數(shù) ????int?co_nlocals;????????????? ????//執(zhí)行該段代碼塊需要的??臻g ????int?co_stacksize;?????? ????//參數(shù)類型標識???? ????int?co_flags;??????????? ????//代碼塊在文件中的行號?? ????int?co_firstlineno;????????? ????//指令集,也就是字節(jié)碼,它是一個bytes對象? ????PyObject?*co_code;????????? ????//常量池,一個元組,保存代碼塊中的所有常量? ????PyObject?*co_consts;??????? ????//一個元組,保存代碼塊中引用的其它作用域的變量 ????PyObject?*co_names;?????? ????//一個元組,保存當前作用域中的變量??? ????PyObject?*co_varnames;???? ????//內(nèi)層函數(shù)引用的外層函數(shù)的作用域中的變量 ????PyObject?*co_freevars;?????? ????//外層函數(shù)的作用域中被內(nèi)層函數(shù)引用的變量 ????//本質(zhì)上和co_freevars是一樣的 ????PyObject?*co_cellvars;?????? ????//無需關注 ????Py_ssize_t?*co_cell2arg;???? ????//代碼塊所在的文件名 ????PyObject?*co_filename;???? ????//代碼塊的名字,通常是函數(shù)名、類名,或者文件名 ????PyObject?*co_name;????????? ????//字節(jié)碼指令與python源代碼的行號之間的對應關系 ????//以PyByteObject的形式存在? ????PyObject?*co_lnotab;???????? }?PyCodeObject;
這里面的每一個成員,我們一會兒都會逐一演示進行說明??傊?Python 編譯器在對源代碼進行編譯的時候,對于代碼中的每一個 block,都會創(chuàng)建一個 PyCodeObject 與之對應。
但多少代碼才算得上是一個 block 呢?事實上,Python 有一個簡單而清晰的規(guī)則:當進入一個新的名字空間,或者說作用域時,就算是進入了一個新的 block 了。舉個例子:
class?A: ????a?=?123 def?foo(): ????a?=?[]
我們仔細觀察一下上面這個文件,它在編譯完之后會有三個 PyCodeObject 對象,一個是對應整個 py 文件(模塊)的,一個是對應 class A 的,一個是對應 def foo 的。因為這是三個不同的作用域,所以會有三個 PyCodeObject 對象。
所以一個 code block 對應一個作用域、同時也對應一個 PyCodeObject 對象。Python 的類、函數(shù)、模塊都有自己獨立的作用域,因此在編譯時也都會有一個 PyCodeObject 對象與之對應。
PyCodeObject 代碼演示
PyCodeObject 我們知道它是干什么的了,那如何才能拿到這個對象呢?首先該對象在 Python 里面的類型是 <class 'code'>,但是底層沒有將這個類暴露給我們,因此 code 這個名字在 Python 里面只是一個沒有定義的變量罷了。
但是我們可以通過其它的方式進行獲取,比如函數(shù)。
def?func(): ????pass print(func.__code__)??#?<code?object?...... print(type(func.__code__))??#?<class?'code'>
我們可以通過函數(shù)的 __code__ 屬性拿到底層對應的PyCodeObject對象,當然也可以獲取里面的成員,我們來演示一下。
co_argcount:可以通過位置參數(shù)傳遞的參數(shù)個數(shù)
def?foo(a,?b,?c=3): ????pass print(foo.__code__.co_argcount)??#?3 def?bar(a,?b,?*args): ????pass print(bar.__code__.co_argcount)??#?2 def?func(a,?b,?*args,?c): ????pass print(func.__code__.co_argcount)??#?2
foo 中的參數(shù) a、b、c 都可以通過位置參數(shù)傳遞,所以結果是 3;對于 bar,則是兩個,這里不包括 *args;而函數(shù) func,顯然也是兩個,因為參數(shù) c 只能通過關鍵字參數(shù)傳遞。
co_posonlyargcount:只能通過位置參數(shù)傳遞的參數(shù)個數(shù),Python3.8 新增
def?foo(a,?b,?c): ????pass print(foo.__code__.co_posonlyargcount)??#?0 def?bar(a,?b,?/,?c): ????pass print(bar.__code__.co_posonlyargcount)??#?2
注意:這里是只能通過位置參數(shù)傳遞的參數(shù)個數(shù)。對于 foo 而言,里面的三個參數(shù)既可以通過位置參數(shù)、也可以通過關鍵字參數(shù)傳遞;而函數(shù) bar,里面的 a、b 只能通過位置參數(shù)傳遞。
co_kwonlyargcount:只能通過關鍵字參數(shù)傳遞的參數(shù)個數(shù)
def?foo(a,?b=1,?c=2,?*,?d,?e): ????pass print(foo.__code__.co_kwonlyargcount)??#?2
這里是 d 和 e,它們必須通過關鍵字參數(shù)傳遞。
co_nlocals:代碼塊中局部變量的個數(shù),也包括參數(shù)
def?foo(a,?b,?*,?c): ????name?=?"xxx" ????age?=?16 ????gender?=?"f" ????c?=?33 print(foo.__code__.co_nlocals)??#?6
局部變量有 a、b、c、name、age、gender,所以我們看到在編譯之后,函數(shù)的局部變量就已經(jīng)確定了,因為它們是靜態(tài)存儲的。
co_stacksize:執(zhí)行該段代碼塊需要的??臻g
def?foo(a,?b,?*,?c): ????name?=?"xxx" ????age?=?16 ????gender?=?"f" ????c?=?33 print(foo.__code__.co_stacksize)??#?1
這個暫時不需要太關注。
co_flags:參數(shù)類型標識
標識函數(shù)的參數(shù)類型,如果一個函數(shù)的參數(shù)出現(xiàn)了 *args,那么 co_flags & 0x04 為真;如果一個函數(shù)的參數(shù)出現(xiàn)了 **kwargs,那么 co_flags & 0x08 為真;
def?foo1(): ????pass #?結果全部為假 print(foo1.__code__.co_flags?&?0x04)??#?0 print(foo1.__code__.co_flags?&?0x08)??#?0 def?foo2(*args): ????pass #?co_flags?&?0x04?為真,因為出現(xiàn)了?*args print(foo2.__code__.co_flags?&?0x04)??#?4 print(foo2.__code__.co_flags?&?0x08)??#?0 def?foo3(*args,?**kwargs): ????pass #?顯然?co_flags?&?0x04?和?co_flags?&?0x08?均為真 print(foo3.__code__.co_flags?&?0x04)??#?4 print(foo3.__code__.co_flags?&?0x08)??#?8
當然啦,co_flags 可以做的事情并不止這么簡單,它還能檢測一個函數(shù)的類型。比如函數(shù)內(nèi)部出現(xiàn)了 yield,那么它就是一個生成器函數(shù),調(diào)用之后可以得到一個生成器;使用 async def 定義,那么它就是一個協(xié)程函數(shù),調(diào)用之后可以得到一個協(xié)程。
這些在詞法分析的時候就可以檢測出來,編譯之后會體現(xiàn)在 co_flags 這個成員中。
#?如果是生成器函數(shù) #?那么?co_flags?&?0x20?為真 def?foo1(): ????yield print(foo1.__code__.co_flags?&?0x20)??#?32 #?如果是協(xié)程函數(shù) #?那么?co_flags?&?0x80?為真 async?def?foo2(): ????pass print(foo2.__code__.co_flags?&?0x80)??#?128 #?顯然?foo2?不是生成器函數(shù) #?所以?co_flags?&?0x20?為假 print(foo2.__code__.co_flags?&?0x20)??#?0 #?如果是異步生成器函數(shù) #?那么?co_flags?&?0x200?為真 async?def?foo3(): ????yield print(foo3.__code__.co_flags?&?0x200)??#?512 #?顯然它不是生成器函數(shù)、也不是協(xié)程函數(shù) #?因此和?0x20、0x80?按位與之后,結果都為假 print(foo3.__code__.co_flags?&?0x20)??#?0 print(foo3.__code__.co_flags?&?0x80)??#?0
在判斷函數(shù)種類時,這種方式是最優(yōu)雅的。
co_firstlineno:代碼塊在對應文件的起始行
def?foo(a,?b,?*,?c): ????pass #?顯然是文件的第一行 #?或者理解為?def?所在的行 print(foo.__code__.co_firstlineno)??#?1
如果函數(shù)出現(xiàn)了調(diào)用呢?
def?foo(): ????return?bar def?bar(): ????pass print(foo().__code__.co_firstlineno)??#?4
如果執(zhí)行 foo,那么會返回函數(shù) bar,最終得到的就是 bar 的字節(jié)碼,因此最終結果是 def bar(): 所在的行數(shù)。所以每個函數(shù)都有自己的作用域,以及 PyCodeObject 對象。
co_names:符號表,一個元組,保存代碼塊中引用的其它作用域的變量
c?=?1 def?foo(a,?b): ????print(a,?b,?c) ????d?=?(list,?int,?str) print( ????foo.__code__.co_names )??#?('print',?'c',?'list',?'int',?'str')
一切皆對象,但看到的都是指向對象的變量,所以 print, c, list, int, str 都是變量,它們都不在當前 foo 函數(shù)的作用域中。
co_varnames:符號表,一個元組,保存在當前作用域中的變量
c?=?1 def?foo(a,?b): ????print(a,?b,?c) ????d?=?(list,?int,?str) print(foo.__code__.co_varnames)??#?('a',?'b',?'d')
a、b、d 是位于當前 foo 函數(shù)的作用域當中的,所以編譯階段便確定了局部變量是什么。
co_consts:常量池,一個元組,保存代碼塊中的所有常量
x?=?123 def?foo(a,?b): ????c?=?"abc" ????print(x) ????print(True,?False,?list,?[1,?2,?3],?{"a":?1}) ????return?">>>" print( ????foo.__code__.co_consts )??#?(None,?'abc',?True,?False,?1,?2,?3,?'a',?'>>>')
co_consts 里面出現(xiàn)的都是常量,但 [1, 2, 3] 和 {"a": 1} 卻沒有出現(xiàn),由此我們可以得出,列表和字典絕不是在編譯階段構建的。編譯時,只是收集了里面的元素,然后等到運行時再去動態(tài)構建。
不過問題來了,在構建的時候解釋器怎么知道是要構建列表、還是字典、亦或是其它的什么對象呢?所以這就依賴于字節(jié)碼了,解釋字節(jié)碼的時候,會判斷到底要構建什么樣的對象。
因此解釋器執(zhí)行的是字節(jié)碼,核心邏輯都體現(xiàn)在字節(jié)碼中。但是光有字節(jié)碼還不夠,它包含的只是程序的主干邏輯,至于變量、常量,則從符號表和常量池里面獲取。
co_name:代碼塊的名字
def?foo(): ????pass #?這里就是函數(shù)名 print(foo.__code__.co_name)??#?foo
co_code:字節(jié)碼
def?foo(a,?b,?/,?c,?*,?d,?e): ????f?=?123 ????g?=?list() ????g.extend([tuple,?getattr,?print]) print(foo.__code__.co_code) #b'd\x01}\x05t\x00\x83\x00}\x06|\x06......'
這便是字節(jié)碼,它只保存了要操作的指令,因此光有字節(jié)碼是肯定不夠的,還需要其它的靜態(tài)信息。顯然這些信息連同字節(jié)碼一樣,都位于 PyCodeObject 中。
字節(jié)碼與反編譯
Python 執(zhí)行源代碼之前會先編譯得到 PyCodeObject 對象,里面的 co_code 指向了字節(jié)碼序列。
虛擬機會根據(jù)這些字節(jié)碼序列來進行一系列的操作(當然也依賴其它的靜態(tài)信息),從而完成對程序的執(zhí)行。
每個操作都對應一個操作指令、也叫操作碼,總共有120多種,定義在 Include/opcode.h 中。
#define?POP_TOP???????????????????1 #define?ROT_TWO???????????????????2 #define?ROT_THREE?????????????????3 #define?DUP_TOP???????????????????4 #define?DUP_TOP_TWO???????????????5 #define?NOP???????????????????????9 #define?UNARY_POSITIVE???????????10 #define?UNARY_NEGATIVE???????????11 #define?UNARY_NOT????????????????12 #define?UNARY_INVERT?????????????15 #define?BINARY_MATRIX_MULTIPLY???16 #define?INPLACE_MATRIX_MULTIPLY??17 #define?BINARY_POWER?????????????19 #define?BINARY_MULTIPLY??????????20 #define?BINARY_MODULO????????????22 #define?BINARY_ADD???????????????23 #define?BINARY_SUBTRACT??????????24 #define?BINARY_SUBSCR????????????25 #define?BINARY_FLOOR_DIVIDE??????26 #define?BINARY_TRUE_DIVIDE???????27 #define?INPLACE_FLOOR_DIVIDE?????28 //?... //?...
操作指令只是一個整數(shù),然后我們可以通過反編譯的方式查看每行 Python 代碼都對應哪些操作指令:
#?Python中的dis模塊專門負責干這件事情 import?dis def?foo(a,?b): ????c?=?a?+?b ????return?c #?里面接收一個字節(jié)碼 #?當然函數(shù)也是可以的,會自動獲取co_code dis.dis(foo) """ ??5???????????0?LOAD_FAST????????????????0?(a) ??????????????2?LOAD_FAST????????????????1?(b) ??????????????4?BINARY_ADD ??????????????6?STORE_FAST???????????????2?(c) ??6???????????8?LOAD_FAST????????????????2?(c) ?????????????10?RETURN_VALUE """
字節(jié)碼反編譯后的結果多么像匯編語言,其中第一列是源代碼行號,第二列是字節(jié)碼偏移量,第三列是操作指令(也叫操作碼),第四列是指令參數(shù)(也叫操作數(shù))。Python 的字節(jié)碼指令都是成對出現(xiàn)的,每個指令都會帶有一個指令參數(shù)。
查看字節(jié)碼也可以使用 opcode 模塊:
from?opcode?import?opmap opmap?=?{v:?k?for?k,?v?in?opmap.items()} def?foo(a,?b): ????c?=?a?+?b ????return?c code?=?foo.__code__.co_code for?i?in?range(0,?len(code),?2): ????print("操作碼:?{:<12}?操作數(shù):?{}".format( ????????opmap[code[i]],?code[i+1] ????)) """ 操作碼:?LOAD_FAST????操作數(shù):?0 操作碼:?LOAD_FAST????操作數(shù):?1 操作碼:?BINARY_ADD???操作數(shù):?0 操作碼:?STORE_FAST???操作數(shù):?2 操作碼:?LOAD_FAST????操作數(shù):?2 操作碼:?RETURN_VALUE?操作數(shù):?0 """
總之字節(jié)碼就是一段字節(jié)序列,轉成列表之后就是一堆數(shù)字。偶數(shù)位置表示指令本身,而每個指令后面都會跟一個指令參數(shù),也就是奇數(shù)位置表示指令參數(shù)。
所以指令本質(zhì)上只是一個整數(shù):
虛擬機會根據(jù)不同的指令執(zhí)行不同的邏輯,說白了 Python 虛擬機執(zhí)行字節(jié)碼的邏輯就是把自己想象成一顆 CPU,并內(nèi)置了一個巨型的 switch case 語句,其中每個指令都對應一個 case 分支。
然后遍歷整條字節(jié)碼,拿到每一個指令和指令參數(shù)。然后對指令進行判斷,不同的指令進入不同的 case 分支,執(zhí)行不同的處理邏輯,直到字節(jié)碼全部執(zhí)行完畢或者程序出錯。
到此這篇關于一文帶你深度解密Python的字節(jié)碼的文章就介紹到這了,更多相關Python字節(jié)碼內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
python連接mysql數(shù)據(jù)庫并讀取數(shù)據(jù)的實現(xiàn)
這篇文章主要介紹了python連接mysql數(shù)據(jù)庫并讀取數(shù)據(jù)的實現(xiàn),文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友們下面隨著小編來一起學習學習吧2020-09-09educoder之Python數(shù)值計算庫Numpy圖像處理詳解
這篇文章主要為大家介紹了educoder之Python數(shù)值計算庫Numpy圖像處理詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-04-04Python 網(wǎng)頁解析HTMLParse的實例詳解
這篇文章主要介紹了Python 網(wǎng)頁解析HTMLParse的實例詳解的相關資料,python里提供了一個簡單的解析模塊HTMLParser類,使用起來也是比較簡單的,解析語法沒有用到XPath類似的簡潔模式,需要的朋友可以參考下2017-08-08python中使用xlrd讀excel使用xlwt寫excel的實例代碼
這篇文章主要介紹了python中使用xlrd讀excel使用xlwt寫excel的實例代碼,非常不錯,具有參考借鑒價值,需要的朋友可以參考下2018-01-01