IL2CPP 深入讲解:P/Invoke封装

(译注:P/Invoke,全称是platform invoke service,平台调用服务,简单的说就是允许托管代码调用在 DLL 中实现的非托管函数。而在这期间一个重要的工作就是marshall:让托管代码中的数据和原生代码中的数据可以相互访问。我在下文中都称之为内存转换。)

这是IL2CPP深入讲解的第六篇。在这篇文章里,我们会讨论il2cpp.exe是如何生成在托管代码和原生代码间进行交互操作而使用到的封装函数和类型。特别的,我们将深入探讨blittable和non-blittable之间的区别,理解String,Array数据在内存上的转换,以及了解这些转换所付出的代价。我编写托管和原生间的交互代码已经有好一段时间了,但是要让p/invoke在C#中的声明始终保持正确是一件很困难的事情。理解运行时那些对象是如何在内存上进行处理的就更加令人感觉神秘了。因为IL2CPP在这方面为我们做了绝大部分的工作,我们可以查看(甚至调试)这些内存转换行为,为我们处理问题和效率分析提供良好的支持。

这篇文章不会提供内存转换或者是原生代码交互的基础介绍。这是一个非常宽泛的话题,一篇博文根本不可能放得下。Unity的官方文档有讨论原生插件是如何与Unity交互的。Mono和Microsoft也对p/invoke提供了足够多的信息。

老生常谈了:在这个系列中,我们所探索的代码都很有可能在以后的Unity版本中发生变化。然而不管代码怎么变,其基础的概念是不会改变的。所以这个系列中的所有讨论的代码都属于实现细节。

项目设置

在这篇文章中,我使用的是Unity 5.0.2p4在OSX上的版本,目标平台是iOS,编译构架上我选择的是“通用”(“Universal”)。最终我会使用XCode 6.3.2来为ARMv7和ARM64编译代码。

首先我们看看原生代码:
 

#include <cstring>
#include <cmath>

extern "C" 
{
    int Increment(int i) {
        return i + 1;
    }

    bool StringsMatch(const char* l, const char* r) {
        return strcmp(l, r) == 0;
    }

    struct Vector {
        float x;
        float y;
        float z;
    };

    float ComputeLength(Vector v) {
        return sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
    }

    void SetX(Vector v, float value) {
        v->x = value;
    }

    struct Boss {
        char* name;
        int health;
    };

    bool IsBossDead(Boss b) {
        return b.health == 0;
    }

    int SumArrayElements(int* elements, int size) {
        int sum = 0;

        for (int i = 0; i < size; ++i) {
            sum += elements;
        } 
        return sum;
    }

    int SumBossHealth(Boss* bosses, int size) {
        int sum = 0;
        for (int i = 0; i < size; ++i) {
            sum += bosses.health;
        } 
        return sum;
    }
}

在Unity中的托管代码仍然在HelloWorld.cs文件中:

void Start () { 
    Debug.Log(string.Format("Using a blittable argument: {0}", Increment (42)));  

    Debug.Log(string.Format(
        "Marshaling strings: {0}", 
        StringsMatch ("Hello", "Goodbye")
    )); 

    var vector = new Vector (1.0f, 2.0f, 3.0f);  
    Debug.Log (string.Format(
        "Marshaling a blittable struct: {0}", 
        ComputeLength (vector)
    )); 

    SetX (ref vector, 42.0f);

    Debug.Log(string.Format(
        "Marshaling a blittable struct by reference: {0}", 
        vector.x
    ));  

    Debug.Log(string.Format(
        "Marshaling a non-blittable struct: {0}", 
        IsBossDead (new Boss("Final Boss", 100))
    )); 

    int[] values = {1, 2, 3, 4};  

    Debug.Log(string.Format(
        "Marshaling an array: {0}",
        SumArrayElements(values, values.Length)
    ));
 
    Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};  
    Debug.Log(string.Format(
        "Marshaling an array by reference: {0}",
        SumBossHealth(bosses, bosses.Length)
    ));
}

cs代码中的每一个函数最终都会调用到上面原生代码的一个函数中。后面我们将逐一分析每一个托管函数的申明。

为啥需要内存转换?

既然IL2CPP已经把C#代码都变成了C++代码,我们干嘛还需要从C#做内存转换到C++?虽然生成的C++代码是原生代码,但是在某些情况下,C#中数据类型的呈现还是和C++有所区别的,因此IL2CPP在运行的时候必须在两边来回转换。il2cpp.exe对数据类型和方法都会做相同的转换操作。

在托管代码层面,所有的数据类型都被分为两类:blittable或者non-blittable。blittable类型意味着在托管和原生代码中,内存的表现是一致的,没有区别(比如:byte,int,float)。Non-blittable类型在两者中的内存表现就不一致。(比如:bool,string,array)。正因为这样,blittable类型数据能够直接传递给原生代码,但是non-blittable类型就需要做转换工作了。而这个转换工作很自然的就牵扯到新内存的分配。

为了告诉托管编译器某些函数是在原生代码中实现的,我们需要使用“extern”关键字。使用这个关键字,和“DllImport”属性相配合,使得托管的运行时库能够找到原生中的函数并且调用他们。il2cpp.exe会为每一个extern函数产生一个封装。这层封装执行了以下一些很重要的任务:
1.为原生代码生成一个typedef以用来通过函数指针进行函数调用。
2.通过名字找到原生代码中的函数,并且将其赋值给一个函数指针
3.如果有必要,将托管代码中的参数内存转换到原生代码格式
4.调用原生函数
5.如果有必要,将原生函数的返回值内存转换到托管代码的格式
6.如果有必要,还需要处理具有关键字是“out”或者“ref”的参数,将他们的内容从原生格式转换到托管代码格式。

下面我们就来看看产生的这些封装函数都是什么个情况。

内存转换blittable数据类型

最简单的extern封装只牵扯到blittable类型。

[DllImport("__Internal")]
private extern static int Increment(int value);

在Bulk_Assembly-CSharp_0.cpp文件中,查找“HelloWorld_Increment_m3”函数。为“Increment”提供封装的函数像下面这个样子:

extern "C" {
    int32_t DEFAULT_CALL Increment(int32_t);
}

extern "C" int32_t HelloWorld_Increment_m3 (
    Object_t * __this /* static, unused */, 
    int32_t ___value, const MethodInfo* method
)
{
    typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
    static PInvokeFunc _il2cpp_pinvoke_func;

    if (!_il2cpp_pinvoke_func)
    {
        _il2cpp_pinvoke_func = (PInvokeFunc)Increment;

        if (_il2cpp_pinvoke_func == NULL)
        {
            il2cpp_codegen_raise_exception(
                il2cpp_codegen_get_not_supported_exception(
                    "Unable to find method for p/invoke: 'Increment'"
                )
            );
        }
    }
    int32_t _return_value = _il2cpp_pinvoke_func(___value);

    return _return_value;
}

首先,我们来一个typedef:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

其他的封装函数一开始看起来也差不多会是这样。在这里,这个*PInvokeFunc 是一个有int32参数并且返回一个int32的函数指针。

接下来,封装尝试找到对应的函数并且将其地址赋值给这个函数指针

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

而实际的Increment函数是通过extern关键字表明它处在C++代码中。

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}

在iOS平台上,原生函数会被静态的链接到单一的bin文件中(通过在DllImport中的“__Internal”关键字),因此IL2CPP运行时并不需要动态的查找相应的函数指针。相反,这部分工作是在link期间完成的。在其他平台上,IL2CPP可能会根据需要进行函数指针的查找。

事实情况是:在iOS平台,非正确的p/invoke在c++编译器link的阶段就会体现出来而不是等到运行时才发现。因此所有的p/invoke都必须正确,哪怕他们实际没有被执行到。

最终,原生代码通过函数指针被调用,函数的返回值被送回托管代码中。请注意在上面的例子中,参数是按值传递的,所以任何对参数值的改变都不会最终影响到托管代码中。

内存转换non-blittable类型

当处理non-blittable数据类型比如string的时候,事情会变得更加有趣。还记得前面文中提到的吗?在IL2CPP中string实际上是一个通过UTF-16编码的,最前面加上了一个4字节前缀的,两字节宽的数组。这种内存格式和C中的char或者wchar_t都不兼容,因此我们必须做一些转换。如果我们看一下StringsMatch函数(在生成代码中叫HelloWorld_StringsMatch_m4):

DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch(
    [MarshalAs(UnmanagedType.LPStr)]string l, 
    [MarshalAs(UnmanagedType.LPStr)]string r
);

我们会发现每一个string参数都会被转换成char*(通过UnmangedType.LPStr指令)。

typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);

具体的转换看上去是这样的(对于第一个参数而言):

char* ____l_marshaled = { 0 };

____l_marshaled = il2cpp_codegen_marshal_string(___l);

一个适当长度的char内存块被分配,将string中的内容拷贝到新的内存中。当然,当函数执行完毕后,我们会将这个内存块释放。

il2cpp_codegen_marshal_free(____l_marshaled);

____l_marshaled = NULL;

因此内存转换像string这样的non-blittable类型是一个费时的操作。

内存转换用户自定义类型

像int或者是string这样的类型还算好理解,那么如果有更加复杂的用户自定义类型会发生什么呢?假设我们想对有着三个float的Vector类型进行内存转换,我们会发现如果一个自定义结构中的所有成员都是blittable的话,这个类型就可以作为blittable来对待。因此我们可以直接调用ComputeLength(在生成的代码中叫HelloWorld_ComputeLength_m5)而不用对参数做任何转换。

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.
float _return_value = _il2cpp_pinvoke_func(___v);

return _return_value;

同样的,参数是按值传递的,就像上面那个int的例子一样。如果我们想改变Vector的值,我们必须按引用传递这个变量,就像下面SetX函数(HelloWorld_SetX_m6)所做的那样:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);

Vector_t1 * ____v_marshaled = { 0 };

Vector_t1  ____v_marshaled_dereferenced = { 0 };

____v_marshaled_dereferenced = *___v;

____v_marshaled = &____v_marshaled_dereferenced;

float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);

Vector_t1  ____v_result_dereferenced = { 0 };

Vector_t1 * ____v_result = &____v_result_dereferenced;

*____v_result = *____v_marshaled;

*___v = *____v_result;

return _return_value;

作为引用的话,参数在原生代码中就变成了指针,所生成的代码也有一些繁琐。本质上,代码会创建相同类型的局部变量,将参数中的内容拷贝到此局部变量,然后用此局部变量指针作为参数调用原生函数,在函数返回后,将局部变量的值拷贝回参数变量中以便让托管代码访问到变化后的值。

内存转换non-blittable用户自定义类型

列子中的Boss这样的non-blittable用户自定义类型也是可以做内存转换的。但是需要更多一些的工作:类型中的每一个成员都必须单独的转换成原生的表现形式。再进一步,生成的C++代码中必须要有和原生代码中表现一致的自定义结构。

让我们来看一下IsBossDead声明:

[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);

这个函数的封装是HelloWorld_IsBossDead_m7:

extern "C" bool HelloWorld_IsBossDead_m7 (
    Object_t * __this /* static, unused */, 
    Boss_t2  ___b, 
    const MethodInfo* method
)
{
    typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);

    Boss_t2_marshaled ____b_marshaled = { 0 };

    Boss_t2_marshal(___b, ____b_marshaled);

    uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);

    Boss_t2_marshal_cleanup(____b_marshaled);

    return _return_value;
}

传递封装函数的参数是Boss_t2,和托管代码中的Boss结构相对应。但是在传递给原生函数的时候Boss_t2_marshaled。如果我们跳转到这个类型的定义,我们会发现Boss_t2_marshaled和原生C++库中的Boss类型的定义是一致的:

struct Boss_t2_marshaled
{
    char* ___name_0;
    int32_t ___health_1;
};

我们还是使用UnmanagedType.LPStr在C#中来指引string转换成char*。如果你发现在调试non-blittable用户自定义类型时有困难。在生成的代码中查看一下带_marshaled后缀的结构会很有帮助。如果结构和原生代码中的结构不一致,那么内存转换肯定会出问题。

上面的例子中,Boss_t2_marshal函数用来对Boss类中的每个成员进行转换。而Boss_t2_marshal_cleanup则负责进行清除工作。

内存转换数组

最后,我们来看一下如果内存转换blittable和non-blittable的数组。SumArrayElements传递的是一个整数型数组:

[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);

数组会进行内存转换,不过因为其每个元素都是blittable的int形,转换的代价是非常小的:

int32_t* ____elements_marshaled = { 0 };

____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);

il2cpp_codegen_marshal_array函数仅仅是返回托管代码中数组的首地址。

然而,内存转换non-blittable类型的数组开销就会大得多。SumBossHealth函数传递的是一个Boss数组:

[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);

封装不得不分配一个新数组,然后对数组中的每一个元素都做一次内存转换:

Boss_t2_marshaled* ____bosses_marshaled = { 0 };

size_t ____bosses_Length = 0;

if (___bosses != NULL)
{
    ____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;

    ____bosses_marshaled = il2cpp_codegen_marshal_allocate_array<Boss_t2_marshaled>(____bosses_Length);
}

for (int i = 0; i < ____bosses_Length; i++)
{
    Boss_t2  const& item = *reinterpret_cast<Boss_t2 *>(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));

    Boss_t2_marshal(item, (____bosses_marshaled));
}

当然,我们还必须在函数调用完成后进行内存释放操作。

结论

在内存转换上, IL2CPP的行为和Mono是一致的。因为IL2CPP会对extern函数和类型产生封装代码,因此我们可以检查交互调用的开销。对于blittable而言,开销通常还好,但是对于non-blittable而言,会让开销上升的很快。我们只是对内存转换做了个简单的介绍。有关返回值和带out关键字的参数,原生函数指针和托管中的代理,用户自定义的引用类型的内存转换,还请大家探索源码自行分析。

下一篇文章我们将探索IL2CPP和垃圾回收器的集成。



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

猜你喜欢

转载自blog.csdn.net/chqj_163/article/details/83627092