2015年6月2日 星期二

深入IL2CPP核心 – 生成碼的除錯技巧

作者:JOSH PETERSON 原文


這是專題系列文章-「深入IL2CPP核心」的第三篇文章。本篇我們將探討一些如何更容易地為IL2CPP所生成的C++程式碼進行除錯的技巧。我們將了解如何設定中斷點、如何查看字串與使用者自定型別的內容,以及如何判斷例外事件的發生處。

要繼續之前,請先假想此時的我們正對由.NET IL程式碼所產生得來的C++程式碼進行除錯。這樣的除錯過程是不太可能會讓人開心得起來。然而,藉由一些小技巧的幫助,讓我們得以對發佈在實機上的Unity程式碼是如何地執行,能有著更透徹的理解(我們會在文章的最後淺談如何對managed程式碼進行除錯)。

同時,請留意在你的專案中所得的生成碼將有可能與此文件所呈現的生成碼存在些許差異。那是因為在每個Unity新版本中,我們都將持續地尋用更好、更快並具更小的生成碼產生方法。

設定

在這篇文章中,我是在OSX系統上使用Unity 5.0.1p3來進行說明的。雖然我將使用的專案是已在另一篇討論生成碼的文章中示範過的同一個專案,但是這次除了同樣選擇了IL2CPP來作為編譯後端外,發佈的目標平台則改為iOS。與上篇文章中所示範的一樣,我將選擇“Development Player”選項來進行發佈,因此il2cpp.exe這個程式會參照IL程式碼中的型別與方法來生成C++程式碼中的型別與方法。

在Unity完成Xcode專案的生成後,用Xcode開啟該專案(我使用的版本是6.3.1,但是其他略新的版本應該也是能使用的),選擇發佈至我的設備(我的設備是一台iPad Mini 3,但是發佈到其他的iOS設備應該也是可以的),最後以Xcode來建立專案。

設定中斷點

執行專案之前,我會先在HelloWorld類別裡的Start方法的一開始設定一個中斷點。正如先前文章中所示,在所生成的C++程式碼中該方法將被命名為HelloWorld_Start_m3。我们可以在Xcode裡使用快速鍵Cmd+Shift+O,輸入想要尋找的方法名稱,找到後,在該處設定一個中斷點。




我們也可以使用Xcode選單:依序選擇Debug > Breakpoints > Create Symbolic Breakpoint,然後填入方法名稱設定中斷點。




現在當我執行Xcode專案時,可以立即看到專案會在該方法開始的時候中斷暫停。

也就是說,如果我們已經知道了某個方法的名稱,就可在生成碼中直接找出該方法並在其上設定中斷點。我們也可以在Xcode裡為生成碼的某一行設定中斷點。事實上,由於所生成的檔案都用以組建Xcode專案。因此可以在專案導覽區塊(Project Navigator)中的Classes/Native次目錄下找到這些檔案。



觀察字串變數

在Xcode中有兩種方法可以用來觀察一個IL2CPP字串變數的表示值。我們可以直接觀察這字串的記憶體,或是藉由呼叫libil2cpp中的字串工具程式轉換該字串成為Xcode可以顯示的std::string型別。一起來看一下變數名_stringLiterall的變數值(先透露一下:其值為“Hello, IL2CPP!”)。

在使用插件Ctags (或是在Xcode中使用Cmd+Ctrl+J)而得的生成碼所建置的程式碼中,我們可以跳到_stringLiteral1定義的所在處並且得知其型別為Il2CppString_14:



實際上,IL2CPP中的所有字串的表示都類似於此。我們可以在標頭檔object-internals.h中找到Il2CppString 的定義來進行觀察。這些字串首先含有IL2CPP中某一managed(受管)型別的標準標頭檔,這是個Il2CppObject (透過Il2CppDataSegmentString 型別定義進行存取),再來是一個4 bytes大小的整數用以記錄字串長度,最後是一個由2 bytes文字所組成的陣列。在編譯時期所定義的字串,如stringLiteral1 ,最後的chars 陣列其長度將是固定的,而在執行時期產生的字串的其陣列大小是分配而取得。這些字串裡的文字都是使用UTF-16來進行文字編碼。

如果在Xcode中把變數_stringLiteral1加入到監看視窗後,我們可以選擇「View Memory of “_stringLiteral1”」這個選項,以便在記憶體中對這個字串格式進行觀察。




接著在記憶體監視器中,我們可以觀察到如下圖所示:



這個字串的header成員,其大小是16 bytes,在向後跳過該header後,我們可以看到大小為4 bytes的值0x000E (14)。而在長度之後的下一個byte是字串的第一個字元, 0x0048 (‘H’)。因為每個文字大小為2 bytes,而在此字串中的所有文字都只需要一個byte就足夠了,因此Xcode在右側顯示字元時,會在每個文字之間以點相隔。如此字串內容依然明白可讀。這樣的觀察字串方法確實可行,可是對於複雜的字串來說就顯得有點困難。

在Xcode中我們也能透過除錯器lldb的命令列下達來觀察字串的內容。標頭檔utils/StringUtils.h提供給我們一些在函式庫libil2cpp裡能使用的的字串工具的界面。此時,讓我們在lldb的命令列中呼叫方法Utf16ToUtf8。其界面如下所示:



1
static std::string Utf16ToUtf8 (const uint16_t* utf16String);

我們可以把C++結構中的字元成員做為參數傳進這個方法,如此將回傳一個UTF-8編碼的std::string。而若在lldb命令提示列中下達指令p,則能印出此字串的內容。



(lldb) p il2cpp::utils::StringUtils::Utf16ToUtf8(_stringLiteral1.chars)
(std::__1::string) $1 = "Hello, IL2CPP!"

觀察使用者定義型別

我們也能觀察使用者定義型別的內容值。在此專案所使用簡單的腳本程式碼中,我們建立了一個名為Important 的 C#型別,內含一個名為InstanceIdentifier的屬性。在脚本中,如果我在建立第二個Important型別實體的程式碼後即設定一個中斷點,則可以看到生成碼與預期相符地將InstanceIdentifier的值設定為1。



所以觀察生成碼的使用者定義型別內容就跟平時在Xcode中觀察C++程式碼內容時所用的方法相同。


生成碼例外事件的中斷

我常發現自己在試著追查臭蟲的成因時會去對生成碼進行除錯。很多時候這些臭蟲看起來像是發生了managed
程式碼的例外狀況。如上一篇文章所討論的,IL2CPP使用C++的例外狀況是實作自managed 程式碼的例外狀況,所以我們可以在Xcode裏用幾種方法讓程式在發生managed例外狀況時進行中斷。

在managed
(受管)例外狀況被丟出時進行中斷最簡單的方法是在函式il2cpp_codegen_raise_exception上設定一個中斷點,il2cpp.exe利用此中斷點在managed例外狀況明確發生處進行中斷。



如果我接著執行了這個專案,Xcode將在函式Start中發生了InvalidOperationException例外狀況時中斷。這是個得以很方便觀察字串內容的地方。如果我點入深究exargument的成員,則可發現有個名叫___message_2的成員,他是用來表示例外狀況訊息的字串。



小小炫技一下,我們可以印出這字串的值並且查看發生了什麼問題:

(lldb) p il2cpp::utils::StringUtils::Utf16ToUtf8(&ex->___message_2->___start_char_1)
(std::__1::string) $88 = "Don't panic"

需要注意的是這裡出現的字串配置跟之前的都一樣,但是所生成的屬性名卻有些許不同。原本叫chars 的屬性,在這裡被命名為___start_char_1,而且型別也從uint16_t[]變成uint16_t。不過其值依然是一個陣列的第一個字元,所以我們可以把這字元的位址傳入轉換函式,隨後我們發現所得的例外狀況訊息內容將更易於閱讀。

但是並非所有的managed
(受管)例外狀況都會被生成碼所丟出。有時執行時期程式碼libil2cpp將會丟出managed例外狀況,但非藉由呼叫函式il2cpp_codegen_raise_exception來達成。此時我們該如何獲取這些例外狀況呢?

如果我們使用Xcode的選單,Debug > Breakpoints > Create Exception Breakpoint,然後編輯中斷點,設定Exception選項為C++,named選項為Il2CppExceptionWrapper 使這型別的例外狀況發生時中斷。因為這C++型別總是包含了所有的managed例外狀況,如此將得以獲得所有的managed例外狀況。



讓我們藉由在腳本中Start函式的一開始新增以下兩行程式碼來驗證一下這方法可行:



1
2
Important boom = null;
Debug.Log(boom.InstanceIdentifier);

如此第二行將會使得例外狀況NullReferenceException 被丟出。如果我們在Xcode中執行了已設定例外狀況中斷點的程式碼,我們將會看到當此例外狀況被丟出時,Xcode也將確實中斷。然而,此中斷點是設在libli2cpp的程式碼裏,所以我們所能看到的也只有組譯程式碼。如果我們往call stack裏看一下,可以知道我們需要上移幾個frames使之到NullCheck 這個方法,此方法是被il2cpp.exe所插入至生成碼中的。



從那裡,我們可以回移一個以上的frame,然後觀察型別Important 的實體其值是否確實為NULL。



結論

在討論過對生成碼除錯的一些小技巧後,我希望大家對如何追查那些可能在IL2CPP生成C++程式碼時衍生出的問題,能有更進一步的了解。我鼓勵大家研究IL2CPP使用的其他型別配置,藉此學習更多有關如何對生成碼除錯的知識。

可是針對IL2CPP managed程式碼的除錯器在哪裏呢?難道我們不能對正透過IL2CPP編譯後端執行在設備上的那些managed程式碼進行除錯嗎?實際上,這是可能。我們現在已有一個內部、已進入Alpha 階段,適用於L2CPP的managed程式碼除錯器。雖然還沒到達release階段,但是這已在我們的規劃中,敬請關注。

本專題系列文章的下一篇將探討,在managed程式碼中使用IL2CPP編譯後端,實作出各種方法調用型別的不同方法。我們將一起看看每個方法調用型別的執行成本。

沒有留言:

張貼留言

著作人