我們都知道編譯源碼需要詞法分析、語法分析、語義分析與中間代碼產生、優化和目標代碼生成等五個過程。對於一個語言來說,有兩個最重要功能,編譯器和解釋器。實現由原始碼到字節碼的轉化,然後才能執行。本文中蟲蟲以CPython 3.6位元組碼為實例,實現一個我們自己的字節碼編譯器和解釋器,以此來熟悉基本的編譯器工作原理(),當然如果想深入理論學習,建議大家去學習了《編譯原理》這本教材。
基本定義
首先,我們介紹一些簡單的定義,以幫助區分解釋器的類型:
樹遍歷解釋器:逐節點地處理程序AST(abstract syntax tree,抽象語法樹),基於某些評估規則進行遞歸評估。對於像Add(Lit 1,Lit 2)這樣的程序,它可能會看到添加規則,遞歸地計算參數,將它們一起添加,然後將結果打包為適當的值類型。
字節碼解釋器不直接在AST上工作。他們研究預處理的AST。這簡化了執行模型,可以產生令人印象深刻的速度。像Add(Lit 1,Lit 2)這樣的程序會被編譯成以下字節碼:
PUSH 1
PUSH 2
ADD
然後解釋器將按指令進行解析,這個過程有和CPU工作處理類似。
JIT解釋器和字節碼解釋器一樣,除了編譯為特定於語言實現的字節碼,它們嘗試編譯為本機的機器指令。大多數生產級JIT解釋器會緩衝最多被調用的函數來"預熱處理",然後在後臺將它們編譯為本機代碼。所以 "熱代碼"會得到優化並且具有較小的性能損失。
樹遍歷解釋器
Lisp解釋器具有非常簡單的語義。下面是一個Lisp REPL(read-eval-print-loop)示例,其中所有命令都被解析為AST,然後立即處理輸出。
>>> 1
1
>>> '1'
1
>>> "hello"
hello
>>> (val x 3)
None
>>> x
3
>>> (set x 7)
None
>>> x
7
>>> (if True 3 4)
3
>>> (lambda (x) (+ x 1))
>>> ((lambda (x) (+ x 1)) 5)
6
>>> (define f (x) (+ x 1))
None
>>> f
>>> +
>>> (f 10)
11
>>>
類似的,我們的解釋器,首先編寫一個名為compile的函數,它接受一個由Python列表表示的表達式(類似於['+',5,['+',3,5]])並返回一個列表字節碼指令。然後我們將編寫eval,它接受這些指令並返回一個Python值(在本例中為int 13)。除了更快之外,它應該與樹遍歷解釋器的行為相同。
指令集
在我們的解釋器中,我們還需要一組指令,為了便捷我們從CPython運行時指令集架構中直接帶過來。 CPython是一個基於堆棧的架構,所以我們的解釋器架構也是如此:
LOAD_CONST
將常量推入堆棧。
STORE_NAME
將值存儲到環境中。
LOAD_NAME
從環境中讀取值。
CALL_FUNCTION
調用函數(內置或用戶定義)。
RELATIVE_JUMP_IF_TRUE
如果堆棧頂部的值為true,則跳轉。
RELATIVE_JUMP
跳轉。
MAKE_FUNCTION
從堆棧上的代碼對象創建一個函數對象,並將其推送到堆棧上。
通過上述指令元語,我們來定義整個語言。一般情況下,在指令集中都會定義數學運算用來提高速度,此處我們將其定義為內置函數,因為它快速而簡單。
Opcode枚舉 和Instruction類
我們定義了一個Opcode枚舉:
它的唯一目的是枚舉所有可能的操作碼,稍後我們會添加。Python的枚舉API有點複雜,我們不再多說,你把他想像成一個個整數列表就好了。
接著,我們編寫一個存儲操作碼和可選參數對的Instruction類:
我們為每個操作碼定義一個指令實例,將制定好參數。然後,當字節碼編譯時,我們可以通過使用給定的參數調用它們,創建每個指令的實例:
創建父節點操作碼:
STORE_NAME = Instruction(Opcode.STORE_NAME, "name")
創建實例:
[STORE_NAME("x"),STORE_NAME("y")]
定義好了編譯基礎設施,讓我們開始編譯。
整型
根據我們上面的基礎,我們開始撰寫編譯函數:
現在,它還只是一個原型,沒有啥用處。我們陸續擴展功能。
最簡單的編譯方法是整數。我先來給整數添加操作的指令,然後實現它。
LOAD_CONST,對應Python操作碼,它可以做類似的操作。我們可以調用PUSH_CONST,在我看來更好地暗示它將被推送到VM堆棧。我們指定了CONST,因為該指令只應用於文本值:5,"hello"等。值由表達式本身完全定義。
這是Instruction類的並行(在模塊範圍定義):
LOAD_CONST = Instruction(Opcode.LOAD_CONST, "const")
參數名稱"const"僅用於字節碼文檔。創建和執行該指令時,它將被實際值替換。接下來,讓我們給compile函數添加功能:
compile將遍歷表達式樹並將已編譯的子表達式中的指令列表合併在一起,因此每個分支都必須返回一個列表。當我們有嵌套的情況時,看起來會更好。我們調用測試一下:
既然我們已經有了指令,我們也應該能夠運行它。我們寫一個基本的eval循環:
pc是"程序計數器"的縮寫,相當於術語"指令指針"。我們的想法是逐個遍歷指令,並按順序執行它們。
當它無法處理特定指令時,此循環將拋出,因此它是合理的腳手架,但不多。讓我們添加一個實例來處理eval中的LOAD_CONST。
稍後我們會調用它,eval會返回堆棧頂部的值。這是我們開始。
assert eval(compile(5)) == 5
assert eval(compile(7)) == 7
現在我們在運行它:
命名
現在,我們的例程還不能做任何事情,唯一做的事是將一個數字推入堆棧的程序。下面讓我們添加一些將這些值賦值給變量的操作碼。
下面我們會添加指令STORE_NAME,它從堆棧中取出一個值並將其存儲在當前環境中,還有LOAD_NAME,它從當前環境讀取值並將其推送到堆棧中。
先來說說上面提到的環境概念,其抽象為一個Env類:
執行模型將為每個函數調用框架創建一個新的Env,並為每個閉包框架創建一個新的env,目前沒有完全實現,請暫時忽略。
我們現在關心的是全局環境。我們將創建一個用於存儲值的全局環境。然後我們將通過eval函數對其進行處理,以便我們可以使用它。先擴展commpile函數的功能:
我們新增加了第二個分支來檢查表達式是否是一個列表。由於我們現在幾乎沒有錯誤處理,我還添加了一些斷言以幫助簡化代碼。
在val的情況下,我們想要提取名稱和子表達式:編譯像5這樣的簡單值,還有(+ 1 2)類似的表達式。然後,我們要編譯子表達式並添加STORE_NAME指令。
讓我們測試一下我們能做什麼:
assert compile(['val', 'x', 5]) == [LOAD_CONST(5), STORE_NAME('x')]
現在讓我們回到eval函數:
注意到,我們添加一個env參數,以便我們可以在不同的上下文中計算表達式並獲得不同的結果。我們還為STORE_NAME添加了一個實例。執行會報錯:
為了,正常運行,我們必須修改,其他測試以傳遞一個env參數。
讓我們創建第一個環境並測試STORE_NAME。
現在我們添加編譯器和評估器功能來讀取這些存儲的值。編譯器只需檢查變量訪問,表示為字符串。
並為它添加一個測試:
assert compile('x') == [LOAD_NAME('x')]
現在我們可以生成LOAD_NAME了,讓我們為它添加eval支持。
為了測試它,我們首先手動將名稱存儲到環境中,然後查看LOAD_NAME是否可以將其讀回。
內置函數
我們還可以按需添加函數操作碼,或者添加一個操作碼,允許我們調用本機(Python)代碼並以這種方式擴展我們的解釋器。
在該實例中,我們將添加CALL_FUNCTION操作碼,它將用於內置函數和用戶定義函數。我們稍後會介紹用戶定義的函數。
當表達式的形式為(x0 x1 x2 ...)並且x0不是像val這樣的預設識別名稱之一時,將生成CALL_FUNCTION。編譯器應生成代碼以首先加載函數,然後將參數加載到堆棧中。然後CALL_FUNCTION指令。這與CPython的實現非常相似。
CALL_FUNCTION指令的定義如下:
CALL_FUNCTION = Instruction(Opcode.CALL_FUNCTION, "nargs")
CALL_FUNCTION指令帶有傳遞給函數的參數個數,以便我們可以正確調用它。請注意,我們這樣做而沒有在函數中存儲正確數量的參數,主要是這樣函數就可以可以自持可變數量的參數。
我們擴充complie函數增加調用表達式功能。
我為列表表達式添加了一個默認情況。看到它編譯名稱,然後編譯參數,然後發出一個CALL_FUNCTION。我們添加一些測試實例:
現在讓我們同步擴充eval函數的功能:
請注意,我們正在從堆棧中讀取參數是以相反的順序從堆棧中讀取的。這是堆棧的LIFO特性(先入後出)。
我們需要檢查fn是否可調用。由於我們在堆棧上允許原始Python對象,因此我們必須確保我們實際上要調用Python函數。然後我們將使用參數列表調用該函數並將其結果推送到堆棧上。
以下是現實生活中的測試結果:
如果我們將lambda表達式填充到環境中,我們將獲得這些超級簡單的內置函數。但這不是最優+功能。讓我們製作一個可變的變量。
因為我們將參數傳遞給列表,所以我們可以做各種類似的操作。
只提到過,我們在STORE_NAME中添加一個用於編譯嵌套表達式的測試。
你還可以隨心所以添加自己需要的內置函數,比如添加print功能:
應該能看到以正確順序傳遞的參數。注意,如果使用的是Python 2,則應將print打包在lambda中,在Python 2 語法中print不是函數。
條件語句
如果沒有條件,一個語言就做不了啥事。雖然我們可以將條件作為內置函數。但是我們在這選擇更正常的傳統條件語法:
(if a b c)
會被編譯為
我們還將採用CPython方法生成相對跳轉JUMP。
為此,我們需要新添加兩個操作碼和說明:
RELATIVE_JUMP_IF_TRUE = Instruction(Opcode.RELATIVE_JUMP_IF_TRUE, "off")
RELATIVE_JUMP = Instruction(Opcode.RELATIVE_JUMP, "off")
它們中的每一個都採用偏移量來表示跳轉的距離。
我們的編譯器函數需要新增加:
首先編譯條件。然後,編譯將在條件通過時執行的分支。 if-false分支有點棘手,那裡包含了跳轉到結尾。這是為了跳轉到if-true分支的偏移計算是正確。
同樣我們添加一些測試來檢查我們的工作:
我們特意添加了第二個測試來仔細檢查嵌套表達式是否正常工作。
增加eval函數的功能:
對應的測試案例:
自定義函數
用戶定義的函數對於一個語言是否真正可以用很關鍵。我們一個簡單的Function對象。
params將是一個命名元組,body是一個指令元組,env是一個環境。
所有函數都是一個閉包。可以引用定義函數的範圍中定義的變量。基本格式如下:
((lambda (x) (+ x 1)) 5)
或者為:
(define inc (x) (+ x 1))
(inc 5)
實際上,我們將使用語法轉換來根據val和lambda重寫定義。
要實現這些功能,我們需要新增加操作碼:MAKE_FUNCTION。該指令會將在堆棧中的一些對象轉換為Function對象。
MAKE_FUNCTION = Instruction(Opcode.MAKE_FUNCTION, "nargs")
這需要一個整數,即函數所期望的參數數量。現在我們只允許位置,非可選參數。如果我們想要有額外的調用約定,我們必須稍後添加它們。
來給complie增加功能:
對於lambda來說,它非常簡單。push參數,push body代碼,然後就可以用。
define有點小技巧。他是一個宏並在編譯之前重寫AST。如果我們想要更專業,需要新建一個宏系統,以便標準庫可以定義define和if ...這超出本文範圍。
測試代碼:
為了讓功能真正可用,我們給eval函數增加處理MAKE_FUNCTION操作碼。
與調用函數一樣,我們按照push順序的相反方式讀取所有內容。首先是body,然後是參數,檢查他的長度,然後push新的Function對象。
但是函數在沒有可調用的情況下並不是特別有用。為了讓其調用實現功能:
第一個策略是簡單的策略,即在CALL_FUNCTION中添加另一個處理Function對象的案例。這大多數程式語言中都是這樣的:
另一種方法更像是Pythonic。它將Function轉換為可調用對象,將自定義設置代碼放入Function本身。如果選擇這樣做,請單獨保留CALL_FUNCTION並以這種方式修改Function:
然後eval應該調用函數,就像它是一個普通的Python函數一樣。
測試用例:
最後,我們寫一個遞歸函數實例:
表達式列表
編寫一個可以獲取表達式列表,編譯表達式並加函數compile_program就更好。所以我們還要添加一個begin關鍵字。
編譯函數增加對應功能:
對應測試用例:
本文中,我們使用Python實例編寫了一個字節碼編譯和解釋器,用以說明相應的編譯原理,和開頭提到的一樣,基本原理適用於任何語言,你可以使用你最那首的語言Perl,golang,rust,js甚至是R來實現自己的例子。