在这篇文章中,我们将探讨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)));
}
既然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执行一些重要的任务:
接下来,我们将查看为一些extern方法声明生成的wrapper方法。
最简单的外部包装器只处理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++代码中的值的任何更改在托管代码中都不可用。
对于非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)的开销可能很大。
像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++变量中的值被复制回参数中,然后该值在托管代码中可用。
用户定义的非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释放在封送处理过程中分配的所有内存。
最后,我们将探讨如何封送blittable和非blittable类型的数组。向SumArrayElements方法传递一个整数数组:
这个数组被封送,但是由于数组的元素类型(int)是blittable,封送的成本非常小:
1 2 |
int32_t* ____elements_marshaled = { 0 }; ____elements_marshaled = il2cpp_codegen_marshal_array |
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]);
}
当然,所有这些分配也会在本机方法调用完成后清除。
IL2CPP脚本后端支持与Mono脚本后端相同的marshalling行为。因为IL2CPP为extern方法和类型生成包装器,所以可以看到托管到本地互操作调用的成本。对于blittable类型,这种成本通常不算太坏,但非blittable类型可能很快使互操作变得非常昂贵。