IL2CPP Internals: P/Invoke Wrappers

在这篇文章中,我们将探讨il2cpp.exe如何生成用于托管代码和本机代码之间互操作的wrap方法和类型。具体来说,我们将查看blittable和非blittable类型之间的区别,理解字符串和数组封送处理,并了解封送处理的成本。

The setup

本机代码是这样的:

#include 
 
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[i];
}
return sum;
}
 
int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].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)));
}

Why do we need marshaling?

既然IL2CPP已经生成了c++代码,为什么我们还需要从c#封送到c++的代码呢?尽管生成的c++代码是原生代码,但c#中的类型表示在很多情况下与c++不同,因此IL2CPP运行时必须能够在两端的表示之间来回转换。IL2CPP.exe对类型和方法都这样做。

在托管代码中,所有类型都可以归类为blittable或nonblittable。Blittable类型在托管代码和本机代码中有相同的表示(例如字节、int、float)。非blittable类型在托管代码和本机代码中有不同的表示(例如:bool、string、array类型)。因此,blittable类型可以直接传递给本机代码,但是非blittable类型在传递给本机代码之前需要进行一些转换。这种转换通常涉及新的内存分配。

为了告诉托管代码编译器,一个给定的方法是在本地代码中实现的,在c#中使用extern关键字。这个关键字以及一个DllImport属性允许托管代码运行时查找c++方法定义并调用它。il2cpp.exe为每个extern方法生成一个c++方法的wrapper。这个wrapper执行一些重要的任务:

  • 它为c++方法定义了一个类型定义,用于通过函数指针调用该方法。
  • 它通过名称解析c++方法,获取指向该方法的函数指针。
  • 它将参数从其托管表示转换为其c++表示(如果需要)。
  • 它调用c++方法。
  • 它将方法的返回值从其c++表示形式转换为其托管表示形式(如果需要)。
  • 将任何out或ref参数从其c++表示转换为其托管表示(如果需要).

接下来,我们将查看为一些extern方法声明生成的wrapper方法。

Marshaling a blittable type

最简单的外部包装器只处理blittable类型。

1

2

[DllImport("__Internal")]

private extern static int Increment(int value);

 在Bulk_Assembly-CSharp_0.cpp文件中,搜索字符串“HelloWorld_Increment_m3”。递增方法的包装器函数是这样的:

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

 首先,注意C++函数的类型定义:

1

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

在每个wrapper函数中都会出现类似的情况。这个c++函数接受一个int32_t并返回一个int32_t。

接下来,wrapper找到合适的函数指针并将其存储在一个静态变量中::

1

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

这里的Increment函数实际上来自一个extern语句(在c++代码中): 

1

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

 在iOS上,c++方法被静态链接到一个二进制文件中(通过DllImport属性中的“剩余内部”字符串表示),因此IL2CPP运行时不查找函数指针。相反,在其他平台上,这个extern语句通知链接器在链接时找到合适的函数,IL2CPP运行时可以使用特定于平台的API方法执行查找(如果需要)来获得这个函数指针。

实际上,这意味着在iOS上,托管代码中错误的p/invoke签名将在生成的代码中显示为链接器错误。该错误不会在运行时发生。所以所有的p/invoke签名必须是正确的,即使它们在运行时没有使用

最后,通过函数指针调用c++方法,并返回返回值。请注意,参数是按值传递给c++函数的,因此如我们所料,对c++代码中的值的任何更改在托管代码中都不可用。 

Marshaling a non-blittable type

对于非blittable类型,比如string,情况会更令人兴奋一些。在以前的文章中,IL2CPP中的字符串是用UTF-16编码的两字节字符数组表示的,前缀是4字节长度的值。这个表示与iOS上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);

我们可以看到,每个字符串参数将被转换为char*(由于UnmangedType.LPStr的指令))。

1

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

转换是这样的(对于第一个参数):

1

2

char* ____l_marshaled = { 0 };

____l_marshaled = il2cpp_codegen_marshal_string(___l);

 分配适当长度的新char缓冲区,并将字符串的内容复制到新缓冲区中。当然,在本机方法被调用后,我们需要清理那些分配的缓冲区:

1

2

il2cpp_codegen_marshal_free(____l_marshaled);

____l_marshaled = NULL;

因此封送非blittable类型(如string)的开销可能很大。 

Marshaling a user-defined type

像int和string这样的简单类型很好,但是更复杂的用户定义类型呢?假设我们想对上面的向量结构进行封送,它包含三个浮点值。事实证明,当且仅当用户定义的类型的所有字段都是blittable时,该类型才是blittable。所以我们可以调用ComputeLength (HelloWorld_ComputeLength_m5在生成的代码中)而不需要转换参数:

2

3

4

5

6

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):

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;

在这里,Vector参数通过指针传递给c++代码。生成的代码有点冗长,但它基本上是创建一个相同类型的局部变量,将参数的值复制到局部变量,然后使用指向该局部变量的指针调用c++中的方法。c++函数返回后,c++变量中的值被复制回参数中,然后该值在托管代码中可用。

Marshaling a non-blittable user defined type

用户定义的非blittable类型(如上面定义的Boss类)也可以进行封送,但需要多做一些工作。此类型的每个字段都必须封送成c++表示的方法。此外,生成的c++代码需要与c++代码中的表示相匹配的托管类型的表示。

Let’s take a look at the IsBossDead extern declaration:

2

3

[DllImport("__Internal")]

[return: MarshalAs(UnmanagedType.U1)]

private extern static bool IsBossDead(Boss b);

这个方法的wrapper命名为HelloWorld_IsBossDead_m7:

1

2

3

4

5

6

7

8

9

10

11

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类型传递给wrapper函数,这是为Boss struct 生成的类型。注意,它传递给c++函数的类型是不同的:Boss_t2_marshaled。如果我们跳转到这个类型的定义,我们可以看到它与我们的c++静态库代码中的Boss结构的定义相匹配:

2

3

4

5

struct Boss_t2_marshaled

{

char* ___name_0;

int32_t ___health_1;

};

 我们在c#中再次使用了UnmanagedType.LPStr指令,表示该字符串字段应以char*形式封送。如果您发现自己在调试一个非blittable用户定义类型的问题,那么在生成的代码中查看这个_marshaled结构非常有帮助。如果字段布局与c++端不匹配,则托管代码中的封送处理指令可能不正确。

Boss_t2_marshal函数是一个生成的对每个字段进行封送处理的函数,而Boss_t2_marshal_cleanup释放在封送处理过程中分配的所有内存。

Marshaling an array

最后,我们将探讨如何封送blittable和非blittable类型的数组。向SumArrayElements方法传递一个整数数组:

这个数组被封送,但是由于数组的元素类型(int)是blittable,封送的成本非常小:

1

2

int32_t* ____elements_marshaled = { 0 };

____elements_marshaled = il2cpp_codegen_marshal_array((Il2CppCodeGenArray*)___elements);

il2cpp_codegen_marshal_array函数只是返回一个指向现有托管数组内存的指针,仅此而已!

但是,封送非blittable类型数组的开销要大得多。SumBossHealth方法传递一个Boss实例数组:

1

2

[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(____bosses_Length);
}
 
for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2  const& item = *reinterpret_cast(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}

当然,所有这些分配也会在本机方法调用完成后清除。

Conclusion

IL2CPP脚本后端支持与Mono脚本后端相同的marshalling行为。因为IL2CPP为extern方法和类型生成包装器,所以可以看到托管到本地互操作调用的成本。对于blittable类型,这种成本通常不算太坏,但非blittable类型可能很快使互操作变得非常昂贵。

 

 

 

 

你可能感兴趣的:(iL2Cpp)