1. 程式人生 > >Mono為何能跨平臺?聊聊CIL

Mono為何能跨平臺?聊聊CIL

http://www.infoq.com/cn/articles/why-mono-can-cross-platform-talk-about-cil

前言:

其實小匹夫在U3D的開發中一直對U3D的跨平臺能力很好奇。到底是什麼原理使得U3D可以跨平臺呢?後來發現了Mono的作用,並進一步瞭解到了CIL的存在。所以,作為一個對Unity3D跨平臺能力感興趣的U3D程式猿,小匹夫如何能不關注CIL這個話題呢?那麼下面各位看官就拾起語文老師教導我們的作文口訣(Why、What、How),和小匹夫一起走進CIL的世界吧~

Why?

回到本文的題目,U3D或者說Mono的跨平臺是如何做到的?

如果換做小匹夫或者看官你來做,應該怎麼實現一套程式碼對應多種平臺呢?

其實原理想想也簡單,生活中也有很多可以參考的例子,比如下圖(誰讓小匹夫是做移動端開發的呢,只能物盡其用從自己身邊找例子了T.T):

像這樣一根線,管你是安卓還是ios都能充電。所以從這個意義上,這貨也實現了跨平臺。那麼我們能從它身上學到什麼呢?對的,那就是從一樣的能源(電)到不同的平臺(ios,安卓)之間需要一箇中間層過度轉換一下。

那麼來到U3D為何能跨平臺,簡而言之,其實現原理在於使用了叫CIL(Common Intermediate Language通用中間語言,也叫做MSIL微軟中間語言)的一種程式碼指令集,CIL可以在任何支援CLI(Common Language Infrastructure,通用語言基礎結構)的環境中執行,就像.NET是微軟對這一標準的實現,Mono則是對CLI的又一實現。由於CIL能執行在所有支援CLI的環境中,例如剛剛提到的.NET執行時以及Mono執行時,也就是說和具體的平臺或者CPU無關。這樣就無需根據平臺的不同而部署不同的內容了。所以到這裡,各位也應該恍然大了。程式碼的編譯只需要分為兩部分就好了嘛:

  1. 從程式碼本身到CIL的編譯(其實之後CIL還會被編譯成一種位元碼,生成一個CLI assembly)
  2. 執行時從CIL(其實是CLI assembly,不過為了直觀理解,不必糾結這種細節)到本地指令的即時編譯(這就引出了為何U3D官方沒有提供熱更新的原因:在iOS平臺中Mono無法使用JIT引擎,而是以Full AOT模式執行的,所以此處說的即時編譯不包括IOS

What?

上文也說了CIL是指令集,但是不是還是太模糊了呢?所以語文老師教導我們,描述一個東西時肯定要先從外貌寫起。遵循老師的教導,我們不妨先通過工具來看看CIL到底長什麼樣。

工具就是ildasm了。下面小匹夫寫一個簡單的.cs看看生成的CIL程式碼長什麼樣。

C#程式碼:

class Class1
{
    public static void Main(string[] args)
    {
            System.Console.WriteLine("hi");
    }
}

CIL程式碼:

.class private auto ansi beforefieldinit Class1
   extends [mscorlib]System.Object
{
  .method public hidebysig static void  Main(string[] args) cil managed
  {
    .entrypoint
    // 程式碼大小       13 (0xd)
    .maxstack  8
    IL_0000:  nop
    IL_0001:  ldstr      "hi"
    IL_0006:  call       void   [mscorlib]System.Console::WriteLine(string)
    IL_000b:  nop
    IL_000c:  ret
  } // end of method Class1::Main

  .method public hidebysig specialname rtspecialname 
      instance void  .ctor() cil managed
  {
    // 程式碼大小       7 (0x7)
    .maxstack  8
    IL_0000:  ldarg.0
    IL_0001:  call       instance void [mscorlib]System.Object::.ctor()
    IL_0006:  ret
  } // end of method Class1::.ctor

} // end of class Class1

好啦。程式碼雖然簡單,但是也能說明足夠多的問題。那麼和CIL的第一次親密接觸,能給我們留下什麼直觀的印象呢?

  1. 以“.”一個點號開頭的,例如上面這份程式碼中的:.class、.method 。我們稱之為CIL指令(directive),用於描述.NET程式集總體結構的標記。為啥需要它呢?因為你總得告訴編譯器你處理的是啥吧。
  2. 貌似CIL程式碼中還看到了private、public這樣的身影。姑且稱之為CIL特性(attribute)。它的作用也很好理解,通過CIL指令並不能完全說明.NET成員和類,針對CIL指令進行補充說明成員或者類的特性的。市面上常見的還有:extends,implements等等。
  3. 每一行CIL程式碼基本都有的,對,那就是CIL操作碼咯。小匹夫從網上找了一份漢化的操作碼錶放在附錄部分,當然英文版的你的vs就有。

直觀的印象有了,但是離我們的短期目標,說清楚(或者說介紹個大概)CIL是What,甚至是終極目標,搞明白Mono為何能跨平臺還有2萬4千9百里的距離。

好啦,話不多說,繼續亂侃。

參照附錄中的操作碼錶,對照可以總結出一份更易讀的表格。那就是如下的表啦。

檢視完整大圖:

在此,小匹夫想請各位認真讀表,然後心中默數3個數,最後看看都能發現些什麼。

基於堆疊

如果是小匹夫的話,第一感覺就是基本每一條描述中都包含一個“棧”。不錯,CIL是基於堆疊的,也就是說CIL的VM(mono執行時)是一個棧式機。這就意味著資料是推入堆疊,通過堆疊來操作的,而非通過CPU的暫存器來操作,這更加驗證了其和具體的CPU架構沒有關係。為了說明這一點,小匹夫舉個例子好啦。

大學時候學微控制器的時候記得做加法大概是這樣的:

add eax,-2

其中的eax是啥?暫存器。所以如果CIL處理資料要通過cpu的暫存器的話,那也就不可能和cpu的架構無關了。

當然,CIL之所以是基於堆疊而非CPU的另一個原因是相比較於cpu的暫存器,操作堆疊實在太簡單了。回到剛才小匹夫說的大學時候曾經學過的微控制器那門課程上,當時記得各種暫存器,各種標誌位,各種。。。,而堆疊只需要簡單的壓棧和彈出,因此對於虛擬機器的實現來說是再合適不過了。所以想要更具體的瞭解CIL基於堆疊這一點,各位可以去看一下堆疊方面的內容。這裡小匹夫就不拓展了。

面向物件

那麼第二感覺呢?貌似附錄的表中有new物件的語句呀。嗯,的確,CIL同樣是面向物件的。

這意味著什麼呢?那就是在CIL中你可以建立物件,呼叫物件的方法,訪問物件的成員。而這裡需要注意的就是對方法的呼叫。

回到上表中的右上角。對,就是對引數的操作部分。靜態方法和例項方法是不同的哦~

  1. 靜態方法:ldarg.0沒有被佔用,所以引數從ldarg.0開始。
  2. 例項方法:ldarg.0是被this佔用的,也就是說實際上的引數是從ldarg.1開始的。

舉個例子:假設你有一個類Murong中有一個靜態方法Add(int32 a, int32 b),實現的內容就如同它的名字一樣使兩個數相加,所以需要2個引數。和一個例項方法TellName(string name),這個方法會告訴你傳入的名字。

class  Murong
{
    public void TellName(string name)
    {
        System.Console.WriteLine(name);
    }

    public static int Add(int a, int b)
    {
        return a + b;
    }
}

靜態方法的處理:

那麼其中的靜態方法Add的CIL程式碼如下:

//小匹夫註釋一下。
.method public hidebysig static int32   Add(int32 a,
                                        int32 b) cil managed
{
  // 程式碼大小       9 (0x9)
  .maxstack  2
  .locals init ([0] int32 CS$1$0000)   //初始化區域性變數列表。因為我們只返回了一個int型。所以這裡聲明瞭一個int32型別。索引為0
  IL_0000:  nop
  IL_0001:  ldarg.0     //將索引為 0 的引數載入到計算堆疊上。
  IL_0002:  ldarg.1     //將索引為 1 的引數載入到計算堆疊上。
  IL_0003:  add          //計算
  IL_0004:  stloc.0      //從計算堆疊的頂部彈出當前值並將其儲存到索引 0 處的區域性變數列表中。
  IL_0005:  br.s       IL_0007
  IL_0007:  ldloc.0     //將索引 0 處的區域性變數載入到計算堆疊上。
  IL_0008:  ret           //返回該值
} // end of method Murong::Add

那麼我們呼叫這個靜態函式應該就是這樣咯。

Murong.Add(1, 2);

對應的CIL程式碼為:

IL_0001:  ldc.i4.1 //將整數1壓入棧中
IL_0002:  ldc.i4.2 //將整數2壓入棧中
IL_0003:  call       int32 Murong::Add(int32,
                                     int32)  //呼叫靜態方法

可見CIL直接call了Murong的Add方法,而不需要一個Murong的例項。

例項方法的處理:

Murong類中的例項方法TellName()的CIL程式碼如下:

.method public hidebysig instance void  TellName(string name) cil managed
{
  // 程式碼大小       9 (0x9)
  .maxstack  8
  IL_0000:  nop
  IL_0001:  ldarg.1     //看到和靜態方法的區別了嗎?
  IL_0002:  call       void [mscorlib]System.Console::WriteLine(string)
  IL_0007:  nop
  IL_0008:  ret
} // end of method Murong::TellName

看到和靜態方法的區別了嗎?對,第一個引數對應的是ldarg.1中的引數1,而不是靜態方法中的0。因為此時引數0相當於this,this是不用參與引數傳遞的。

那麼我們再看看呼叫例項方法的C#程式碼和對應的CIL程式碼是如何的。

C#:

//C#
Murong murong = new Murong();
murong.TellName("chenjiadong");

CIL:

.locals init ([0] class Murong murong)   //因為C#程式碼中定義了一個Murong型別的變數,所以區域性變數列表的索引0為該型別的引用。
//....
IL_0009:  newobj     instance void  Murong::.ctor() //相比上面的靜態方法的呼叫,此處new一個新物件,出現了instance方法。
IL_000e:  stloc.0
IL_000f:  ldloc.0
IL_0010:  ldstr      "chenjiadong" //小匹夫的名字入棧
IL_0015:  callvirt   instance void  Murong::TellName(string) //例項方法的呼叫也有instance

到此,受制於篇幅所限(小匹夫不想寫那麼多字啊啊啊!)CIL是What的問題大致介紹一下。當然沒有再拓展,以後小匹夫可能會再詳細寫一下這塊。

How?

記得語文老師說過,寫作文最重要的一點是要首尾呼應。既然咱們開篇就提出了U3D為何能跨平臺的問題,那麼接近文章的結尾咱們就再來

提問:

Q:上面的Why部分,咱們知道了U3D能跨平臺是因為存在著一個能通吃的中間語言CIL,這也是所謂跨平臺的前提,但是為啥CIL能通吃各大平臺呢?當然可以說CIL基於堆疊,跟你CPU怎麼架構的沒啥關係,但是感覺過於理論化、學術化,那還有沒有通俗化、工程化的說法呢?

A:原因就是前面小匹夫提到過的,.Net執行時和Mono執行時。也就是說CIL語言其實是執行在虛擬機器中的,具體到咱們的U3D也就是mono的執行時了,換言之mono執行的其實CIL語言,CIL也並非真正的在本地執行,而是在mono執行時中執行的,執行在本地的是被編譯後生成的原生程式碼。當然看官博的文章,他們似乎也在開發自己的“mono”,也就是被稱為指令碼的未來的IL2Cpp,這種類似執行時的功能是將IL再編譯成c++,再由c++編譯成原生程式碼,據說效率提升很可觀,小匹夫也是蠻期待的。

這裡為了“實現跨平臺式的演示”,小匹夫用mac給各位做個測試好啦:

從C#到CIL

新建一個cs檔案,然後使用mono來執行。這個cs檔案內容如下:

然後咱們直接在命令列中執行這個cs檔案試試~

說的很清楚,檔案沒有包含一個CIL映像。可見mono是不能直接執行cs檔案的。假如我們把它編譯成CIL呢?那麼我們用mono帶的mcs來編譯小匹夫的Test.cs檔案。

mcs Test.cs

生成了什麼呢?如圖:

好像沒見有叫.IL的檔案生成啊?反而好像多了一個.exe檔案?可是沒聽說Mac能執行exe檔案呀?可為啥又生成了.exe呢?各位看官可能要說,小匹夫你是不是拿windows截圖P的啊?嘿嘿,小匹夫可不敢。辣麼真相其實就是這個exe並不是讓Mac來執行的,而是留給mono執行時來執行的,換言之這個檔案的可執行程式碼形式是CIL的位元碼形態。到此,我們完成了從C#到CIL的過程。接下來就讓我們執行下剛剛的成果好啦。

mono Test.exe

結果是輸出了一個大大的“Hi”。這裡,就引出了下一個部分。

從CIL到Native Code

這個“HI”可是在小匹夫的MAC終端上出現的呀,那麼就證明這個C#寫的程式碼在MAC上執行的還挺“嗨”。

為啥呢?為啥C#寫的程式碼能跑在MAC上呢?這就不得不提從CIL如何到本機原生程式碼的過程了。Mono提供了兩種編譯方式,就是我們經常能看到的:JIT(Just-in-Time compilation,即時編譯)和AOT(Ahead-of-Time,提前編譯或靜態編譯)。這兩種方式都是將CIL進一步編譯成平臺的原生程式碼。這也是實現跨平臺的最後一步。下面就分頭介紹一下。

JIT即時編譯:

從名字就能看的出來,即時編譯,或者稱之為動態編譯,是在程式執行時才編譯程式碼,解釋一條語句執行一條語句,即將一條中間的託管的語句翻譯成一條機器語句,然後執行這條機器語句。但同時也會將編譯過的程式碼進行快取,而不是每一次都進行編譯。所以可以說它是靜態編譯和直譯器的結合體。不過你想想機器既要處理程式碼的邏輯,同時還要進行編譯的工作,所以其執行時的效率肯定是受到影響的。因此,Mono會有一部分程式碼通過AOT靜態編譯,以降低在程式執行時JIT動態編譯在效率上的問題。

不過一向嚴苛的IOS平臺是不允許這種動態的編譯方式的,這也是U3D官方無法給出熱更新方案的一個原因。而Android平臺恰恰相反,Dalvik虛擬機器使用的就是JIT方案。

AOT靜態編譯:

其實Mono的AOT靜態編譯和JIT並非對立的。AOT同樣使用了JIT來進行編譯,只不過是被AOT編譯的程式碼在程式執行之前就已經編譯好了。當然還有一部分程式碼會通過JIT來進行動態編譯。下面小匹夫就手動操作一下mono,讓它進行一次AOT編譯。

//在命令列輸入
mono --aot Test.exe

結果:

檢視完整大圖:

從圖中可以看到JIT time: 39 ms,也就是說Mono的AOT模式其實會使用到JIT,同時我們看到了生成了一個適應小匹夫的MAC的動態庫Test.exe.dylib,而在Linux生成就是.so(共享庫)。

AOT編譯出來的庫,除了包括我們的程式碼之外,還有被快取的元資料資訊。所以我們甚至可以只編譯元資料資訊而不編譯程式碼。例如這樣:

//只包含元資料的資訊
mono --aot=metadata-only Test.exe

檢視完整大圖:

可見程式碼沒有被包括進來。

那麼簡單總結一下AOT的過程:

  1. 收集要被編譯的方法
  2. 使用JIT進行編譯
  3. 發射(Emitting)經JIT編譯過的程式碼和其他資訊
  4. 直接生成檔案或者呼叫本地彙編器或聯結器進行處理之後生成檔案。(例如上圖中使用了小匹夫本地的gcc)

Full AOT

當然上文也說了,IOS平臺是禁止使用JIT的,可看樣子Mono的AOT模式仍然會保留一部分程式碼會在程式執行時動態編譯。所以為了破解這個問題,Mono提供了一個被稱為Full AOT的模式。即預先對程式集中的所有CIL程式碼進行AOT編譯生成一個原生代碼映像,然後在執行時直接載入這個映像而不再使用JIT引擎。目前由於技術或實現上的原因在使用Full AOT時有一些限制,不過這裡不再多說了。以後也還會更細的分析下AOT。

總結

好啦,寫到現在也已經到了凌晨3:04分了。感覺寫的內容也差不多了。那麼對本文的主題U3D為何能跨平臺以及CIL做個最終的總結陳詞:

  1. CIL是CLI標準定義的一種可讀性較低的語言。
  2. 以.NET或mono等實現CLI標準的執行環境為目標的語言要先編譯成CIL,之後CIL會被編譯,並且以位元碼的形式存在(原始碼--->中間語言的過程)。
  3. 這種位元碼執行在虛擬機器中(.net mono的執行時)。
  4. 這種位元碼可以被進一步編譯成不同平臺的原生程式碼(中間語言--->原生程式碼的過程)。
  5. 面向物件
  6. 基於堆疊

感謝郭蕾對本文的審校。