無限循環似乎應該很容易避免。但時不時就會遇到它的變種。曾經有次因為錯誤的随機函數傳回了1.000001這個值導緻無限循環。還有就是衰退的網格恰好給沒有做輸入檢測的while(1) { d += 1.0; if(d>10.0) break; } 循環發送了NaN這個資料。再後來還有壞掉的資料結構中貫穿了一個規則假定了current = current.next;并認為這個規則一定會有個終點。
如果在Unity中遇到過無限循環,就會知道這很不爽。Unity無法響應,并且需要強行關閉整個編輯器來結束無限循環。如果幸運的在遊戲中附加了調試元件,那就可以中斷應用。但通常隻能靠猜測在合适的位置設定斷點。
到最近我才明白這個小技巧起作用的根本原因,以及如何利用這個小技巧找到一種合适的方法來中斷Unity腳本。在新功能開發完成并釋出之前,都可使用該技巧。或者享受反彙編JIT(即時編譯)代碼的樂趣,反正這樣也不會出啥錯,何樂而不為呢?
實際項目别這麼做!
作為一個受過良好訓練的專家,都知道練習的重要性,是以正式将其用于項目前,先在測試項目中試一下這個小技巧。打開Unity并建立一個空的項目,在空的場景中添加一個盒子對象再建立一個C#腳本,命名為 “流沙”并附加在盒子對象上。腳本代碼如下:
using UnityEngine;
class Quicksand : public Monobehaviour
{
public OnMouseDown()
{
while(true)
{
// "Mind you, you'll keep sinking forever!!", -- My mom
}
}
}
現在點選運作後單擊盒子對象。可以看到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很底層幹了些壞事,是以比較保險的做法是先儲存項目并重新開機編輯器。此例中一切正常,但還是小心點為好。
為什麼可以這麼做呢?
之是以可以這麼做的原因可以歸結于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。最簡單的解決方式是點選繼續(或按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,通過這種方式就可以正确産生異常。
結論
通過一點投機取巧就能中斷看起來無法中斷的死循環。趕緊來試試看吧,這樣你就可以跟小夥伴們說“想當年我也是玩過反彙編的人!”。後續我們還會推出更好的解決方案,敬請期待哦!
原文連結:http://blogs.unity3d.com/2016/05 … n-a-unity-c-script/
原文作者:PETER ANDREASEN
感謝Unity官方中文社群翻譯組成員:“fubb” 對本文翻譯所做的貢獻。
文章轉載自Unity中如何中斷C#腳本的無限循環,感謝原作者及翻譯者的貢獻。