IL2CPP 内部:查看生成的代码

这是IL2CPP Internals系列的第二篇博文。在本帖中,我们将研究由 il2cpp.exe 生成的 C++ 代码。在学习过程中,我们将了解托管类型在本地代码中的表示方法,了解用于支持 .NET 虚拟机的运行时检查,了解循环的生成方法等!
我们将进入一些非常具有版本针对性的代码,这些代码在 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 构建这个项目,在 Windows 上运行 Unity 编辑器。我在 "构建设置 "中选择了 "开发播放器 "选项,这样我们就能在生成的 C++ 代码中获得相对漂亮的名称。我还将 "WebGL 播放器设置 "中的 "启用异常 "选项设置为 "完全"。
生成代码概览
WebGL 生成完成后,生成的 C++ 代码可以在我的项目目录下的 Temp\StagingArea\Data\il2cppOutput 目录中找到。编辑器关闭后,该目录将被删除。不过,只要编辑器是打开的,这个目录就不会改变,因此我们可以检查它。
即使对于这个小项目,il2cpp.exe 工具也会生成大量文件。我看到 4625 个头文件和 89 个 C++ 源代码文件。为了掌握所有这些代码,我喜欢使用与Exuberant CTags 兼容的文本编辑器。CTags 通常会为这些代码快速生成一个标签文件,使其更易于浏览。
最初,你可以看到生成的许多 C++ 文件并非来自简单的脚本代码,而是标准库中代码的转换版本,如 mscorlib.dll。正如本系列第一篇文章所述,IL2CPP 脚本后台使用与 Mono 脚本后台相同的标准库代码。请注意,每次运行 il2cpp.exe 时,我们都会转换 mscorlib.dll 和其他标准库程序集中的代码。这似乎没有必要,因为代码不会改变。
不过,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++ 中的成员函数。所有方法都是自由函数,其中第一个参数是 "this "指针。对于托管代码中的静态函数,IL2CPP 总是为第一个参数传递 null 值。通过始终将 "this "指针作为第一个参数来声明方法,我们简化了 il2cpp.exe 中的方法生成代码,并使通过其他方法(如委托)调用方法的生成代码变得更简单。
- 每个方法都有一个 MethodInfo* 类型的附加参数,其中包括方法的元数据,用于虚拟方法调用等。Mono 脚本后台使用特定平台的蹦床来传递这些元数据。对于 IL2CPP,我们决定避免使用蹦床,以方便携带。
- 所有方法都声明为 extern "C",这样,il2cpp.exe 有时就能欺骗 C++ 编译器,将所有方法视为具有相同类型。
- 类型以后缀"_t "命名。方法以后缀"_m "命名。命名冲突可通过在每个名称后附加一个唯一编号来解决。如果用户脚本代码中的任何内容发生变化,这些数字也会随之变化,因此在每次构建时都不能依赖这些数字。
前两点意味着每个方法至少有两个参数,即 "this "指针和 MethodInfo 指针。这些额外的参数是否会造成不必要的开销?虽然这些参数显然会增加开销,但到目前为止,我们还没有看到任何迹象表明这些额外参数会导致性能问题。虽然看起来是这样,但分析表明,性能上的差异是无法测量的。
我们可以使用 Ctags 跳转到 ToString 方法的定义。它位于 Bulk_UnityEngine_0.cpp 文件中。该方法定义中的代码与 Vector3::ToString() 方法中的 C# 代码不太一样。不过,如果使用ILSpy等工具来反映 Vector3::ToString() 方法的代码,就会发现生成的 C++ 代码与 IL 代码非常相似。
为什么 il2cpp.exe 不能像方法声明那样,为每种类型的方法定义生成单独的 C++ 文件?这个 Bulk_UnityEngine_0.cpp 文件相当大,实际上有 20,481 行!我们发现,我们使用的 C++ 编译器在处理大量源文件时会出现问题。编译四千个 .cpp 文件比编译八十个 .cpp 文件所需的时间要长得多。因此,il2cpp.exe 会将类型的方法定义分组,并为每组生成一个 C++ 文件。
现在跳回方法声明头文件,注意文件顶部附近的这一行:
#include "codegen/il2cpp-codegen.h"il2cpp-codegen.h 文件包含生成代码用来访问 libil2cpp 运行时服务的接口。稍后我们将讨论生成的代码如何使用运行时。
方法序言
让我们来看看 Vector3::ToString() 方法的定义。具体来说,它有一个共同的序言,由 il2cpp.exe 在所有方法中发出。
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 类型的 Localization 变量。该变量用于跟踪托管调用堆栈,以便 IL2CPP 在 Environment.StackTrace 等调用中进行报告。该条目代码生成实际上是可选的,在本例中通过传递给 il2cpp.exe 的 --enable-stacktrace 选项启用(因为我将 WebGL 播放器设置中的 "启用异常 "选项设置为 "完全")。对于小型函数,我们发现该变量的开销会对性能产生负面影响。因此,对于可以使用特定平台堆栈跟踪信息的 iOS 和其他平台,我们绝不会在生成的代码中引入这一行。对于 WebGL,我们没有特定平台的堆栈跟踪支持,因此有必要允许托管代码异常正常工作。
序言的第二部分会对方法主体中使用的数组或泛型类型的类型元数据进行懒惰初始化。因此,ObjectU5BU5D_t4 是 System.Object[] 类型的名称。序章的这一部分只执行一次,而且如果该类型已在其他地方初始化,这一部分通常不会有任何作用,因此我们没有发现生成的代码对性能有任何不利影响。
这些代码对线程安全吗?如果两个线程同时调用 Vector3::ToString() 会怎样?实际上,这段代码没有问题,因为 libil2cpp 运行时中用于类型初始化的所有代码都可以在多个线程中安全调用。il2cpp_codegen_class_from_type 函数有可能(甚至很有可能)被调用多次,但它的实际工作只会在一个线程上发生一次。在初始化完成之前,不会继续执行方法。因此,这个方法序幕是线程安全的。
运行时检查
该方法的下一部分创建了一个对象数组,将 Vector3 的 x 字段的值存储在 Localization 中,然后框选 Local 并将其添加到数组的索引零处。下面是生成的 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 注入的。
- 如果数组的值为 null,NullCheck 代码将抛出 NullReferenceException。
- 如果数组索引不正确,IL2CPP_ARRAY_BOUNDS_CHECK 代码将抛出 IndexOutOfRangeException 异常。
- 如果被添加到数组中的元素类型不正确,ArrayElementTypeCheck 代码将引发 ArrayTypeMismatchException 异常。
这三种运行时检查都是由 .NET 虚拟机提供的保证。Mono 脚本后端不是注入代码,而是使用平台特定的信号机制来处理这些相同的运行时检查。对于 IL2CPP,我们希望更加不依赖平台,并支持像 WebGL 这样没有特定平台信号机制的平台,因此 il2cpp.exe 注入了这些检查。
这些运行时检查是否会导致性能问题?在大多数情况下,我们没有发现它们对性能有任何不利影响,而且它们还提供了 .NET 虚拟机所需的优势和安全性。但在一些特殊情况下,我们发现这些检查会导致性能下降,尤其是在紧密循环中。我们现在正在研究一种方法,允许在 il2cpp.exe 生成 C++ 代码时对托管代码进行注释,以移除这些运行时检查。敬请期待。
静态字段
现在我们已经了解了实例字段的外观(在 Vector3 类型中),下面让我们看看静态字段是如何转换和访问的。查找 HelloWorld_Start_m3 方法的定义,该方法位于我的构建文件中的 Bulk_Assembly-CSharp_0.cpp 文件中。从这里跳转到 Important_t1 类型(在AssemblyU2DCSharp_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;
};请注意,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 类型单例的指针,该实例用于获取静态字段值。
例外情况
托管异常由 il2cpp.exe 转换为 C++ 异常。我们选择这条道路是为了再次避免特定平台的解决方案。当 il2cpp.exe 需要执行代码引发托管异常时,它会调用 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 类型封装。当生成的代码捕获到该类型的异常时,它会解压缩托管异常的 C++ 表示(其类型为 Exception_t8)。在这种情况下,我们只寻找 InvalidOperationException 异常,因此如果没有找到该类型的异常,就会再次抛出 C++ 异常的副本。如果我们确实找到了正确的类型,代码就会跳转到捕获处理程序的执行,并写出异常信息。
后藤
这段代码提出了一个有趣的问题。这些标签和 goto 语句是干什么用的?在结构化程序设计中不需要这些构造!不过,IL 没有循环和 if/then 语句等结构化编程概念。由于是低级程序,il2cpp.exe 在生成的代码中遵循低级程序的概念。
例如,让我们看看 HelloWorld_Start_m3 方法中的 for 循环:
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 目前是直接从 IL 生成 C++ 代码,而不使用中间抽象语法树表示法。如果您猜对了,那就没错了。您可能还注意到,在上面的运行时检查部分,有些生成的代码看起来像这样:
float L_1 = (__this->___x_1);
float L_2 = L_1;显然,这里不需要 L_2 变量。大多数 C++ 编译器都可以优化掉这个额外的赋值,但我们希望完全避免发出这个赋值。我们目前正在研究使用 AST 来更好地理解 IL 代码的可能性,并针对涉及 Local 变量和 for 循环等情况生成更好的 C++ 代码。
结论
我们只是对 IL2CPP 脚本后台为一个非常简单的项目生成的 C++ 代码进行了初步了解。如果您还没有这样做,我建议您研究一下项目中生成的代码。在探索过程中,请记住生成的 C++ 代码在 Unity 的未来版本中将会有所不同,因为我们一直在努力改进 IL2CPP 脚本后台的构建和运行性能。
通过将 IL 代码转换为 C++,我们在代码的可移植性和性能之间取得了很好的平衡。我们可以拥有托管代码的许多对开发人员友好的功能,同时还能获得 C++ 编译器为各种平台提供的高质量机器代码的好处。
在今后的文章中,我们将探讨更多生成的代码,包括方法调用、共享方法实现和调用本地库的封装器。不过,下次我们将使用 Xcode 调试 iOS 64 位构建生成的部分代码。
