1. 程式人生 > >IL2CPP 深入講解:P/Invoke封裝

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.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;
        } 
        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和垃圾回收器的整合。

 



作者:IndieACE
連結:https://www.jianshu.com/p/2fcbc313cf5c
來源:簡書
簡書著作權歸作者所有,任何形式的轉載都請聯絡作者獲得授權並註明出處。