(轉)Mono爲何能跨平臺?聊聊CIL(MSIL)

前言:

其實小匹夫在U3D的開發中一直對U3D的跨平臺能力很好奇。到底是什麼原理使得U3D可以跨平臺呢?後來發現了Mono的作用,並進一步瞭解到了CIL的存在。所以,作爲一個對Unity3D跨平臺能力感興趣的U3D程序猿,小匹夫如何能不關注CIL這個話題呢?那麼下面各位看官就拾起語文老師教導我們的作文口訣(WhyWhatHow),和小匹夫一起走進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,甚至是終極目標,搞明白Uniyt3D爲何能跨平臺還有2萬4千9百里的距離。

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

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

主要操作 操作數範圍/條件 操作數類型 操作數
縮寫 全稱 含義 縮寫 全稱 含義 縮寫 全稱 含義 縮寫 全稱 含義
ld load 將操作數壓到堆棧當中,相當於:
push ax
arg argument 參數 ? ? 操作數中的數值 .0 ? 第零個參數 
.1 ? 第一個參數
.2 ? 第二個參數
.3 ? 第三個參數
.s xx (short) 參數xx
a address 操作數的地址 只有 .s xx,參見ldarg.s
loc local 局部變量 參見ldarg
fld field 字段(類的全局變量) 參見ldarg xx ? xx字段,eg:
ldfld xx
c const 常量 .i4 int 4 bytes C#裏面的int,其他的類型例如short需要通過conv轉換 .m1 minus 1 -1
.0 ? 0
.1 ? 1
……
.8   8
.s (short) 後面跟一個字節以內的整型數值(有符號的)
? ? 後面跟四個字節的整型數值
.i8 int 8 bytes C#裏面的long ? ? 後面跟八個字節的整型數值
.r4 real 4 bytes C#裏面的float ? ? 後面跟四個字節的浮點數值
.r8 real 8 bytes C#裏面的double ? ? 後面跟八個字節的浮點數值
null null 空值(也就是0) ? ? ? ? ? ?
st store 計算堆棧的頂部彈出當前值,相當於:
pop ax
參見ld 
conv convert 數值類型轉換,僅僅用純粹的數值類型間的轉換,例如int/float等 ? ? ? .i1 int 1 bytes C#裏面的sbyte ? ? ?
.i2 int 2 bytes C#裏面的short
.i4 int 4 bytes C#裏面的int
.i8 int 8 bytes C#裏面的long
.r4 real 4 bytes C#裏面的float
.r8 real 8 bytes C#裏面的double
.u4 uint 4 bytes C#裏面的uint
.u8 uint 8 bytes C#裏面的ulong
b/br branch 條件和無條件跳轉,相當於:
jmp/jxx label_jump
br ? ? 無條件跳轉 ? ? ? ? ? 後面跟四個字節的偏移量(有符號)
.s (short) 後面跟一個字節的偏移量(有符號)
false false 值爲零的時候跳轉 ? ? ? 參見br
true true 值不爲零的時候跳轉 ? ? ?
b eq equal to 相等 ? ? ?
ne not equal to 不相等 un unsigned or unordered 無氟好的(對於整數)或者無序的(對於浮點)
gt greater than 大於
lt less than 小於
ge greater than or equal to 大於等於
le less than or equal to 小於等於
call call 調用 ? ? ? ? ? (非虛函數) ?
? ? ? virt virtual 虛函數

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

基於堆棧

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

大學時候學單片機(大概是8086,記不清了)的時候記得做加法大概是這樣的:

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#
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的過程。接下來就讓我們運行下剛剛的成果好啦。

1
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. 基於堆棧

如果各位看官覺得文章寫得還好,那麼就容小匹夫跪求各位給點個“推薦”,謝啦~

裝模作樣的聲明一下:本博文章若非特殊註明皆爲原創,若需轉載請保留原文鏈接http://www.cnblogs.com/murongxiaopifu/p/4211964.html及作者信息慕容小匹夫

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章