(譯注:P/Invoke,全稱是platform invoke service,平臺(tái)調(diào)用服務(wù),簡(jiǎn)單的說就是允許托管代碼調(diào)用在 DLL 中實(shí)現(xiàn)的非托管函數(shù)。而在這期間一個(gè)重要的工作就是marshall:讓托管代碼中的數(shù)據(jù)和原生代碼中的數(shù)據(jù)可以相互訪問。我在下文中都稱之為內(nèi)存轉(zhuǎn)換。)
這是IL2CPP深入講解的第六篇。在這篇文章里,我們會(huì)討論il2cpp.exe是如何生成在托管代碼和原生代碼間進(jìn)行交互操作而使用到的封裝函數(shù)和類型。特別的,我們將深入探討blittable和non-blittable之間的區(qū)別,理解String,Array數(shù)據(jù)在內(nèi)存上的轉(zhuǎn)換,以及了解這些轉(zhuǎn)換所付出的代價(jià)。我編寫托管和原生間的交互代碼已經(jīng)有好一段時(shí)間了,但是要讓p/invoke在C#中的聲明始終保持正確是一件很困難的事情。理解運(yùn)行時(shí)那些對(duì)象是如何在內(nèi)存上進(jìn)行處理的就更加令人感覺神秘了。因?yàn)镮L2CPP在這方面為我們做了絕大部分的工作,我們可以查看(甚至調(diào)試)這些內(nèi)存轉(zhuǎn)換行為,為我們處理問題和效率分析提供良好的支持。
這篇文章不會(huì)提供內(nèi)存轉(zhuǎn)換或者是原生代碼交互的基礎(chǔ)介紹。這是一個(gè)非常寬泛的話題,一篇博文根本不可能放得下。Unity的官方文檔有討論原生插件是如何與Unity交互的。Mono和Microsoft也對(duì)p/invoke提供了足夠多的信息。
老生常談了:在這個(gè)系列中,我們所探索的代碼都很有可能在以后的Unity版本中發(fā)生變化。然而不管代碼怎么變,其基礎(chǔ)的概念是不會(huì)改變的。所以這個(gè)系列中的所有討論的代碼都屬于實(shí)現(xiàn)細(xì)節(jié)。
項(xiàng)目設(shè)置
在這篇文章中,我使用的是Unity 5.0.2p4在OSX上的版本,目標(biāo)平臺(tái)是iOS,編譯構(gòu)架上我選擇的是“通用”(“Universal”)。最終我會(huì)使用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.xv.x + v.yv.y + v.zv.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代碼中的每一個(gè)函數(shù)最終都會(huì)調(diào)用到上面原生代碼的一個(gè)函數(shù)中。后面我們將逐一分析每一個(gè)托管函數(shù)的申明。
為啥需要內(nèi)存轉(zhuǎn)換?
既然IL2CPP已經(jīng)把C#代碼都變成了C++代碼,我們干嘛還需要從C#做內(nèi)存轉(zhuǎn)換到C++?雖然生成的C++代碼是原生代碼,但是在某些情況下,C#中數(shù)據(jù)類型的呈現(xiàn)還是和C++有所區(qū)別的,因此IL2CPP在運(yùn)行的時(shí)候必須在兩邊來回轉(zhuǎn)換。il2cpp.exe對(duì)數(shù)據(jù)類型和方法都會(huì)做相同的轉(zhuǎn)換操作。
在托管代碼層面,所有的數(shù)據(jù)類型都被分為兩類:blittable或者non-blittable。blittable類型意味著在托管和原生代碼中,內(nèi)存的表現(xiàn)是一致的,沒有區(qū)別(比如:byte,int,float)。Non-blittable類型在兩者中的內(nèi)存表現(xiàn)就不一致。(比如:bool,string,array)。正因?yàn)檫@樣,blittable類型數(shù)據(jù)能夠直接傳遞給原生代碼,但是non-blittable類型就需要做轉(zhuǎn)換工作了。而這個(gè)轉(zhuǎn)換工作很自然的就牽扯到新內(nèi)存的分配。
為了告訴托管編譯器某些函數(shù)是在原生代碼中實(shí)現(xiàn)的,我們需要使用“extern”關(guān)鍵字。使用這個(gè)關(guān)鍵字,和“DllImport”屬性相配合,使得托管的運(yùn)行時(shí)庫(kù)能夠找到原生中的函數(shù)并且調(diào)用他們。il2cpp.exe會(huì)為每一個(gè)extern函數(shù)產(chǎn)生一個(gè)封裝。這層封裝執(zhí)行了以下一些很重要的任務(wù):
1.為原生代碼生成一個(gè)typedef以用來通過函數(shù)指針進(jìn)行函數(shù)調(diào)用。
2.通過名字找到原生代碼中的函數(shù),并且將其賦值給一個(gè)函數(shù)指針
3.如果有必要,將托管代碼中的參數(shù)內(nèi)存轉(zhuǎn)換到原生代碼格式
4.調(diào)用原生函數(shù)
5.如果有必要,將原生函數(shù)的返回值內(nèi)存轉(zhuǎn)換到托管代碼的格式
6.如果有必要,還需要處理具有關(guān)鍵字是“out”或者“ref”的參數(shù),將他們的內(nèi)容從原生格式轉(zhuǎn)換到托管代碼格式。
下面我們就來看看產(chǎn)生的這些封裝函數(shù)都是什么個(gè)情況。
內(nèi)存轉(zhuǎn)換blittable數(shù)據(jù)類型
最簡(jiǎn)單的extern封裝只牽扯到blittable類型。
[DllImport("__Internal")]
private extern static int Increment(int value);
在Bulk_Assembly-CSharp_0.cpp文件中,查找“HelloWorld_Increment_m3”函數(shù)。為“Increment”提供封裝的函數(shù)像下面這個(gè)樣子:
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;
}
首先,我們來一個(gè)typedef:
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
其他的封裝函數(shù)一開始看起來也差不多會(huì)是這樣。在這里,這個(gè)*PInvokeFunc 是一個(gè)有int32參數(shù)并且返回一個(gè)int32的函數(shù)指針。
接下來,封裝嘗試找到對(duì)應(yīng)的函數(shù)并且將其地址賦值給這個(gè)函數(shù)指針
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
而實(shí)際的Increment函數(shù)是通過extern關(guān)鍵字表明它處在C++代碼中。
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
在iOS平臺(tái)上,原生函數(shù)會(huì)被靜態(tài)的鏈接到單一的bin文件中(通過在DllImport中的“__Internal”關(guān)鍵字),因此IL2CPP運(yùn)行時(shí)并不需要?jiǎng)討B(tài)的查找相應(yīng)的函數(shù)指針。相反,這部分工作是在link期間完成的。在其他平臺(tái)上,IL2CPP可能會(huì)根據(jù)需要進(jìn)行函數(shù)指針的查找。
事實(shí)情況是:在iOS平臺(tái),非正確的p/invoke在c++編譯器link的階段就會(huì)體現(xiàn)出來而不是等到運(yùn)行時(shí)才發(fā)現(xiàn)。因此所有的p/invoke都必須正確,哪怕他們實(shí)際沒有被執(zhí)行到。
最終,原生代碼通過函數(shù)指針被調(diào)用,函數(shù)的返回值被送回托管代碼中。請(qǐng)注意在上面的例子中,參數(shù)是按值傳遞的,所以任何對(duì)參數(shù)值的改變都不會(huì)最終印象到托管代碼中。
內(nèi)存轉(zhuǎn)換non-blittable類型
當(dāng)處理non-blittable數(shù)據(jù)類型比如string的時(shí)候,事情會(huì)變得更加有趣。還記得前面文中提到的嗎?在IL2CPP中string實(shí)際上是一個(gè)通過UTF-16編碼的,最前面加上了一個(gè)4字節(jié)前綴的,兩字節(jié)寬的數(shù)組。這種內(nèi)存格式和C中的char或者wchar_t都不兼容,因此我們必須做一些轉(zhuǎn)換。如果我們看一下StringsMatch函數(shù)(在生成代碼中叫HelloWorld_StringsMatch_m4):
DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);
我們會(huì)發(fā)現(xiàn)每一個(gè)string參數(shù)都會(huì)被轉(zhuǎn)換成char*(通過UnmangedType.LPStr指令)。
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);
具體的轉(zhuǎn)換看上去是這樣的(對(duì)于第一個(gè)參數(shù)而言):
char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);
一個(gè)適當(dāng)長(zhǎng)度的char內(nèi)存塊被分配,將string中的內(nèi)容拷貝到新的內(nèi)存中。當(dāng)然,當(dāng)函數(shù)執(zhí)行完畢后,我們會(huì)將這個(gè)內(nèi)存塊釋放。
il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;
因此內(nèi)存轉(zhuǎn)換像string這樣的non-blittable類型是一個(gè)費(fèi)時(shí)的操作。
內(nèi)存轉(zhuǎn)換用戶自定義類型
像int或者是string這樣的類型還算好理解,那么如果有更加復(fù)雜的用戶自定義類型會(huì)發(fā)生什么呢?假設(shè)我們想對(duì)有著三個(gè)float的Vector類型進(jìn)行內(nèi)存轉(zhuǎn)換,我們會(huì)發(fā)現(xiàn)如果一個(gè)自定義結(jié)構(gòu)中的所有成員都是blittable的話,這個(gè)類型就可以作為blittable來對(duì)待。因此我們可以直接調(diào)用ComputeLength(在生成的代碼中叫HelloWorld_ComputeLength_m5)而不用對(duì)參數(shù)做任何轉(zhuǎn)換。
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );
// I’ve omitted the function pointer code.
float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;
同樣的,參數(shù)是按值傳遞的,就像上面那個(gè)int的例子一樣。如果我們想改變Vector的值,我們必須按引用傳遞這個(gè)變量,就像下面SetX函數(shù)(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;
作為引用的話,參數(shù)在原生代碼中就變成了指針,所生成的代碼也有一些繁瑣。本質(zhì)上,代碼會(huì)創(chuàng)建相同類型的局部變量,將參數(shù)中的內(nèi)容拷貝到此局部變量,然后用此局部變量指針作為參數(shù)調(diào)用原生函數(shù),在函數(shù)返回后,將局部變量的值拷貝回參數(shù)變量中以便讓托管代碼訪問到變化后的值。
內(nèi)存轉(zhuǎn)換non-blittable用戶自定義類型
列子中的Boss這樣的non-blittable用戶自定義類型也是可以做內(nèi)存轉(zhuǎn)換的。但是需要更多一些的工作:類型中的每一個(gè)成員都必須單獨(dú)的轉(zhuǎn)換成原生的表現(xiàn)形式。再進(jìn)一步,生成的C++代碼中必須要有和原生代碼中表現(xiàn)一致的自定義結(jié)構(gòu)。
讓我們來看一下IsBossDead聲明:
[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);
這個(gè)函數(shù)的封裝是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;
}
傳遞封裝函數(shù)的參數(shù)是Boss_t2,和托管代碼中的Boss結(jié)構(gòu)相對(duì)應(yīng)。但是在傳遞給原生函數(shù)的時(shí)候Boss_t2_marshaled。如果我們跳轉(zhuǎn)到這個(gè)類型的定義,我們會(huì)發(fā)現(xiàn)Boss_t2_marshaled和原生C++庫(kù)中的Boss類型的定義是一致的:
struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};
我們還是使用UnmanagedType.LPStr在C#中來指引string轉(zhuǎn)換成char*。如果你發(fā)現(xiàn)在調(diào)試non-blittable用戶自定義類型時(shí)有困難。在生成的代碼中查看一下帶_marshaled后綴的結(jié)構(gòu)會(huì)很有幫助。如果結(jié)構(gòu)和原生代碼中的結(jié)構(gòu)不一致,那么內(nèi)存轉(zhuǎn)換肯定會(huì)出問題。
上面的例子中,Boss_t2_marshal函數(shù)用來對(duì)Boss類中的每個(gè)成員進(jìn)行轉(zhuǎn)換。而Boss_t2_marshal_cleanup則負(fù)責(zé)進(jìn)行清除工作。
內(nèi)存轉(zhuǎn)換數(shù)組
最后,我們來看一下如果內(nèi)存轉(zhuǎn)換blittable和non-blittable的數(shù)組。SumArrayElements傳遞的是一個(gè)整數(shù)型數(shù)組:
[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);
數(shù)組會(huì)進(jìn)行內(nèi)存轉(zhuǎn)換,不過因?yàn)槠涿總€(gè)元素都是blittable的int形,轉(zhuǎn)換的代價(jià)是非常小的:
int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);
il2cpp_codegen_marshal_array函數(shù)僅僅是返回托管代碼中數(shù)組的首地址。
然而,內(nèi)存轉(zhuǎn)換non-blittable類型的數(shù)組開銷就會(huì)大得多。SumBossHealth函數(shù)傳遞的是一個(gè)Boss數(shù)組:
[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);
封裝不得不分配一個(gè)新數(shù)組,然后對(duì)數(shù)組中的每一個(gè)元素都做一次內(nèi)存轉(zhuǎn)換:
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));
}
當(dāng)然,我們還必須在函數(shù)調(diào)用完成后進(jìn)行內(nèi)存釋放操作。
結(jié)論
在內(nèi)存轉(zhuǎn)換上, IL2CPP的行為和Mono是一致的。因?yàn)镮L2CPP會(huì)對(duì)extern函數(shù)和類型產(chǎn)生封裝代碼,因此我們可以檢查交互調(diào)用的開銷。對(duì)于blittable而言,開銷通常還好,但是對(duì)于non-blittable而言,會(huì)讓開銷上升的很快。我們只是對(duì)內(nèi)存轉(zhuǎn)換做了個(gè)簡(jiǎn)單的介紹。有關(guān)返回值和帶out關(guān)鍵字的參數(shù),原生函數(shù)指針和托管中的代理,用戶自定義的引用類型的內(nèi)存轉(zhuǎn)換,還請(qǐng)大家探索源碼自行分析。
下一篇文章我們將探索IL2CPP和垃圾回收器的集成。