版權(quán)聲明
- 作者:Steve Rabin, Nintendo of America Inc.
- 郵箱:steve@aiwisdom.com
- 譯者:沙鷹
- 校對(duì):萬太平
概述
調(diào)試游戲程序,和調(diào)試任何其它軟件的代碼一樣,都可能是一項(xiàng)艱巨的任務(wù)。一般說來,有經(jīng)驗(yàn)的程序員能迅速地識(shí)別并糾正哪怕是最難的bug,但是對(duì)于新手而言,改bug可能更像是一件難以處理的,并且容易使人灰心喪氣的任務(wù)。更糟的是,當(dāng)你初步著手開始尋找bug的根源時(shí),永遠(yuǎn)也不會(huì)知道究竟要花費(fèi)多長時(shí)間才能找到。此時(shí)不必慌張,要像個(gè)訓(xùn)練有素的程序員,集中精力尋找bug。一旦你消化了本文介紹的技巧和知識(shí),你將能夠擊退最“兇猛”的bug,重獲對(duì)游戲的控制。
運(yùn)用本文描述的五步調(diào)試法,困難的調(diào)試過程也可能變得簡單一些。訓(xùn)練有素地運(yùn)用該方法,將確保你花費(fèi)最少的時(shí)間在尋找和定位每一個(gè)bug上。在你著手對(duì)付一些有難度的bug時(shí),牢記一些專家技巧也很重要,因此本文也收集了一些有價(jià)值的、經(jīng)過時(shí)間考驗(yàn)的技巧。然后本文還列出了一些有難度的調(diào)試情境,解釋了當(dāng)遇到一些特定的bug模式時(shí)應(yīng)當(dāng)做些什么。因?yàn)楹玫墓ぞ邔?duì)于調(diào)試任何游戲都很重要,本文還將討論一些特定的工具,你可將這些工具嵌入你的游戲中,從而幫助調(diào)試一些游戲編程所獨(dú)有的調(diào)試情形。最后讓我們回顧一些在前期預(yù)防bug的簡單技術(shù)。
五步調(diào)試法
老練的程序員們具有一種超能力,能夠迅速地、駕輕就熟地捕捉到即使是最不可思議的bug。他們總是神奇地、近乎直覺地知道錯(cuò)誤源自何方,這一點(diǎn)實(shí)在令人敬畏。他們之所以顯得天才,除了因?yàn)閾碛胸S富的經(jīng)驗(yàn)外,還因?yàn)樗麄儗?duì)于勘探和減少需排查的可能的原因的方法訓(xùn)練有素、融會(huì)貫通。下面給出的五步調(diào)試法旨在重現(xiàn)他們所熟練掌握的技能,助你在跟蹤bug的問題上形成一種有系統(tǒng)的、且注意力集中的風(fēng)格。
第一步:始終如一地重現(xiàn)問題
不論是什么bug,重要的是,你應(yīng)當(dāng)了解如何能夠始終如一地重現(xiàn)它。
試圖糾正一個(gè)隨機(jī)出現(xiàn)的bug常會(huì)使人感到挫敗,而且通常不過是浪費(fèi)時(shí)間。事實(shí)是,幾乎所有的bug都會(huì)在特定的情境下可靠地重現(xiàn),因此發(fā)現(xiàn)這個(gè)情景和規(guī)律就成為你的、或貴公司測試部同仁的工作。
讓我們舉一個(gè)假想的游戲bug為例,在測試員報(bào)告里寫道:“有時(shí)候,游戲會(huì)在玩家殺死敵人時(shí)死機(jī)(Crash)。”不幸地,像這樣的bug報(bào)告太過于含糊,而且由于這個(gè)問題看上去不是百分之百會(huì)出現(xiàn)的,多數(shù)時(shí)候玩家仍可以正常地摧毀敵人。因此當(dāng)游戲crash時(shí),必然還有一些其它相關(guān)因素。
對(duì)于不容易重現(xiàn)的bug,理想情形是創(chuàng)建一系列“重現(xiàn)步驟(Repro Steps)”,說明每次應(yīng)怎樣才能重現(xiàn)bug。例如,下面的步驟極大地改善了之前的bug報(bào)告。
重現(xiàn)步驟:
- 開始單人游戲。
- 選擇在第44號(hào)地圖上進(jìn)行Skirmish也就是多人練習(xí)模式的游戲。
- 找到敵人營地。
- 在一定的距離開外,使用投射類武器(Projectile Weapon)攻擊在營地里的敵人。
- 結(jié)果:90%的時(shí)候游戲死機(jī)。
顯然,重現(xiàn)步驟是一種很好的方法,測試人員藉此幫助其他人重現(xiàn)bug。不過,精簡可能導(dǎo)致bug發(fā)生的事件鏈(Chain of Events)的過程也是至關(guān)重要的,其原因有三。第一:對(duì)當(dāng)時(shí)bug為何發(fā)生提供了有價(jià)值的線索。第二:提供了一種比較系統(tǒng)地測試bug是否已被徹底改正的方法。第三:可用于回歸測試,確保bug不再卷土重來。
盡管這里的信息沒有告訴我們bug的直接誘因,它使我們能夠始終重現(xiàn)bug。一旦你確定了bug發(fā)生的環(huán)境,你就可以進(jìn)行下一步驟,開始搜集有用的線索。
第二步:搜集線索
現(xiàn)在你能夠可靠地使bug重現(xiàn),下一步請(qǐng)你戴上偵探的鴨舌帽并搜集線索。每條蛛絲馬跡都是排除一個(gè)可能的原因并縮短疑點(diǎn)列表的機(jī)會(huì)。有了足夠的線索,bug的發(fā)源地會(huì)變得明顯。因此為了明了每條線索并理解其潛臺(tái)詞,付出的努力是值得的。
不過有一點(diǎn)要注意,你應(yīng)當(dāng)總是在心里質(zhì)疑每一條已發(fā)現(xiàn)的線索,是不是誤導(dǎo)的,或不正確的。舉例來說,我們被告知某個(gè)bug總發(fā)生在爆炸之后。盡管這可能是一條非常重要的線索,但它仍然可能是一個(gè)虛假的誤導(dǎo)。時(shí)刻準(zhǔn)備著放棄那些與收集來的信息沖突的線索。
還是以上面的bug報(bào)告為例,我們了解到游戲的crash發(fā)生在玩家使用投射類武器攻擊某個(gè)特定的敵人營地的時(shí)候。究竟關(guān)于投射類武器和從遠(yuǎn)處攻擊這兩者,有什么特別之處?這是需要深思的重點(diǎn),但也不要耗費(fèi)太多時(shí)間思考。親臨其境,觀察錯(cuò)誤究竟是如何發(fā)生的,因?yàn)槲覀冃枰@取更多的確鑿的證據(jù),而留連于表面的線索是獲得實(shí)際證據(jù)最不有效的方式。
在本例中,當(dāng)我們進(jìn)入游戲,并實(shí)際觀察錯(cuò)誤的發(fā)生時(shí),我們會(huì)發(fā)現(xiàn)游戲死機(jī)發(fā)生在一個(gè)“箭”對(duì)象里,錯(cuò)誤的癥狀是一個(gè)無效指針。進(jìn)一步的檢查顯示,該指針本來是應(yīng)當(dāng)指向那個(gè)發(fā)射此箭的角色的。在此情況下,這支箭原本要向其發(fā)射者報(bào)告它擊中了某個(gè)敵人,使發(fā)射者為該次成功的攻擊獲得一定的經(jīng)驗(yàn)值。但盡管看上去找到了原因所在,我們對(duì)真實(shí)的潛原因仍然一無所知。我們必須首先找出是什么擾亂了這個(gè)指針。
第三步:查明錯(cuò)誤的源頭
當(dāng)你認(rèn)為收集到的線索已經(jīng)夠多時(shí),就到了專注于搜索和查明錯(cuò)誤的源頭的時(shí)候了。有兩個(gè)主要方法,第一個(gè)方法是先提出關(guān)于bug發(fā)生原因的假設(shè),接著對(duì)該假設(shè)進(jìn)行驗(yàn)證(或證明它不正確);第二個(gè)方法是較為系統(tǒng)的分而治之的方法。
方法1:假設(shè)法
搜集了足夠的線索,你會(huì)開始懷疑有些什么事情導(dǎo)致了bug發(fā)生。這就是你的假設(shè)(Hypothesis)。當(dāng)你能夠在心里清楚地陳述這假設(shè),你就可以開始設(shè)計(jì)一些能驗(yàn)證該假設(shè),或反證證明該假設(shè)不正確的測試用例。
在我們的例子里,通過測試得出了以下線索和關(guān)于游戲設(shè)計(jì)的信息:
- 當(dāng)一支箭射出的時(shí)候,該箭被賦予一個(gè)指向射箭人的指針。
- 當(dāng)一支箭射中某個(gè)敵人的時(shí)候,將獎(jiǎng)勵(lì)送給射箭人。
- 游戲死機(jī)發(fā)生在一支箭試圖通過一個(gè)無效指針向射箭人傳回獎(jiǎng)勵(lì)。
我們的第一個(gè)假設(shè)可能是這樣,指針的值在箭的飛行途中被損壞。基于此種假設(shè),我們開始設(shè)計(jì)測試,并搜集數(shù)據(jù)來支持或推翻此原因。例如我們可以讓每一支箭都將射箭人的指針注冊(cè)到同一個(gè)備份區(qū)域。當(dāng)我們又捕捉到crash時(shí),可以檢查備份下來的數(shù)據(jù),看無效指針的值是否與這支箭在被射出的時(shí)候所賦予的值相同。
不幸的是在我們所舉的例子里,最后發(fā)現(xiàn)這條假設(shè)是不正確的。備份的指針和導(dǎo)致游戲死機(jī)的指針具有相同的值。這樣一來,我們就面臨著一個(gè)抉擇。是再提一個(gè)假設(shè)并進(jìn)行驗(yàn)證,還是重頭尋找更多的線索?現(xiàn)在讓我們?cè)囍偬嵋粭l假設(shè)。
如果箭的發(fā)射人指針從沒有被破壞(新線索),或許從箭射出到箭射中敵人的這段時(shí)間里,這個(gè)發(fā)射人被刪除了。為了檢查這點(diǎn),讓我們記錄下敵人營地里死亡的每個(gè)角色的指針。當(dāng)crash發(fā)生時(shí),我們可以將出錯(cuò)指針和死亡并從內(nèi)存中刪除的敵人的列表進(jìn)行比較。這樣進(jìn)行,很快就證實(shí)原因正是如此。射箭人死時(shí),箭還在飛行途中。
方法2:分治法
兩個(gè)假設(shè)使我們找出了bug,同時(shí)也表現(xiàn)了分而治之的概念。我們知道指針的值無效,但我們不知道它是因?yàn)橹当恍薷倪^而損壞,或者這個(gè)指針在更早些的時(shí)候就已經(jīng)無效。通過測試第一個(gè)假設(shè),我們排除了兩個(gè)可能性中的一個(gè)。像歇洛克·福爾摩斯(Sherlock Holmes)曾說過的:“……當(dāng)你排除了不可能的情況后,其余的情況,盡管多么不可能,卻必定是真實(shí)的。”[譯注:綠玉皇冠案(柯南·道爾)]
有人將分而治之的方法簡單形容為確定故障發(fā)生的時(shí)刻,并從輸入開始回溯而發(fā)現(xiàn)錯(cuò)誤。比如有一個(gè)并不會(huì)造成死機(jī)的bug,在某個(gè)時(shí)刻發(fā)生的初始錯(cuò)誤將影響層層傳遞,最終導(dǎo)致故障發(fā)生。確定初始錯(cuò)誤通常通過在所有輸入分支上設(shè)置(有條件或無條件的)斷點(diǎn)(Breakpoint)來進(jìn)行,直到找到那個(gè)不能正常輸出——也就是導(dǎo)致bug的輸入。
當(dāng)從故障發(fā)生的時(shí)刻開始回溯,你在局部變量和棧里面的上級(jí)函數(shù)中尋找任何異常。對(duì)于死機(jī)bug來說,通常你會(huì)試圖尋找一個(gè)空值(NULL)或極大的數(shù)字值。如果是關(guān)于浮點(diǎn)數(shù)的bug,在棧上尋找NAN或極大的數(shù)字。
無論是對(duì)問題進(jìn)行有根據(jù)的推測,檢驗(yàn)假設(shè),還是有系統(tǒng)地搜捕肇事代碼,最終你會(huì)找到問題所在。在這個(gè)過程中你要相信自己,并保持清醒。本文接下來的部分將詳細(xì)討論一些可用于在這步驟中的專門技術(shù)。
第四步:糾正問題
當(dāng)我們發(fā)現(xiàn)bug的真正根源,接下來要做的便是提出和實(shí)現(xiàn)一個(gè)解決方案。無論如何,修改必須對(duì)項(xiàng)目所處的階段是恰當(dāng)?shù)摹@纾陂_發(fā)的后期,通常不能只為了糾正一個(gè)bug,就修改底部的數(shù)據(jù)結(jié)構(gòu)或程序體系結(jié)構(gòu)。參照開發(fā)工作所處的階段,主程序員或系統(tǒng)架構(gòu)師將決定應(yīng)當(dāng)進(jìn)行何種類型的修改。在關(guān)鍵的時(shí)刻,個(gè)別工程師(初級(jí)或中級(jí))常常做出不好的決定,因?yàn)樗麄儧]有全盤考慮。
此外需要特別注意的是,理想情況下,代碼的編寫者應(yīng)當(dāng)負(fù)責(zé)修改自己代碼里的bug。不過如果必須修改別人的代碼,你至少應(yīng)當(dāng)在進(jìn)行修改前和原作者進(jìn)行討論。討論將使你了解一些方面,例如在以往對(duì)于類似的問題是怎么處理的,如果實(shí)施你的方案提議可能會(huì)造成什么影響等。總之,在未徹底理解由別人編寫的代碼的上下文前,急于進(jìn)行修改是非常危險(xiǎn)的。
繼續(xù)討論我們的例子,死機(jī)源于一個(gè)指向了一個(gè)不復(fù)存在的對(duì)象的無效指針。對(duì)此類問題模式的一個(gè)好的解決方案是使用一層間接引用,使crash不再發(fā)生。通常,正是因?yàn)檫@個(gè)理由,游戲使用對(duì)象的句柄而不是直接指針。這將是一個(gè)合理的修改。
但是,如果游戲項(xiàng)目因?yàn)槟硞€(gè)里程碑、或一個(gè)重要的演示版交付日迫在眉睫,而需要快速完成修改,你可能會(huì)傾向于對(duì)現(xiàn)有的特殊情況實(shí)現(xiàn)一個(gè)較為直接的修改方案(例如讓射箭者在自身被刪除的時(shí)候使其射出的箭中關(guān)于自身的指針失效)。如果在程序里打上了這一類的快速補(bǔ)丁(Quick Hack),你要記得將有關(guān)的注釋文檔化,以使其在這截止期限后被重新評(píng)估。開發(fā)中這樣的情況屢見不鮮:快速補(bǔ)丁被人們遺忘,而在幾個(gè)月后才造成了難于發(fā)現(xiàn)和解決的麻煩。
雖然看上去我們發(fā)現(xiàn)了bug并且確定了一種修改(使用句柄而非指針),探索其他可能造成同樣問題出現(xiàn)的途徑是很關(guān)鍵的。這雖然需要額外的時(shí)間,但是為了確保bug從根本上被消滅,而非只是消除了bug的一種表現(xiàn)形式,這努力是值得的。在我們的例子中,可能其他類型的投射類武器同樣會(huì)造成游戲死機(jī),但其它非武器對(duì)象的關(guān)系、甚至角色之間的關(guān)系也會(huì)受到同一個(gè)設(shè)計(jì)缺陷的影響。應(yīng)找出所有這些相關(guān)的場合,使你的修改方案針對(duì)的是問題的核心,而非僅僅是問題的某一種征兆。
第五步:對(duì)所作的修改進(jìn)行測試
解決方案實(shí)施后,還必須進(jìn)行測試以確認(rèn)它的確修補(bǔ)了錯(cuò)誤。第一步要確保先前有效的重現(xiàn)步驟不會(huì)導(dǎo)致bug重現(xiàn)。通常應(yīng)當(dāng)讓bug修改者以外的其他人,例如測試員,獨(dú)立地確認(rèn)bug被修復(fù)與否。
第二步還要確保沒有新的bug被引入游戲。你應(yīng)當(dāng)讓游戲運(yùn)行一段可觀的時(shí)間,確保所作的修改沒有影響其它部分。這是非常重要的,因?yàn)楹芏鄷r(shí)候,尤其是在項(xiàng)目開發(fā)周期接近尾聲的時(shí)候,為修改bug所作的改動(dòng),會(huì)導(dǎo)致其他系統(tǒng)出錯(cuò)。在項(xiàng)目的后期,你還應(yīng)當(dāng)讓主程序員或其他開發(fā)者來檢視每一個(gè)修改,這額外的可靠性檢驗(yàn)要保證新的修改不會(huì)對(duì)版本有負(fù)面影響。
高級(jí)調(diào)試技巧
如果你遵循以上所述的基本調(diào)試步驟,你應(yīng)能找到并修復(fù)大多數(shù)bug。不過在你嘗試提出假設(shè)、驗(yàn)證/否決一個(gè)候選的原因、或者嘗試找出出錯(cuò)位置的時(shí)候,或許你會(huì)愿意考慮下列的技巧。
分析你的假設(shè)
調(diào)試程序的時(shí)候要保持心胸開闊是很重要的,而且不要作太多假設(shè)。如果你假設(shè)某些貌似簡單的東西總是正確的,你可能就過早地縮小了搜索范圍,從而完全錯(cuò)過了找出真相的機(jī)會(huì)。舉例來說,不要總是想當(dāng)然地認(rèn)為你正在使用最新的軟件或程序庫。檢驗(yàn)?zāi)愕募僭O(shè)是否正確常常是值得的。
將交互和干擾最小化
有時(shí),多個(gè)系統(tǒng)之間會(huì)以某種方式交互,這會(huì)使調(diào)試復(fù)雜化。試試看關(guān)閉那些你認(rèn)為和問題無關(guān)的子系統(tǒng)(例如,關(guān)閉聲音子系統(tǒng)),從而將系統(tǒng)之間的交互降到最低限度。有時(shí)候這有助于識(shí)別問題,因?yàn)樵蚩赡芫驮谀汴P(guān)閉的系統(tǒng)中,這樣你就知道接下來該看那里。
將隨機(jī)性最小化
通常,bug之所以難于重現(xiàn),要?dú)w咎于從幀速率和實(shí)際隨機(jī)數(shù)等方面引入的可變性。如果你的游戲沒有采取固定的幀速率,試試看將“在每幀內(nèi)流逝的時(shí)間”鎖定為常量。至于隨機(jī)數(shù),可以關(guān)閉隨機(jī)數(shù)發(fā)生器,或給它固定的常數(shù)作為隨機(jī)發(fā)生種子,這樣每次運(yùn)行都會(huì)得到同樣的序列。不幸地是,玩家會(huì)給游戲帶來無法控制的顯著的隨機(jī)性。如果連這玩家?guī)淼碾S機(jī)性也必須得到控制,請(qǐng)考慮將玩家的輸入記錄下來,從而能以可預(yù)料的方式將輸入記錄直接送入游戲[Dawson01]。
將復(fù)雜的計(jì)算拆分成幾步進(jìn)行
若某行代碼含有大量計(jì)算,或許將這行拆分為多個(gè)步驟會(huì)有助于識(shí)別問題。例如,可能其中的某小段計(jì)算產(chǎn)生了類型轉(zhuǎn)換錯(cuò)誤,或某個(gè)函數(shù)并未返回你期望它返回的值,或運(yùn)算進(jìn)行的順序并不是你所想的那樣。這也使你能夠檢查每一步中間過程的計(jì)算。
檢查邊界條件
幾乎我們中的每一個(gè)人都曾被經(jīng)典的“差一錯(cuò)誤”(Off-by-one)問題折磨過。要檢查算法的邊界條件,特別是在循環(huán)結(jié)構(gòu)中。
分解并行計(jì)算
如果你懷疑程序里的競爭條件(Race Condition,不同的執(zhí)行順序會(huì)產(chǎn)生不同的結(jié)果),試試看將代碼改寫為串行的,然后檢查bug是否消失。在線程中,增加額外的延遲,觀察是否問題也隨之變化。問題范圍能縮小——若你能夠確定問題是競爭條件,并通過試驗(yàn)將問題孤立出來。
充分利用調(diào)試器提供的工具
明白和懂得如何使用條件斷點(diǎn)、內(nèi)存watch、寄存器watch、棧,以及匯編級(jí)/混合調(diào)試。工具能幫你尋找線索和確鑿的證據(jù),這是識(shí)別bug的關(guān)鍵。
檢查新近改動(dòng)的代碼
調(diào)試也可以通過源代碼版本控制來進(jìn)行,這真是一個(gè)令人驚訝的方法。如果你清楚地記得在某個(gè)日期前程序還是工作的,但是從某天開始就失靈了,你就可以專注于期間改動(dòng)過的代碼,從而較快地找到引入缺陷的代碼段。至少,也可以將搜索范圍縮小至某個(gè)特定子系統(tǒng),或某幾個(gè)文件。
另一個(gè)利用版本控制的方法是生成游戲在bug出現(xiàn)之前的一個(gè)版本。當(dāng)你看不清問題的時(shí)候這尤其有用。將新老版本分別在調(diào)試器中運(yùn)行,將值互相比較,你就可能找出問題的關(guān)鍵所在。
向其他人解釋bug
常常在你向他人解釋bug的時(shí)候,你會(huì)追憶起一些步驟,并意識(shí)到一些遺漏或忘記檢查的地方。與其他的程序員交流的益處還在于他們可能會(huì)精辟地提出別樣的值得檢驗(yàn)的假設(shè)。不要低估和他人交談的作用,也永遠(yuǎn)不要羞于尋求他人的建議。你團(tuán)隊(duì)中的同事是你的伙伴,也是你與最有難度的bug戰(zhàn)斗時(shí)最精良的武器之一。
和同事一起調(diào)試
這通常是很合算的,因?yàn)槊總€(gè)人在對(duì)付bug上都有自己的獨(dú)門經(jīng)驗(yàn)和策略。你也能學(xué)到新的技術(shù),學(xué)會(huì)從從未嘗試過的角度入手處理bug。讓某人看著你進(jìn)行調(diào)試,這可能是追捕bug最有效的方法之一。
暫時(shí)放下問題
有的時(shí)候,你已經(jīng)如此接近問題,以至于無法再清楚全面地看待它。試試看改變一下環(huán)境,出門閑逛一下。當(dāng)你放松,再回到問題上,你可能會(huì)有新的認(rèn)識(shí)。有時(shí)候,當(dāng)你決定讓自己休息一下時(shí),你的心里下意識(shí)地還在思考問題,過后答案就自然浮現(xiàn)了。
尋求外部的幫助
獲得幫助有多種很好的途徑。如果是在開發(fā)視頻游戲,那么每家游戲機(jī)制造商都有一整班的人,他們將在你遇到麻煩的時(shí)候協(xié)助你。了解他們的聯(lián)系方式。三大游戲機(jī)制造商現(xiàn)在都提供電話支持、電子郵件支持、和開發(fā)者互相幫助的新聞?dòng)懻摻M。
困難的調(diào)試情景和模式
消滅bug常有模式可循。在艱苦的調(diào)試情景中,模式是關(guān)鍵。在此經(jīng)驗(yàn)起了很大作用。如果你曾經(jīng)見過某個(gè)模式,你就可能迅速地找出bug所在。希望下列情景和模式能給你一些方向。
Bug僅在發(fā)布版里出現(xiàn),調(diào)試版則正常
通常,Bug只出現(xiàn)于發(fā)布版(Release Build)中意味著這是數(shù)據(jù)未初始化,或與代碼優(yōu)化有關(guān)的bug。一般來說,即使你沒有特地編寫進(jìn)行初始化的代碼,調(diào)試版(Debug Build)也會(huì)自動(dòng)將變量初始化為零。而這隱式初始化在發(fā)布版中是不存在的,因而出現(xiàn)了bug。
找出原因的另一個(gè)策略是:在調(diào)試版里,慢慢地逐一打開優(yōu)化開關(guān)。對(duì)每一點(diǎn)優(yōu)化都進(jìn)行測試,你可以找到罪魁禍?zhǔn)住@纾谡{(diào)試版里,函數(shù)一般都不是內(nèi)聯(lián)的。但在優(yōu)化后有些函數(shù)自動(dòng)進(jìn)行了內(nèi)聯(lián),有時(shí)某個(gè)bug就這樣發(fā)作了。
還有一點(diǎn)值得注意的是,在發(fā)布版中也可以打開調(diào)試符號(hào)(Debug Symbol)。這使得在一定程度上(雖然一般并不)對(duì)優(yōu)化過的代碼進(jìn)行調(diào)試成為可能,你甚至可以讓一部分調(diào)試系統(tǒng)保持開啟。舉例來說,你可以讓你的異常處理函數(shù)在崩潰的現(xiàn)場執(zhí)行一個(gè)全面的堆棧回溯(這需要符號(hào))。這是非常有用的,因?yàn)楫?dāng)測試員必須運(yùn)行優(yōu)化過的游戲版本的時(shí)候,你還是可以回溯程序崩潰。
在作了一些無害的改動(dòng)后,bug不見了
如果bug在一些完全無關(guān)的改動(dòng)(例如添加了一行無害的代碼)后不見了,那么這就像是一個(gè)時(shí)序問題,或內(nèi)存覆蓋問題。盡管表面上bug已經(jīng)消失了,但是實(shí)際上可能只是轉(zhuǎn)移到了代碼的另一個(gè)部分。不要錯(cuò)過這個(gè)找出bug的機(jī)會(huì)。Bug就在那兒,將來遲早有一天它肯定會(huì)不知不覺地、狡猾地害你。
確實(shí)具有間歇性的問題
像前面提過的那樣,許多問題會(huì)在合適的環(huán)境下穩(wěn)定地重現(xiàn)。但如果你無法控制環(huán)境,那就必須要趁問題抬起它丑陋的小腦袋時(shí)抓住問題。這里的關(guān)鍵是在捕捉到問題的時(shí)候要記下盡可能多的信息,以便隨后可在必要時(shí)檢查。機(jī)會(huì)可不是很多的,因此要充分利用每一次出錯(cuò)的機(jī)會(huì)。還有一個(gè)有效的技巧就是將程序出錯(cuò)時(shí)收集得到的數(shù)據(jù)和程序正常時(shí)收集的數(shù)據(jù)進(jìn)行比較,發(fā)現(xiàn)其中差異。
無法解釋的行為
有時(shí)當(dāng)你在單步執(zhí)行代碼的時(shí)候,卻發(fā)現(xiàn)變量自說自話地被修改了。這種真正怪異的現(xiàn)象通常表示系統(tǒng)或調(diào)試器失去了同步。解決方案是試試看“加快清除緩存的頻率”,使系統(tǒng)重獲同步。
感謝Scott Bilas為清除緩存歸納出如下的“四重”方針。
- 重試(Retry):清除游戲的當(dāng)前狀態(tài)再運(yùn)行。
- 重建(Rebuild):刪除已編譯過的中間對(duì)象,并進(jìn)行徹底的版本重建。
- 重啟(Reboot):通過硬復(fù)位,將你機(jī)器里的內(nèi)存擦除。
- 重裝(Reinstall):通過重裝,恢復(fù)你的工具和操作系統(tǒng)中的文件和設(shè)置。
在這“四重”里,“重建”是最重要的。有時(shí)候,編譯器不能正確地識(shí)別代碼間的依賴關(guān)系,導(dǎo)致受牽連的代碼不能通過編譯。癥狀常常是不可思議的怪異。一次徹底的重建有時(shí)就能解決問題。
處理這些無法解釋的行為的時(shí)候,一定要預(yù)先猜測調(diào)試器會(huì)給出何種結(jié)果。通過printf函數(shù)輸出并檢驗(yàn)變量的實(shí)際值,因?yàn)檎{(diào)試器有時(shí)候會(huì)被迷惑,而無法準(zhǔn)確地反映真實(shí)的值。
編譯器內(nèi)部錯(cuò)誤
偶爾你會(huì)碰到這種情況,編譯器承認(rèn)它無法理解你的代碼,從而拋出一個(gè)編譯器內(nèi)部錯(cuò)誤(Internal Compiler Error)。這些錯(cuò)誤可能顯示在代碼中存在合法性問題,也可能根本是編譯器軟件自身的問題(例如,超出了內(nèi)存上限,或無法處理你如同天書一般的模板代碼)。遇到編譯器內(nèi)部錯(cuò)誤的時(shí)候,建議執(zhí)行如下步驟:
- 進(jìn)行完整的版本重建。
- 重啟電腦,再進(jìn)行一次完整的版本重建。
- 檢查是否正在使用最新版本的編譯器。
- 檢查任何正在使用的庫是否是最新版本。
- 試驗(yàn)同樣的代碼是否能在其他電腦上通過編譯。
如果這些步驟不能解決問題,試試確定究竟是那段代碼引起了錯(cuò)誤。如果可能的話,用分治法減少編譯到的代碼,直至編譯器內(nèi)部錯(cuò)誤消失。當(dāng)故障的位置已經(jīng)確定后,檢視這段代碼并保證它看上去沒錯(cuò)(最好能多請(qǐng)幾個(gè)人讀它)。如果代碼看上去的確合理,下一步試著重新組織一下代碼,希望編譯器能報(bào)告出更有意義的錯(cuò)誤信息。最后你還可以嘗試用舊版本的編譯器來編譯。很可能在最新版的編譯器里存在bug,而使用舊版本的編譯器就能順利完成編譯。
如果這些辦法都不奏效,試試看在網(wǎng)上搜索相似的問題。如果還是沒有用,向編譯器的制造商尋求額外的幫助。
當(dāng)你懷疑問題不是出在自己的代碼里
不象話,應(yīng)該總是懷疑自己的代碼!不過,如果你確信不是你們的代碼的問題,最好的行動(dòng)方針是到網(wǎng)站上尋找所使用的函數(shù)庫或編譯器的更新補(bǔ)丁。詳細(xì)閱讀其readme文件,或者在網(wǎng)上搜索關(guān)于此函數(shù)庫或編譯器的已知問題。很多時(shí)候,其他的人也碰到了相似的問題,解決辦法或補(bǔ)丁也已經(jīng)有了。
不過,你發(fā)現(xiàn)的bug來自他人提供的函數(shù)庫,或來自有故障的硬件(碰巧你是第一個(gè)發(fā)現(xiàn)它的人)的幾率不大。雖然不太可能,但有時(shí)還是會(huì)發(fā)生的。最快解決方法是編寫一小段例程將問題隔離開來。然后你可以把這段程序email給函數(shù)庫的作者,或硬件生產(chǎn)商,以便他們進(jìn)一步就此問題進(jìn)行調(diào)查。如果這真是其他人造成的bug,由于你的幫助,他人可以快速地識(shí)別和重現(xiàn)問題,從而bug以最快速度得到改正。
理解底層系統(tǒng)
有時(shí)為了找到一些難度很高的bug,你必須了解底層系統(tǒng)。僅僅通曉C或C++還遠(yuǎn)遠(yuǎn)不夠。為了成為一個(gè)優(yōu)秀的程序員,你必須懂得編譯器是如何實(shí)現(xiàn)較高層次的概念,必須懂匯編語言,還必須了解硬件的細(xì)節(jié)(尤其是對(duì)游戲機(jī)游戲開發(fā)而言)。雖然認(rèn)為高級(jí)語言掩蓋了所有的復(fù)雜性并沒有錯(cuò),但是事實(shí)是當(dāng)系統(tǒng)崩潰時(shí)你會(huì)感覺手足無措,除非你的理解深刻至抽象以下。若要進(jìn)一步討論高層抽象會(huì)如何造成隱患,請(qǐng)參見“The Law of Leaky Abstractions”[Spolsky02]。
那么,有哪些底層細(xì)節(jié)需要了解呢?就游戲而言,你應(yīng)當(dāng)了解如下事項(xiàng):
- 了解編譯器實(shí)現(xiàn)代碼的原理。熟悉繼承、虛函數(shù)調(diào)用、調(diào)用約定、異常是如何實(shí)現(xiàn)的。懂得編譯器如何分配內(nèi)存和處理內(nèi)存對(duì)齊。
- 了解你所使用的硬件的細(xì)節(jié)。例如,懂得與某個(gè)特定硬件的高速緩存有關(guān)的問題(緩存中的數(shù)據(jù)何時(shí)會(huì)和主存儲(chǔ)器中不同)、內(nèi)存對(duì)齊的限制、字節(jié)順序(Endianness,高位還是低位字節(jié)在前)、棧的大小、類型的大小(如整型int、長整型long、布爾型bool)。
- 了解匯編語言的工作原理,能夠閱讀匯編代碼。這在調(diào)試器無法跟蹤源代碼時(shí),例如在優(yōu)化后的版本里查找問題時(shí),很有幫助。
如不能牢牢掌握這些知識(shí),在對(duì)付真正困難的bug的時(shí)候,你的致命弱點(diǎn)就會(huì)暴露出來。所以必須理解底層的系統(tǒng),熟悉其規(guī)則。
增加有助于調(diào)試的基礎(chǔ)設(shè)施
沒有合適的工具的幫助,在真空中調(diào)試程序必定會(huì)很費(fèi)勁。解決辦法是走另一個(gè)極端,直接將好的調(diào)試工具整合到游戲里。下列工具能極大地幫助修理bug。
允許在運(yùn)行中修改游戲變量
調(diào)試和重現(xiàn)bug時(shí),在運(yùn)行中修改游戲變量的值的功能是非常有用的。實(shí)現(xiàn)此功能的經(jīng)典界面是通過游戲中的一個(gè)調(diào)試命令行接口(CLI,Command-Line Interface)用鍵盤修改變量。按下某個(gè)鍵后,調(diào)試信息覆蓋顯示在游戲屏幕上,提示你用鍵盤進(jìn)行輸入。例如,當(dāng)你想把游戲里的天氣改成狂風(fēng)暴雨,你可以在提示下輸入“weather stormy”。此類界面在調(diào)節(jié)和檢查變量的值或特定游戲狀態(tài)的時(shí)候也很好用。
可視化的AI診斷
在調(diào)試中,好的工具是無價(jià)之寶,而標(biāo)準(zhǔn)調(diào)試器在診斷AI問題的時(shí)候總是那么力不從心。各種調(diào)試器雖然在某個(gè)具體時(shí)刻能給出很好的深度,但在解答AI系統(tǒng)怎樣隨著游戲進(jìn)行而變化這個(gè)問題上完全無用。解決辦法是在游戲里直接構(gòu)造能夠監(jiān)控任意角色的診斷數(shù)據(jù)的可視化版本。通過將文字和3D線條組合起來,一些重要的AI系統(tǒng)如尋路(Pathfinding)、警覺邊界(Awareness Boundaries)、當(dāng)前目標(biāo)等,會(huì)較容易跟蹤和查錯(cuò)[Tozour02][Laming03]。
日志的能力
通常,我們?cè)谟螒蚶镉谐啥训慕巧舜私换ズ屯ㄓ崳缘玫椒浅?fù)雜的行為。當(dāng)交互失敗,bug出現(xiàn)之時(shí),關(guān)鍵在于能夠記錄導(dǎo)致bug的每個(gè)角色的個(gè)別狀態(tài)及事件。通過對(duì)每個(gè)角色創(chuàng)建單獨(dú)的日志,將帶有時(shí)戳的關(guān)鍵事件記錄下來,我們就可能通過檢查日志來發(fā)現(xiàn)錯(cuò)誤。
記錄和回放的能力
像前面提到的那樣,找出bug的關(guān)鍵在于可重現(xiàn)性。極致的可重現(xiàn)性需要通過記錄和回放玩家的輸入來實(shí)現(xiàn)[Dawson01]。對(duì)于那些概率很小的死機(jī)bug,記錄和回放是找出確切原因的關(guān)鍵工具。但是為了支持記錄和回放,你必須讓游戲的行為是可預(yù)料的,也就是說對(duì)于同樣的初始狀態(tài),同樣的玩家輸入必定會(huì)得到同樣的輸出結(jié)果。這并不意味著你的游戲?qū)ν婕襾碚f是可以預(yù)知的,只是意味著你應(yīng)當(dāng)小心處理隨機(jī)數(shù)的產(chǎn)生[Lecky-Thompson00][Freeman-Hargis03]、初始狀態(tài)、輸入等方面,并能在程序崩潰時(shí)將輸入序列保存下來[Dawson99]。
跟蹤存儲(chǔ)分配事件
這樣實(shí)現(xiàn)你的存儲(chǔ)分配算子,使其對(duì)每次分配操作都進(jìn)行全面的棧跟蹤。通過不斷地記錄究竟是誰在申請(qǐng)內(nèi)存,你將不再有內(nèi)存泄漏問題需要解決。
崩潰時(shí)打印出盡可能多的信息
“事后調(diào)試(Post-mortem Debug)”是很重要的。程序崩潰時(shí),理想的情況下,你會(huì)希望能夠捕捉到調(diào)用堆棧、寄存器以及所有其它可能相關(guān)的狀態(tài)信息。這些信息可以顯示在屏幕上,寫入某個(gè)文件,或自動(dòng)發(fā)送至開發(fā)者的電子信箱。這一類的工具讓你迅速找出崩潰的源頭,只消幾分鐘而不是幾個(gè)小時(shí)。尤其是當(dāng)故障發(fā)生在美工或策劃同仁的機(jī)器上,而他們并不記得是怎樣觸發(fā)這次崩潰的時(shí)候。
對(duì)整個(gè)團(tuán)隊(duì)進(jìn)行培訓(xùn)
雖然這并非一個(gè)能夠編程實(shí)現(xiàn)的結(jié)構(gòu),但是你應(yīng)當(dāng)確定團(tuán)隊(duì)正確使用你創(chuàng)建的工具。請(qǐng)他們不要忽視錯(cuò)誤對(duì)話框,確信他們知道怎樣搜集信息從而不會(huì)丟失已找到的bug等等。花時(shí)間來培訓(xùn)測試員、美工、策劃是值得的。
預(yù)防bug
關(guān)于調(diào)試的討論,若沒有一段文字指導(dǎo)如何在第一時(shí)間避免bug,便不能算完整。遵照這些指導(dǎo)方針,你或可避免編寫出有bug的代碼,或可在偶然之間發(fā)現(xiàn)自己不知不覺寫出來的bug。不論是什么結(jié)果,都會(huì)最后幫你排除bug。
將編譯器的警告級(jí)別(Warning level)調(diào)到最高,并指示將警告當(dāng)作錯(cuò)誤處理(Enable warnings as errors)。首先盡可能多地排除警告,最后才用#pragma將剩下的警告關(guān)閉掉。有時(shí),自動(dòng)類型轉(zhuǎn)換及其它一些警告級(jí)的問題會(huì)帶來潛在的bug。
使你的游戲能在多個(gè)編譯器上編譯通過。如果你確保游戲用多個(gè)編譯器、面向多個(gè)平臺(tái)都能編譯通過,不同的編譯器之間在警告和錯(cuò)誤方面的差異將保證你的代碼總體上更可靠。例如,編寫任天堂GameCube™游戲機(jī)上的程序的人也可以在Win32下生成一個(gè)功能稍弱的版本。這也使你能夠判斷某個(gè)bug是否是具體平臺(tái)所特有的。
編寫你自己的內(nèi)存管理器。這對(duì)于游戲機(jī)游戲是至關(guān)重要的。你必須清楚地知道正在使用那幾塊內(nèi)存,并對(duì)內(nèi)存上溢進(jìn)行保護(hù)。由于內(nèi)存溢出會(huì)帶來一些最難查處的bug,首先確保不發(fā)生溢出是很重要的。在調(diào)試版本中使用預(yù)留的上溢和下溢保護(hù)內(nèi)存塊能使bug更早地暴露身份。對(duì)PC開發(fā)者來說,編寫自己的內(nèi)存管理器不是必須的,因?yàn)閂C++里的內(nèi)存系統(tǒng)功能已經(jīng)很強(qiáng)了,而且還有像SmartHeap之類的好工具可以用來確定內(nèi)存錯(cuò)誤。
用assert來檢驗(yàn)假設(shè)。在函數(shù)的開頭加上assert來檢驗(yàn)關(guān)于參數(shù)的假設(shè)(例如指針非空或范圍檢查)。另外,如果switch語句的default情況不應(yīng)該被執(zhí)行到,在其中加上assert。還有,標(biāo)準(zhǔn)assert可以被擴(kuò)展以得到更好的調(diào)試性能[Rabin00b]。例如,讓assert將調(diào)用堆棧打印出來是很有用的。
總是在聲明變量的時(shí)候初始化它們。如果你無法在聲明某個(gè)變量時(shí)賦予它一個(gè)有意義的值,那么就給它賦一個(gè)將來一眼就能認(rèn)出它有沒有被初始化過的容易辨認(rèn)的值。有時(shí)候我們會(huì)用0xDEADBEEF、0xCDCDCDCD,或直接使用零。
總是將循環(huán)體和if語句體用花括號(hào)({})括起來。也就是將你所想的代碼老老實(shí)實(shí)地包起來,使代碼所實(shí)現(xiàn)的功能更直觀。
變量起名要容易區(qū)分彼此。例如,m_objectITime和m_objectJTime看上去幾乎一模一樣。此類問題的典型例子是把“i”和“j”用作循環(huán)計(jì)數(shù)變量。“i”和“j”看上去很相似,你很容易把其中一個(gè)誤認(rèn)為另一個(gè)。可供選擇的方法是,你可以用“i”和“k”,或者干脆使用更能描述其意義的名字。更多有關(guān)變量命名的認(rèn)知差異的信息可以在[McConnell93]中找到。
避免在多處重復(fù)同樣的代碼。一模一樣的代碼同時(shí)出現(xiàn)在幾個(gè)不同的地方,這是不利的。如果對(duì)其中一處代碼作了改動(dòng),其余幾個(gè)地方不一定也會(huì)被改動(dòng)。如果看上去重復(fù)代碼是必要的,重新考慮一下其核心功能,盡量將大多數(shù)的代碼集中到一處。
避免使用那些中固定寫死的“神奇數(shù)”(Magic numbers)。當(dāng)單獨(dú)一個(gè)數(shù)字出現(xiàn)在代碼中,其意義可能是完全不為人知的。如果沒有寫注釋,就無法讓人理解之所以選擇這個(gè)數(shù)字的理由,及這個(gè)數(shù)字代表什么。如果必須使用神奇數(shù),將它們聲明為有字面意義的常量或define。
測試的時(shí)候要注意代碼覆蓋率。在編寫完一段代碼之后,應(yīng)驗(yàn)證它的每一個(gè)分支都能正確地執(zhí)行。若其中一個(gè)分支從未被執(zhí)行過,那么很可能其中正潛伏著bug。在測試不同分支的過程中你可能會(huì)發(fā)現(xiàn)這樣一個(gè)bug,即其中某個(gè)分支是根本不可能被執(zhí)行到的。這樣的bug越早發(fā)現(xiàn)就越好。
結(jié)論
本文向你介紹了有效率地調(diào)試游戲所需的工具。調(diào)試有時(shí)候被形容為一門藝術(shù),但那只是由于人們?cè)接薪?jīng)驗(yàn)就做得越好。當(dāng)你把五步調(diào)試法融會(huì)貫通,又學(xué)會(huì)了識(shí)別bug模式,并將自己的調(diào)試工具集成到游戲中,再形成自己在調(diào)試上的個(gè)人風(fēng)格和絕招,很快地,你將熟練地有系統(tǒng)地追捕到并且消滅最困難的bug。最后再加上一點(diǎn)預(yù)防,我想你的游戲開發(fā)會(huì)一帆風(fēng)順,一個(gè)bug都沒有也說不定。
致謝
感謝Scott Bilas和Jack Matthews,他們提了極好的建議,并為本文貢獻(xiàn)了一些個(gè)人經(jīng)驗(yàn)和智慧。人們看待調(diào)試有各自的角度,因此他們的意見在推敲本文建議的時(shí)候起了非常大的作用。
參考文獻(xiàn)
- [Dawson99] Dawson, Bruce, “Structured Exception Handling,” Game Developer Magazine (Jan 1999), pp. 52–54.
- [Dawson01] Dawson, Bruce, “Game Input Recording and Playback,” Game Programming Gems 2, Charles River Media, 2001.
- [Freeman-Hargis03] Freeman-Hargis, James, “The Statistics of Random Numbers,” AI Game Programming Wisdom 2, Charles River Media, 2003.
- [Laming03] Laming, Brett, “The Art of Surviving a Simulation Title,” AI Game Programming Wisdom 2, Charles River Media, 2003.
- [Lecky-Thompson00] Lecky-Thompson, Guy, “Predictable Random Numbers,” Game Programming Gems, Charles River Media, 2000.
- [McConnell93] McConnell, Steve, Code Complete: A Practical Handbook of Software Construction, Microsoft Press, 1993.
- [Rabin00a] Rabin, Steve, “Designing a General Robust AI Engine,” Game Programming Gems, Charles River Media, 2000.
- [Rabin00b] Rabin, Steve, “Squeezing More Out of Assert,” Game Programming Gems, Charles River Media, 2000.
- [Rabin02] Rabin, Steve, “Implementing a State Machine Language,” AI Game Programming Wisdom, Charles River Media, 2000.
- [Spolsky02] Spolsky, Joel, “The Law of Leaky Abstractions,” Joel on Software, 2002, available online at www.joelonsoftware.com/articles/LeakyAbstractions.html.
- [Tozour02] Tozour, Paul, “Building an AI Diagnostic Toolset,” AI Game Programming Wisdom, Charles River Media, 2002.