代码总览
在Unity打包过程中IL2CPP会生成il2cpp代码。生成的目录是Temp/StagingArea/Il2Cpp/il2cppOutput。因为是在Temp目录,Unity关闭时会移除它。可以复制出来研究。以我现在工作的项目来说,有616个C++文件,总共1.07G大小。生成的文件总体概述如下:
Bulk_Assembly-CSharp_{递增数字}.cpp这些是游戏内Assembly-CSharp.dll中类型对应生成,主要是逻辑代码。
Bulk_Assembly-CSharp-firstpass_{递增数字}.cpp这些是游戏内Assembly-CSharp-firstpass.dll中类型对应生成,是Plugins中的类型。
Bulk_Generics_{递增数字}.cpp是泛型特化对应的生成代码
Bulk_mscorlib_{递增数字}.cpp mscorlib核心库对应的生成代码
Bulk_System.Xml_{递增数字}.cpp是System.Xml命名空间对应的生成代码,这样的还有不少。
GenericMethods{递增数字}.cpp泛型方法特化对应的生成代码。
Il2CppCompilerCalculateTypeValues_{递增数字}Table.cpp包含泛型属性的类型对应的生成代码。
{递增数字}这个在后面将一再看到,是一种避免冲突的好办法!!!
编译过程见拙著IL2CPP编译过程从其中发现还依赖于Unity本身提供的一些库(都在/Applications/Unity/Unity.app/Contents/il2cpp/目录下面):
external/boehmgc就是boehm垃圾回收器
libil2cpp/icalls/下面是些C#都有的扩展类比如:CurrentSystemTimeZone RuntimeFieldHandle
libil2cpp/vm下面是虚拟机代码
libil2cpp/os下面是操作系统对应扩展代码
代码分析
全部函数方法(包括成员函数)都是全局函数。 _m{递增数字}来确保不会重名。第一个参数是实例指针,如果是静态函数就对应NULL。
如果是类型通过附加 _t{递增数字}来确保不会重名。从下面可以看到新版生成的代码已经不是附加数字了,是一种类似hash值的东西。
// UnityEngine.Vector3 AkAmbientLargeModePositioner::get_Up()
extern "C" IL2CPP_METHOD_ATTR Vector3_tDCF05E21F632FE2BA260C06E0D10CA81513E6720 AkAmbientLargeModePositioner_get_Up_m4173F97E4E545A66EDF0297A0AF41E0AA9A29AA8 (AkAmbientLargeModePositioner_tAA1DE4C2E8BB1AD248413B957B5EB537DB58ED5E * __this, const RuntimeMethod* method)
从上面可以看到生成的函数上面通过注释标识了原本的函数,这样方便分析。最末尾加上一个参数MethodInfo* 传递metadata用于虚函数调用。mono用的是平台相关的trampolines来传递。cpp为了移植性就改换成这种方式。extern "C"避免以C++的方式处理函数名。如下分析一段生成代码:
V_2 = 0;
gotoIL_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);
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
从上面发现C++代码是从IL生成的,而不是从AST语法分析产生的,比较啰嗦。循环是由goto语句产生的(其实生成的语句中包含许多goto)。还有3个运行时检查NullCheck() IL2CPP_ARRAY_BOUNDS_CHECK() ArrayElementTypeCheck ()。
函数调用成本
IL中函数调用有两种方式调用:call和callvirt。call一般是以非虚的方式来调用函数的,callvirt是以已多态的方式来调用函数的。callvirt对应的生成代码如下(这些生成的代码一般在文件头部。名字含有Func的有返回值,名字含有Action的无返回值,根据参数个数名字末尾的数字也不同)
template
struct VirtFuncInvoker0
{
typedef R (*Func)(void*, const RuntimeMethod*);
static inline R Invoke (Il2CppMethodSlot slot, RuntimeObject* obj)
{
const VirtualInvokeData& invokeData = il2cpp_codegen_get_virtual_invoke_data(slot, obj);// 查找
return ((Func)invokeData.methodPtr)(obj, invokeData.method);
}
};
struct VirtActionInvoker0
{
typedef void (*Action)(void*, const RuntimeMethod*);
static inline void Invoke (Il2CppMethodSlot slot, RuntimeObject* obj)
{
const VirtualInvokeData& invokeData = il2cpp_codegen_get_virtual_invoke_data(slot, obj);// 查找
((Action)invokeData.methodPtr)(obj, invokeData.method);
}
};
之所以采用这种方式而不使用变参数模板(Vardic Template)是因为为了兼容老的编译器
1. 成员函数和静态函数直接调用(差别就是静态函数的第一个参数是NULL)成本最低。
2. 编译时delegate
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;
// Create the delegate.
IntPtr_t L_2 = { &Important_Method_m1_MethodInfo };
ImportantMethodDelegate_t4 * L_3 = (ImportantMethodDelegate_t4 *)il2cpp_codegen_object_new (InitializedTypeInfo(&ImportantMethodDelegate_t4_il2cpp_TypeInfo));
ImportantMethodDelegate__ctor_m4(L_3, L_1, L_2, /*hidden argument*/&ImportantMethodDelegate__ctor_m4_MethodInfo);
V_1 = L_3;
ImportantMethodDelegate_t4 * L_4 = V_1;
// Call the method
NullCheck(L_4);
VirtFuncInvoker1< int32_t, String_t* >::Invoke(&ImportantMethodDelegate_Invoke_m5_MethodInfo, L_4, (String_t*) &_stringLiteral1);
上面的消耗主要是创建delegate,VirtFuncInvoker1里面查找然后调用。
3. interface调用
Important_t1 * L_0 = (Important_t1 *)il2cpp_codegen_object_new (InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo));
Important__ctor_m0(L_0, /*hidden argument*/&Important__ctor_m0_MethodInfo);
V_0 = L_0;
Object_t * L_1 = V_0;
NullCheck(L_1);
InterfaceFuncInvoker1< int32_t, String_t* >::Invoke(&Interface_MethodOnInterface_m22_MethodInfo, L_1/*interface必须*/, (String_t*) &_stringLiteral1); // 是因为接口函数在虚函数表中有一个统一偏移。所以调用虚函数和调用接口函数有一样的负载。InterfaceFuncInvoker本身也和VirtFuncInvoker类似,代码如下:
template
struct InterfaceFuncInvoker0
{
typedef R (*Func)(void*, const RuntimeMethod*);
static inline R Invoke (Il2CppMethodSlot slot, RuntimeClass* declaringInterface, RuntimeObject* obj)
{
const VirtualInvokeData& invokeData = il2cpp_codegen_get_interface_invoke_data(slot, obj, declaringInterface);
return ((Func)invokeData.methodPtr)(obj, invokeData.method);
}
};
4. 运行时delegate。代码更多就不在这贴了,整体步骤是: 获取实例,获取delegate类型,创建delegate,创建参数数组,调用Delegate_DynamicInvoke。在Delegate_DynamicInvoke内部调用的VirtFuncInvoker。
5.运行时delegate,整体步骤是: 获取实例,获取delegate类型,使用字符串参数调用VirtFuncInvoker获取函数,创建参数数组,调用VirtFuncInvoker。由此可见这种调用的成本是最大的。
Generic-Sharing
在C#层的泛型怎么让它的生成代码量最小呢?IL2CPP提供了generic-sharing。支持的类型包括引用类型(string,object和自定义类型)以及整型和枚举。无法为值类型提供generic-sharing,因为它们的占用内存不一样。这能实现是依赖于C#的引用类型基类System.Object在C++层有一个Object_t对应物
class GenericType
public T UsesGenericParameter(T value) {
return value;
}
会生成如下的 fully shared type的方法
extern "C"Object_t *GenericType_1_UsesGenericParameter_m10449_gshared (GenericType_1_t2159 * __this,Object_t *___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}
Marshal
因为类型和函数在C++和C#中有不同表示,所以类型分blittable和non-blittable。如果是blittable的,表示两端(C#和C++)有相同的表示[可以直接穿透]这包括byte,int,flat。non-blittable的就有不同的表示,这包括bool, string, array-types.这就需要转化了,会带来内存损耗。c#为了引用本地代码的函数,需要extern和DllImport属性。需要生成胶水代码。步骤如下:
为函数指针定义typedef
通过名字解析获取到函数的指针
把参数从从托管代码表示方式转化为本地代码表示方式
调用函数
把返回值从本地表示方式转化为托管表示方式
out和ref参数也要这样处理
[DllImport("__Internal")]
private extern static int Increment(int value); // c#这样声明
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);} 这个在C++层
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{函数内会有static指针查询保存C++层的Increment,然后调用}
对于non-blittable类型,比如string。在il2cpp中表示为2字节字符的数组编码方式为UTF-16前缀是个4字节长度的值表示字符串长度这与char*和wchar_t*类型都不一样,需要一系列转化,il2cpp_codegen_marshal_string会有内存分配与拷贝。如果参数是引用传递,native代码传入的是变量指针。会在函数体内生成一个局部同类型变量,拷贝进去函数调用再拷贝出来。如果是non-blittable类型作为参数,就需要为这参数生成对应的marshaled类型,还需要专门的清理函数来清理分配的内存。比如int数组,因为int是blittable的,所以il2cpp_codegen_marshal_array函数直接返回的是托管数组内存指针。如果是non-blittable的数组,则要为它们分配内存并逐个拷贝,最后还要清理释放。
垃圾收集
当前用的是Boehm-Demers-Weiser垃圾回收算法,并不是分代垃圾回收算法(以后将使用分代垃圾回收器CoreCLR)。Boehm垃圾回收算法由root判断可达性,如果不可达就判定为垃圾,等待回收。
可以作为root的的变量包括:栈上的局部变量,静态变量,GCHandle对象。托管代码中创建一个线程,这个线程就会作为一个gc的root(线程栈的局部变量变成root)。创建函数可能是il2cpp_gc_register_thread。当线程退出时il2cpp_gc_unregister_thread告知GC不用再将它们作为root。这样c++端的对应类型的实例的占用内存就可以回收了。还有类的静态字段并没有直接放在c++类里面,而是另外创建结构。这是为了控制内存布局,并且方便与GC系统协作。
struct HelloWorld_t2 : public MonoBehaviour_t3
{
};
struct HelloWorld_t2_StaticFields{
// AnyClass HelloWorld::staticAnyClass
AnyClass_t1 * ___staticAnyClass_2;
};
第一次初始化类型时也会引起GC系统为这个HelloWorld_t2_StaticFields类型分配内存,这样内存就由GC系统管理了,作为root。方法是il2cpp_gc_alloc_fixed。
对于从托管内存中传递一个指针到本地代码,由本地代码来获得它的所有权并能够被gc系统处理。这需要在托管代码中的GCHandle