1. 程式人生 > >IL2CPP 深入講解:方法呼叫介紹

IL2CPP 深入講解:方法呼叫介紹

IL2CPP深入講解:方法呼叫介紹

IL2CPP INTERNALS: METHOD CALLS

作者:JOSH PETERSON

翻譯:Bowie

這裡是本系列的第四篇博文。在這篇文章裡,我們將看到il2cpp.exe如何為託管程式碼中的各種函式呼叫生成C++程式碼。我們在這裡會著重的分析6種不同型別的函式呼叫:

類例項的成員函式呼叫和類的靜態函式呼叫。
編譯期生成的代理函式呼叫
虛擬函式呼叫
C#介面(Interface)函式呼叫
執行期決定的代理函式呼叫
通過反射機制的函式呼叫

對於每種情況,我們主要探討兩點:相應的C++程式碼都做了些啥以及這麼做的開銷如何。

和以往的文章一樣,我們這裡所討論的程式碼,很可能在新的Unity版本中已經發生了變化。儘管如此,文章所闡述的基本概念是不會變的。而文章中關於程式碼的部分都是屬於實現細節。

專案設定

這次我採用的Unity版本是5.0.1p4。執行環境為Windows,目標平臺選擇了WebGL。同樣的,在構建設定中勾選了“Development Player”並且將“Enable Exceptions”選項設定成“Full”。

我將使用一個在上一篇文章中的C#程式碼,做一點小的修改,放入不同型別的呼叫方法。程式碼以一個介面(Interface)定義和類的定義開始:
 

Interface Interface 
{ 
    int MethodOnInterface(string question); 
} 

class Important : Interface 
{ 
    public int Method(string question) { 
        return 42; 
    } 

    public int MethodOnInterface(string question) 
    { 
        return 42; 
    } 

    public static int StaticMethod(string question) 
    { 
        return 42; 
    } 
}

接下來是後面程式碼要用到的常數變數和代理型別:
 

private const string question = "What is the answer to the ultimate question of life, " 
+ "the universe, and everything?"; 

private delegate int ImportantMethodDelegate(string question);

最後是我們討論的主題:6種不同的函式呼叫的程式碼(以及必須要有的啟動函式,啟動函式具體程式碼就不放上來了):
 

private void CallDirectly() 
{ 
    var important = ImportantFactory(); 
    important.Method(question); 
}
 
private void CallStaticMethodDirectly() 
{ 
    Important.StaticMethod(question); 
} 

private void CallViaDelegate() 
{ 
    var important = ImportantFactory(); 
    ImportantMethodDelegate indirect = important.Method; 
    indirect(question); 
} 

private void CallViaRuntimeDelegate() 
{ 
    var important = ImportantFactory(); 
    var runtimeDelegate = Delegate.CreateDelegate(typeof (ImportantMethodDelegate), important, "Method"); 
    runtimeDelegate.DynamicInvoke(question); 
} 

private void CallViaInterface() 
{ 
    Interface importantViaInterface = new Important();     
    importantViaInterface.MethodOnInterface(question); 
} 

private void CallViaReflection() 
{ 
    var important = ImportantFactory(); 
    var methodInfo = typeof(Important).GetMethod("Method");     
    methodInfo.Invoke(important, new object[] {question}); 
} 

private static Important ImportantFactory() 
{ 
    var important = new Important(); 
    return important; 
} 

void Start () {}

有了這些以後,我們就可以開始了。還記得所有生成的C++程式碼都會臨時存放在Temp\StagingArea\Data\il2cppOutput目錄下麼?(只要Unity Editor保持開啟)別忘了你也可以使用 Ctags 去標註這些程式碼,讓閱讀變得更容易。
直接函式呼叫

最簡單(當然也是最快速)呼叫函式的方式,就是直接呼叫。下面是CallDirectly方法的C++實現:
 

Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(
    NULL 
    /*static, unused*/, 
    /*hidden argument*/
    &HelloWorld_ImportantFactory_m15_MethodInfo
); 

V_0 = L_0; 

Important_t1 * L_1 = V_0; 

NullCheck(L_1); 

Important_Method_m1(
    L_1, 
    (String_t*) &_stringLiteral1, 
    /*hidden argument*/
    &Important_Method_m1_MethodInfo
);

程式碼的最後一行是實際的函式呼叫。其實沒有什麼特別的地方,就是一個普通的C++全域性函式呼叫而已。大家是否還記得“程式碼生成之旅”文章中提到的內容:il2cpp.exe產生的C++程式碼的函式全部是類C形式的全域性函式,這些函式不是虛擬函式也不是屬於任何類的成員函式。接下來,直接靜態函式的呼叫和前面的處理很相似。下面是靜態函式CallStaticMethodDirectly的C++程式碼:
 

Important_StaticMethod_m3(
    NULL /*static, unused*/, 
    (String_t*) &_stringLiteral1, 
    /*hidden argument*/
    &Important_StaticMethod_m3_MethodInfo
);

相比之前,我們可以說靜態函式的程式碼處理要簡單的多,因為我們不需要類的例項,所以我們也不需要建立例項,進行例項檢查的那些個程式碼。靜態函式的呼叫和一般函式呼叫的區別僅僅在於第一個引數:靜態函式的第一個引數永遠是NULL。

由於這兩類函式的區別是如此之小,因此在後面的文章中,我們只會拿一般函式呼叫來討論。但是這些討論的內容同樣適用於靜態函式。
編譯期代理函式呼叫

像這種通過代理函式來進行非直接呼叫的稍微複雜點的情況會發生什麼呢?CallViaDelegate函式呼叫的C++程式碼如下:

// Get the object instance used to call the method. 
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(
    NULL /*static, unused*/, 
    /*hidden argument*/
    &HelloWorld_ImportantFactory_m15_MethodInfo
); 

V_0 = L_0; 
Important_t1 * L_1 = V_0; 

// Create the delegate. 
IntPtr_t L_2 = { 
    &Important_Method_m1_MethodInfo 
}; 

ImportantMethodDelegate_t4 * L_3 = (ImportantMethodDelegate_t4 *)il2cpp_codegen_object_newInitializedTypeInfo(&ImportantMethodDelegate_t4_il2cpp_TypeInfo)); 

ImportantMethodDelegate__ctor_m4(
    L_3, 
    L_1, 
    L_2, 
    /*hidden argument*/
    &ImportantMethodDelegate__ctor_m4_MethodInfo
); 

V_1 = L_3; 

ImportantMethodDelegate_t4 * L_4 = V_1;
 
// Call the method 
NullCheck(L_4); 
VirtFuncInvoker1 <int32_t, String_t*>::Invoke(
    &ImportantMethodDelegate_Invoke_m5_MethodInfo, 
    L_4, 
    (String_t*) &_stringLiteral1
);

我加入了一些註釋以標明上面程式碼的不同部分。需要注意的是實際上在C++中呼叫的是VirtFuncInvoker1<int32_t, String_t>::Invoke這個函式。此函式位於GeneratedVirtualInvokers.h標頭檔案中。它不是由我們寫的IL程式碼生成的,相反的,il2cpp.exe是根據虛擬函式是否有返回值,和虛擬函式的引數個數來生成這個函式的。(譯註:VirtFuncInvokerN是表示有N個引數有返回值的虛擬函式呼叫,而VirtActionInvokerN 則表示有N個引數但是沒有返回值的虛擬函式呼叫,上面的例子中VirtFuncInvoker1<int32_t, String_t>::Invoke的第一個模板引數int32_t就是函式的返回值,而VirtFuncInvoker1中的1表示此函式還有一個引數,也就是模板引數中的第二個引數:String_t*。因此可以推斷VirtFuncInvoker2應該是類似這樣的形式:VirtFuncInvoker2<R, S, T>::Invoke,其中R是返回值,S,T是兩個引數)

具體的Invoke函式看起來是下面這個樣子的:
 

template <typename R, typename T1> struct VirtFuncInvoker1 
{ 
    typedef R (*Func)(void*, T1, MethodInfo*); 

    static inline R Invoke (MethodInfo* method, void* obj, T1 p1) 
    { 
        VirtualInvokeData data = il2cpp::vm::Runtime::GetVirtualInvokeData (method, obj); 
        return ((Func)data.methodInfo->method)(data.target, p1, data.methodInfo); 
    } 
};

libil2cpp中的GetVirtualInvokeData函式實際上是在一個虛擬函式表的結構中尋找對應的虛擬函式。而這個虛擬函式表是根據C#託管程式碼建立的。在找到了這個虛擬函式後,程式碼就直接呼叫它,傳入需要的引數,從而完成了函式呼叫過程。

你可能會問,為什麼我們不用C++11標準中的可變引數模板 (譯註:所謂可變引數模板是諸如template<typename T, typename...Args>,這樣的形式,後面的...和函式中的可變引數...作用是一樣的)來實現這些個VirtFuncInvokerN函式?這恰恰是可變引數模板能解決的問題啊。然而,考慮到由il2cpp.exe生成的C++程式碼要在各個平臺的C++編譯器中進行編譯,而不是所有的編譯器都支援C++11標準。所以我們再三權衡,沒有使用這項技術。

那麼虛擬函式呼叫又是怎麼回事?我們呼叫的不是C#類裡面的一個普通函式嗎?大家回想下上面的程式碼:我們實際上是通過一個代理方法來呼叫類中的函式的。再來看看上面的C++程式碼,實際的函式呼叫是通過傳遞一個MethodInfo*結構(函式元資訊結構):ImportantMethodDelegate_Invoke_m5_MethodInfo作為引數來完成的。再進一步看ImportantMethodDelegate_Invoke_m5_MethodInfo中的內容,會發現它實際上呼叫的是C#程式碼中ImportantMethodDelegate型別的Invoke函式(譯註:也就是C#代理函式型別的Invoke函式)。而這個Invoke函式是個虛擬函式,所以最終我們也是以虛擬函式的方式呼叫的。

Wow,這夠我們消化一陣子的了。在C#中的一點小小的改變,在我們的C++程式碼中從簡單的函式呼叫變成了一系列的複雜函式呼叫,這中間還牽扯到了查詢虛擬函式表。顯然通過代理的方法呼叫比直接函式呼叫更耗時。
還有一點需要注意的是在代理方法呼叫處理時候使用的這個查詢虛擬函式表的操作,也同樣適用於虛擬函式呼叫。

介面方法呼叫

在C#中通過介面方法呼叫當然也是可以的。在C++程式碼實現中和虛擬函式的處理方式差不多:
 

Important_t1 * L_0 = (Important_t1 *)il2cpp_codegen_object_new (
    InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)
); 

Important__ctor_m0(
    L_0, 
    /*hidden argument*/
    &Important__ctor_m0_MethodInfo
); 

V_0 = L_0; 

Object_t * L_1 = V_0; 

NullCheck(L_1); 

InterfaceFuncInvoker1< int32_t, String_t* >::Invoke(
    &Interface_MethodOnInterface_m22_MethodInfo, 
    L_1, 
    (String_t*) &_stringLiteral1
);

實際上的函式呼叫是通過InterfaceFuncInvoker1::Invoke來完成的。這個函式存在於GeneratedInterfaceInvokers.h標頭檔案中。就像上面提到過的VirtFuncInvoker1類,InterfaceFuncInvoker1類也是通過il2cpp::vm::Runtime::GetInterfaceInvokeData查詢虛擬函式表來確定實際呼叫的函式的。

那為什麼介面的方法呼叫和虛擬函式的呼叫在libil2cpp庫中是不同的API呢?那是因為在介面方法呼叫中,除了方法本身的元資訊,函式引數之外,我們還需要介面本身(在上面的例子中就是L_1)在虛擬函式表中介面的方法是被放在一個特定的偏移上的。因此il2cpp.exe需要介面的資訊去計算出被呼叫的函式到底是哪一個。
從程式碼的最後一行可以看出,呼叫介面的方法和呼叫虛擬函式的開銷在IL2CPP中是一樣的。

執行期決定的代理方法呼叫

使用代理的另一個方法是在執行時由Delegate.CreateDelegate動態的建立代理例項。這個過程實際上和編譯期的代理很像,只是多了一些執行時的處理。為了程式碼的靈活性,我們總是要付出些代價的。下面是實際的程式碼:
 

// Get the object instance used to call the method. 
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(
    NULL /*static, unused*/, 
    /*hidden argument*/
    &HelloWorld_ImportantFactory_m15_MethodInfo
); 

V_0 = L_0; 

// Create the delegate. 
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo)); 

Type_t * L_1 = Type_GetTypeFromHandle_m19(
    NULL /*static, unused*/, 
    LoadTypeToken(&ImportantMethodDelegate_t4_0_0_0), 
    /*hidden argument*/
    &Type_GetTypeFromHandle_m19_MethodInfo
); 

Important_t1 * L_2 = V_0; 

Delegate_t12 * L_3 = Delegate_CreateDelegate_m20(
    NULL /*static, unused*/, 
    L_1, 
    L_2, 
    (String_t*) &_stringLiteral2, 
    /*hidden argument*/
    &Delegate_CreateDelegate_m20_MethodInfo
); 

V_1 = L_3; 

Delegate_t12 * L_4 = V_1;
 
// Call the method 
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(
    ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 
    1
)); 

NullCheck(L_5); 
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0); 

ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1); 

*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) 
= (Object_t *)(String_t*) &_stringLiteral1;
 
NullCheck(L_4); 

Delegate_DynamicInvoke_m21(
    L_4, 
    L_5, 
    /*hidden argument*/
    &Delegate_DynamicInvoke_m21_MethodInfo
);

首先我們使用了一些程式碼來建立代理這個例項,隨後處理函式呼叫的程式碼也不少。在後面的過程中我們先建立了一個數組用來存放被呼叫函式的引數。然後呼叫代理例項中的DynamicInvoke方法。如果我們更深入的研究下DynamicInvoke方法,會發現它實際上在內部呼叫了VirtFuncInvoker1::Invoke函式,就如同編譯期代理所做的那樣。所以從程式碼執行量上來說,執行時代理方法比靜態編譯代理方法多了一個函式建立,比且還多了一次虛擬函式表的查詢。

通過反射機制進行方法呼叫

毫無疑問的,通過反射來呼叫函式開銷是最大的。下面我們來看看具體的CallViaReflection函式所生成的C++程式碼:
 

// Get the object instance used to call the method. 
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(
    NULL /*static, unused*/, 
    /*hidden argument*/
    &HelloWorld_ImportantFactory_m15_MethodInfo
); 

V_0 = L_0; 

// Get the method metadata from the type via reflection. 
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo)); 
Type_t * L_1 = Type_GetTypeFromHandle_m19(
    NULL /*static, unused*/, 
    LoadTypeToken(&Important_t1_0_0_0), 
    /*hidden argument*/
    &Type_GetTypeFromHandle_m19_MethodInfo
); 

NullCheck(L_1); 

MethodInfo_t * L_2 = (MethodInfo_t *)VirtFuncInvoker1< MethodInfo_t *, String_t* >::Invoke(
    &Type_GetMethod_m23_MethodInfo, 
    L_1, 
    (String_t*) &_stringLiteral2
); 

V_1 = L_2; 
MethodInfo_t * L_3 = V_1; 

// Call the method. 
Important_t1 * L_4 = V_0; 
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1)); 

NullCheck(L_5); 

IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0); 

ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1); 

*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1; 

NullCheck(L_3); 

VirtFuncInvoker2< Object_t *, Object_t *, ObjectU5BU5D_t9* >::Invoke(
    &MethodBase_Invoke_m24_MethodInfo, 
    L_3, 
    L_4, 
    L_5
);

就和執行時代理方法呼叫一樣,我們需要用額外的程式碼建立函式引數陣列。然後還需要呼叫一個MethodBase::Invoke (實際上是MethodBase_Invoke_m24函式)虛擬函式,由這個函式呼叫另外一個虛擬函式,在能最終得到實際的函式呼叫!

總結

雖然Unity沒有針對C++函式呼叫的效能分析器,但是我們可以從C++的原始碼中看出不同型別方法呼叫的不同複雜程度的實現。如何可能,請儘量避免使用執行時代理方法和反射機制方法的呼叫。當然,想要提高執行效率還是要在專案的早期階段就使用效能分析器進行診斷。
我們也在一直想辦法優化il2cpp.exe產生的程式碼。因此再次強調,這篇文章中所產生的C++程式碼或許會在以後的Unity版本中發生變化。
下篇文章我們將更進一步的深入到函式中,看看我們是如何共享方法簡化C++程式碼並減小最終可執行檔案的尺寸的。



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