IL2CPP 内部:P/Invoke 封装程序

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 Min
IL2CPP 内部:P/Invoke 封装程序
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。
本文是 IL2CPP Internals 系列的第六篇文章。在本篇文章中,我们将探讨 il2cpp.exe 如何生成用于托管代码和本地代码之间互操作的封装方法和类型。具体来说,我们将了解可忽略类型和不可忽略类型之间的区别,理解字符串和数组 marshaling,并了解 marshaling 的成本。

我写过不少托管与本地互操作代码,但要在 C# 中正确地使用 p/invoke 声明,至少可以说仍然很困难。至于运行时是如何对我的对象进行 marshal 的,这就更加令人费解了。由于 IL2CPP 在生成的 C++ 代码中完成了大部分编译工作,因此我们可以看到(甚至调试!)其行为,为故障排除和性能分析提供更好的洞察力。

本帖无意提供有关 marshaling 和本地互操作的一般信息。这个话题很宽泛,一个帖子说不完。Unity 文档讨论了本地插件如何与 Unity 交互。Mono微软都提供了大量有关 p/invoke 的优秀信息。

与本系列的所有文章一样,我们将探讨的代码可能会发生变化,事实上,在更新的 Unity 版本中也很可能会发生变化。不过,概念应保持不变。请将本系列中讨论的所有内容视为实施细节。不过,我们喜欢在可能的情况下揭露和讨论这样的细节!

设置

在这篇文章中,我使用的是 OSX 上的 Unity 5.0.2p4。我将使用 "通用 "的 "架构 "值为 iOS 平台构建。我在 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[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)));
}

这段代码中的每个方法调用都是在上图所示的本地代码中进行的。稍后我们将查看每个方法的托管方法声明。

我们为什么需要调度?

既然 IL2CPP 已经在生成 C++ 代码,为什么还需要将 C# 代码 marshaling 到 C++ 代码呢?虽然生成的 C++ 代码是本地代码,但 C# 中的类型表示法在很多情况下与 C++ 不同,因此 IL2CPP 运行时必须能够来回转换双方的表示法。il2cpp.exe实用程序可对类型和方法进行此操作。

在托管代码中,所有类型都可归类为 可闪烁非可拆卸.Blittable 类型在托管代码和本地代码中具有相同的表示形式(如字节、int、float)。非可渗透类型在托管代码和本地代码中有不同的表示(如 bool、字符串、数组类型)。因此,可混合类型可以直接传递给本地代码,但不可混合类型需要进行一些转换后才能传递给本地代码。这种转换通常需要分配新的内存。

为了告诉托管代码编译器某个方法是在本地代码中实现的,C# 中使用了 extern 关键字。该关键字与 DllImport 属性一起允许托管代码运行时找到本地方法定义并调用它。il2cpp.exe 实用程序会为每个外部方法生成一个封装 C++ 方法。该封装器执行几项重要任务:

- 它为本地方法定义了一个类型定义,用于通过函数指针调用该方法。

- 它通过名称解析本地方法,获取指向该方法的函数指针。

- 它将参数从托管表示法转换为本地表示法(如有必要)。

- 它会调用本地方法。

- 它将方法的返回值从本地表示转换为托管表示(如有必要)。

- In 会将任何 out 或 ref 参数从本地表示法转换为托管表示法(如有必要)。

接下来,我们将看看为一些外部方法声明生成的封装方法。

转义可爆破类型

最简单的外部包装器只处理可混合类型。

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



In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:

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 int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

每个封装函数中都会出现类似的内容。这个本地函数接受一个 int32_t,并返回一个 int32_t。

接下来,封装器会找到合适的函数指针,并将其存储在静态变量中:

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

这里的 Increment 函数实际上来自(C++ 代码中的)外部语句:

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

在 iOS 上,本地方法被静态链接到一个二进制文件中(由 DllImport 属性中的"__Internal "字符串表示),因此 IL2CPP 运行时无需查找函数指针。相反,该外部语句会通知链接器在链接时找到合适的函数。在其他平台上,IL2CPP 运行时可能会使用特定平台的 API 方法执行查找(如有必要),以获取该函数指针。

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

最后,通过函数指针调用本地方法,并返回返回值。请注意,参数是以值的形式传递给本地函数的,因此在本地代码中对参数值的任何更改都不会在托管代码中生效,这也是我们所期望的。

转置非可删减类型

如果使用字符串等不可涂抹类型,情况就会变得更加令人兴奋。回想一下之前的文章,IL2CPP 中的字符串是以通过 UTF-16 编码的 2 字节字符数组表示的,前缀是 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 指令)。

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

转换过程如下(第一个参数):

char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);

系统会分配一个长度合适的新字符缓冲区,并将字符串内容复制到新缓冲区中。当然,在调用本地方法后,我们需要清理这些已分配的缓冲区:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

因此,对像字符串这样的不可擦除类型进行 marsh 处理的代价会很高。

Marshaling 一个用户定义的类型

int 和字符串等简单类型固然不错,但更复杂的用户自定义类型又如何呢?假设我们要调用上面的向量结构,其中包含三个浮点数值。事实证明,当且仅当一个用户定义的类型的所有字段都是可闪烁的时候,它才是可闪烁的。因此,我们可以调用 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;

这里的向量参数是作为本地代码的指针传递的。生成的代码有点繁琐,但基本上就是创建一个相同类型的 Localization 变量,将参数值复制到 Local,然后使用指向该 Local 变量的指针调用本地方法。本地函数返回后,Localization 变量中的值会被复制到参数中,然后该值就可以在托管代码中使用了。

用户定义的不可擦除类型,如上面定义的 "Boss "类型,也可以被 marshal,但需要更多的工作。该类型的每个字段都必须转换为其本地表示形式。此外,生成的 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_t2 是 Boss 结构的生成类型。请注意,它传递给本地函数的类型是不同的:Boss_t2_marshaled.如果我们跳转到该类型的定义,就会发现它与 C++ 静态库代码中 Boss struct 的定义相匹配:

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

我们再次使用 C# 中的 UnmanagedType.LPStr 指令来指示字符串字段应作为 char* 进行 marshal。如果你发现自己在调试一个不可擦除的用户定义类型时遇到了问题,看看下面的内容会很有帮助 _marshaled 结构非常有用。如果字段布局与本地端不匹配,那么托管代码中的 marshaling 指令就可能不正确。

Boss_t2_marshal 函数是一个生成函数,用于对每个字段进行编译,Boss_t2_marshal_cleanup 会释放编译过程中分配的内存。

转置用户定义的不可删减类型
对数组进行装订

最后,我们将探讨如何对可忽略和不可忽略类型的数组进行编组。SumArrayElements 方法传递的是一个整数数组:

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

这个数组是可以调用的,但由于数组的元素类型(int)是可调用的,因此调用它的代价非常小:

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

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

但是,对不可拆分类型的数组进行调用的成本要高得多。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)[i]);
}

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

结论

IL2CPP 脚本后端支持与 Mono 脚本后端相同的编译行为。由于 IL2CPP 为外部方法和类型生成了包装器,因此可以看到托管到本地互操作调用的成本。对于可混合类型来说,这种成本通常不会太高,但对于不可混合类型来说,互操作的成本很快就会非常高。和往常一样,我们在这篇文章中只是浅尝了 "调度 "的皮毛。请进一步查看生成的代码,了解如何对返回值和输出参数、本地函数指针和托管委托以及用户定义的引用类型进行 marshaling。

下一次,我们将探讨 IL2CPP 如何与垃圾回收器集成。