前一階段我們完成了編譯器中的重要階段——語義分析。現在,程式中的每一個變數和類型都有其正確的定義;每一個運算式和語句的類型都是合法的;每一處方法調用都選擇了正確的方法定義。現在即將進入下一個階段——代碼產生。代碼產生的最終目的,是產生能在目標機器上啟動並執行機器碼,或者可以和其他庫連結在一起的可重新導向對象。代碼產生,和這一階段的各個最佳化手段,統稱為編譯器的後端。目前大部分編譯器,在代碼產生時,都傾向於先將前段解析的結果轉化成一種中間表示,再將中間表示翻譯成最終的機器碼。比如Java語言會翻譯成JVM bytecode,C#語言會翻譯成CIL,再經由各自的虛擬機器執行;IE9的javascript也會先翻譯成一種bytecode,再由解譯器執行或者進行JIT翻譯;即使靜態編譯的語言如C++,也存在先翻譯成中繼語言,再翻譯成最終機器碼的過程。中間表示也不一定非得是一種bytecode,我們在文法分析階段產生的抽象文法樹(AST)就是一種很常用的中間表示。.NET 3.5引入的Expression Tree正是採用AST作為中間表示的動態語言運行庫。那為什麼這種做法非常流行呢?因為翻譯中中繼語言有如下好處:
- 使用中繼語言可以良好地將編譯器的前端和後端拆分開,使得兩部分可以相對獨立地進行。
- 同一種中繼語言可以由多種不同的源語言編譯而來,而又可以針對多種不同的目標機器產生代碼。CLR的CIL就是這一特點的典型代表。
- 有許多最佳化可以直接針對中繼語言進行,這樣最佳化的結果就可以應用到不同的目標平台。
我們這次動手編寫編譯器,自然也少不了中繼語言這一步。為了達到親手實踐的目的,我們將會自己定義中繼語言,但是那樣的話想要把編譯出的程式運行起來就還需要很多工作。為了提前體驗運行目標代碼的成就感,同時驗證編譯器前端的正確性,我們這次先將miniSharp編譯成CLR的中繼語言——CIL(Common IL,MSIL),並且就使用.NET內建的Reflection.Emit庫來做。
首先來瞭解一下CIL的特點。CIL是一種bytecode,在.NET的程式集裡他是二進位方式存在的。我們常常見到的是用ILDASM或者ILSpy反組譯碼而成的彙編形態。例如這一段:
.method public hidebysig newslot instance int32 ComputeFac ( int32 num ) cil managed { // Method begins at RVA 0x2050 // Code size 30 (0x1e) .maxstack 6 .locals init ( [0] int32 ) IL_0000: ldarg.1 IL_0001: ldc.i4.1 IL_0002: clt IL_0004: brfalse IL_0010 IL_0009: ldc.i4.1 IL_000a: stloc.0 IL_000b: br IL_001c IL_0010: ldarg.1 IL_0011: ldarg.0 IL_0012: ldarg.1 IL_0013: ldc.i4.1 IL_0014: sub IL_0015: call instance int32 Fac::ComputeFac(int32) IL_001a: mul IL_001b: stloc.0 IL_001c: ldloc.0 IL_001d: ret } // end of method Fac::ComputeFac |
和機器語言相比,CIL是一種高度抽象的中繼語言。程式集裡有非常豐富的中繼資料,可以直接對應到原始碼裡的類和方法。而CIL僅僅用於描述方法體的邏輯。CIL較少反應出運行時真正發生在CPU上的事情,而更多地與原始碼中的語句和運算式接近。所以我們說CIL是一種相當進階的中繼語言。CIL是一種棧式機。要注意的是,這裡的“棧”與運行時的記憶體堆和棧的“棧”沒有任何關係。CIL的棧是一個運算棧(evaluation stack),它在運行時實際是不存在的,但我們必須要在理解CIL運行過程時想象它存在。運算棧在CIL中的作用是儲存運算的中間結果,這與寄存器機的寄存器有些類型。CIL的每一條指令都只能對運算棧頂進行操作。
看上面的IL代碼,第一行ldarg.1指令的作用是將1號實參載入到運算棧的棧頂上,第二條ldc.i4.1指令是將32位整型常量1壓入運算棧。注意“ldc.i4.1”是一條指令,它是不帶參數的。IL中有許多這種縮短格式的指令,以消除或減少指令參數,從而減少目標代碼的體積。經過這兩條指令後,運算棧中有兩個值:棧頂是32位常量1,其下面是方法的1號參數值。這時遇到指令clt,這條指令會將運算棧先後彈出兩個值,並比較它們的大小,如果後彈出的值小於先彈出的值,則將32位整數“1”壓入運算棧,反之則將“0”壓入運算棧。假設該方法第一個實參傳的是“0”,以上過程:
| 執行指令 |
運算棧 |
| |
空 |
| ldarg.1 |
壓入參數值0
|
| ldc.i4.1 |
壓入常數1
|
| clt |
彈出1 彈出0 比較0 < 1,所以壓入1
|
接下來的brfalse指令又會彈出運算棧頂的值,並根據這個值決定是否要進行跳轉。以此類推,即可理解每條指令的作用。任何一條IL指令總會將一些值壓入棧;或者從棧中彈出一些值;或者先彈出一些值,再壓入一些值。這些不同的動作稱為這條指令的棧轉換行為。每條指令都有固定的棧轉換行為,只要理解了棧轉換行為,就等於完全理解一條IL指令。
MSDN中OpCodes類的協助中詳細介紹了每一條指令的棧轉換規則。當我們需要瞭解CIL指令的含義時,這個協助就是最好的資料。簡單瞭解了CIL與運算棧之後,大部分指令的行為都是很好理解的。我這裡稍微解釋一下某些特殊的規則。
在CIL指令表當中大家會看到許多指令有多個版本。比如ldloc指令用於將局部變數載入到運算棧頂。這個指令就有ldloc、ldloc.s、ldloc.0、ldloc.1等不同的版本。這其中的ldloc是該指令的長版本,其他指令則是短版本。因為CIL是bytecode,所以這些指令在程式集中都是一個或兩個位元組的代碼。ldloc長版本指令自身編碼為兩個位元組(FE 06),而且它需要一個uint16(兩位元組)的參數,所以它一共需要佔四個位元組的空間。我們知道一個方法很少有65536這麼多個本地變數,很多也就是1-2個,有十幾個的已經算是非常多了。所以都用這麼長的指令非常浪費。短版本ldloc.s自身編碼只有一個位元組(11),而且它的參數是uint8(一個位元組),該指令只佔2個位元組的空間。但是ldloc.s只能載入索引在0-255範圍內的本地變數。最後,針對最常用的頭4個本地變數,還有四個最短版本。比如ldloc.0,僅佔一個位元組的編碼(06)沒有參數。我們在產生代碼的時候需要根據訪問本地變數的索引來選取不同的指令:
private void EmitLoadLocal(int locIndex){ switch (locIndex) { case 0: m_ilgen.Emit(OpCodes.Ldloc_0); break; case 1: m_ilgen.Emit(OpCodes.Ldloc_1); break; case 2: m_ilgen.Emit(OpCodes.Ldloc_2); break; case 3: m_ilgen.Emit(OpCodes.Ldloc_3); break; default: if (locIndex <= 255) { m_ilgen.Emit(OpCodes.Ldloc_S, (byte)locIndex); } else { m_ilgen.Emit(OpCodes.Ldloc, (short)locIndex); } break; }} |
下面我們就開始為miniSharp語言編寫CIL代碼產生器。和語義分析階段類似,我們只需要編寫一個AST的Visitor實現即可。要注意,我們不僅需要產生方法的IL代碼,還需要產生程式集、模組、類、方法、建構函式、欄位等定義。Reflection.Emit為這些結構提供了各類Builder類型,使用非常方便,但必須注意一些規則:
- 為了產生exe,程式集入口的PEFileKind應當是ConsoleApplication(預設是Dll)。
- 每個類對應一個TypeBuilder,產生一個類之後必須調用其CreateType方法真正組建類型。一個類CreateType之前,它的父類必須已經CreateType過才行。所以要按照繼承順序建立各個類。
- TypeBuilder上也有Type類的所有方法,如GetConstructor、GetMethod之類,但只有當TypeBuilder調用過CreateType之後這些方法才能使用。所以我們必須自己儲存未完成類型的成員資訊。
下面的代碼展示了按照類繼承順序產生各類的代碼:
public override AstNode VisitProgram(Program ast){ List<ClassDecl> classesInHierarchyOrder = new List<ClassDecl>(); var topBaseClasses = from c in ast.Classes where c.BaseClass.Type == null select c; classesInHierarchyOrder.AddRange(topBaseClasses); while (classesInHierarchyOrder.Count < ast.Classes.Count) { foreach (var c in ast.Classes) { foreach (var b in classesInHierarchyOrder.ToArray()) { if (c.BaseClass.Type == b.Type) { classesInHierarchyOrder.Add(c); } } } } foreach (var c in classesInHierarchyOrder) { Visit(c); } Visit(ast.MainClass); return ast;} |
下面展示MainClass的產生方法。這裡用了一個技巧,即static class = abstract + sealed。
public override AstNode VisitMainClass(MainClass ast){ m_currentType = m_module.DefineType( ast.Type.Name, TypeAttributes.Class | TypeAttributes.Abstract | TypeAttributes.Sealed); m_currentMethod = m_currentType.DefineMethod( "Main", MethodAttributes.Public | MethodAttributes.Static, typeof(void), new[] { typeof(string[]) }); m_ilgen = m_currentMethod.GetILGenerator(); foreach (var s in ast.Statements) { Visit(s); } m_ilgen.Emit(OpCodes.Ret); m_currentType.CreateType(); m_mainMethod = m_currentMethod; return ast;} |
搞定類和方法之後,就開始要產生方法體的代碼了。這一部分最主要的翻譯對象是語句和運算式,有一個要注意的規則:
- 運算式執行之後,該運算式的結果應當壓入運算棧。
- 語句執行後,運算棧應當被清空。
如果不滿足上述規則,產生的程式碼就很有可能是錯的,要非常小心。下面展示兩個最基本的語句——if else和while的產生方法。
public override AstNode VisitIfElse(IfElse ast){ var ifBlock = m_ilgen.DefineLabel(); var elseBlock = m_ilgen.DefineLabel(); var endif = m_ilgen.DefineLabel(); Visit(ast.Condition); //the e-stack should have a bool value m_ilgen.Emit(OpCodes.Brfalse, elseBlock); //if block m_ilgen.MarkLabel(ifBlock); Visit(ast.TruePart); m_ilgen.Emit(OpCodes.Br, endif); //elseblock m_ilgen.MarkLabel(elseBlock); Visit(ast.FalsePart); //after if m_ilgen.MarkLabel(endif); return ast;}public override AstNode VisitWhile(While ast){ var beforeWhile = m_ilgen.DefineLabel(); var afterWhile = m_ilgen.DefineLabel(); m_ilgen.MarkLabel(beforeWhile); Visit(ast.Condition); //the e-stack should have a bool value m_ilgen.Emit(OpCodes.Brfalse, afterWhile); Visit(ast.LoopBody); m_ilgen.Emit(OpCodes.Br, beforeWhile); m_ilgen.MarkLabel(afterWhile); return ast;} |
這裡if語句採用的是brfalse指令。實際上CIL中有許多條件分支語句,如blt、bge等等,可直接翻譯if ( a > b )這樣的結構,效率更高。此次我採用偷懶的做法,全都用clt, cgt這樣的有傳回值的指令來計算大於小於等比較運算,但後統一用brfalse來執行條件跳轉。上面這段代碼還展示了Label在Emit API中的使用方法。翻譯指派陳述式和數組指派陳述式時要注意,為本地變數、本地參數或類的欄位賦值時採用的指令和棧轉換動作均有所不同,需要分別考慮。比如ldfld之前必須先將目標對象壓棧,如果是this的話應該用ldarg.0指令(執行個體方法預設第0個參數是this引用)
再來示範兩個基本運算式的翻譯,二元運算子和方法調用:
public override AstNode VisitBinary(Binary ast){ //push operands Visit(ast.Left); Visit(ast.Right); switch (ast.Operator) { case BinaryOperator.Add: m_ilgen.Emit(OpCodes.Add); break; case BinaryOperator.Substract: m_ilgen.Emit(OpCodes.Sub); break; case BinaryOperator.Multiply: m_ilgen.Emit(OpCodes.Mul); break; case BinaryOperator.Divide: m_ilgen.Emit(OpCodes.Div); break; case BinaryOperator.Less: m_ilgen.Emit(OpCodes.Clt); break; case BinaryOperator.Greater: m_ilgen.Emit(OpCodes.Cgt); break; case BinaryOperator.Equal: m_ilgen.Emit(OpCodes.Ceq); break; case BinaryOperator.LogicalAnd: m_ilgen.Emit(OpCodes.And); break; case BinaryOperator.LogicalOr: m_ilgen.Emit(OpCodes.Or); break; default: m_ilgen.Emit(OpCodes.Pop); m_ilgen.Emit(OpCodes.Pop); m_ilgen.Emit(OpCodes.Ldc_I4_0); break; } return ast;}public override AstNode VisitCall(Call ast){ var methodRInfo = GetClrMethod(ast.Method.MethodInfo); //push target object Visit(ast.Target); //push arguments foreach (var arg in ast.Arguments) { Visit(arg); } m_ilgen.EmitCall(OpCodes.Call, methodRInfo, null); return ast;} |
注意這裡翻譯&&和||運算子時沒有產生“短路”操作,因此與C#的語義稍有不同。如果要支援短路也非常容易,大家可以親自實驗一下。翻譯二元運算子時,如果語義分析正確無誤,不應該進入default分支。所以在此只進行一種錯誤處理的邏輯,它仍然要保持運算棧的平衡。翻譯方法調用時,應當先將方法的目標對象壓棧,然後從左至右依次壓入每個實參,最後調用call指令完成調用。
所有的TypeBuilder都調用CreateType之後,最後調用AssemblyBuilder.Save方法,就可以將目標程式集寫入磁碟了!
public void Create(Ast.AstNode ast, string url){ Visit(ast); Debug.Assert(m_assembly != null); m_assembly.SetEntryPoint(m_mainMethod, PEFileKinds.ConsoleApplication); m_assembly.Save(url);} |
現在終於可以試試看了,我們來編譯一段miniSharp代碼試試:(階乘計算)
static class 程式入口{ //中文注釋 public static void Main(string[] args) { Fac o; o = new Fac(); System.Console.WriteLine(o.ComputeFac(8)); }}class Fac{ public int ComputeFac(int num) { int num_aux; if (num < 1) num_aux = 1; else num_aux = num * (this.ComputeFac(num - 1)); return num_aux; }} |
產生的程式集:
運行結果:
看到自己的編譯器正確地編譯原始碼,是否覺得很有成就感呢?如果只想做一個託管程式設計語言,那麼產生CIL就是最後一步了。但是CLR幫我們做的實在太多了,不能滿足我們的求知慾。所以,下一階段我們將親手實現從中繼語言到目標機器代碼的編譯器後端部分。從下一篇開始本系列的間隔時間會變得比較長而且不確定,因為我自己也需要一邊學習一邊實踐。
希望大家繼續關注我的VBF項目:https://github.com/Ninputer/VBF 和我的微博:http://weibo.com/ninputer 多謝大家支援!