Python 從源碼到執行

0.介紹一下常見的編譯模型: Java, Python, C

在今天的主題以前,先來了解下幾個典型的編譯模型。
松本行弘先生,在講解語言處理器構成時列舉了一個通用架構。java

source code
    |
    |
   \./
----------                 ---------
|Compiler| ---mid code---> |Runtime|
----------                 ---------
                              /.\
                               |
                               |
                           ---------    
                           |  Lib  |
                           ---------

處理器主要由三部分組成: 編譯器(Compiler),運行時(Runtime),庫(Lib)。python

  • 編譯器(Compiler): 顧名思義,就是編譯源碼的程序。一般狀況下,它會將源碼編譯成運行時(Runtime)識別的中間碼,可是在極端狀況下,如 C 中,由於沒有運行時(Runtime),就直接輸出機器碼了。編譯器(Compiler)在這過程當中可能還會本身對源碼進行優化,並剔除一些運行沒必要要的信息,好比註釋等。
  • 運行時程序(Runtime): 這個程序用於代碼的具體執行,最被熟知的是 JVM,因此也能夠把它叫作虛擬機好了
  • 庫(Lib): 庫很好理解,就比如一個詞典,運行這個程序所須要的一些額外支持。最基礎的,標準庫應該包含基本的 IO 庫,如 stdio.h,還有平臺所提供的系統調用等等。

Java 在這裏就頗有表明性,中規中矩的按照這個流程走。
首先 Java 的編譯器(Javac)會將源碼 .java 的文件編譯爲字節碼形式的 .class 文件。
而後將文件中的字節碼引入虛擬機中(JVM),這裏的 JVM 承擔的就是運行時(Runtime)的任務。
運行過程當中從 JDK 中引入須要的庫(Lib)。git

那麼 Python 會有什麼不同呢 ?
Python 也是按照這個流程走的~~
可是 Python 中編譯器(Compiler)承擔的工做比重相對較少,由於沒有了複雜的語法檢查還有類型校驗等工做,大部分工做都在運行時完成,大部分錯誤也只有在運行過程當中才能發現。
像這種運行時(Runtime)部分承擔大部分工做的語言,外觀上給人一種像是直接從源代碼執行的錯覺,因此被叫作「解釋型」。
也是由於如此,Python 的執行效率遠不及 Java ,還有 C 這些靜態編譯的語言。github

最後 C 呢?
C 又是另外一個極端,C 語言的 GCC 編譯器(compiler)異常強大,幾乎包辦了大部分工做。它能根據須要執行相應的編譯優化,如轉化機器不須要的變量名,加入混淆等等,幾乎跳過了運行時(Runtime)直接輸出包含機器碼,輸出文件通過連接器能夠轉換成平臺可執行的文件。
若是有了解過反編譯的同窗應該看過反編譯回來的 C 源碼可讀性大打折扣,有時只能轉到彙編瞭解程序的運行邏輯,相比較而言 Java 的 .class 文件中的字節碼保留了更多信息,反編譯回來的代碼可讀性更好一點。
在我很是喜歡的美劇《硅谷》中也有這樣一個橋段,Hooli 專門組織了一個團隊反編譯 Richard 的音樂程序,來獲取他的數據壓縮技術。
這是一個很是有意思的過程,有不少書花了長篇大論專門介紹這個,這裏再也不展開了。編程

1.CPython 編譯流程

咱們先從編譯器開始,Python 的編譯器大體分爲下面四步流程:windows

  1. 將源代碼解析爲解析樹(Parser Tree)
  2. 將解析樹轉換爲抽象語法樹(Abstract Syntax Tree)
  3. 將抽象語法樹轉換到控制流圖(Control Flow Graph)
  4. 根據流圖將字節碼(bytecode) 發送給虛擬機(ceval)

這是在最新的 CPython3.8.4 中的 python 源碼編譯及執行過程架構

PyObject *
PyRun_FileExFlags(FILE *fp, const char *filename_str, int start, PyObject *globals,
                  PyObject *locals, int closeit, PyCompilerFlags *flags)
{
    PyObject *ret = NULL;
    mod_ty mod;
    PyArena *arena = NULL;
    PyObject *filename;

    filename = PyUnicode_DecodeFSDefault(filename_str);
    if (filename == NULL)
        goto exit;

    arena = PyArena_New();
    if (arena == NULL)
        goto exit;

    /* PyParser 包含了前述中的 step1 和 2 */
    mod = PyParser_ASTFromFileObject(fp, filename, NULL, start, 0, 0,
                                     flags, NULL, arena);
    if (closeit)
        fclose(fp);
    if (mod == NULL) {
        goto exit;
    }

    /* run_mod 包含 step3, 4 以及虛擬機的運做過程 */
    ret = run_mod(mod, filename, globals, locals, flags, arena);

exit:
    Py_XDECREF(filename);
    if (arena != NULL)
        PyArena_Free(arena);
    return ret;
}

大部分人對這一塊應該沒多大興趣,我就簡單帶過,編譯器主要會進行詞法分析以及語法分析,遍歷語法樹生成流,最後生成虛擬機的機器碼,也就是字節碼。這裏的每一點都包含很大的信息量,就再也不詳細敘述編譯的細節了。下面解析一下 Python 的執行,那就要牽扯到虛擬機和字節碼了。編程語言

2.虛擬機和字節碼

虛擬機(vm): 這裏所說的虛擬機不是 KVM,VMware 虛擬機。指的是在軟件層面模擬了 CPU 執行邏輯的程序,其中最有名的應該是 JVM 了。在解釋型語言 Python, Ruby 中一樣包含了解析程序指令的虛擬機。ide

字節碼(bytecode): 字節碼是相對於機器碼的存在。機器碼是 CPU 能讀懂的機器指令,全部指令都包含在一個指令集裏面,那字節碼就是虛擬機能理解的指令。函數

那麼在編程語言中,它們基於 CPU 的原理在軟件層實現了一個指令集,相應的將程序翻譯成虛擬機理解的字節碼再加載到虛擬機中運行。

這也給代碼的移植性帶來好處,只要相應的平臺(Intel, ARM)上的操做系統(*nux, windows)安裝有相應的虛擬機,就能夠直接運行程序。

一個簡單的例子:

import dis

def hello(): 
    print("Hello World")

print(dis.dis(hello))

# 0 LOAD_GLOBAL              0 (print)
# 2 LOAD_CONST               1 ('Hello World')
# 4 CALL_FUNCTION            1
# 6 POP_TOP
# 8 LOAD_CONST               0 (None)
# 10 RETURN_VALUE

上面的註釋部分就是虛擬機要運行的指令部分,和在學校的時候學習的 x86 彙編很是類似,目前在 CPython3.8.4 中包含了163條指令(opcode),不一樣版本之間指令有一些指令差別,在本身嘗試的時候可能會看到不一樣的指令是正常的,感興趣的話能夠在源碼(Include/opcode.h)中查看所有指令。

另外須要提一點,CPython 中使用的是棧式虛擬機架構,相對的還有寄存器式虛擬機。

這是一個簡單的打印 Hello World 的程序。
咱們來逐一解釋如下:

  • LOAD_GLOBAL: 將全局變量 print 壓入(push)棧
  • LOAD_CONST: 將常量 Hello World壓入(push)棧
  • CALL_FUNCTION: 執行 print 方法,彈出常量 'Hello World' 以及 print 變量,將結果壓入(push)棧中。
  • POP_TOP: 彈出(pop)棧頂元素,就是剛剛的 print 的返回值
  • LOAD_CONST: 將常量 None 壓入(push)棧
  • RETURN_VALUE: 彈出(pop)棧頂元素做爲最終返回值

就是這樣看起來複雜的六條指令拼湊出了 hello 函數。

我以爲還有些疑問: CALL_FUNCTION 是如何 CALL 到這個函數的? 指令(opcode)旁邊出現的數字有什麼含義?

咱們得從字節碼中找到答案。

首先觀察指令前面的數字 0,2,4,..,10,不難看出這是字節碼的長度,0-2表示字節碼的長度是 2 bytes,也就是說一條完整的代碼指令實際上是以字(word, 1 word= 2 bytes)的形式出現的,這裏其實更適合叫作「字碼」。

而後是字碼的構成,一個 word 有 16 個bit,前面說過 CPython 目前支持的指令(opcode)是163個,要怎麼存呢?

opcode 佔用 8 bit,也就是說目前最多能夠擴展到 256 個 opcode,另外 8 bit 存參數長度,這麼說來一次函數調用最多隻能壓入(Push) 255 個參數(這個還真沒試過,感興趣能夠試一下)。

因此你看到的指令(opcode)後面的 0 和 1 實際上是目前的參數長度,當 CALL 的時候,虛擬機會經過參數長度從棧頂向下檢索調用位置運行函數。

這樣差很少對字節碼有了必定的認識了。

問題

經過上面逐一對指令進行了解析,相信你們對 python 又有了新瞭解了吧。可是還會有其餘疑問。

  • print 這個全局變量是從哪裏來的 ?
  • 'Hello Word' 字符串,還有 None 爲何應該是常量 ?
  • 這裏只揭示了棧式虛擬機,那寄存器式虛擬機是什麼樣子的呢 ?
  • 另一個老生長談的問題,除了虛擬機負擔了更多工做,還有哪些因素致使了 Python 的慢 ?

這些問題一下也說不完,下次必定 :)

參考

我的博客連接: https://00kai0.github.io/cpy-compile-and-runtime/

相關文章
相關標籤/搜索