IL2CPP 内部:P/Invoke 封装程序

我写过不少托管与本地互操作代码,但要在 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 处理的代价会很高。
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 如何与垃圾回收器集成。
