IL2CPP 深入講解:P/Invoke封裝

(譯注:P/Invoke,全稱是platform invoke service,平臺調用服務,簡單的說就是允許托管代碼調用在 DLL 中實現的非托管函數。而在這期間一個重要的工作就是marshall:讓托管代碼中的數據和原生代碼中的數據可以相互訪問。我在下文中都稱之為內存轉換。)

這是IL2CPP深入講解的第六篇。在這篇文章里,我們會討論il2cpp.exe是如何生成在托管代碼和原生代碼間進行交互操作而使用到的封裝函數和類型。特別的,我們將深入探討blittable和non-blittable之間的區別,理解String,Array數據在內存上的轉換,以及了解這些轉換所付出的代價。我編寫托管和原生間的交互代碼已經有好一段時間了,但是要讓p/invoke在C#中的聲明始終保持正確是一件很困難的事情。理解運行時那些對象是如何在內存上進行處理的就更加令人感覺神秘了。因為IL2CPP在這方面為我們做了絕大部分的工作,我們可以查看(甚至調試)這些內存轉換行為,為我們處理問題和效率分析提供良好的支持。

這篇文章不會提供內存轉換或者是原生代碼交互的基礎介紹。這是一個非常寬泛的話題,一篇博文根本不可能放得下。Unity的官方文檔有討論原生插件是如何與Unity交互的。Mono和Microsoft也對p/invoke提供了足夠多的信息。

老生常談了:在這個系列中,我們所探索的代碼都很有可能在以后的Unity版本中發生變化。然而不管代碼怎么變,其基礎的概念是不會改變的。所以這個系列中的所有討論的代碼都屬于實現細節。

項目設置

在這篇文章中,我使用的是Unity 5.0.2p4在OSX上的版本,目標平臺是iOS,編譯構架上我選擇的是“通用”(“Universal”)。最終我會使用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代碼中的每一個函數最終都會調用到上面原生代碼的一個函數中。后面我們將逐一分析每一個托管函數的申明。

為啥需要內存轉換?

既然IL2CPP已經把C#代碼都變成了C++代碼,我們干嘛還需要從C#做內存轉換到C++?雖然生成的C++代碼是原生代碼,但是在某些情況下,C#中數據類型的呈現還是和C++有所區別的,因此IL2CPP在運行的時候必須在兩邊來回轉換。il2cpp.exe對數據類型和方法都會做相同的轉換操作。

在托管代碼層面,所有的數據類型都被分為兩類:blittable或者non-blittable。blittable類型意味著在托管和原生代碼中,內存的表現是一致的,沒有區別(比如:byte,int,float)。Non-blittable類型在兩者中的內存表現就不一致。(比如:bool,string,array)。正因為這樣,blittable類型數據能夠直接傳遞給原生代碼,但是non-blittable類型就需要做轉換工作了。而這個轉換工作很自然的就牽扯到新內存的分配。

為了告訴托管編譯器某些函數是在原生代碼中實現的,我們需要使用“extern”關鍵字。使用這個關鍵字,和“DllImport”屬性相配合,使得托管的運行時庫能夠找到原生中的函數并且調用他們。il2cpp.exe會為每一個extern函數產生一個封裝。這層封裝執行了以下一些很重要的任務:
1.為原生代碼生成一個typedef以用來通過函數指針進行函數調用。
2.通過名字找到原生代碼中的函數,并且將其賦值給一個函數指針
3.如果有必要,將托管代碼中的參數內存轉換到原生代碼格式
4.調用原生函數
5.如果有必要,將原生函數的返回值內存轉換到托管代碼的格式
6.如果有必要,還需要處理具有關鍵字是“out”或者“ref”的參數,將他們的內容從原生格式轉換到托管代碼格式。

下面我們就來看看產生的這些封裝函數都是什么個情況。

內存轉換blittable數據類型

最簡單的extern封裝只牽扯到blittable類型。

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

在Bulk_Assembly-CSharp_0.cpp文件中,查找“HelloWorld_Increment_m3”函數。為“Increment”提供封裝的函數像下面這個樣子:

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:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

其他的封裝函數一開始看起來也差不多會是這樣。在這里,這個*PInvokeFunc 是一個有int32參數并且返回一個int32的函數指針。

接下來,封裝嘗試找到對應的函數并且將其地址賦值給這個函數指針

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

而實際的Increment函數是通過extern關鍵字表明它處在C++代碼中。

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

在iOS平臺上,原生函數會被靜態的鏈接到單一的bin文件中(通過在DllImport中的“__Internal”關鍵字),因此IL2CPP運行時并不需要動態的查找相應的函數指針。相反,這部分工作是在link期間完成的。在其他平臺上,IL2CPP可能會根據需要進行函數指針的查找。

事實情況是:在iOS平臺,非正確的p/invoke在c++編譯器link的階段就會體現出來而不是等到運行時才發現。因此所有的p/invoke都必須正確,哪怕他們實際沒有被執行到。

最終,原生代碼通過函數指針被調用,函數的返回值被送回托管代碼中。請注意在上面的例子中,參數是按值傳遞的,所以任何對參數值的改變都不會最終印象到托管代碼中。

內存轉換non-blittable類型

當處理non-blittable數據類型比如string的時候,事情會變得更加有趣。還記得前面文中提到的嗎?在IL2CPP中string實際上是一個通過UTF-16編碼的,最前面加上了一個4字節前綴的,兩字節寬的數組。這種內存格式和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);

我們會發現每一個string參數都會被轉換成char*(通過UnmangedType.LPStr指令)。

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

具體的轉換看上去是這樣的(對于第一個參數而言):

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

一個適當長度的char內存塊被分配,將string中的內容拷貝到新的內存中。當然,當函數執行完畢后,我們會將這個內存塊釋放。

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

因此內存轉換像string這樣的non-blittable類型是一個費時的操作。

內存轉換用戶自定義類型

像int或者是string這樣的類型還算好理解,那么如果有更加復雜的用戶自定義類型會發生什么呢?假設我們想對有著三個float的Vector類型進行內存轉換,我們會發現如果一個自定義結構中的所有成員都是blittable的話,這個類型就可以作為blittable來對待。因此我們可以直接調用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;

作為引用的話,參數在原生代碼中就變成了指針,所生成的代碼也有一些繁瑣。本質上,代碼會創建相同類型的局部變量,將參數中的內容拷貝到此局部變量,然后用此局部變量指針作為參數調用原生函數,在函數返回后,將局部變量的值拷貝回參數變量中以便讓托管代碼訪問到變化后的值。

內存轉換non-blittable用戶自定義類型

列子中的Boss這樣的non-blittable用戶自定義類型也是可以做內存轉換的。但是需要更多一些的工作:類型中的每一個成員都必須單獨的轉換成原生的表現形式。再進一步,生成的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結構相對應。但是在傳遞給原生函數的時候Boss_t2_marshaled。如果我們跳轉到這個類型的定義,我們會發現Boss_t2_marshaled和原生C++庫中的Boss類型的定義是一致的:

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

我們還是使用UnmanagedType.LPStr在C#中來指引string轉換成char*。如果你發現在調試non-blittable用戶自定義類型時有困難。在生成的代碼中查看一下帶_marshaled后綴的結構會很有幫助。如果結構和原生代碼中的結構不一致,那么內存轉換肯定會出問題。

上面的例子中,Boss_t2_marshal函數用來對Boss類中的每個成員進行轉換。而Boss_t2_marshal_cleanup則負責進行清除工作。

內存轉換數組

最后,我們來看一下如果內存轉換blittable和non-blittable的數組。SumArrayElements傳遞的是一個整數型數組:

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

數組會進行內存轉換,不過因為其每個元素都是blittable的int形,轉換的代價是非常小的:

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

il2cpp_codegen_marshal_array函數僅僅是返回托管代碼中數組的首地址。

然而,內存轉換non-blittable類型的數組開銷就會大得多。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));
}

當然,我們還必須在函數調用完成后進行內存釋放操作。

結論

在內存轉換上, IL2CPP的行為和Mono是一致的。因為IL2CPP會對extern函數和類型產生封裝代碼,因此我們可以檢查交互調用的開銷。對于blittable而言,開銷通常還好,但是對于non-blittable而言,會讓開銷上升的很快。我們只是對內存轉換做了個簡單的介紹。有關返回值和帶out關鍵字的參數,原生函數指針和托管中的代理,用戶自定義的引用類型的內存轉換,還請大家探索源碼自行分析。

下一篇文章我們將探索IL2CPP和垃圾回收器的集成。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容

  • IL2CPP 深入講解:泛型共享 IL2CPP INTERNALS: GENERIC SHARING IMPLEM...
    IndieACE閱讀 2,633評論 0 5
  • Spring Cloud為開發人員提供了快速構建分布式系統中一些常見模式的工具(例如配置管理,服務發現,斷路器,智...
    卡卡羅2017閱讀 134,991評論 19 139
  • 1. Java基礎部分 基礎部分的順序:基本語法,類相關的語法,內部類的語法,繼承相關的語法,異常的語法,線程的語...
    子非魚_t_閱讀 31,778評論 18 399
  • 真正的內心強大,就是當你獨自一人的時候,也可以活的很好:為自己煲一個湯,為自己燒一桌菜,化漂亮的妝容穿最美麗的衣裳...
    云殤_閱讀 271評論 4 5
  • 【本文1781個字,建議閱讀時間5分鐘】 純母乳,好處多多卻也不易做到,數據顯示,在中國只有不到28%的母親可以給...
    不想用真名閱讀 210評論 0 0