(八)Unity5.0新特性------IL2CPP Internals: 生成的代码之旅

??

孙广东  2015.5.25

转载请注明出处吧

这是 IL2CPP Internals系列中的第二个博客文章。在这篇文章,我们将探讨由 il2cpp.exe 生成的 c + + 代码。一路走来,我们将看到托管的类型怎么样表示在本机代码中,看看运行时检查用来支持.NET 虚拟机,请参阅如何循环生成的更多 !
     我们会遇到一些非常特定于版本的代码,更高版本的Unity一定会改变。尽管如此,但概念将保持不变。
示例项目:
我会为此示例使用Unity5.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);
    }
  }
}

我就会在 Windows 上运行 Unity 编辑器为 WebGL,建立这一项目。我选择Development Player选项在Build Settings中,这样我们可以得到相对不错的名字生成的 c + + 代码中。我也已经设置Enable Exceptions选项在 WebGL Player Settings中值为Full。

生成的代码的概述:
     WebGL build完成后,生成的 c + + 代码是在我的项目目录中的 Temp\StagingArea\Data\il2cppOutput 目录中。一旦关闭了编辑器,将删除此目录。只要编辑器是打开的此目录将保持不变,所以我们可以检查它。

Il2cpp.exe 实用程序生成的文件的数目,甚至小项目。4625个 头文件和 89 c + + 源代码文件, 若要获得此代码的所有句柄,我喜欢使用一个文本编辑器 Exuberant CTags。CTags 通常会迅速产生为这段代码的标签文件,这使得它易于导航。

最初,你可以看到很多生成的 c + + 文件,而不是从简单的脚本代码,但相反的转换版本代码在标准库,如 mscorlib.dll 中的代码。在本系列中的第一篇文章中提到,IL2CPP 脚本后端使用相同的标准库代码作为Mono的脚本后端。注意: 我们转换 mscorlib.dll 和其他标准库程序集,每个时间 il2cpp.exe 运行中的代码。这可能似乎不必要的因为该代码不会改变。

然而,IL2CPP 脚本后端始终使用字节代码剥离减小该可执行文件的大小。因此,即使在脚本代码中微小的变化可以引发许多不同的部分要使用标准库代码,根据具体情况。因此,我们需要每次转换将 mscorlib.dll 程序集。我们正在研究如何更好地做增量生成,但我们还没有任何好的解决方案。


如何将托管的代码映射到生成的 c + + 代码:

对于在托管代码中的每个类型,il2cpp.exe 将生成一个 c + +的头文件 中定义类型 和类型的方法声明在另一个头文件。例如,让我们看看转换后的 UnityEngine.Vector3 类型的内容。头文件的类型是命名为 UnityEngine_UnityEngine_Vector3.h。创建了一种基于程序集名称,UnityEngine.dll 紧跟的命名空间和类型的名称的名称。代码如下所示:
// 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 实用程序已转换三个实例字段,并且做一点点的名称重整以避免和保留字冲突。通过使用前导下划线,我们在 c + + 中使用一些保留的名称,但到目前为止,我们没看到任何冲突与 c + + 标准库代码。
UnityEngine_UnityEngine_Vector3MethodDeclarations.h 文件中包含的所有Vector3方法声明。例如,Vector3 重写 Object.ToString 方法:

// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR
 
注意注释,指示此本机声明表示的托管的方法。我经常发现搜索文件中输出托管方法在此格式中有用,尤其是对于以常见的名称如 tostring。
通知都由 il2cpp.exe 转换的方法是有趣的事情:

    · 在c++中这些不是成员函数。所有方法都是free functions,其中的第一个参数是"this"指针。关于托管代码中的静态函数,IL2CPP 总是让这第一个参数this设为 NULL 值。通过始终声明具有"this"指针作为第一个参数的方法,我们简化了il2cpp.exe生成代码方法和我们使其他方法 (如委托) 的调用方法生成的代码更简单。

 · 每个方法有一个额外的参数的类型是MethodInfo *,其中包括用于类的虚拟方法调用的有关方法的元数据。Mono的脚本后端使用特定于平台的trampolines传递此元数据。关于IL2CPP,我们已经决定避免使用trampolines,有助于可移植性。

 · 所有方法都声明 extern "C",以便 il2cpp.exe 有时可以对 c++ 编译器撒谎和对待所有方法,因为如果他们有相同的类型。

 · 以"_t"后缀命名的类型。以"_m"后缀命名的方法。命名冲突解决的每个名称后附加一个唯一的数字。如果任何用户脚本代码中发生更改这些数字将会改变,所以你不能在build时指望他们。

 前两个点暗示每个方法有至少两个参数,是"this"指针和 MethodInfo 指针。这些额外的参数会导致不必要的开销吗?尽管他们会增加开销,到目前为止我们从来没有见过那些额外的参数会导致性能问题。尽管它看起来他们可能会导致,分析表明性能的差异是不可以衡量太小了。

 我们可以使用 Ctags 工具跳转到这个 ToString 方法的定义。它是在 Bulk_UnityEngine_0.cpp 文件中。代码中该方法的定义看起来不太像 C# 代码中的 Vector3::ToString() 方法。然而,如果你使用像 ILSpy 这样的工具来反射 Vector3::ToString() 方法的代码,您将看到生成的 c + + 代码看起来非常类似于 IL 代码。

 为什么 il2cpp.exe 不会为每个类型的一样的方法声明分别产生一个单独的 c + + 文件,这个 Bulk_UnityEngine_0.cpp 文件是相当大,其实20,481 行 !我们发现我们正在使用的 c + + 编译器有大量的源代码文件的麻烦。编译四千个.cpp 文件时间远远多于 80 个.cpp相同的源代码 文件编译。所以 il2cpp.exe 类型分组的批方法定义,并每个组生成一个 c + + 文件,。

现在跳回方法声明的头文件,并注意到该文件的顶部附近的这行:
#include "codegen/il2cpp-codegen.h"


il2cpp-codegen.h文件包含生成的代码用来访问 libil2cpp 运行时服务的接口。我们将讨论一些运行时使用的方法生成的代码。

Method prologues
 让我们看看 Vector3::ToString() 方法的定义。具体说来,它具有共同的prologue部分,由 il2cpp.exe emitted的所有方法。


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;
}
 

 这样的prologue的第一行创建一个局部变量的类型 StackTraceSentry。此变量用于跟踪托管的调用堆栈,因此,IL2CPP 可以报告它在调用像 Environment.StackTrace。此条目的代码生成是实际上是可选的并在这种情况下启用的--启用栈跟踪选项传递给 il2cpp.exe (因为我在WebGL Player Settings中设置Enable Exceptions选项为Full)。对于小函数,我们发现此变量的开销对性能有负面的影响。所以对于 iOS 和其他平台,在那里我们可以使用特定于平台的堆栈跟踪信息,我们永远不会发出这条线到生成的代码。WebGL,我们没有特定于平台的堆栈跟踪支持,因此有必要允许托管的代码异常才能正常工作。

 prologue的第二部分没有延迟初始化的数组或在方法体中使用的泛型类型的类型元数据。所以名称 ObjectU5BU5D_t4 是类型名为 System.Object [] 。prologue的这一部分只执行一次,并经常做什么如果类型已初始化在其他地方,所以我们还没有看到任何负面性能影响从生成的代码。

 可是此代码线程安全吗?如果两个线程同时调用 Vector3::ToString()?其实,此代码并不成问题,因为所有的 libil2cpp 运行时用于类型中的代码初始化是安全的从多个线程中调用。它是可能 (甚至可能) 会不止一次,调用 il2cpp_codegen_class_from_type 函数,但它的实际工作才会有一次,发生在一个线程上。方法执行不会继续,直到初始化已完成。所以这方法开场白是线程安全的。

Runtime checks运行时检查
 该方法的下一部分创建一个对象数组、 Vector3的 x 字段的值存储在本地,然后盒当地和将其添加到索引从零开始的数组。下面是生成的 c + + 代码 (用一些注释功能):
 
 // 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,如果数组的值为 null。
? 该 IL2CPP_ARRAY_BOUNDS_CHECK 代码将引发 IndexOutOfRangeException,如果数组索引不正确。
? 该 ArrayElementTypeCheck 代码会引发的 ArrayTypeMismatchException,如果被添加到该数组中元素的类型不正确。

 这些三个运行时检查是由.NET 虚拟机提供的所有保证。而不是注入代码,Mono脚本后端使用平台特定的信号转导机制来处理这些相同的运行时检查。对于 IL2CPP,我们想要更多的平台得到不可知论的和支持的平台,像 WebGL,那里有没有特定于平台的信号转导机制,所以 il2cpp.exe 注入这些检查。
 
 做这些运行时检查会导致性能问题吗?在大多数情况下,在性能上,我们没看到任何不利的影响,他们提供的好和.NET 虚拟机所需的安全。不过,在几个特定的情况下我们看到这些检查,导致性能下降,尤其是在紧凑循环中。我们正在做现在允许托管的代码进行注释以移除这些运行时检查,当 il2cpp.exe 生成 c + + 代码。敬请关注这一方面。
 
Static Fields静态字段
 现在,我们已经看到如何实例字段(Vector3 类型),让我们看到了静态字段转换和访问。找到的 HelloWorld_Start_m3 方法定义,是在我生成的 Bulk_Assembly CSharp_0.cpp 文件中定义。从那里,跳转到 Important_t1 类型 (在 theAssemblyU2DCSharp_HelloWorld_Important.h 文件中):

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;
};
 

Notice that il2
Notice that il2cpp.exe has generated a separate C++ struct to hold the static field for this type, since the static field is shared between all instances of this type. So at runtime, there will be one instance of the Important_t1_StaticFields type created, and all of the instances of the Important_t1 type will share that instance of the static fields type. In generated code, the static field is accessed like this:
请注意,il2cpp.exe 已对此类型的静态字段生成一个单独的 c + + 结构体,因为该静态字段这种类型的所有实例之间要共享。所以在运行时,会有创建,Important_t1_StaticFields 类型的一个实例,所有 Important_t1 类型的实例将共享该实例的静态字段的类型。生成的代码中访问静态字段时像这样:

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

Important_t1 的类型元数据握着一个指针,指向的 Important_t1_StaticFields 类型的一个实例,该实例用于获取静态字段的值。

Exceptions例外情况

托管异常被il2cpp.exe转换为c + +异常。我们选择了这条道路,以再次避免特定于平台的解决办法。当 il2cpp.exe 需要emit代码引发的托管的异常时,它将调用 il2cpp_codegen_raise_exception 函数。
 在我们的 HelloWorld_Start_m3 方法来引发和捕捉托管的异常的代码如下所示:
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)
 

所有托管的异常将包装在 c + + 的Il2CppExceptionWrapper 类型内。当生成的代码捕获该类型的异常时,它解包 (其类型 Exception_t8) 的托管异常的 c + + 表示。我们期待在这种情况下,只为能反转,所以如果我们找不到该类型的异常的 c + + 异常的副本又扔了回来。如果我们找到正确的类型,该代码跳转到的 catch 处理程序,执行并写出的异常消息。

Goto!?!
 这段代码提出了一个有趣的点。这些标签和 goto 语句在那里做什么?这些构造是不必要的结构化编程 !然而,IL 没有结构化编程概念,如循环及 if/then 语句。因为它是较低级别,il2cpp.exe 遵循低级别概念生成的代码中。
例如,让我们看看 for 循环在 HelloWorld_Start_m3 中的方法:
 
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,然后递增下面这一行中的循环:
 V_2 = ((int32_t)(V_2+1));

然后在这里检查循环的结束条件:
 if ((((int32_t)V_2) < ((int32_t)3)))

只要 V_2 是小于 3,goto 语句跳转到 IL_00af 标签,这是循环体的顶部。你可能能够猜出那 il2cpp.exe 器当前正在生成 c + + 代码直接从 IL,而无需使用中间的抽象语法树表示形式。如果您猜到这,你是正确的。你可能已经还注意到在运行时检查上述一些生成的代码看起来像这样:
 float L_1 = (__this->___x_1);
 float L_2 = L_1;
 
显然,采用 L_2 变量在这里不是必要的。大多数 c + + 编译器可以优化掉这额外的任务,但是我们想要避免emitting它在所有。我们目前正在研究使用 AST 来更好地理解 IL 代码和生成更好的 c + + 代码涉及本地变量的情况下,for 循环,其中的可能性。

Conclusion结论
 我们只是抓到一个非常简单的项目的 IL2CPP 脚本后端所生成的 c + + 代码的表面。如果你没这么做过,我鼓励你来到您的项目中生成的代码。当你在探索,请牢记我们正在不断努力提高构建和运行时性能的 IL2CPP 脚本后端所生成的 c + + 代码将看上去不同的,未来版本中。

 通过将 IL 代码转换为 c + + 中,我们已经能够获得很好的平衡,便携式和高性能代码之间。我们可以有很多不错的开发人员友好功能的托管代码中,同时仍获得 c + + 编译器提供各种平台的质量机器代码的好处。

 在将来职位,我们会探索更多生成的代码,包括方法调用、 分享的方法实现和调用到本机库的包装。但下一次我们将调试一些为使用 Xcode iOS 64 位内部版本生成的代码。

文章的源地址:
http://blogs.unity3d.com/2015/05/13/il2cpp-internals-a-tour-of-generated-code/


郑重声明:本站内容如果来自互联网及其他传播媒体,其版权均属原媒体及文章作者所有。转载目的在于传递更多信息及用于网络分享,并不代表本站赞同其观点和对其真实性负责,也不构成任何其他建议。