IL2CPP原理简述

作者: 塘朗山小钻风 | 来源:发表于2021-03-03 08:57 被阅读0次

    代码总览

    在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 <typename R>

    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 <typename R>

    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<T> {

    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

    相关文章

      网友评论

        本文标题:IL2CPP原理简述

        本文链接:https://www.haomeiwen.com/subject/cvoufltx.html