動手寫IL到Lua的翻譯器——准備


文章里的代碼粘過來的時候格式有點問題,原因是一開始文章是在訂閱號上寫的(gamedev101,文末有二維碼),不知道為啥貼過來就沒了格式,還要手動刪行號,就沒搞了。

 


 

介紹下問題背景:

小說君正在參與的項目,服務端邏輯以C#為主。

之前的一篇文章,《公式計算機》也有提到,這個項目的服務端需要提供讓策划寫游戲業務的能力。

不過跟文章里的方案不同,最后策划用來寫業務的語言是C#。

 

實踐下來,策划寫的業務分為兩大類:

  1. 戰斗相關的流程性質的邏輯。例如技能結算的流程性邏輯。

  2. 各模塊中經常變動的運算邏輯。例如面板屬性的運算邏輯。

 

如圖,簡單直接,就是程序寫的Foo調用策划寫的Formula。

 


 

這些邏輯如果只放在服務端,那就什么問題也沒有。

 

第一類邏輯,由於游戲類型的原因(MMO),基本上只有服務端會用,服務端想怎么更新就怎么更新。

 

第二類邏輯,面板屬性運算,不僅服務端需要計算完推給客戶端做顯示用,客戶端自己也需要做屬性預覽。

要解決這個問題,一般的做法要么是客戶端每次問服務端計算下數據,顯示出來;要么是客戶端也維護一份相關邏輯的定義。

 

第一種做法,是項目實在改不動了才不得不用。

第二種做法,如果客戶端大部分面板邏輯跑在C#上,那還好辦,策划的邏輯打成Assembly,客戶端服務端兩邊共用。

但是現在Lua普及程度已經這么高了,很少還有面板主要靠C#的手游。

 

這樣,如標題所說,如果我們有個工具可以把C#轉成Lua,平時策划用C#寫業務,持續集成流程自動把客戶端服務端共用的業務邏輯轉成Lua,客戶端用自動生成的對應的Lua函數做一些面板預覽計算邏輯,就解決了上面說的所有問題。

 

 

  • 策划用C#寫Formula,強類型,減少犯錯。

  • 工具把C#版本的Formula轉成Formula.lua。

  • 客戶端的xxx.lua直接require Formula,調用。

 


 

把C#翻譯成Lua的方法有很多種。

比如可以直接給Roslyn寫插件,集成在編譯流程里,取到C#的語法樹,然后做自動生成。

再比如可以讀C#的編譯后程序集,反編譯,拿到語法樹,然后做代碼生成。

 

兩種方法相比較,小說君更傾向於后者。原因也很簡單:

  • C#是一種多范式編程語言,語法特性多而雜。而且C#版本越新,語法糖越多,Lua很難覆蓋。前者拿到的就是源代碼對應的語法樹,要變換的東西太多。

  • C#編譯出的IL就簡單多了,由於抽象層次介於底層語言和高級語言之間,基本上不用做任何變換就可以用任一門高級語言完整表達。

 

最關鍵的是,對於IL來說,有強大的ILSpy工具,可以讀取IL,可以選擇性地做反編譯變換,方便生成適用於目標語言的語法樹。

 


 

接下來進一段背景知識,用過C#的同學都知道,C#源代碼會被編譯為IL Assembly。然后由具體的runtime加載Assembly,編譯為native code並執行,這也是現在幾乎所有虛擬機語言的執行流程。

 

 

.Net Core/Mono是兩個比較常見的加載執行Assembly的backend。既可以運行時JIT編譯為native code直接執行,也可以編譯期AOT。

IL2CPP與上面兩個稍微不同,但是本質屬於一種AOT。Assembly被翻譯成CPP代碼集合,與支持庫編譯、link為目標文件。

 

Assembly的信息除了一些元信息比如模塊、類定義之外,主要存的是每個方法的IL指令集合。

IL是一種基於操作棧的虛擬機語言,所有的IL指令要么是把參數或返回值push到操作棧,要么是從操作棧pop值。

圍繞IL稱呼的名詞比較多,不過由於這次的系列主題不會太深入,所以就簡單統稱為IL了。有興趣的同學可以查閱ECMA335深入學習下IL。

 

C#中的200+100,翻譯為IL后,就是依次push 200、push 100,然后調用add指令,從操作棧pop兩個值,相加把結果push回操作棧。

 


 

介紹完IL,我們繼續看把IL翻譯成Lua的方案。

先看下參考方案,Unity的IL2CPP。

 

Unity在4.x開始引入了IL2CPP,用來在一些平台上替代mono這個邏輯腳本的backend。

IL2CPP整套工具鏈除了支持工具以外,主要分為兩塊:

  • 把CIL Assembly翻譯成CPP的工具集。

  • 支撐翻譯后的CPP正常運行在各個目標平台上的Native庫。

 

總的來說,IL2CPP做的事情就是把IL Assembly翻譯成C++文件集合,然后提供一些庫函數,保證原來的IL能怎么在Mono上跑起來,現在的so就也能直接跑起來。

相比之下,由於我們的需求比較簡單,所以ILToLua要做的事情就簡單很多了。比如IL2CPP需要提供gc相關的庫支持,lua就不用考慮這個問題。

再比如IL2CPP需要自己搞一套異常處理機制在C++中支持IL中的try-catch-finally語義,我們就可以有限支持。

 

先訂個小目標:我們實現一個工具,可以解析IL Assembly,將其中特定類型的定義轉為一個Lua module。

 

比如這樣一個簡單的類定義:

 1public class Test
 2{
 3    private Random r = new Random();
 4
 5    public void Foo(Custom a, Custom b, Context ctx)
 6    {
 7        if ((a.Count - b.Count) > 0)
 8        {
 9            b.Rate = Modify(b.Rate, 0.003f * (b.Count - a.Count) * (b.Count - a.Count));
10
11            var t = Math.Min(1 - b.Rate, a.Rate);
12
13            a.Rate = Modify(a.Rate, t - a.Rate);
14        }
15    }
16
17    private float Modify(float old, float diff)
18    {
19        var newVal = old + diff;
20
21        if (newVal < 0f)
22        {
23            newVal = 0f;
24        }
25        return newVal;
26    }
27}

 

里面的邏輯也比較簡單,剛入門的策划寫起來完全沒問題。

 

我們需要的大概的翻譯效果:

 1local Prelude = require("LX6/Base/Prelude")
 2local Math = require("LX6/Base/Math")
 3
 4local Random = System.Random
 5
 6local Formula = {}
 7
 8Formula.r = Random.New()
 9
10function Formula:Foo(a, b, ctx)
11    if a.Count - b.Count > 0 then
12        b.Rate = self:Modify(b.Rate, 0.003 * (b.Count - a.Count) * (b.Count - a.Count))
13        local t = Math.Min(1 - b.Rate, a.Rate)
14        a.Rate = self:Modify(a.Rate, t - a.Rate)
15    end
16end
17
18function Formula:Modify(old, diff)
19    local newVal = old + diff
20    if newVal < 0 then
21        newVal = 0
22    end
23    return newVal
24end
25
26return Formula

把這個類翻譯為Lua中的一個table。

簡化起見,這里就略去了table的構造函數。

 

兩個特點:

  1. 只翻譯一個類型。

  2. 由於lua本身的特性,函數用到的所有復雜參數都是鴨子類型(具體為table或udata)。

 

這兩點跟IL2CPP很不一樣,我們只需要把一個類型翻譯成Lua,不需要遞歸地去翻譯這個類型引用的其他類型。比如例子中的Custom和Context。

外面想調用的時候傳一個有Count和Rate成員的table也可以,傳一個真的符合類型的udata也可以。

 


 

接下來就開始進入正題了。不過由於這次文章的主題關聯的內容比較多,小說君打算分成幾篇短文來寫。每篇聚焦的內容稍微少一點。

 

大概的安排是:

  • 本篇剩下的篇幅介紹下Mono.Cecil,然后初步認識下ILSpy。

  • 接下來介紹ILSpy的一些原理性質的東西,以及相應的實現細節。

  • 然后開始進入ILToLua的主題,跟大家分享下實現細節。

 

IL2CPP把IL Assembly翻譯成CPP的部分,就是靠Mono.Cecil做的。

 

Mono.Cecil,官方解釋

Cecil is a library written by Jb Evain to generate and inspect programs and libraries in the ECMA CIL format. 

 

簡單來說,就是Mono.Cecil是符合ECMA335規范的。我們借助這個庫,可以結構化地讀Assembly,用起來跟.Net帶的反射庫差不多,只不過Mono.Cecil有自己的類型定義。可以修改Assembly。可以運行時Emit代碼。

 

Mono.Cecil可以用來寫編譯器,寫反編譯器,以及各種東西。

Unity用到的大量工具集都用了這個庫,比如用來裁剪未引用的字節碼的工具,用來在Editor熱更新腳本的工具等等。

 

 

Mono.Cecil wiki上介紹了現在用到這個庫的一些工具。基本上編譯、反編譯、混淆、AOP相關的工具都有用到。

 


 

IL本身是一種抽象層次比較高的語言,用Mono.Cecil可以比較容易地拿到Assembly中定義的全部類型,以及每個類型包含方法的IL集合。

 

還是之前的代碼示例,摳出來一個簡單函數:

 1private float Modify(float old, float diff)
 2{
 3    var newVal = old + diff;
 4
 5    if (newVal < 0f)
 6    {
 7        newVal = 0f;
 8    }
 9    return newVal;
10}

用ILSpy看到的IL是這樣的:

 1.method private hidebysig 
 2    instance float32 Modify (
 3        float32 old,
 4        float32 diff
 5    ) cil managed 
 6{
 7    // Method begins at RVA 0x2110
 8    // Code size 20 (0x14)
 9    .maxstack 2
10    .locals init (
11        [0] float32
12    )
13
14    // float newVal = old + diff;
15    IL_0000: ldarg.1
16    IL_0001: ldarg.2
17    IL_0002: add
18    IL_0003: stloc.0
19    // if (newVal < 0f)
20    IL_0004: ldloc.0
21    IL_0005: ldc.r4 0.0
22    IL_000a: bge.un.s IL_0012
23
24    // newVal = 0f;
25    IL_000c: ldc.r4 0.0
26    IL_0011: stloc.0
27
28    // return newVal;
29    IL_0012: ldloc.0
30    // (no C# code)
31    IL_0013: ret
32} // end of method Test::Modify
33

IL2CPP翻譯成這樣:

 1// System.Single ConsoleApplication13.Test::Modify(System.Single,System.Single)
 2extern "C"  float Test_Modify_m3633460209 (Test_t2103423000 * __this, float ___old0, float ___diff1, const RuntimeMethod* method)
 3{
 4    float V_0 = 0.0f;
 5    {
 6        float L_0 = ___old0;
 7        float L_1 = ___diff1;
 8        V_0 = ((float)((float)L_0+(float)L_1));
 9        float L_2 = V_0;
10        if ((!(((float)L_2) < ((float)(0.0f)))))
11        {
12            goto IL_0012;
13        }
14    }
15    {
16        V_0 = (0.0f);
17    }
18
19IL_0012:
20    {
21        float L_3 = V_0;
22        return L_3;
23    }
24}

比較直接。只做了比較簡單的塊划分,和數據流分析,沒做Inlining,也沒做控制流分析。

 

我們在ILSpy中看到的信息,如果不反編譯的話,大部分都是借助Mono.Cecil讀出來的。比如Assembly依賴的其他Assembly,Assembly里面的命名空間和類型定義,具體到每個類型定義的Method、Field、Property等定義,以及最關鍵的,每個Method的IL Instruction。

 

Mono.Cecil拿到的Assembly元信息層次關系圖:

 

然后是BCL反射庫拿到的:

除了叫法有區別,其他能拿到的信息都是差不多的。

最大的區別就是Mono.Cecil可以直接拿到帶類型的IL Instruction,比較方便。當然,修改,回寫的接口就不用說了,BCL反射庫是沒有的。

 


 

ILSpy反編譯的流程,就是根據Mono.Cecil,拿到具體類型,拿到類型定義的方法,以及各自的MethodBody。

然后對MethodBody中的IL Instructions做數據流分析,控制流分析,最后轉為AST,再輸出為C#代碼。

 

這篇就到這里。

下篇小說君重點介紹下ILSpy的數據流分析和控制流分析過程和具體實現細節。

 


 

個人訂閱號:gamedev101「說給開發游戲的你」,聊聊服務端,聊聊游戲開發。

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM