首頁>技術>

眾所周知,執行Python程式可以直接使用python.exe命令,如下所示:

python abc.py

看到python直接執行了abc.py,可能很多同學認為python是解釋執行abc.py的,其實不然。如果要真是解釋執行,那效率慢的就沒法用了。實際上,Python與Java一樣,也是玩位元組碼出身。Java的位元組碼叫Java ByteCode,Python的位元組碼叫Python ByteCode。Python在第一次執行abc.py檔案時,會將原始碼檔案編譯成位元組碼,然後再執行。當然,還可以選擇直接生成位元組碼檔案(副檔名是pyc),然後直接執行Python位元組碼檔案。

通常Python是以原始碼形式釋出的,不過對於一些敏感資訊,不希望以原始碼形式釋出,就可以用位元組碼形式釋出。當然,位元組碼也可以被反編譯。為了讓Python原始碼更安全,可以製作自己的私有Python環境,這些內容我們後面再說。

相信很多沒接觸過過Python位元組碼的同學一定有很多疑問,那麼就繼續看後面的內容吧!

1. 如何檢視Python位元組碼

我們首先來檢視一下Python的位元組碼,以證明在執行Python指令碼時確實是先將Python程式碼編譯成位元組碼,然後執行的是位元組碼,而不是直接執行Python原始碼。

先看下面的程式碼:

在這段程式碼中有一個fun函式,裡面使用了全域性變數value和區域性變數name,並輸出了這兩個變數的值。最後匯入了dis模組。在該模組中有一個disassemble函式,用於輸出任何包含__code__屬性的Python程式碼段的位元組碼形式。

現在執行這段程式碼,會輸出如下內容:

很明顯,disassemble輸出了類似彙編程式碼的東西。其實這就是Python位元組碼的可讀形式。每一條指令對應一個位元組碼。那麼為什麼要檢視位元組碼呢?其實對於應用開發者來說,最直接的作用就是更好地理解Python原始碼。

例如,本例使用了全域性變數,也就是global關鍵字,那麼global關鍵字到底代表什麼呢?從Python位元組碼中就可以很容易看出端倪。

在Python原始碼中發生了2次賦值,程式碼如下:

其中value是全域性變數,name是fun函式的區域性變數。將這兩條賦值操作轉換為Python位元組碼,會得到如下的程式碼:

從Python位元組碼可以看出,每一條賦值語句轉換成了2條Python位元組碼。其中都使用了LOAD_CONST指令,這是裝載常量的指令。因為value和name都被賦予了一個常量,只是一個是整數,另一個是字串。不過由於Python在使用變數時不需要指定變數型別(變數有型別,但不需要在定義變數時指定,使用變數時再確定變數的型別),所以不管是裝載什麼型別的常量給變數賦值,都使用LOAD_CONST指令。

但第2條指令就不同了,對於全域性變數value,使用STORE_GLOBAL指令將常量賦給變數,而區域性變數name,使用了STORE_FAST指令將常量賦給了變數。這兩條指令的區別就是儲存的位置不同。由於Python將全域性變數和變數放到了不同的位置,所以這兩條指令會分別將常量值儲存到這些位置。

從這一點判斷,global value這條語句其實並沒有執行,他只是一個開關,如果加上global value,當為value賦值時就使用STORE_GLOBAL指令,如果沒有global value,當為value賦值時就使用STORE_FAST指令。

如果除了global value外,其他的程式碼都去掉,就看不到global value的身影了。

看下面的Python程式碼:

執行這段程式碼,只會得到下面2條Python位元組碼:

這2條Python位元組碼實際是讓fun函式有一個預設的返回值,也就是如果函式不顯式返回一個值,那麼預設就會返回None。這裡面並沒有看到global value的身影。

2. 用Python程式碼編譯Python程式碼

在使用python命令執行指令碼時,儘管將Python原始碼編譯成了位元組碼,但並沒有將編譯結果儲存成檔案,而一切都是在記憶體中完成的。如果頻繁執行Python的某段程式,執行的實際上是記憶體中的Python位元組碼。不過在釋出時,我們期望像Java一樣,可以釋出.class檔案,其實Python也有類似的檔案,這就是.pyc檔案。

用Python程式碼和命令列都可以將Python原始碼編譯成.pyc檔案,只是在預設情況下,Python做得比較隱蔽,會將.pyc檔案生成到一個預設的目錄,而且很多IDE(如PyCharm)是不會顯示這個目錄的。這個目錄就是__pycache__。

現在做一個實驗,首先建立一個demo.py檔案,然後輸入下面的程式碼:

現在執行下面的程式碼將demo.py檔案編譯生成.pyc檔案。

so easy,只需要兩行程式碼(還有一行是import語句),就可以編譯demo.py,執行程式後,如果在IDE中,什麼都不會發生,別急,切換到demo.py檔案所在的目錄,會看到多了一個__pycache__目錄,開啟一看,目錄裡有一個名為demo.cpython-38.pyc的檔案。在讀者的機器上檔名可能不同,差異就在最後的數字上,這裡的38表示我用的Python版本是3.8,這裡不會顯示小版本號。如果讀者使用的是3.7,那麼生成的.pyc檔案就是demo.cpython-37.pyc。

現在進入控制檯,進入demo.cpython-38.pyc檔案所在的目錄,執行python demo.cpython-38.pyc命令,同樣可以輸出結果,與python demo.py執行的結果完全相同。所以在釋出Python應用時,可以直接釋出pyc檔案。

compile函式在編譯Python檔案時,可以指定第2個引數值,表示要生成的.pyc檔名,這樣就可以指定將pyc檔案放到特定的目錄,程式碼如下:

執行這段程式碼,可以在當前目錄生成一個名為demo.pyc的檔案,執行python demo.pyc命令,同樣會得到我們期望的結果。

如果需要編譯的Python指令碼太多,可以多次呼叫compile函式,也可以使用compileall模組中的compile_dir函式遞迴編譯指定目錄中的所有Python指令碼檔案。

現在做一個實驗,在當前目錄建立3層子目錄:aa/bb/cc,並在每一層目錄建立一個或多個Python指令碼檔案,可以不寫任何程式碼(空檔案即可),如圖1所示。

圖1

現在執行下面的程式碼編譯aa目錄中所有的Python指令碼檔案。

執行這段程式碼,首先會遞迴掃描所有的目錄,然後會編譯所有發現的Python指令碼檔案,如圖2所示。

圖2

檢視這幾個目錄,每一個目錄都有一個名為__pycache__目錄,裡面是對應的pyc檔案。

如果不想遞迴編譯所有目錄中的Python指令碼檔案,可以使用compile_dir函式的第2個引數指定遞迴層次,0表示當前目錄(不遞迴),1表示遞迴一層目錄,以此類推。例如,下面的程式碼只編譯當前目錄中所有的Python指令碼檔案。

3. 在命令列中編譯Python指令碼

python命令同樣可以將.py檔案編譯成.pyc檔案,例如,如果要編譯demo.py檔案,可以使用下面的命令:

python -m demo.py

這裡的-m命令列引數表示編譯demo.py,執行這行命令後,會在當前目錄的__pycache__目錄生成demo.cpython-38.pyc檔案,然後可以使用python直接執行這個檔案。

如果想遞迴編譯目錄中所有的Python檔案,可以使用下面的命令:

python -m compileall aa

這行命令可以遞迴編譯aa目錄中的所有Python檔案。如果還想對編譯結果進行最佳化,可以加-O或-OO,那麼這兩個最佳化引數有什麼區別呢?

如果不加最佳化引數,只加-m,那麼就不會進行最佳化,也就是最佳化層次(Level)為0,當不最佳化時,Python的內部變數__debug__為True,讀者可以在Python Shell中輸出這個變數值。如果設定了-O引數,那麼最佳化層次是1,在這一最佳化層次,會將__debug__變數的值設為False。如果使用-OO引數,最佳化層次是2,不僅將__debug__變數的值設為False,而且將Python中的docstrings也去除了。docstrings就是Python中的文件註釋,可以用來為API自動產生文件。也就是3對單引號或雙引號括起來的部分。

其中上一部分講的compile函式和compile_dir函式也有設定最佳化level的引數,就拿compile函式來說,該函式的第4個引數用於設定最佳化層次,預設值是-1,相當於-O引數。還可以設定為0(不最佳化)、1(與預設值相同)和2(相當於-OO引數)。下面的程式碼用level = 2的層次最佳化編譯demo.py。

py_compile.compile('demo.py', 'demo.pyc', False, 2)

其實這裡的最佳化,並不是指最佳化Python Byte Code,而是去掉不同的除錯資訊和文件。這裡的除錯資訊主要是指為了在Console或日誌中輸出的一些用於展示程式執行狀態的資訊。如果這些隨著程式釋出,會讓程式執行效率大打折扣。因為執行在Console或日誌中輸出資訊的程式碼是很慢的(相對於直接在記憶體中執行的程式碼)。

如果使用命令列方式最佳化編譯.py檔案,如果使用的是-O引數,生成的目標檔案是:demo.cpython-38.opt-1.pyc,如果使用的是-OO引數,生成的目標檔案是:demo.cpython-38.opt-2.pyc。

4. 如何對Python程式碼加密

儘管可以將.py檔案編譯生成.pyc檔案,但.pyc檔案和Java的.class檔案一樣,很容易被反編譯。更穩妥的方式是製作一個私有的Python編譯和執行環境,說白了,就是修改Python編譯器的原始碼。聽著很高大上,其實並不複雜,只需要修改其中的常量即可。

首先下載Python原始碼,然後找到如下兩個檔案:

<Python原始碼根目錄>/Lib/opcode.py <Python原始碼根目錄>/Include/opcode.h

大家可以開啟這兩個檔案看看,opcode.py檔案中的程式碼片段是這樣的:

opcode.h檔案中的程式碼片段是這樣的:

我們可以看到,在opcode.h檔案中定義了一堆宏(相當於常量),而opcode.py檔案中同樣定義了與opcode.h同名的值,對應的整數值也相等。做過編譯器的同學應該能猜出來這是什麼東西,其實就是Python Byte Code對應的指令編碼。編譯出來的.pyc檔案都是由這些指令組成的。例如,for指令定義如下:

也就是說,如果Python程式碼中有for迴圈,就一定會有這個指令。我們可以做個試驗,下面有一段包含1個for迴圈的Python程式碼:

輸出這段這段程式碼的Python位元組碼,如下:

我們可以看到,第4行就是FOR_ITER指令,每一條指令由2個位元組組成,第1個位元組表示指令本身,第2個位元組表示運算元。而在第11行的JUMP_ABSOLUTE指令是跳轉指令,FOR_ITER與JUMP_ABSOLUTE配合才能形成迴圈。JUMP_ABSOLUTE直接跳到了6,也就是FOR_ITER指令所在的位置。

由於FOR_ITER指令對應的數值是93,這是十進位制,轉換為十六進位制是5d,如果考慮後面的運算元12(十六程序是0C,至於為什麼運算元是12,這是FOR_ITER指令的特性,讀者可以查閱Python位元組碼的相關文件,這個問題與本文無關,這裡先不做闡述),那麼完整的指令應該是5d0c。所以編譯demo.py,生成對應的.pyc檔案,然後開啟.pyc檔案(用可以檢視二進位制資料的軟體開啟),會看到如圖1所示的十六進位制形式的程式碼,在第6行可以找到5d0c,這就是for迴圈的起始指令。

圖3

讀者可以再加一個for迴圈,程式碼如下:

檢視pyc檔案的程式碼,會看到如圖4的形式。很明顯,第6行和第7行都有5d0c指令,這就表明這段程式碼中包含2條for語句。

圖4

Python位元組碼的反編譯器都是根據這些規則實現的,但問題是,如果5d不表示for迴圈,而表示if語句,那麼原有的反編譯器豈不是不好使了。

如果在程式碼中有if語句,那麼根據不同的場景,會使用POP_JUMP_IF_FALSE指令或POP_JUMP_IF_TRUE指令,這兩條指令在opcode.h的定義如下:

如果有下面的Python程式碼:

那麼會使用POP_JUMP_IF_FALSE指令,這時pyc程式碼中就會包含72(114的十六進位制表示),但如果將FOR_ITER的93和POP_JUMP_IF_FALSE的114調換一下,變成如下形式,那麼按Python的標準指令會將for當成if,if當成for,這樣反編譯出來的程式碼就亂套了。而反編譯器是無法知道你是如何互換指令值得。這就像直接用標準的base64編碼是無法加密的,但如果將標準的base64編碼隨機打亂,用這個打亂的base64編碼規則進行編碼,是無法用標準的base64編碼表解碼的。除非拿到了變化後的base64編碼表,如果要測試每一種排列,會有64的階乘這麼多種可能,在有限的時間內是根本不可能破解的。而這種修改Python原始碼的方式,就相當於打亂標準base64編碼表的順序,增加了破解的難度和時間。

另外,光修改前面介紹的兩個檔案還不行,還需要修改另外一個檔案,路徑如下:

<Python原始碼根目錄>/Python/opcode_targets.h

讀者可以開啟這個檔案,看看為什麼要修改這個檔案,檔案的程式碼片段如下:

很明顯,這段程式碼用來定義Python位元組碼的指令,而在opcode.h檔案中定義的每一個宏對應的值,就是opcode_targets陣列的索引。我們知道,C語言陣列索引從0開始,所以opcode_targets陣列的第1個元素是一個佔位符(&&_unknown_opcode),而POP_TOP指令在opcode.h檔案中值正是1,所以正好與opcode_targets陣列的第2個元素對應。

我們可以繼續檢視opcode_targets陣列的程式碼,看到下面的程式碼形式:找到TARGET_INPLACE_TRUE_DIVIDE,對應的是INPLACE_TRUE_DIVIDE指令,如圖5所示。

圖5

然後在opcode.h檔案中找到INPLACE_TRUE_DIVIDE指令,正好值是29,正好對應opcode_targets中索引為29的元素值。而TARGET_INPLACE_TRUE_DIVIDE下面是一堆&&_unknown_opcode佔位符,這也說明INPLACE_TRUE_DIVIDE後面有很多空閒的值,再看看opcode.h檔案中的定義,如圖6所示。

圖6

很顯然,INPLACE_TRUE_DIVIDE指令後面的RERAISE指令就直接從48開始了,所以要用多個&&_unknown_opcode作為佔位符,否則就無法找到對應的指令了。

所以修改Python原始碼要遵循下面的規則:

(1)修改opcode.py檔案和opcode.h檔案中程式碼,要統一互換,不能只互換一個;

(2)然後將opcode_targets.h中opcode_targets陣列的相對位置也換過來,否則就無法找到對應的指令了;

都改完了,然後就可以編譯Python程式碼了,執行下面的命令即可:

configure

make

make install

最後在釋出程式時,需要帶上自己編譯的Python環境,標準的Python環境已經無法執行我們自己生成的pyc檔案了。

當然,包含Python程式碼的方式有好很多種,例如,對Python程式碼混淆、將Python程式碼轉換成C程式碼等等,這些內容我後面會專門寫文章講解。

21
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 教你如何透過內部外掛實現Pycharm漢化,無需漢化包