作者:Steve Rabin, Nintendo of America Inc.
邮箱:steve@aiwisdom.com
译者:沙鹰
校对:万太平
|
调 |
试游戏程序,和调试任何其它软件的代码一样,都可能是一项艰巨的任务。一般说来,有经验的程序员能迅速地识别并纠正哪怕是最难的bug,但是对于新手而言,改bug可能更像是一件难以处理的,并且容易使人灰心丧气的任务。更糟的是,当你初步着手开始寻找bug的根源时,永远也不会知道究竟要花费多长时间才能找到。此时不必慌张,要像个训练有素的程序员,集中精力寻找bug。一旦你消化了本文介绍的技巧和知识,你将能够击退最“凶猛”的bug,重获对游戏的控制。
运用本文描述的五步调试法,困难的调试过程也可能变得简单一些。训练有素地运用该方法,将确保你花费最少的时间在寻找和定位每一个bug上。在你着手对付一些有难度的bug时,牢记一些专家技巧也很重要,因此本文也收集了一些有价值的、经过时间考验的技巧。然后本文还列出了一些有难度的调试情境,解释了当遇到一些特定的bug模式时应当做些什么。因为好的工具对于调试任何游戏都很重要,本文还将讨论一些特定的工具,你可将这些工具嵌入你的游戏中,从而帮助调试一些游戏编程所独有的调试情形。最后让我们回顾一些在前期预防bug的简单技术。
1.1.1 五步调试法
老练的程序员们具有一种超能力,能够迅速地、驾轻就熟地捕捉到即使是最不可思议的bug。他们总是神奇地、近乎直觉地知道错误源自何方,这一点实在令人敬畏。他们之所以显得天才,除了因为拥有丰富的经验外,还因为他们对于勘探和减少需排查的可能的原因的方法训练有素、融会贯通。下面给出的五步调试法旨在重现他们所熟练掌握的技能,助你在跟踪bug的问题上形成一种有系统的、且注意力集中的风格。
1.1.2 第一步:始终如一地重现问题
不论是什么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发生的环境,你就可以进行下一步骤,开始搜集有用的线索。
1.1.3 第二步:搜集线索
现在你能够可靠地使bug重现,下一步请你戴上侦探的鸭舌帽并搜集线索。每条蛛丝马迹都是排除一个可能的原因并缩短疑点列表的机会。有了足够的线索,bug的发源地会变得明显。因此为了明了每条线索并理解其潜台词,付出的努力是值得的。
不过有一点要注意,你应当总是在心里质疑每一条已发现的线索,是不是误导的,或不正确的。举例来说,我们被告知某个bug总发生在爆炸之后。尽管这可能是一条非常重要的线索,但它仍然可能是一个虚假的误导。时刻准备着放弃那些与收集来的信息冲突的线索。
还是以上面的bug报告为例,我们了解到游戏的crash发生在玩家使用投射类武器攻击某个特定的敌人营地的时候。究竟关于投射类武器和从远处攻击这两者,有什么特别之处?这是需要深思的重点,但也不要耗费太多时间思考。亲临其境,观察错误究竟是如何发生的,因为我们需要获取更多的确凿的证据,而留连于表面的线索是获得实际证据最不有效的方式。
在本例中,当我们进入游戏,并实际观察错误的发生时,我们会发现游戏死机发生在一个“箭”对象里,错误的症状是一个无效指针。进一步的检查显示,该指针本来是应当指向那个发射此箭的角色的。在此情况下,这支箭原本要向其发射者报告它击中了某个敌人,使发射者为该次成功的攻击获得一定的经验值。但尽管看上去找到了原因所在,我们对真实的潜原因仍然一无所知。我们必须首先找出是什么扰乱了这个指针。
1.1.4 第三步:查明错误的源头
当你认为收集到的线索已经够多时,就到了专注于搜索和查明错误的源头的时候了。有两个主要方法,第一个方法是先提出关于bug发生原因的假设,接着对该假设进行验证(或证明它不正确);第二个方法是较为系统的分而治之的方法。
方法1:假设法
搜集了足够的线索,你会开始怀疑有些什么事情导致了bug发生。这就是你的假设(Hypothesis)。当你能够在心里清楚地陈述这假设,你就可以开始设计一些能验证该假设,或反证证明该假设不正确的测试用例。
在我们的例子里,通过测试得出了以下线索和关于游戏设计的信息:
· 当一支箭射出的时候,该箭被赋予一个指向射箭人的指针。
· 当一支箭射中某个敌人的时候,将奖励送给射箭人。
· 游戏死机发生在一支箭试图通过一个无效指针向射箭人传回奖励。
我们的第一个假设可能是这样,指针的值在箭的飞行途中被损坏。基于此种假设,我们开始设计测试,并搜集数据来支持或推翻此原因。例如我们可以让每一支箭都将射箭人的指针注册到同一个备份区域。当我们又捕捉到crash时,可以检查备份下来的数据,看无效指针的值是否与这支箭在被射出的时候所赋予的值相同。
不幸的是在我们所举的例子里,最后发现这条假设是不正确的。备份的指针和导致游戏死机的指针具有相同的值。这样一来,我们就面临着一个抉择。是再提一个假设并进行验证,还是重头寻找更多的线索?现在让我们试着再提一条假设。
如果箭的发射人指针从没有被破坏(新线索),或许从箭射出到箭射中敌人的这段时间里,这个发射人被删除了。为了检查这点,让我们记录下敌人营地里死亡的每个角色的指针。当crash发生时,我们可以将出错指针和死亡并从内存中删除的敌人的列表进行比较。这样进行,很快就证实原因正是如此。射箭人死时,箭还在飞行途中。
方法2:分治法
两个假设使我们找出了bug,同时也表现了分而治之的概念。我们知道指针的值无效,但我们不知道它是因为值被修改过而损坏,或者这个指针在更早些的时候就已经无效。通过测试第一个假设,我们排除了两个可能性中的一个。像歇洛克·福尔摩斯(Sherlock Holmes)曾说过的:“……当你排除了不可能的情况后,其余的情况,尽管多么不可能,却必定是真实的。”[译注:绿玉皇冠案(柯南·道尔)]
有人将分而治之的方法简单形容为确定故障发生的时刻,并从输入开始回溯而发现错误。比如有一个并不会造成死机的bug,在某个时刻发生的初始错误将影响层层传递,最终导致故障发生。确定初始错误通常通过在所有输入分支上设置(有条件或无条件的)断点(Breakpoint)来进行,直到找到那个不能正常输出——也就是导致bug的输入。
当从故障发生的时刻开始回溯,你在局部变量和栈里面的上级函数中寻找任何异常。对于死机bug来说,通常你会试图寻找一个空值(NULL)或极大的数字值。如果是关于浮点数的bug,在栈上寻找NAN或极大的数字。
无论是对问题进行有根据的推测,检验假设,还是有系统地搜捕肇事代码,最终你会找到问题所在。在这个过程中你要相信自己,并保持清醒。本文接下来的部分将详细讨论一些可用于在这步骤中的专门技术。
1.1.5 第四步:纠正问题
当我们发现bug的真正根源,接下来要做的便是提出和实现一个解决方案。无论如何,修改必须对项目所处的阶段是恰当的。例如,在开发的后期,通常不能只为了纠正一个bug,就修改底部的数据结构或程序体系结构。参照开发工作所处的阶段,主程序员或系统架构师将决定应当进行何种类型的修改。在关键的时刻,个别工程师(初级或中级)常常做出不好的决定,因为他们没有全盘考虑。
此外需要特别注意的是,理想情况下,代码的编写者应当负责修改自己代码里的bug。不过如果必须修改别人的代码,你至少应当在进行修改前和原作者进行讨论。讨论将使你了解一些方面,例如在以往对于类似的问题是怎么处理的,如果实施你的方案提议可能会造成什么影响等。总之,在未彻底理解由别人编写的代码的上下文前,急于进行修改是非常危险的。
继续讨论我们的例子,死机源于一个指向了一个不复存在的对象的无效指针。对此类问题模式的一个好的解决方案是使用一层间接引用,使crash不再发生。通常,正是因为这个理由,游戏使用对象的句柄而不是直接指针。这将是一个合理的修改。
但是,如果游戏项目因为某个里程碑、或一个重要的演示版交付日迫在眉睫,而需要快速完成修改,你可能会倾向于对现有的特殊情况实现一个较为直接的修改方案(例如让射箭者在自身被删除的时候使其射出的箭中关于自身的指针失效)。如果在程序里打上了这一类的快速补丁(Quick Hack),你要记得将有关的注释文档化,以使其在这截止期限后被重新评估。开发中这样的情况屡见不鲜:快速补丁被人们遗忘,而在几个月后才造成了难于发现和解决的麻烦。
虽然看上去我们发现了bug并且确定了一种修改(使用句柄而非指针),探索其他可能造成同样问题出现的途径是很关键的。这虽然需要额外的时间,但是为了确保bug从根本上被消灭,而非只是消除了bug的一种表现形式,这努力是值得的。在我们的例子中,可能其他类型的投射类武器同样会造成游戏死机,但其它非武器对象的关系、甚至角色之间的关系也会受到同一个设计缺陷的影响。应找出所有这些相关的场合,使你的修改方案针对的是问题的核心,而非仅仅是问题的某一种征兆。
1.1.6 第五步:对所作的修改进行测试
解决方案实施后,还必须进行测试以确认它的确修补了错误。第一步要确保先前有效的重现步骤不会导致bug重现。通常应当让bug修改者以外的其他人,例如测试员,独立地确认bug被修复与否。
第二步还要确保没有新的bug被引入游戏。你应当让游戏运行一段可观的时间,确保所作的修改没有影响其它部分。这是非常重要的,因为很多时候,尤其是在项目开发周期接近尾声的时候,为修改bug所作的改动,会导致其他系统出错。在项目的后期,你还应当让主程序员或其他开发者来检视每一个修改,这额外的可靠性检验要保证新的修改不会对版本有负面影响。
1.1.7 高级调试技巧
如果你遵循以上所述的基本调试步骤,你应能找到并修复大多数bug。不过在你尝试提出假设、验证/否决一个候选的原因、或者尝试找出出错位置的时候,或许你会愿意考虑下列的技巧。
分析你的假设
调试程序的时候要保持心胸开阔是很重要的,而且不要作太多假设。如果你假设某些貌似简单的东西总是正确的,你可能就过早地缩小了搜索范围,从而完全错过了找出真相的机会。举例来说,不要总是想当然地认为你正在使用最新的软件或程序库。检验你的假设是否正确常常是值得的。
将交互和干扰最小化
有时,多个系统之间会以某种方式交互,这会使调试复杂化。试试看关闭那些你认为和问题无关的子系统(例如,关闭声音子系统),从而将系统之间的交互降到最低限度。有时候这有助于识别问题,因为原因可能就在你关闭的系统中,这样你就知道接下来该看那里。
将随机性最小化
通常,bug之所以难于重现,要归咎于从帧速率和实际随机数等方面引入的可变性。如果你的游戏没有采取固定的帧速率,试试看将“在每帧内流逝的时间”锁定为常量。至于随机数,可以关闭随机数发生器,或给它固定的常数作为随机发生种子,这样每次运行都会得到同样的序列。不幸地是,玩家会给游戏带来无法控制的显著的随机性。如果连这玩家带来的随机性也必须得到控制,请考虑将玩家的输入记录下来,从而能以可预料的方式将输入记录直接送入游戏[Dawson01]。
将复杂的计算拆分成几步进行
若某行代码含有大量计算,或许将这行拆分为多个步骤会有助于识别问题。例如,可能其中的某小段计算产生了类型转换错误,或某个函数并未返回你期望它返回的值,或运算进行的顺序并不是你所想的那样。这也使你能够检查每一步中间过程的计算。
检查边界条件
几乎我们中的每一个人都曾被经典的“差一错误”(Off-by-one)问题折磨过。要检查算法的边界条件,特别是在循环结构中。
分解并行计算
如果你怀疑程序里的竞争条件(Race Condition,不同的执行顺序会产生不同的结果),试试看将代码改写为串行的,然后检查bug是否消失。在线程中,增加额外的延迟,观察是否问题也随之变化。问题范围能缩小——若你能够确定问题是竞争条件,并通过试验将问题孤立出来。
充分利用调试器提供的工具
明白和懂得如何使用条件断点、内存watch、寄存器watch、栈,以及汇编级/混合调试。工具能帮你寻找线索和确凿的证据,这是识别bug的关键。
检查新近改动的代码
调试也可以通过源代码版本控制来进行,这真是一个令人惊讶的方法。如果你清楚地记得在某个日期前程序还是工作的,但是从某天开始就失灵了,你就可以专注于期间改动过的代码,从而较快地找到引入缺陷的代码段。至少,也可以将搜索范围缩小至某个特定子系统,或某几个文件。
另一个利用版本控制的方法是生成游戏在bug出现之前的一个版本。当你看不清问题的时候这尤其有用。将新老版本分别在调试器中运行,将值互相比较,你就可能找出问题的关键所在。








