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_new (InitializedTypeInfo(&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++代碼并減小最終可執行文件的尺寸的。