2016年5月29日 星期日

中斷! 如何在Unity裡用C#停止一個無限迴圈

作者:PETER ANDREASEN 原文連結

(這篇心得是Peter在今年Unity內部活動HackWeek所做的內容。)

這篇文章將為大家介紹在Unity中如何中斷無限迴圈的小技巧。可用於64位元Windows系統的編輯模式,以及有開啟除錯模式的專案。稍微改一下就可以相容32位元環境,甚至不用開啟除錯模式也可以用。

無限迴圈看起來很容易避免。但偶爾就是會遇到它的變種。曾經有次因為有問題的亂數函式回傳了1.000001導致無限迴圈。也曾經一個壞掉的網格剛好幫沒做輸入檢查的while(1) { d += 1.0; if(d>10.0) break; /* .. */ } 迴圈發送了NaN這個資料。還曾發生壞掉的資料結構被執行一個規則current = current.next;並認定這個規則一定會有個終點等等...

如果在Unity中碰過這種無限迴圈,你應該理解這會讓人很不爽。Unity會沒回應,並且需要強行關閉整個程式來結束無限迴圈。如果運氣好的話在之前有打開除錯模式,那或許還能中斷。但你還是得猜哪裡是合適的中斷點。

加入Unity之前,我有找到一個解決方案解決這種問題,直到我參加了HackWeek並碰到了一些專家,我了解到根本原理與思考更好的解決方法。或許未來這個功能會加入Unity裡面,在那之前,你可以使用今天介紹的方法,或者享受反編譯程式的樂趣,沒問題的。

本測試別用真實專案!


受過良好訓練的專家都知道測試的重要性,因此正式用於專案之前,請先在測試專案中試一下這個小技巧。打開Unity並新建一個空專案,在空的場景中加一個Cube物件再建立一個C#腳本,命名為 “Quicksand”並放在Cube物件上。腳本內容如下:
using UnityEngine;
class Quicksand : public Monobehaviour
{
            public OnMouseDown()
            {
                    while(true)
                    {
                   // "Mind you, you'll keep sinking forever!!", -- My mom
                    }
            }
}


現在點Play後按一下Cube物件。你會發現Unity卡住,不要驚慌,這只是個測試不是實際項目。

腳本已經卡住,Unity似乎也當了。接下來開啟Visual Studio。

為了要保證這個方法有用,你會需要在安裝Visual Studio時勾選C++程式設計語言。在Debug功能表下選擇 Attach to Process(注意:這個選項並不是通常選用的Attaching to Unity)。找到Unity並綁定:



把除錯器加在卡住的Unity後,依次點“Debug > Break all”然後找到Disassembly介面,這裡顯示了主執行緒正在執行的程式。操作步驟見下圖。可能還需要點擊“Show Disassembly”或者一些其它按鈕,這取決於Visual Studio的設定。在我的測試機上,需要點F10進行一次單步除錯來打開Disassembly視窗:


眾所皆知,為了執行效率更高,編寫好的程式往往被編譯成機器語言來執行。這也稱為jit-compiling(即時編譯)。執行的結果可以在Disassembly視圖中查看。如下:



在這個例子中出現了無限迴圈(參考上圖的紅色尖頭)。這裡有一個mov,一個cmp和很多nop然後 jmp 迴圈回了開始的位置。沒有任何出路。

這只是測試專案,在實際情況中,專案的C#程式要更複雜,也更難判斷到底發生了什麼,還好開發者並不需要理解這些,因為技巧就是:不停按F10(只需一步)直到看到“cmp dword ptr [r11], 0″這條指令。它們應該不受限的分散在程式的各個位置,因為它們是除錯的基礎。再執行幾步之後,看到這樣的提示就可以結束了:



幸運的話這裡會出現“Autos”視窗(如果沒有,點Debug > Windows > Auto打開)。視窗裡面顯示了目前正在註冊執行中的值:


現在只需將R11的值設為0,如下:



現在執行cmp指令,它會嘗試讀取記憶體位址為0的資料,這將導致異常。這也正是我們想要的,所以接下來按F5鍵讓程式繼續執行,並在彈出對話方塊中點擊“Continue”繼續:



如果一切順利,此時Unity控制台會顯示(Mono)異常資訊,迴圈已被終止且Unity恢復正常。這時可以先保存專案再看看控制台顯示是哪裡的腳本程式導致的問題。



這樣就中斷了無限迴圈!到這裡建議是存檔並重啟整個Unity,因為我們已經跑到Unity很底層並幹了些壞事。雖然範例是一切正常但建議還是小心為妙。

為什麼可以這樣搞?


之所以可行的原因是因為Mono有內建的腳本除錯系統。它的工作原理是穿插一些即時編譯的程式(實際上是每句C#程式一次)到讀取指定記憶體位址的過程中。也就是上面的“cmp dword ptr [r11], 0”指令。當在除錯模式下對程式進行單步除錯時,系統會將持有該記憶體位址的頁設為唯讀,這將導致每句C#代碼產生一次異常。Mono框架可以從JIT代碼外部捕獲異常並暫停代碼執行。

我們在上面用到的技巧就是將註冊的r11設為0,由於記憶體位址0是不可讀的,如此就不會再產生同類的異常。此時調試器會認為正在進行類似單步除錯的行為,但實際上這裡並未進行除錯,所以這裡會拋出NullReferenceException的異常,我們也會看到很有用的堆疊資訊。非常方便!

這個技巧對於編譯出來的可執行程式同樣適用。將Unity連接到你的遊戲Exe執行檔,全部中斷,找到JIT代碼,強制記憶體讀取失敗即可。只是這裡需要在log檔中查看堆疊資訊。

特別案例


上面只是為了展示而展示的簡單範例。現實遠比展示複雜,可能會遇到各種異常。即使拆包可能存取的也不是“純”JIT的程式。如果C#程式呼叫了任意API,這段程式可能會跑進Unity核心代碼部分。如下:


這裡的程式呼叫了GetPosition。當Call Stack頂部包含真正的函數名稱而非一些天書般的記憶體位址時,這就表示已經脫離Mono或JIT代碼了。這時要點幾次Shift+F11跳出當下步驟直至回到純JIT代碼(大量的nop指令也是純JIT代碼的象徵)。

有時你可以設法在某個主執行緒不活躍的位置中斷Unity。最簡單的解決方式是點contunue(或按F5)然後中斷所有直至主執行緒啟動即可。可能還有更多怪異的情況,但除錯就是這樣,隨機應變吧!

32位元系統?


在32位元系統下也能使用該方式。只是JIT代碼看起來有些區別,如下:


這裡表示從0xB10000的位置讀取資料。為了引發系統頁出錯,就需要實際更改程式,因為這裡的位址是硬編碼到指令中的,不像64位元系統那樣位於註冊器中。打開記憶體視圖(點Debug > Windows > Memory > Memory1)找到指令位址(上圖黃箭頭的位址)0x65163DC。顯示如下:


可以找到位址,然後將從頭開始第四個位元組“b1”改為“00”後點繼續。這會有些作用,但與64位元系統不同,這裡每次跑到這個位置都會導致中斷。

如果是非除錯模式呢?


如果實在不幸,這狀況只會發生在不勾選程式除錯的情況下才會出現的Bug,這就真的要即興發揮了。你可以看看程式然後找到某種方法引起讀取出錯,可能會有新的進展,但這可能不太容易。最後的絕招是通過手動寫入程式,類似cmp eax, dword ptr ds:[0x0]指令,這樣就能像上面那樣知道位址是3b 05 00 00 00 00。可以試試看上面的腳本。先中斷:


最壞的情況出現了,編譯器優化導致只有jmp指令在自己迴圈。這樣就沒有空間加入cmp了(與jmp相關,最多占2個位元組)。這裡不要多想了直接通過記憶體讀取來破壞程式。在記憶體視圖找到位址4D34446,不管是什麼內容都往最上方填充3b 05 00 00 00 00。然後點繼續。本範例(單機遊戲)成功中斷了,可以在log檔中查看堆疊資訊:



這時應該立即關掉遊戲,因為你已經毀掉了腳本的一部分JIT產生的程式,可能遊戲無法正常執行了。但至少可以知道是哪裡出了問題。

有時可以在中斷位置附近發現一些讀取指令。這時可以按右鍵指令並選擇“Set next statement”然後將註冊器設為0,透過這種方式就可以正確產生異常。

結論


透過一點小技巧就能中斷本來無法中斷的無限迴圈。趕緊來試試看吧,這樣你就然後你就可以跟別人臭屁說“想當年我也是玩過反編譯的喔!”。未來正式版的Unity,我們會推出更好的正式方案,敬請期待哦!

沒有留言:

張貼留言

著作人