芳草春晖

偶尔记录自己思绪的地方...

 

调试游戏程序的学问(转)

链接:http://blog.csdn.net/fannyfish/

版权声明

  • 作者:Steve Rabin, Nintendo of America Inc.
  • 邮箱:steve@aiwisdom.com
  • 译者:沙鹰
  • 校对:万太平

概述

调试游戏程序,和调试任何其它软件的代码一样,都可能是一项艰巨的任务。一般说来,有经验的程序员能迅速地识别并纠正哪怕是最难的bug,但是对于新手而言,改bug可能更像是一件难以处理的,并且容易使人灰心丧气的任务。更糟的是,当你初步着手开始寻找bug的根源时,永远也不会知道究竟要花费多长时间才能找到。此时不必慌张,要像个训练有素的程序员,集中精力寻找bug。一旦你消化了本文介绍的技巧和知识,你将能够击退最“凶猛”的bug,重获对游戏的控制。

运用本文描述的五步调试法,困难的调试过程也可能变得简单一些。训练有素地运用该方法,将确保你花费最少的时间在寻找和定位每一个bug上。在你着手对付一些有难度的bug时,牢记一些专家技巧也很重要,因此本文也收集了一些有价值的、经过时间考验的技巧。然后本文还列出了一些有难度的调试情境,解释了当遇到一些特定的bug模式时应当做些什么。因为好的工具对于调试任何游戏都很重要,本文还将讨论一些特定的工具,你可将这些工具嵌入你的游戏中,从而帮助调试一些游戏编程所独有的调试情形。最后让我们回顾一些在前期预防bug的简单技术。

五步调试法

老练的程序员们具有一种超能力,能够迅速地、驾轻就熟地捕捉到即使是最不可思议的bug。他们总是神奇地、近乎直觉地知道错误源自何方,这一点实在令人敬畏。他们之所以显得天才,除了因为拥有丰富的经验外,还因为他们对于勘探和减少需排查的可能的原因的方法训练有素、融会贯通。下面给出的五步调试法旨在重现他们所熟练掌握的技能,助你在跟踪bug的问题上形成一种有系统的、且注意力集中的风格。

第一步:始终如一地重现问题

不论是什么bug,重要的是,你应当了解如何能够始终如一地重现它。

试图纠正一个随机出现的bug常会使人感到挫败,而且通常不过是浪费时间。事实是,几乎所有的bug都会在特定的情境下可靠地重现,因此发现这个情景和规律就成为你的、或贵公司测试部同仁的工作。

让我们举一个假想的游戏bug为例,在测试员报告里写道:“有时候,游戏会在玩家杀死敌人时死机(Crash)。”不幸地,像这样的bug报告太过于含糊,而且由于这个问题看上去不是百分之百会出现的,多数时候玩家仍可以正常地摧毁敌人。因此当游戏crash时,必然还有一些其它相关因素。

对于不容易重现的bug,理想情形是创建一系列“重现步骤(Repro Steps)”,说明每次应怎样才能重现bug。例如,下面的步骤极大地改善了之前的bug报告。

重现步骤:

  1. 开始单人游戏。
  2. 选择在第44号地图上进行Skirmish也就是多人练习模式的游戏。
  3. 找到敌人营地。
  4. 在一定的距离开外,使用投射类武器(Projectile Weapon)攻击在营地里的敌人。
  5. 结果:90%的时候游戏死机。

显然,重现步骤是一种很好的方法,测试人员藉此帮助其他人重现bug。不过,精简可能导致bug发生的事件链(Chain of Events)的过程也是至关重要的,其原因有三。第一:对当时bug为何发生提供了有价值的线索。第二:提供了一种比较系统地测试bug是否已被彻底改正的方法。第三:可用于回归测试,确保bug不再卷土重来。

尽管这里的信息没有告诉我们bug的直接诱因,它使我们能够始终重现bug。一旦你确定了bug发生的环境,你就可以进行下一步骤,开始搜集有用的线索。

第二步:搜集线索

现在你能够可靠地使bug重现,下一步请你戴上侦探的鸭舌帽并搜集线索。每条蛛丝马迹都是排除一个可能的原因并缩短疑点列表的机会。有了足够的线索,bug的发源地会变得明显。因此为了明了每条线索并理解其潜台词,付出的努力是值得的。

不过有一点要注意,你应当总是在心里质疑每一条已发现的线索,是不是误导的,或不正确的。举例来说,我们被告知某个bug总发生在爆炸之后。尽管这可能是一条非常重要的线索,但它仍然可能是一个虚假的误导。时刻准备着放弃那些与收集来的信息冲突的线索。

还是以上面的bug报告为例,我们了解到游戏的crash发生在玩家使用投射类武器攻击某个特定的敌人营地的时候。究竟关于投射类武器和从远处攻击这两者,有什么特别之处?这是需要深思的重点,但也不要耗费太多时间思考。亲临其境,观察错误究竟是如何发生的,因为我们需要获取更多的确凿的证据,而留连于表面的线索是获得实际证据最不有效的方式。

在本例中,当我们进入游戏,并实际观察错误的发生时,我们会发现游戏死机发生在一个“箭”对象里,错误的症状是一个无效指针。进一步的检查显示,该指针本来是应当指向那个发射此箭的角色的。在此情况下,这支箭原本要向其发射者报告它击中了某个敌人,使发射者为该次成功的攻击获得一定的经验值。但尽管看上去找到了原因所在,我们对真实的潜原因仍然一无所知。我们必须首先找出是什么扰乱了这个指针。

第三步:查明错误的源头

当你认为收集到的线索已经够多时,就到了专注于搜索和查明错误的源头的时候了。有两个主要方法,第一个方法是先提出关于bug发生原因的假设,接着对该假设进行验证(或证明它不正确);第二个方法是较为系统的分而治之的方法。

方法1:假设法

搜集了足够的线索,你会开始怀疑有些什么事情导致了bug发生。这就是你的假设(Hypothesis)。当你能够在心里清楚地陈述这假设,你就可以开始设计一些能验证该假设,或反证证明该假设不正确的测试用例。

在我们的例子里,通过测试得出了以下线索和关于游戏设计的信息:

  • 当一支箭射出的时候,该箭被赋予一个指向射箭人的指针。
  • 当一支箭射中某个敌人的时候,将奖励送给射箭人。
  • 游戏死机发生在一支箭试图通过一个无效指针向射箭人传回奖励。

我们的第一个假设可能是这样,指针的值在箭的飞行途中被损坏。基于此种假设,我们开始设计测试,并搜集数据来支持或推翻此原因。例如我们可以让每一支箭都将射箭人的指针注册到同一个备份区域。当我们又捕捉到crash时,可以检查备份下来的数据,看无效指针的值是否与这支箭在被射出的时候所赋予的值相同。  

不幸的是在我们所举的例子里,最后发现这条假设是不正确的。备份的指针和导致游戏死机的指针具有相同的值。这样一来,我们就面临着一个抉择。是再提一个假设并进行验证,还是重头寻找更多的线索?现在让我们试着再提一条假设。

如果箭的发射人指针从没有被破坏(新线索),或许从箭射出到箭射中敌人的这段时间里,这个发射人被删除了。为了检查这点,让我们记录下敌人营地里死亡的每个角色的指针。当crash发生时,我们可以将出错指针和死亡并从内存中删除的敌人的列表进行比较。这样进行,很快就证实原因正是如此。射箭人死时,箭还在飞行途中。

方法2:分治法

两个假设使我们找出了bug,同时也表现了分而治之的概念。我们知道指针的值无效,但我们不知道它是因为值被修改过而损坏,或者这个指针在更早些的时候就已经无效。通过测试第一个假设,我们排除了两个可能性中的一个。像歇洛克·福尔摩斯(Sherlock Holmes)曾说过的:“……当你排除了不可能的情况后,其余的情况,尽管多么不可能,却必定是真实的。”[译注:绿玉皇冠案(柯南·道尔)]

有人将分而治之的方法简单形容为确定故障发生的时刻,并从输入开始回溯而发现错误。比如有一个并不会造成死机的bug,在某个时刻发生的初始错误将影响层层传递,最终导致故障发生。确定初始错误通常通过在所有输入分支上设置(有条件或无条件的)断点(Breakpoint)来进行,直到找到那个不能正常输出——也就是导致bug的输入。

当从故障发生的时刻开始回溯,你在局部变量和栈里面的上级函数中寻找任何异常。对于死机bug来说,通常你会试图寻找一个空值(NULL)或极大的数字值。如果是关于浮点数的bug,在栈上寻找NAN或极大的数字。

无论是对问题进行有根据的推测,检验假设,还是有系统地搜捕肇事代码,最终你会找到问题所在。在这个过程中你要相信自己,并保持清醒。本文接下来的部分将详细讨论一些可用于在这步骤中的专门技术。

第四步:纠正问题

当我们发现bug的真正根源,接下来要做的便是提出和实现一个解决方案。无论如何,修改必须对项目所处的阶段是恰当的。例如,在开发的后期,通常不能只为了纠正一个bug,就修改底部的数据结构或程序体系结构。参照开发工作所处的阶段,主程序员或系统架构师将决定应当进行何种类型的修改。在关键的时刻,个别工程师(初级或中级)常常做出不好的决定,因为他们没有全盘考虑。

此外需要特别注意的是,理想情况下,代码的编写者应当负责修改自己代码里的bug。不过如果必须修改别人的代码,你至少应当在进行修改前和原作者进行讨论。讨论将使你了解一些方面,例如在以往对于类似的问题是怎么处理的,如果实施你的方案提议可能会造成什么影响等。总之,在未彻底理解由别人编写的代码的上下文前,急于进行修改是非常危险的。

继续讨论我们的例子,死机源于一个指向了一个不复存在的对象的无效指针。对此类问题模式的一个好的解决方案是使用一层间接引用,使crash不再发生。通常,正是因为这个理由,游戏使用对象的句柄而不是直接指针。这将是一个合理的修改。

但是,如果游戏项目因为某个里程碑、或一个重要的演示版交付日迫在眉睫,而需要快速完成修改,你可能会倾向于对现有的特殊情况实现一个较为直接的修改方案(例如让射箭者在自身被删除的时候使其射出的箭中关于自身的指针失效)。如果在程序里打上了这一类的快速补丁(Quick Hack),你要记得将有关的注释文档化,以使其在这截止期限后被重新评估。开发中这样的情况屡见不鲜:快速补丁被人们遗忘,而在几个月后才造成了难于发现和解决的麻烦。

虽然看上去我们发现了bug并且确定了一种修改(使用句柄而非指针),探索其他可能造成同样问题出现的途径是很关键的。这虽然需要额外的时间,但是为了确保bug从根本上被消灭,而非只是消除了bug的一种表现形式,这努力是值得的。在我们的例子中,可能其他类型的投射类武器同样会造成游戏死机,但其它非武器对象的关系、甚至角色之间的关系也会受到同一个设计缺陷的影响。应找出所有这些相关的场合,使你的修改方案针对的是问题的核心,而非仅仅是问题的某一种征兆。

第五步:对所作的修改进行测试

解决方案实施后,还必须进行测试以确认它的确修补了错误。第一步要确保先前有效的重现步骤不会导致bug重现。通常应当让bug修改者以外的其他人,例如测试员,独立地确认bug被修复与否。

第二步还要确保没有新的bug被引入游戏。你应当让游戏运行一段可观的时间,确保所作的修改没有影响其它部分。这是非常重要的,因为很多时候,尤其是在项目开发周期接近尾声的时候,为修改bug所作的改动,会导致其他系统出错。在项目的后期,你还应当让主程序员或其他开发者来检视每一个修改,这额外的可靠性检验要保证新的修改不会对版本有负面影响。

高级调试技巧

如果你遵循以上所述的基本调试步骤,你应能找到并修复大多数bug。不过在你尝试提出假设、验证/否决一个候选的原因、或者尝试找出出错位置的时候,或许你会愿意考虑下列的技巧。

分析你的假设

调试程序的时候要保持心胸开阔是很重要的,而且不要作太多假设。如果你假设某些貌似简单的东西总是正确的,你可能就过早地缩小了搜索范围,从而完全错过了找出真相的机会。举例来说,不要总是想当然地认为你正在使用最新的软件或程序库。检验你的假设是否正确常常是值得的。

将交互和干扰最小化

有时,多个系统之间会以某种方式交互,这会使调试复杂化。试试看关闭那些你认为和问题无关的子系统(例如,关闭声音子系统),从而将系统之间的交互降到最低限度。有时候这有助于识别问题,因为原因可能就在你关闭的系统中,这样你就知道接下来该看那里。

将随机性最小化

通常,bug之所以难于重现,要归咎于从帧速率和实际随机数等方面引入的可变性。如果你的游戏没有采取固定的帧速率,试试看将“在每帧内流逝的时间”锁定为常量。至于随机数,可以关闭随机数发生器,或给它固定的常数作为随机发生种子,这样每次运行都会得到同样的序列。不幸地是,玩家会给游戏带来无法控制的显著的随机性。如果连这玩家带来的随机性也必须得到控制,请考虑将玩家的输入记录下来,从而能以可预料的方式将输入记录直接送入游戏[Dawson01]。

将复杂的计算拆分成几步进行

若某行代码含有大量计算,或许将这行拆分为多个步骤会有助于识别问题。例如,可能其中的某小段计算产生了类型转换错误,或某个函数并未返回你期望它返回的值,或运算进行的顺序并不是你所想的那样。这也使你能够检查每一步中间过程的计算。

检查边界条件

几乎我们中的每一个人都曾被经典的“差一错误”(Off-by-one)问题折磨过。要检查算法的边界条件,特别是在循环结构中。

分解并行计算

如果你怀疑程序里的竞争条件(Race Condition,不同的执行顺序会产生不同的结果),试试看将代码改写为串行的,然后检查bug是否消失。在线程中,增加额外的延迟,观察是否问题也随之变化。问题范围能缩小——若你能够确定问题是竞争条件,并通过试验将问题孤立出来。

充分利用调试器提供的工具

明白和懂得如何使用条件断点、内存watch、寄存器watch、栈,以及汇编级/混合调试。工具能帮你寻找线索和确凿的证据,这是识别bug的关键。

检查新近改动的代码

调试也可以通过源代码版本控制来进行,这真是一个令人惊讶的方法。如果你清楚地记得在某个日期前程序还是工作的,但是从某天开始就失灵了,你就可以专注于期间改动过的代码,从而较快地找到引入缺陷的代码段。至少,也可以将搜索范围缩小至某个特定子系统,或某几个文件。

另一个利用版本控制的方法是生成游戏在bug出现之前的一个版本。当你看不清问题的时候这尤其有用。将新老版本分别在调试器中运行,将值互相比较,你就可能找出问题的关键所在。

向其他人解释bug

常常在你向他人解释bug的时候,你会追忆起一些步骤,并意识到一些遗漏或忘记检查的地方。与其他的程序员交流的益处还在于他们可能会精辟地提出别样的值得检验的假设。不要低估和他人交谈的作用,也永远不要羞于寻求他人的建议。你团队中的同事是你的伙伴,也是你与最有难度的bug战斗时最精良的武器之一。

和同事一起调试

这通常是很合算的,因为每个人在对付bug上都有自己的独门经验和策略。你也能学到新的技术,学会从从未尝试过的角度入手处理bug。让某人看着你进行调试,这可能是追捕bug最有效的方法之一。

暂时放下问题

有的时候,你已经如此接近问题,以至于无法再清楚全面地看待它。试试看改变一下环境,出门闲逛一下。当你放松,再回到问题上,你可能会有新的认识。有时候,当你决定让自己休息一下时,你的心里下意识地还在思考问题,过后答案就自然浮现了。

寻求外部的帮助

获得帮助有多种很好的途径。如果是在开发视频游戏,那么每家游戏机制造商都有一整班的人,他们将在你遇到麻烦的时候协助你。了解他们的联系方式。三大游戏机制造商现在都提供电话支持、电子邮件支持、和开发者互相帮助的新闻讨论组。

困难的调试情景和模式

消灭bug常有模式可循。在艰苦的调试情景中,模式是关键。在此经验起了很大作用。如果你曾经见过某个模式,你就可能迅速地找出bug所在。希望下列情景和模式能给你一些方向。

Bug仅在发布版里出现,调试版则正常

通常,Bug只出现于发布版(Release Build)中意味着这是数据未初始化,或与代码优化有关的bug。一般来说,即使你没有特地编写进行初始化的代码,调试版(Debug Build)也会自动将变量初始化为零。而这隐式初始化在发布版中是不存在的,因而出现了bug。

找出原因的另一个策略是:在调试版里,慢慢地逐一打开优化开关。对每一点优化都进行测试,你可以找到罪魁祸首。例如,在调试版里,函数一般都不是内联的。但在优化后有些函数自动进行了内联,有时某个bug就这样发作了。

还有一点值得注意的是,在发布版中也可以打开调试符号(Debug Symbol)。这使得在一定程度上(虽然一般并不)对优化过的代码进行调试成为可能,你甚至可以让一部分调试系统保持开启。举例来说,你可以让你的异常处理函数在崩溃的现场执行一个全面的堆栈回溯(这需要符号)。这是非常有用的,因为当测试员必须运行优化过的游戏版本的时候,你还是可以回溯程序崩溃。

在作了一些无害的改动后,bug不见了

如果bug在一些完全无关的改动(例如添加了一行无害的代码)后不见了,那么这就像是一个时序问题,或内存覆盖问题。尽管表面上bug已经消失了,但是实际上可能只是转移到了代码的另一个部分。不要错过这个找出bug的机会。Bug就在那儿,将来迟早有一天它肯定会不知不觉地、狡猾地害你。

确实具有间歇性的问题

像前面提过的那样,许多问题会在合适的环境下稳定地重现。但如果你无法控制环境,那就必须要趁问题抬起它丑陋的小脑袋时抓住问题。这里的关键是在捕捉到问题的时候要记下尽可能多的信息,以便随后可在必要时检查。机会可不是很多的,因此要充分利用每一次出错的机会。还有一个有效的技巧就是将程序出错时收集得到的数据和程序正常时收集的数据进行比较,发现其中差异。

无法解释的行为

有时当你在单步执行代码的时候,却发现变量自说自话地被修改了。这种真正怪异的现象通常表示系统或调试器失去了同步。解决方案是试试看“加快清除缓存的频率”,使系统重获同步。

感谢Scott Bilas为清除缓存归纳出如下的“四重”方针。

  • 重试(Retry):清除游戏的当前状态再运行。
  • 重建(Rebuild):删除已编译过的中间对象,并进行彻底的版本重建。
  • 重启(Reboot):通过硬复位,将你机器里的内存擦除。
  • 重装(Reinstall):通过重装,恢复你的工具和操作系统中的文件和设置。

在这“四重”里,“重建”是最重要的。有时候,编译器不能正确地识别代码间的依赖关系,导致受牵连的代码不能通过编译。症状常常是不可思议的怪异。一次彻底的重建有时就能解决问题。

处理这些无法解释的行为的时候,一定要预先猜测调试器会给出何种结果。通过printf函数输出并检验变量的实际值,因为调试器有时候会被迷惑,而无法准确地反映真实的值。

编译器内部错误

偶尔你会碰到这种情况,编译器承认它无法理解你的代码,从而抛出一个编译器内部错误(Internal Compiler Error)。这些错误可能显示在代码中存在合法性问题,也可能根本是编译器软件自身的问题(例如,超出了内存上限,或无法处理你如同天书一般的模板代码)。遇到编译器内部错误的时候,建议执行如下步骤:

  1. 进行完整的版本重建。
  2. 重启电脑,再进行一次完整的版本重建。
  3. 检查是否正在使用最新版本的编译器。
  4. 检查任何正在使用的库是否是最新版本。
  5. 试验同样的代码是否能在其他电脑上通过编译。

如果这些步骤不能解决问题,试试确定究竟是那段代码引起了错误。如果可能的话,用分治法减少编译到的代码,直至编译器内部错误消失。当故障的位置已经确定后,检视这段代码并保证它看上去没错(最好能多请几个人读它)。如果代码看上去的确合理,下一步试着重新组织一下代码,希望编译器能报告出更有意义的错误信息。最后你还可以尝试用旧版本的编译器来编译。很可能在最新版的编译器里存在bug,而使用旧版本的编译器就能顺利完成编译。

如果这些办法都不奏效,试试看在网上搜索相似的问题。如果还是没有用,向编译器的制造商寻求额外的帮助。

当你怀疑问题不是出在自己的代码里

不象话,应该总是怀疑自己的代码!不过,如果你确信不是你们的代码的问题,最好的行动方针是到网站上寻找所使用的函数库或编译器的更新补丁。详细阅读其readme文件,或者在网上搜索关于此函数库或编译器的已知问题。很多时候,其他的人也碰到了相似的问题,解决办法或补丁也已经有了。

不过,你发现的bug来自他人提供的函数库,或来自有故障的硬件(碰巧你是第一个发现它的人)的几率不大。虽然不太可能,但有时还是会发生的。最快解决方法是编写一小段例程将问题隔离开来。然后你可以把这段程序email给函数库的作者,或硬件生产商,以便他们进一步就此问题进行调查。如果这真是其他人造成的bug,由于你的帮助,他人可以快速地识别和重现问题,从而bug以最快速度得到改正。

理解底层系统

有时为了找到一些难度很高的bug,你必须了解底层系统。仅仅通晓C或C++还远远不够。为了成为一个优秀的程序员,你必须懂得编译器是如何实现较高层次的概念,必须懂汇编语言,还必须了解硬件的细节(尤其是对游戏机游戏开发而言)。虽然认为高级语言掩盖了所有的复杂性并没有错,但是事实是当系统崩溃时你会感觉手足无措,除非你的理解深刻至抽象以下。若要进一步讨论高层抽象会如何造成隐患,请参见“The Law of Leaky Abstractions”[Spolsky02]。

那么,有哪些底层细节需要了解呢?就游戏而言,你应当了解如下事项:

  • 了解编译器实现代码的原理。熟悉继承、虚函数调用、调用约定、异常是如何实现的。懂得编译器如何分配内存和处理内存对齐。
  • 了解你所使用的硬件的细节。例如,懂得与某个特定硬件的高速缓存有关的问题(缓存中的数据何时会和主存储器中不同)、内存对齐的限制、字节顺序(Endianness,高位还是低位字节在前)、栈的大小、类型的大小(如整型int、长整型long、布尔型bool)。
  • 了解汇编语言的工作原理,能够阅读汇编代码。这在调试器无法跟踪源代码时,例如在优化后的版本里查找问题时,很有帮助。

如不能牢牢掌握这些知识,在对付真正困难的bug的时候,你的致命弱点就会暴露出来。所以必须理解底层的系统,熟悉其规则。

增加有助于调试的基础设施

没有合适的工具的帮助,在真空中调试程序必定会很费劲。解决办法是走另一个极端,直接将好的调试工具整合到游戏里。下列工具能极大地帮助修理bug。

允许在运行中修改游戏变量

调试和重现bug时,在运行中修改游戏变量的值的功能是非常有用的。实现此功能的经典界面是通过游戏中的一个调试命令行接口(CLI,Command-Line Interface)用键盘修改变量。按下某个键后,调试信息覆盖显示在游戏屏幕上,提示你用键盘进行输入。例如,当你想把游戏里的天气改成狂风暴雨,你可以在提示下输入“weather stormy”。此类界面在调节和检查变量的值或特定游戏状态的时候也很好用。

可视化的AI诊断

在调试中,好的工具是无价之宝,而标准调试器在诊断AI问题的时候总是那么力不从心。各种调试器虽然在某个具体时刻能给出很好的深度,但在解答AI系统怎样随着游戏进行而变化这个问题上完全无用。解决办法是在游戏里直接构造能够监控任意角色的诊断数据的可视化版本。通过将文字和3D线条组合起来,一些重要的AI系统如寻路(Pathfinding)、警觉边界(Awareness Boundaries)、当前目标等,会较容易跟踪和查错[Tozour02][Laming03]。

日志的能力

通常,我们在游戏里有成堆的角色彼此交互和通讯,以得到非常复杂的行为。当交互失败,bug出现之时,关键在于能够记录导致bug的每个角色的个别状态及事件。通过对每个角色创建单独的日志,将带有时戳的关键事件记录下来,我们就可能通过检查日志来发现错误。

记录和回放的能力

像前面提到的那样,找出bug的关键在于可重现性。极致的可重现性需要通过记录和回放玩家的输入来实现[Dawson01]。对于那些概率很小的死机bug,记录和回放是找出确切原因的关键工具。但是为了支持记录和回放,你必须让游戏的行为是可预料的,也就是说对于同样的初始状态,同样的玩家输入必定会得到同样的输出结果。这并不意味着你的游戏对玩家来说是可以预知的,只是意味着你应当小心处理随机数的产生[Lecky-Thompson00][Freeman-Hargis03]、初始状态、输入等方面,并能在程序崩溃时将输入序列保存下来[Dawson99]。

跟踪存储分配事件

这样实现你的存储分配算子,使其对每次分配操作都进行全面的栈跟踪。通过不断地记录究竟是谁在申请内存,你将不再有内存泄漏问题需要解决。

崩溃时打印出尽可能多的信息

“事后调试(Post-mortem Debug)”是很重要的。程序崩溃时,理想的情况下,你会希望能够捕捉到调用堆栈、寄存器以及所有其它可能相关的状态信息。这些信息可以显示在屏幕上,写入某个文件,或自动发送至开发者的电子信箱。这一类的工具让你迅速找出崩溃的源头,只消几分钟而不是几个小时。尤其是当故障发生在美工或策划同仁的机器上,而他们并不记得是怎样触发这次崩溃的时候。

对整个团队进行培训

虽然这并非一个能够编程实现的结构,但是你应当确定团队正确使用你创建的工具。请他们不要忽视错误对话框,确信他们知道怎样搜集信息从而不会丢失已找到的bug等等。花时间来培训测试员、美工、策划是值得的。

预防bug

关于调试的讨论,若没有一段文字指导如何在第一时间避免bug,便不能算完整。遵照这些指导方针,你或可避免编写出有bug的代码,或可在偶然之间发现自己不知不觉写出来的bug。不论是什么结果,都会最后帮你排除bug。

将编译器的警告级别(Warning level)调到最高,并指示将警告当作错误处理(Enable warnings as errors)。首先尽可能多地排除警告,最后才用#pragma将剩下的警告关闭掉。有时,自动类型转换及其它一些警告级的问题会带来潜在的bug。

使你的游戏能在多个编译器上编译通过。如果你确保游戏用多个编译器、面向多个平台都能编译通过,不同的编译器之间在警告和错误方面的差异将保证你的代码总体上更可靠。例如,编写任天堂GameCube™游戏机上的程序的人也可以在Win32下生成一个功能稍弱的版本。这也使你能够判断某个bug是否是具体平台所特有的。

编写你自己的内存管理器。这对于游戏机游戏是至关重要的。你必须清楚地知道正在使用那几块内存,并对内存上溢进行保护。由于内存溢出会带来一些最难查处的bug,首先确保不发生溢出是很重要的。在调试版本中使用预留的上溢和下溢保护内存块能使bug更早地暴露身份。对PC开发者来说,编写自己的内存管理器不是必须的,因为VC++里的内存系统功能已经很强了,而且还有像SmartHeap之类的好工具可以用来确定内存错误。

用assert来检验假设。在函数的开头加上assert来检验关于参数的假设(例如指针非空或范围检查)。另外,如果switch语句的default情况不应该被执行到,在其中加上assert。还有,标准assert可以被扩展以得到更好的调试性能[Rabin00b]。例如,让assert将调用堆栈打印出来是很有用的。

总是在声明变量的时候初始化它们。如果你无法在声明某个变量时赋予它一个有意义的值,那么就给它赋一个将来一眼就能认出它有没有被初始化过的容易辨认的值。有时候我们会用0xDEADBEEF、0xCDCDCDCD,或直接使用零。

总是将循环体和if语句体用花括号({})括起来。也就是将你所想的代码老老实实地包起来,使代码所实现的功能更直观。

变量起名要容易区分彼此。例如,m_objectITime和m_objectJTime看上去几乎一模一样。此类问题的典型例子是把“i”和“j”用作循环计数变量。“i”和“j”看上去很相似,你很容易把其中一个误认为另一个。可供选择的方法是,你可以用“i”和“k”,或者干脆使用更能描述其意义的名字。更多有关变量命名的认知差异的信息可以在[McConnell93]中找到。

避免在多处重复同样的代码。一模一样的代码同时出现在几个不同的地方,这是不利的。如果对其中一处代码作了改动,其余几个地方不一定也会被改动。如果看上去重复代码是必要的,重新考虑一下其核心功能,尽量将大多数的代码集中到一处。

避免使用那些中固定写死的“神奇数”(Magic numbers)。当单独一个数字出现在代码中,其意义可能是完全不为人知的。如果没有写注释,就无法让人理解之所以选择这个数字的理由,及这个数字代表什么。如果必须使用神奇数,将它们声明为有字面意义的常量或define。

测试的时候要注意代码覆盖率。在编写完一段代码之后,应验证它的每一个分支都能正确地执行。若其中一个分支从未被执行过,那么很可能其中正潜伏着bug。在测试不同分支的过程中你可能会发现这样一个bug,即其中某个分支是根本不可能被执行到的。这样的bug越早发现就越好。

结论

本文向你介绍了有效率地调试游戏所需的工具。调试有时候被形容为一门艺术,但那只是由于人们越有经验就做得越好。当你把五步调试法融会贯通,又学会了识别bug模式,并将自己的调试工具集成到游戏中,再形成自己在调试上的个人风格和绝招,很快地,你将熟练地有系统地追捕到并且消灭最困难的bug。最后再加上一点预防,我想你的游戏开发会一帆风顺,一个bug都没有也说不定。

致谢

感谢Scott Bilas和Jack Matthews,他们提了极好的建议,并为本文贡献了一些个人经验和智慧。人们看待调试有各自的角度,因此他们的意见在推敲本文建议的时候起了非常大的作用。

参考文献

  • [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.

posted on 2010-04-27 20:48 CrazyDev 阅读(411) 评论(0)  编辑 收藏 引用 所属分类: 通用技术


只有注册用户登录后才能发表评论。
网站导航: 博客园   IT新闻   BlogJava   博问   Chat2DB   管理


导航

统计

常用链接

留言簿(1)

随笔档案

文章分类

文章档案

C/C++

CEGUI

Friend Bog

Game Industry

Lua

OGRE

Other

搜索

最新评论

阅读排行榜

评论排行榜