当前位置: 首页 > 工具软件 > J2cpp > 使用案例 >

IL2CPP 深入讲解:方法调用介绍

拓拔富
2023-12-01

IL2CPP深入讲解:方法调用介绍

IL2CPP INTERNALS: METHOD CALLS

作者:JOSH PETERSON

翻译:Bowie

这里是本系列的第四篇博文。在这篇文章里,我们将看到il2cpp.exe如何为托管代码中的各种函数调用生成C++代码。我们在这里会着重的分析6种不同类型的函数调用:

类实例的成员函数调用和类的静态函数调用。
编译期生成的代理函数调用
虚函数调用
C#接口(Interface)函数调用
运行期决定的代理函数调用
通过反射机制的函数调用

对于每种情况,我们主要探讨两点:相应的C++代码都做了些啥以及这么做的开销如何。

和以往的文章一样,我们这里所讨论的代码,很可能在新的Unity版本中已经发生了变化。尽管如此,文章所阐述的基本概念是不会变的。而文章中关于代码的部分都是属于实现细节。

项目设置

这次我采用的Unity版本是5.0.1p4。运行环境为Windows,目标平台选择了WebGL。同样的,在构建设置中勾选了“Development Player”并且将“Enable Exceptions”选项设置成“Full”。

我将使用一个在上一篇文章中的C#代码,做一点小的修改,放入不同类型的调用方法。代码以一个接口(Interface)定义和类的定义开始:
 

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; 
    } 
}

接下来是后面代码要用到的常数变量和代理类型:
 

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

private delegate int ImportantMethodDelegate(string question);

最后是我们讨论的主题:6种不同的函数调用的代码(以及必须要有的启动函数,启动函数具体代码就不放上来了):
 

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目录下么?(只要Unity Editor保持打开)别忘了你也可以使用 Ctags 去标注这些代码,让阅读变得更容易。
直接函数调用

最简单(当然也是最快速)调用函数的方式,就是直接调用。下面是CallDirectly方法的C++实现:
 

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++代码的函数全部是类C形式的全局函数,这些函数不是虚函数也不是属于任何类的成员函数。接下来,直接静态函数的调用和前面的处理很相似。下面是静态函数CallStaticMethodDirectly的C++代码:
 

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

相比之前,我们可以说静态函数的代码处理要简单的多,因为我们不需要类的实例,所以我们也不需要创建实例,进行实例检查的那些个代码。静态函数的调用和一般函数调用的区别仅仅在于第一个参数:静态函数的第一个参数永远是NULL。

由于这两类函数的区别是如此之小,因此在后面的文章中,我们只会拿一般函数调用来讨论。但是这些讨论的内容同样适用于静态函数。
编译期代理函数调用

像这种通过代理函数来进行非直接调用的稍微复杂点的情况会发生什么呢?CallViaDelegate函数调用的C++代码如下:

// 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_newInitializedTypeInfo(&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
);

我加入了一些注释以标明上面代码的不同部分。需要注意的是实际上在C++中调用的是VirtFuncInvoker1<int32_t, String_t>::Invoke这个函数。此函数位于GeneratedVirtualInvokers.h头文件中。它不是由我们写的IL代码生成的,相反的,il2cpp.exe是根据虚函数是否有返回值,和虚函数的参数个数来生成这个函数的。(译注:VirtFuncInvokerN是表示有N个参数有返回值的虚函数调用,而VirtActionInvokerN 则表示有N个参数但是没有返回值的虚函数调用,上面的例子中VirtFuncInvoker1<int32_t, String_t>::Invoke的第一个模板参数int32_t就是函数的返回值,而VirtFuncInvoker1中的1表示此函数还有一个参数,也就是模板参数中的第二个参数:String_t*。因此可以推断VirtFuncInvoker2应该是类似这样的形式:VirtFuncInvoker2<R, S, T>::Invoke,其中R是返回值,S,T是两个参数)

具体的Invoke函数看起来是下面这个样子的:
 

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#托管代码建立的。在找到了这个虚函数后,代码就直接调用它,传入需要的参数,从而完成了函数调用过程。

你可能会问,为什么我们不用C++11标准中的可变参数模板 (译注:所谓可变参数模板是诸如template<typename T, typename...Args>,这样的形式,后面的...和函数中的可变参数...作用是一样的)来实现这些个VirtFuncInvokerN函数?这恰恰是可变参数模板能解决的问题啊。然而,考虑到由il2cpp.exe生成的C++代码要在各个平台的C++编译器中进行编译,而不是所有的编译器都支持C++11标准。所以我们再三权衡,没有使用这项技术。

那么虚函数调用又是怎么回事?我们调用的不是C#类里面的一个普通函数吗?大家回想下上面的代码:我们实际上是通过一个代理方法来调用类中的函数的。再来看看上面的C++代码,实际的函数调用是通过传递一个MethodInfo*结构(函数元信息结构):ImportantMethodDelegate_Invoke_m5_MethodInfo作为参数来完成的。再进一步看ImportantMethodDelegate_Invoke_m5_MethodInfo中的内容,会发现它实际上调用的是C#代码中ImportantMethodDelegate类型的Invoke函数(译注:也就是C#代理函数类型的Invoke函数)。而这个Invoke函数是个虚函数,所以最终我们也是以虚函数的方式调用的。

Wow,这够我们消化一阵子的了。在C#中的一点小小的改变,在我们的C++代码中从简单的函数调用变成了一系列的复杂函数调用,这中间还牵扯到了查找虚函数表。显然通过代理的方法调用比直接函数调用更耗时。
还有一点需要注意的是在代理方法调用处理时候使用的这个查找虚函数表的操作,也同样适用于虚函数调用。

接口方法调用

在C#中通过接口方法调用当然也是可以的。在C++代码实现中和虚函数的处理方式差不多:
 

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来完成的。这个函数存在于GeneratedInterfaceInvokers.h头文件中。就像上面提到过的VirtFuncInvoker1类,InterfaceFuncInvoker1类也是通过il2cpp::vm::Runtime::GetInterfaceInvokeData查询虚函数表来确定实际调用的函数的。

那为什么接口的方法调用和虚函数的调用在libil2cpp库中是不同的API呢?那是因为在接口方法调用中,除了方法本身的元信息,函数参数之外,我们还需要接口本身(在上面的例子中就是L_1)在虚函数表中接口的方法是被放在一个特定的偏移上的。因此il2cpp.exe需要接口的信息去计算出被调用的函数到底是哪一个。
从代码的最后一行可以看出,调用接口的方法和调用虚函数的开销在IL2CPP中是一样的。

运行期决定的代理方法调用

使用代理的另一个方法是在运行时由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
);

首先我们使用了一些代码来创建代理这个实例,随后处理函数调用的代码也不少。在后面的过程中我们先创建了一个数组用来存放被调用函数的参数。然后调用代理实例中的DynamicInvoke方法。如果我们更深入的研究下DynamicInvoke方法,会发现它实际上在内部调用了VirtFuncInvoker1::Invoke函数,就如同编译期代理所做的那样。所以从代码执行量上来说,运行时代理方法比静态编译代理方法多了一个函数创建,比且还多了一次虚函数表的查找。

通过反射机制进行方法调用

毫无疑问的,通过反射来调用函数开销是最大的。下面我们来看看具体的CallViaReflection函数所生成的C++代码:
 

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

// Get the method metadata from the type via reflection. 
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo)); 
Type_t * L_1 = Type_GetTypeFromHandle_m19(
    NULL /*static, unused*/, 
    LoadTypeToken(&Important_t1_0_0_0), 
    /*hidden argument*/
    &Type_GetTypeFromHandle_m19_MethodInfo
); 

NullCheck(L_1); 

MethodInfo_t * L_2 = (MethodInfo_t *)VirtFuncInvoker1< MethodInfo_t *, String_t* >::Invoke(
    &Type_GetMethod_m23_MethodInfo, 
    L_1, 
    (String_t*) &_stringLiteral2
); 

V_1 = L_2; 
MethodInfo_t * L_3 = V_1; 

// Call the method. 
Important_t1 * L_4 = V_0; 
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_3); 

VirtFuncInvoker2< Object_t *, Object_t *, ObjectU5BU5D_t9* >::Invoke(
    &MethodBase_Invoke_m24_MethodInfo, 
    L_3, 
    L_4, 
    L_5
);

就和运行时代理方法调用一样,我们需要用额外的代码创建函数参数数组。然后还需要调用一个MethodBase::Invoke (实际上是MethodBase_Invoke_m24函数)虚函数,由这个函数调用另外一个虚函数,在能最终得到实际的函数调用!

总结

虽然Unity没有针对C++函数调用的性能分析器,但是我们可以从C++的源码中看出不同类型方法调用的不同复杂程度的实现。如何可能,请尽量避免使用运行时代理方法和反射机制方法的调用。当然,想要提高运行效率还是要在项目的早期阶段就使用性能分析器进行诊断。
我们也在一直想办法优化il2cpp.exe产生的代码。因此再次强调,这篇文章中所产生的C++代码或许会在以后的Unity版本中发生变化。
下篇文章我们将更进一步的深入到函数中,看看我们是如何共享方法简化C++代码并减小最终可执行文件的尺寸的。



作者:IndieACE
链接:https://www.jianshu.com/p/1999dcbf4e46
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。

 类似资料: