IL2CPP深⼊讲解:代码⽣成之旅(⼆)
IL2CPP 深⼊讲解:代码⽣成之旅
IL2CPP INTERNALS: A TOUR OF GENERATED CODE
这是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个头⽂件和89个C++⽂件。要处理这么多代码⽂件,我个⼈喜欢⽤ ⽂本编辑⼯具。它可以快速的⽣成代码⽂件标签,让浏览理解这些代码变得更容易。
⼀开始,你会发现这些⽣成的C++⽂件都不是来源于我们那个简单的C#代码,⽽是来源于诸如mscorlib.dll 这样的C#标准库。正如我们在第⼀篇⽂章中提到的,IL2CPP后台使⽤的标准库和Mono使⽤的库是同⼀套,没有任何区别。需要注意的是当每次构建项⽬的时
候,都会把这些标准库转换⼀次。貌似这没啥必要,因为这些库⽂件是不会改变的。
然⽽,在IL2CPP的后端处理中,通常会使⽤字节码剥离(byte code stripping)技术来减少可执⾏⽂件的尺⼨。因此游戏代码的⼀⼩点变化也会导致标准库引⽤的改变,并影响最终剥离代码。所以⽬前我们还是在每次⽣成项⽬的时候转换所有的标准库。我们也在研究是否有其他更好的⽅法可以加快项⽬⽣成的速度,但⽬前为⽌还没有好的进展。
托管代码如何映射到C++代码
在托管代码中的每个类,都会相应的⽣成⼀个有着C++定义的头⽂件和另外⼀个进⾏函数声明的头⽂件。举个例⼦,让我们看看UnityEngine.Vector3是如何被转换的。这个类的头⽂件名字叫UnityEngine_UnityEngine_Vector3.h。头⽂件名的组成:⼀开始是程序集名称(这⾥是UnityEngine),然后跟着命名空间(还是UnityEngine),最后是这个类型的名字(Vector3)。头⽂件的内容如下:
// 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;
};
<对Vector3中三个成员都进⾏了转换,并且适当的处理了下变量名字(在成员变量前⾯添加下划线)以避免和保留字冲突。
UnityEngine_UnityEngine_Vector3MethodDeclarations.h头⽂件中则包含了Vector3这个类中所有相关的函数。⽐如我们熟悉的ToString函数:
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR
请⼤家注意函数前⾯的注释,它能很好的反应出这个函数在原本托管代码中的名称。我时常发现这些个注释⾮常有⽤,能让我在C++代码中快速定位我想要寻的函数。
由⽣成的函数代码有着以下⼀些有趣的特性:
所有的函数都不是成员函数。也就是说函数的第⼀个参数永远都是“this”指针。对于托管代码中的静态函数⽽⾔,IL2CPP会传递NULL作为第⼀个参数的值。这么做的好处是可以让转换代码的逻辑更加简单并且让代理函数的处理变得更加容易。
所有的函数还有⼀个额外的MethodInfo*参数⽤来描述函数的元信息。这些元信息是虚函数调⽤的关键。Mono使⽤和特定平台相关的⽅法来传递这些元信息。⽽IL2CPP出于可移植⽅⾯的考虑,并没有使⽤这些和平台相关的特定代码。所有的函数都被声明成了extern “C”,这样⼀来,在需要的时候我们就可以骗过C++编译器让其认为所有这些函数都是⼀个类型。
托管函数中的类型会被加上“_t”的后缀,函数则是加上“_m”后缀。最后我们加上⼀个唯⼀的数字来避免名字的重复。这些数字会随着项⽬代码的改变⽽改变,因此你不能把数字作为索引或者分析的参照。
前两个指针暗⽰着每个函数都⾄少有两个参数:“this”和“MethodInfo*”。这些额外的参数会加重整个调⽤的负担么?理论上是显⽽易见会加重的,但是我们在实际的测试中还没有发现这些参数对性能产⽣影响。
我们可以⽤Ctags⼯具跳转到ToString函数的定义部分,位于Bulk_UnityEngine_0.cpp⽂件中。在这个函数中的代码看上去和C#中Vector3::ToString()的代码⼀点也不像。但是当你⽤ 获取到Vector3::ToString()内部的代码后,你会发现C++代码和C#的IL代码是⼗分接近的。
为什么不针对每⼀个类中的函数⽣成单独的⼀个cpp⽂件呢?看看Bulk_UnityEngine_0.cpp,你会发现它有惊⼈的20,481⾏!之所以这么做的原因是我们发现C++编译器在处理⼤量的⽂件时会有问题。编译四千多个.cpp⽂件所⽤的时间远⽐编译相同的代码量,但是集中在80个.cpp⽂件中所⽤的时间要长得多。因此将所有类的函数定义放到⼀个组⾥并为这个组⽣成C++⽂件。
现在让我们看看函数声明头⽂件的第⼀⾏:
#include "codegen/il2cpp-codegen.h"
il2cpp-codegen.h⽂件中包含了⽤来调⽤运⾏时库libil2cpp的代码。我们在稍后会谈谈调⽤运⾏时库的⼀些⽅法。
函数预处理代码段(Method prologues )
让我们再仔细的看下Vector3::ToString()函数的定义,你会发现函数中有⼀段特有的代码,这段代码是模板产⽣的,会插⼊到任何函数的最前⾯。
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调⽤中正确的打印出堆栈信息。是否产⽣这⾏代码是可选的,当你在命令⾏中加⼊--enable-stacktrace开关(因为我在WebGL选项中设置了“Enable Exceptions”为“Full”),就会⽣成这⾏代码。我们发现对于简单的⼩函数来说,这⾏代码的加⼊对代码的执⾏性能是有影响的。所以对于iOS或者其他有内置栈信息的平台来说,我们不会加⼊这⾏代码(⽽使⽤平台内置的栈信息)。但是对于WebGL来说,由于是在浏览器中执⾏,所以没有系统内置的栈信息可供调⽤。只能由加⼊以便托管代码的异常机制能正常运作。
代码序的第⼆部分是数组或者和类型相关的元信息的延迟加载。ObjectU5BU5D_t4实际代表的是System.Object[]。这部分代码永远只执⾏⼀次,如果这个类型的元信息已经加载过了,就直接跳过这段代码,啥也不做。所以这段代码不会带来性能下降。
那么这段代码是线程安全的嘛?如果两个线程都同时进⾏Vector3::ToString() 调⽤会发⽣什么?实际上,这不会有任何问题,因为
libil2cpp运⾏时中的类型初始化函数是线程安全的。不管初始化函数被多少个线程同时调⽤,实际的执⾏是同⼀时间只能有⼀个线程的函数在执⾏。其他线程的函数都会被挂起直到当前的函数处理完成。所以总的来说,代码是线程安全的。
运⾏时检查
函数的下个部分创建了⼀个object数组,将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代码中没有出现的三个运⾏时检查是由加⼊的。
如果数组为空,NullCheck代码会抛出NullReferenceException异常。
如果数组的索引不正确,IL2CPP_ARRAY_BOUNDS_CHECK代码会抛出IndexOutOfRangeException异常。
如果加⼊数组的类型和数组类型不符合,ArrayElementTypeCheck代码会抛出ArrayTypeMismatchException异常。
这三个检查本来都是由.NET虚拟机完成的,在Mono实现中,不会插⼊这些个代码⽽是使⽤平台相关的信号机制来进⾏检查。对于
IL2CPP,我们希望做到和平台⽆关的可移植性并且还要⽀持像WebGL这样的平台,所以不能使⽤Mono的机制,⽽是显⽰的插⼊检查代码。
这些检查会引起性能的下降么?在⼤多数情况下,我们并没有看到由此带来的性能损失,并且好处是我们提供了.NET虚拟机需要的安全保护机制。在某些特定的场合,⽐如在⼤量的循环中,我们确实看到了性能的下降。⽬前我们正在寻⽅法在⽣成代码的时候减少这些运⾏时检查,各位有兴趣的可以继续关注。
静态变量
我们已经了解了实例变量(Vector3)如何运作,现在让我们来看看托管代码中的静态变量是如何转换成C++代码并使⽤的。让我们到HelloWorld_Start_m3函数,这个函数应该在Bulk_Assembly-CSharp_0.cpp⽂件中。从这个函数我们到⼀个叫Important_t1的类型(这个类型应该是在U2DCSharp_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;
};
⼤伙⼉可能注意到了,将⽣成的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结构的指针(static_fields),然后通过类型转换再取出需要的值(___ClassIdentifier_0)
异常
在托管代码中的异常会被转换成C++的异常。我们再⼀次的选择了这个策略还是出于可移植性的考虑:去掉和平台相关的⽅案。当需要转换⽣成⼀个托管的异常的时候,它会调⽤il2cpp_codegen_raise_exception函数。
在我们的例⼦中,⽣成的C++异常处理代码如下:
try
{
// begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new
system的头文件
(InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));