2015年8月27日 星期四

深入IL2CPP核心 - 程式碼產生之旅

作者:JOSH PETERSON
原文:http://blogs.unity3d.com/2015/05/13/il2cpp-internals-a-tour-of-generated-code/
翻譯:IndieAce論壇 - Bowie

這是IL2CPP深入講解系列的第二篇文章。在這篇文章中,我們會對由il2cpp產生的C++程式碼進行分析。我們會看到托管程式碼中的類別在C++中如何表示,對.NET虛擬機提供支持的C++程式碼執行時檢查等功能。

後面例子會使用特定版本的Unity,隨著以後新版本的Unity發佈,這些程式碼可能會有所改變。不過這沒有關係,因為我們文中將要提到的概念是不會變的。

範例程序


我將用到Unity 5.0.1p1來創建範例程序。和第一篇文章一樣,我創建了一個空的專案,增加一個文件,加入如下內容:


using UnityEngine;
public class HelloWorld : MonoBehaviour {
  private class Important {
    public static int ClassIdentifier = 42;
    public int InstanceIdentifier;
  }
  void Start () {
    Debug.Log("Hello, IL2CPP!");
    Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);
    var importantData = new [] {
      new Important { InstanceIdentifier = 0 },
      new Important { InstanceIdentifier = 1 } };
    Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
    Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
    try {
      throw new InvalidOperationException("Don't panic");
    }
    catch (InvalidOperationException e) {
      Debug.Log(e.Message);
    }
    for (var i = 0; i < 3; ++i) {
      Debug.LogFormat("Loop iteration: {0}", i);
    }
  }
}

把平台切換到WebGL,並且打開「Development Player」選項以便我們能得到相對可以閱讀的函數,變數名稱。我還將「Enable Exceptions」設定到「Full」以便打開偵測異常。

產生程式碼總覽


在WebGL專案產生之後,產生的C++文件可以在:
Temp\StagingArea\Data\il2cppOutput目錄下找到。一但Unity Editor關閉退出,這個臨時目錄就會被刪除。相反的,只要Editor還開著,這個目錄就會保持不變,方便我們檢視。

雖然這個範例專案很小,只有一個C#程式碼文件,但是il2cpp還是產生了很多文件。我發現有4625個Head文件和89個C++文件。要處理這麼多程式碼文件,我個人喜歡用Exuberant CTags 編輯工具。它可以快速的產生程式碼文件標籤,讓瀏覽理解這些程式碼變得更容易。

一開始,你會發現這些產生的C++文件都不是來源於我們那個簡單的C#程式碼,而是來源於諸如mscorlib.dll 這樣的C#標準庫。正如我們在第一篇文章中提到的,IL2CPP後台使用的標準庫和Mono使用的庫是同一套,沒有任何區別。需要注意的是當每次構建專案的時候,il2cpp.exe都會把這些標準庫轉換一次。感覺這沒啥必要,因為這些庫文件是不會改變的。

然而,在IL2CPP的後端處理中,通常會使用字節碼剝離(byte code stripping)技術來減少可執行文件的大小。因此遊戲程式碼的一小點變化也會導致標準庫引用的改變,並影響最終剝離程式碼。所以目前我們還是在每次產生專案的時候轉換所有的標準庫。我們也在研究是否有其他更好的方法可以加快專案產生的速度,但目前為止還沒有好的進展。

托管程式碼如何映射到C++程式碼


在托管程式碼中的每個類別,il2cpp.exe都會相應的產生一個有著C++定義的頭文件和另外一個進行函數聲明的頭文件。舉個例子,讓我們看看UnityEngine.Vector3是如何被轉換的。這個類別的頭文件名字叫:

UnityEngine_UnityEngine_Vector3.h.頭文件名的組成:一開始是程序集名稱(這裡是UnityEngine),然後跟著命名空間(還是UnityEngine),最後是這個類型的名字(Vector3)。頭文件的內容如下:

1
2
3
4
5
6
7
8
9
10
// UnityEngine.Vector3
struct Vector3_t78
{
  // System.Single UnityEngine.Vector3::x
  float ___x_1;
  // System.Single UnityEngine.Vector3::y
  float ___y_2;
  // System.Single UnityEngine.Vector3::z
  float ___z_3;
};

il2cpp.exe對Vector3中三個成員都進行了轉換,並且適當的處理了下變數名字(在成員變數前面增加下划線)以避免和保留字衝突。

UnityEngine_UnityEngine_Vector3MethodDeclarations.h頭文件中則包含了Vector3這個類別中所有相關的函數。比如我們熟悉的ToString函數:

1
2
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR

請大家注意函數前面的注釋,它能很好的反應出這個函數在原本托管程式碼中的名稱。我時常發現這些個注釋非常有用,能讓我在C++程式碼中快速定位我想要尋找的函數。

由il2cpp.exe產生的函數程式碼有著以下一些有趣的特性:

  • 所有的函數都不是成員函數。也就是說函數的第一個參數永遠都是「this」指針。對於托管程式碼中的靜態函數而言,IL2CPP會傳遞NULL作為第一個參數的值。這麼做的好處是可以讓il2cpp.exe轉換程式碼的邏輯更加簡單並且讓代理函數的處理變得更加容易。 
  • 所有的函數還有一個額外的MethodInfo*參數用來描述函數的元信息。這些元信息是虛函數呼叫的關鍵。Mono使用和特定平台相關的方法來傳遞這些元信息。而IL2CPP出於可移植方面的考慮,並沒有使用這些和平台相關的特定程式碼。 
  • 所有的函數都被聲明成了extern 「C」,這樣一來,在需要的時候我們就可以騙過C++編譯器讓其認為所有這些函數都是一個類型。 
  • 托管函數中的類型會被加上「_t」的後綴,函數則是加上「_m」後綴。最後我們加上一個唯一的數字來避免名字的重復。這些數字會隨著專案程式碼的改變而改變,因此你不能把數字作為索引或者分析的參照。 
前兩個指針暗示著每個函數都至少有兩個參數:「this」和「MethodInfo*」。這些額外的參數會加重整個呼叫的負擔麼?理論上是顯而易見會加重的,但是我們在實際的測試中還沒有發現這些參數對性能產生影響。

我們可以用Ctags工具跳轉到ToString函數的定義部分,位於Bulk_UnityEngine_0.cpp文件中。在這個函數中的程式碼看上去和C#中Vector3::ToString()的程式碼一點也不像。但是當你用ILSpy 獲取到Vector3::ToString()內部的程式碼後,你會發現C++程式碼和C#的IL程式碼是十分接近的。

為什麼il2cpp.exe不針對每一個類別中的函數產生單獨的一個cpp文件呢?看看Bulk_UnityEngine_0.cpp,你會發現它有驚人的20,481行!之所以這麼做的原因是我們發現C++編譯器在處理大量的文件時會有問題。編譯四千多個.cpp文件所用的時間遠比編譯相同的程式碼量,但是集中在80個.cpp文件中所用的時間要長得多。因此il2cpp.exe將所有類別的函數定義放到一個組里並為這個組產生C++文件。

現在讓我們看看函數聲明頭文件的第一行:


1
#include "codegen/il2cpp-codegen.h"

il2cpp-codegen.h文件中包含了用來呼叫執行時庫libil2cpp的程式碼。我們在稍後會談談呼叫執行時庫的一些方法。

函數預處理程式碼段(Method prologues )


讓我們再仔細的看下Vector3::ToString()函數的定義,你會發現函數中有一段特有的程式碼,這段程式碼是il2cpp.exe模板產生的,會插入到任何函數的最前面。 


1
2
3
4
5
6
7
StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
  ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
  Vector3_ToString_m2315_init = true;
}

程式碼的第一行是一個局部變數StackTraceSentry。這個變數是用來跟蹤托管程式碼的堆棧呼叫的。有了這個變數,IL2CPP就能在Environment.StackTrace呼叫中正確的打印出堆棧信息。是否產生這行程式碼是可選的,當你在il2cpp.exe命令行中加入--enable-stacktrace開關(因為我在WebGL選項中設定了「Enable Exceptions」為「Full」),就會產生這行程式碼。我們發現對於簡單的小函數來說,這行程式碼的加入對程式碼的執行性能是有影響的。所以對於iOS或者其他有內置棧信息的平台來說,我們不會加入這行程式碼(而使用平台內置的棧信息)。但是對於WebGL來說,由於是在瀏覽器中執行,所以沒有系統內置的棧信息可供呼叫。只能由il2cpp.exe加入以便托管程式碼的異常機制能正常運作。

程式碼序的第二部分是數組或者和類型相關的元信息的延遲加載。ObjectU5BU5D_t4實際代表的是System.Object[]。這部分程式碼永遠只執行一次,如果這個類型的元信息已經加載過了,就直接跳過這段程式碼,啥也不做。所以這段程式碼不會帶來性能下降。

那麼這段程式碼是線程安全的嘛?如果兩個線程都同時進行Vector3::ToString() 呼叫會發生什麼?實際上,這不會有任何問題,因為libil2cpp執行時中的類型初始化函數是線程安全的。不管初始化函數被多少個線程同時呼叫,實際的執行是同一時間只能有一個線程的函數在執行。其他線程的函數都會被掛起直到當前的函數處理完成。所以總的來說,程式碼是線程安全的。

執行時檢查


函數的下個部分創建了一個object數組,將Vector3的x存在局部變數中,然後將這個變數裝箱並加入到數組的零號位置中。下面是產生的C++程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;

在IL程式碼中沒有出現的三個執行時檢查是由il2cpp.exe加入的:
  • 如果數組為空,NullCheck程式碼會拋出NullReferenceException異常。 
  • 如果數組的索引不正確,IL2CPP_ARRAY_BOUNDS_CHECK程式碼會拋出IndexOutOfRangeException異常。 
  • 如果加入數組的類型和數組類型不符合,ArrayElementTypeCheck程式碼會拋出ArrayTypeMismatchException異常。 

這三個檢查本來都是由.NET虛擬機完成的,在Mono實現中,不會插入這些個程式碼而是使用平台相關的信號機制來進行檢查。對於IL2CPP,我們希望做到和平台無關的可移植性並且還要支持像WebGL這樣的平台,所以不能使用Mono的機制,而是顯示的插入檢查程式碼。

這些檢查會引起性能的下降麼?在大多數情況下,我們並沒有看到由此帶來的性能損失,並且好處是我們提供了.NET虛擬機需要的安全保護機制。在某些特定的場合,比如在大量的循環中,我們確實看到了性能的下降。目前我們正在尋找方法在il2cpp.exe產生程式碼的時候減少這些執行時檢查,各位有興趣的可以繼續關注。

靜態變數


我們已經瞭解了實例變數(Vector3)如何運作,現在讓我們來看看托管程式碼中的靜態變數是如何轉換成C++程式碼並使用的。讓我們找到HelloWorld_Start_m3函數,這個函數應該在Bulk_Assembly-CSharp_0.cpp文件中。從這個函數我們找到一個叫Important_t1的類型(這個類型應該是在:U2DCSharp_HelloWorld_Important.h頭文件里)

1
2
3
4
5
6
7
8
9
10
struct Important_t1  : public Object_t
{
  // System.Int32 HelloWorld/Important::InstanceIdentifier
  int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
  // System.Int32 HelloWorld/Important::ClassIdentifier
  int32_t ___ClassIdentifier_0;
};

大伙兒可能注意到了,il2cpp.exe將產生的C++程式碼分成了兩個結構,一個結構負責普通的成員變數,另一個結構負責靜態成員。因為靜態成員是所有實例共享的數據,因此在執行的時候,Important_t1_StaticFields只有一份。所有的Important_t1實例都共享這個數據。在產生的程式碼中,透過下面的程式碼來獲取靜態數據:

1
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);

在Important_t1的元信息結構中有一個指向Important_t1_StaticFields結構的指針(static_fields),然後透過類型轉換再取出需要的值(___ClassIdentifier_0)

異常


在托管程式碼中的異常會被il2cpp.exe轉換成C++的異常。我們再一次的選擇了這個策略還是出於可移植性的考慮:去掉和平台相關的方案。當il2cpp.exe需要轉換產生一個托管的異常的時候,它會呼叫:

il2cpp_codegen_raise_exception函數。

在我們的例子中,產生的C++異常處理程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
try
{ // begin try (depth: 1)
  InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
  InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
  il2cpp_codegen_raise_exception(L_17);
  // IL_0092: leave IL_00a8
  goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
  __exception_local = (Exception_t8 *)e.ex;
  if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
  goto IL_0097;
  throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
  V_1 = ((InvalidOperationException_t7 *)__exception_local);
  NullCheck(V_1);
  String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
  Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
  goto IL_00a8;
} // end catch (depth: 1)

所有的托管異常都被封裝進了il2CppExceptionWrapper的C++類型。當C++程式碼捕獲了這種異常之後,會試圖將包解開獲得托管異常(Exception_t8)。就這個例子而言,我們期待的是一個InvalidOperationException異常,所以當我們發現拋出的異常不是這個類型的時候,程式碼會創建一個C++異常的拷貝並重新拋出。反之如果異常正是我們所關注的,程式碼就會跳到異常處理的那段。

Goto!?!


這段程式碼有一個有意思的地方:大伙兒發現了labels標籤和goto語句沒有?這些不太使用的東西居然出現在了結構化的程式碼中(譯注:主流觀點都不建議使用labels和goto語句,因為這會破壞程序的結構化導致各種bug的產生)。為什麼會這樣?因為IL!IL是沒有諸如for,while循環和if/then判斷結構化概念的低等級的語言。因為il2cpp.exe需要處理IL程式碼,因此也會出現goto語句。

還是看例子,讓我們看看HelloWorld_Start_m3函數中的循環是個啥樣子的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
IL_00a8:
{
  V_2 = 0;
  goto IL_00cc;
}
IL_00af:
{
  ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
  int32_t L_20 = V_2;
  Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
  NullCheck(L_19);
  IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
  ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
  Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
  V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
  if ((((int32_t)V_2) < ((int32_t)3)))
  {
    goto IL_00af;
  }
}

在這裡變數V_2是循環的索引,從0開始,在循環程式碼的最後進行累加。

1
V_2 = ((int32_t)(V_2+1));

循環的結束檢查程式碼:

1
if ((((int32_t)V_2) < ((int32_t)3)))

只要V_2小於3,goto語句就會跳轉到IL_00af標籤處,也就是循環的一開始繼續執行。你可能會想:嗯。。il2cpp.exe一定在偷懶,直接使用了IL的程式碼而不是使用抽象的語法分析樹。如果你是這麼想的,那麼恭喜你猜對了。。。 你可能還會注意到在上面的這段執行時檢查的程式碼中,有下面的情況:

1
2
float L_1 = (__this->___x_1);
float L_2 = L_1;

很顯然,變數L_2不是必須的,大多數的C++編譯器會將其優化掉。對於我們來說,我們在想辦法不去產生這行程式碼(譯注:因為il2cpp.exe是從IL進行程式碼的轉換,沒有使用高級的語法分析,所以會產生多餘的程式碼)。我們也在研究使用高級的抽象語法樹(Abstract Syntax Tree,縮寫:AST)以便更好的理解IL程式碼從而產生更好的C++程式碼(譯注:可能以後就會去除goto跳轉語句了)

總結


透過一個簡單的專案,我們初窺了IL2CPP如何將托管程式碼轉換成C++程式碼。如果你沒有產生測試專案,我強烈建議你做一遍並進行一些研究。在你做這件事的同時,請記住,在後續Unity的版本中,產生的C++程式碼可能會和本文有所不同。這是正常的,因為我們在不斷的改進和優化IL2CPP。

透過將IL程式碼轉換成C++,我們能夠獲得在可移植和性能上的一個很好的平衡。我們能擁有高效開發的托管程式碼的同時,還能獲得高質量的C++程式碼。

在接下來的文章中,我們將探索更多的C++程式碼,包括函數呼叫,函數對原生庫的封裝和共享等。下篇文章我們將會圍繞iOS 64-bit和Xcode展開。

著作人