L2CPP Internals: Method calls

    技术2025-12-22  12

    在这篇文章中,我们将研究在il2cpp.exe生成的c++代码中如何调用托管代码中的方法。具体来说,我们将研究六种不同类型的方法调用

    Direct calls on instance and static methods:直接调用实例和静态方法Calls via a compile-time delegate:通过编译时委托调用Calls via a virtual method:通过虚方法调用Calls via an interface method:通过接口调用Calls via a run-time delegate:通过运行时委托调用Calls via reflection:通过反射调用

    在每种情况下,我们将关注生成的c++代码在做什么,特别是这些指令的成本。

    Setup

    我将使用Unity 5.0.1p4版本。我将在Windows上运行编辑器,并为WebGL平台构建。我在构建时启用了“Development Player”选项,并将“Enable Exceptions”选项设置为“Full”。

    我将使用一个脚本文件进行构建,该脚本文件从上一篇文章修改而来,这样我们就可以看到不同类型的方法调用。脚本从接口和类定义开始:

    3

    4

    5

    6

    7

    8

    9

    interface Interface

    {

      int MethodOnInterface(string question);

    }

     

    class Important : Interface

    {

      public int Method(string question)

     {

       return 42;

      }

      public int MethodOnInterface(string question)

      { 

       return 42;

      }

      public static int StaticMethod(string question)

      {

      return 42;

      }

    }

    然后我们有一个常量字段和一个委托类型,都在后面的代码中使用:

    3

    private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";

     

    private delegate int ImportantMethodDelegate(string question);

     最后,这些是我们感兴趣的方法(加上强制性的Start方法,这里没有内容)

    3

    4

    5

    6

    7

    8

    9

    10

    11

    12

    13

    14

    15

    16

    17

    18

    19

    20

    21

    22

    23

    24

    25

    26

    27

    28

    29

    30

    31

    32

    33

    34

    35

    36

    37

    38

    private void CallDirectly() {

    var important = ImportantFactory();

    important.Method(question);

    }

     

    private void CallStaticMethodDirectly() {

    Important.StaticMethod(question);

    }

     

    private void CallViaDelegate() {

    var important = ImportantFactory();

    ImportantMethodDelegate indirect = important.Method;

    indirect(question);

    }

     

    private void CallViaRuntimeDelegate() {

    var important = ImportantFactory();

    var runtimeDelegate = Delegate.CreateDelegate(typeof (ImportantMethodDelegate), important, "Method");

    runtimeDelegate.DynamicInvoke(question);

    }

     

    private void CallViaInterface() {

    Interface importantViaInterface = new Important();

    importantViaInterface.MethodOnInterface(question);

    }

     

    private void CallViaReflection() {

    var important = ImportantFactory();

    var methodInfo = typeof(Important).GetMethod("Method");

    methodInfo.Invoke(important, new object[] {question});

    }

     

    private static Important ImportantFactory() {

    var important = new Important();

    return important;

    }

     

    void Start () {}

    有了这些定义,我们开始吧。回忆一下,生成的c++代码将位于项目的Temp\StagingArea\Data\il2cppOutput目录中(只要编辑器保持打开状态)。不要忘记在生成的代码上生成Ctags,以帮助导航。

    Calling a method directly

    调用方法最简单(也最快,我们将看到)的方法是直接调用它。下面是calldirect方法生成的代码:

    3

    4

    5

    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;

    NullCheck(L_1);

    Important_Method_m1(L_1, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_Method_m1_MethodInfo);

     最后一行是实际的方法调用。注意,它没有做任何特殊的事情,只是调用在c++代码中定义的自由函数。回想一下之前关于生成代码的文章,il2cpp.exe将所有方法生成为c++自由函数,方法包含两个参数,一个是this指针,表示该对象,一个是方法的参数,和一个隐藏参数,最后面的那个是隐藏参数,方法调用以类名_方法名_m/t来调用。IL2CPP脚本后端对生成的代码不使用c++成员函数或虚拟函数。接下来,调用静态方法目录应该是类似的。下面是callstaticmethoddirect方法生成的代码:

    Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);

    我们可以说调用静态方法的开销更少,因为我们不需要创建和初始化一个对象实例。但是,方法调用本身是完全相同的,是对一个c++自由函数的调用。这里唯一的区别是第一个参数总是传递一个NULL值。

    由于对静态方法和实例方法的调用之间的差异是如此之小,我们将只在本文的其余部分关注实例方法,但是这些信息同样适用于静态方法。

    Calling a method via a compile-time delegate

    对于稍微有点奇怪的方法调用,比如通过委托进行的间接调用,会发生什么?我们首先来看一个我称之为编译时委托的东西,这意味着我们知道在编译时哪个方法会被调用在哪个对象实例上。此类型调用的代码在CallViaDelegate方法中。它看起来像这样在生成的代码:

    // 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);

    注意,这里实际调用的方法不是生成的代码的一部分。方法VirtFuncInvoker1<int32_t, String_t*>::Invoke位于generatedvirtualinvker .h文件中。该文件由il2cpp.exe生成,但它不来自任何IL代码。相反,il2cpp.exe根据返回值(VirtFuncInvokerN)和不返回值(VirtActionInvokerN)的虚函数的使用创建这个文件,其中N是方法的参数数。

    这里的Invoke方法是这样的:

    4

    5

    6

    7

    8

    9

    10

    11

    template <typename R, typename T1>

    struct VirtFuncInvoker1

    {

    typedef R (*Func)(void*, T1, MethodInfo*);

     

    static inline R Invoke (MethodInfo* method, void* obj, T1 p1)

    {

    VirtualInvokeData data = il2cpp::vm::Runtime::GetVirtualInvokeData (method, obj);

    return ((Func)data.methodInfo->method)(data.target, p1, data.methodInfo);

    }

    };

     调用libil2cpp 中的方法GetVirtualInvokeData,会在基于托管代码生成的虚表结构中查找一个虚方法,然后调用该方法。

    为什么我们不使用c++ 11 variadic templates 来实现这些VirtFuncInvokerN的方法?这看起来像是需要variadic templates的情况,的确如此。但是,由il2cpp.exe生成的c++代码必须与一些还不支持所有c++ 11特性(包括variadic templates)的c++编译器一起工作。至少在这种情况下,我们不认为为c++ 11编译器生成代码值得付出额外的复杂性

    但为什么这是一个虚方法调用呢?我们不是在c#代码中调用了一个实例方法吗?回想一下,我们是通过c#委托调用实例方法的。再次查看上面生成的代码。我们实际要调用的方法是通过MethodInfo*(方法元数据)的参数ImportantMethodDelegate_Invoke_m5_MethodInfo 传入的:。如果我们在生成的代码中搜索名为“ImportantMethodDelegate_Invoke_m5”的方法,我们会看到调用实际上是对ImportantMethodDelegate类型的托管调用方法的调用。这是一个虚拟方法,因此我们需要进行一个虚拟调用

    通过对c#代码做一个看起来很简单的更改,我们现在已经从单个调用到c++自由函数,到多个函数调用,再加上查找表。通过委托调用方法的开销要比直接调用相同方法的开销大得多。也就是在c#中通过委托调用方法,在生成的il2cpp中调用花销大一些

    注意,在查看委托方法调用的过程中,我们还看到了通过虚拟方法调用的工作方式。

    Calling a method via an interface

    在c#中也可以通过接口调用方法。这个调用是由il2cpp.exe实现的,类似于一个虚拟方法调用:

    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, (String_t*) &_stringLiteral1);

    注意,这里实际的方法调用是通过InterfaceFuncInvoker1::Invoke函数完成的,该函数位于生成的interfaceinvoker .h文件中。与VirtFuncInvoker1类类似,InterfaceFuncInvoker1类通过libil2cpp中的il2cpp::vm::Runtime::GetInterfaceInvokeData函数在虚拟表中执行查找。

    为什么在libil2cpp中接口方法调用需要使用与虚拟方法调用不同的API ?注意,对InterfaceFuncInvoker1::Invoke的调用不仅传递要调用的方法及其参数,还传递要调用该方法的接口(本例中为L_1)。每个类型的虚函数表都被存储,这样接口方法就被写入到一个特定的偏移量中。因此,il2cpp.exe需要提供接口,以便确定调用哪个方法。

    最后,在IL2CPP中,调用虚拟方法和通过接口调用方法具有相同的开销。

    Calling a method via a run-time delegate

    使用委托的另一种方法是在运行时通过委托创建它。CreateDelegate方法。这种方法类似于编译时委托,不同之处在于它在运行时可以有更多的修改方式。我们为这种灵活性付出了额外的函数调用。下面是生成的代码:

    // 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; // Create the delegate. IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo)); Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&ImportantMethodDelegate_t4_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo); Important_t1 * L_2 = V_0; Delegate_t12 * L_3 = Delegate_CreateDelegate_m20(NULL /*static, unused*/, L_1, L_2, (String_t*) &_stringLiteral2, /*hidden argument*/&Delegate_CreateDelegate_m20_MethodInfo); V_1 = L_3; Delegate_t12 * L_4 = V_1; // Call the method ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1)); NullCheck(L_5); IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0); ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1); *((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1; NullCheck(L_4); Delegate_DynamicInvoke_m21(L_4, L_5, /*hidden argument*/&Delegate_DynamicInvoke_m21_MethodInfo);

    与运行时委托的情况一样,我们需要花费一些时间为方法的参数创建数组。然后我们对MethodBase::Invoke (MethodBase_Invoke_m24函数)进行一个虚拟方法调用。在我们最终到达实际的方法调用之前,这个函数又会调用另一个虚函数!

    Conclusion

    特别地,如果可能的话,我们希望避免通过运行时委托和反射进行调用。与往常一样,关于性能改进的最佳建议是尽早并且经常使用分析工具进行度量。

    下次我们将深入研究方法实现,看看如何共享泛型方法的实现,以最小化生成的代码和可执行文件的大小。

     

     

     

    Processed: 0.016, SQL: 9