向其他人解释bug
常常在你向他人解释bug的时候,你会追忆起一些步骤,并意识到一些遗漏或忘记检查的地方。与其他的程序员交流的益处还在于他们可能会精辟地提出别样的值得检验的假设。不要低估和他人交谈的作用,也永远不要羞于寻求他人的建议。你团队中的同事是你的伙伴,也是你与最有难度的bug战斗时最精良的武器之一。
和同事一起调试
这通常是很合算的,因为每个人在对付bug上都有自己的独门经验和策略。你也能学到新的技术,学会从从未尝试过的角度入手处理bug。让某人看着你进行调试,这可能是追捕bug最有效的方法之一。
暂时放下问题
有的时候,你已经如此接近问题,以至于无法再清楚全面地看待它。试试看改变一下环境,出门闲逛一下。当你放松,再回到问题上,你可能会有新的认识。有时候,当你决定让自己休息一下时,你的心里下意识地还在思考问题,过后答案就自然浮现了。
寻求外部的帮助
获得帮助有多种很好的途径。如果是在开发视频游戏,那么每家游戏机制造商都有一整班的人,他们将在你遇到麻烦的时候协助你。了解他们的联系方式。三大游戏机制造商现在都提供电话支持、电子邮件支持、和开发者互相帮助的新闻讨论组。
1.1.8 困难的调试情景和模式
消灭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以最快速度得到改正。
1.1.9 理解底层系统
有时为了找到一些难度很高的bug,你必须了解底层系统。仅仅通晓C或C++还远远不够。为了成为一个优秀的程序员,你必须懂得编译器是如何实现较高层次的概念,必须懂汇编语言,还必须了解硬件的细节(尤其是对游戏机游戏开发而言)。虽然认为高级语言掩盖了所有的复杂性并没有错,但是事实是当系统崩溃时你会感觉手足无措,除非你的理解深刻至抽象以下。若要进一步讨论高层抽象会如何造成隐患,请参见“The Law of Leaky Abstractions”[Spolsky02]。
那么,有哪些底层细节需要了解呢?就游戏而言,你应当了解如下事项:
· 了解编译器实现代码的原理。熟悉继承、虚函数调用、调用约定、异常是如何实现的。懂得编译器如何分配内存和处理内存对齐。
· 了解你所使用的硬件的细节。例如,懂得与某个特定硬件的高速缓存有关的问题(缓存中的数据何时会和主存储器中不同)、内存对齐的限制、字节顺序(Endianness,高位还是低位字节在前)、栈的大小、类型的大小(如整型int、长整型long、布尔型bool)。
· 了解汇编语言的工作原理,能够阅读汇编代码。这在调试器无法跟踪源代码时,例如在优化后的版本里查找问题时,很有帮助。
如不能牢牢掌握这些知识,在对付真正困难的bug的时候,你的致命弱点就会暴露出来。所以必须理解底层的系统,熟悉其规则。
1.1.10 增加有助于调试的基础设施
没有合适的工具的帮助,在真空中调试程序必定会很费劲。解决办法是走另一个极端,直接将好的调试工具整合到游戏里。下列工具能极大地帮助修理bug。
允许在运行中修改游戏变量
调试和重现bug时,在运行中修改游戏变量的值的功能是非常有用的。实现此功能的经典界面是通过游戏中的一个调试命令行接口(CLI,Command-Line Interface)用键盘修改变量。按下某个键后,调试信息覆盖显示在游戏屏幕上,提示你用键盘进行输入。例如,当你想把游戏里的天气改成狂风暴雨,你可以在提示下输入“weather stormy”。此类界面在调节和检查变量的值或特定游戏状态的时候也很好用。
可视化的AI诊断
在调试中,好的工具是无价之宝,而标准调试器在诊断AI问题的时候总是那么力不从心。各种调试器虽然在某个具体时刻能给出很好的深度,但在解答AI系统怎样随着游戏进行而变化这个问题上完全无用。解决办法是在游戏里直接构造能够监控任意角色的诊断数据的可视化版本。通过将文字和3D线条组合起来,一些重要的AI系统如寻路(Pathfinding)、警觉边界(Awareness Boundaries)、当前目标等,会较容易跟踪和查错[Tozour02][Laming03]。
日志的能力
通常,我们在游戏里有成堆的角色彼此交互和通讯,以得到非常复杂的行为。当交互失败,bug出现之时,关键在于能够记录导致bug的每个角色的个别状态及事件。通过对每个角色创建单独的日志,将带有时戳的关键事件记录下来,我们就可能通过检查日志来发现错误[Rabin
记录和回放的能力
像前面提到的那样,找出bug的关键在于可重现性。极致的可重现性需要通过记录和回放玩家的输入来实现[Dawson01]。对于那些概率很小的死机bug,记录和回放是找出确切原因的关键工具。但是为了支持记录和回放,你必须让游戏的行为是可预料的,也就是说对于同样的初始状态,同样的玩家输入必定会得到同样的输出结果。这并不意味着你的游戏对玩家来说是可以预知的,只是意味着你应当小心处理随机数的产生[Lecky-Thompson00][Freeman-Hargis03]、初始状态、输入等方面,并能在程序崩溃时将输入序列保存下来[Dawson99]。
跟踪存储分配事件
这样实现你的存储分配算子,使其对每次分配操作都进行全面的栈跟踪。通过不断地记录究竟是谁在申请内存,你将不再有内存泄漏问题需要解决。
崩溃时打印出尽可能多的信息
“事后调试(Post-mortem Debug)”是很重要的。程序崩溃时,理想的情况下,你会希望能够捕捉到调用堆栈、寄存器以及所有其它可能相关的状态信息。这些信息可以显示在屏幕上,写入某个文件,或自动发送至开发者的电子信箱。这一类的工具让你迅速找出崩溃的源头,只消几分钟而不是几个小时。尤其是当故障发生在美工或策划同仁的机器上,而他们并不记得是怎样触发这次崩溃的时候。
对整个团队进行培训
虽然这并非一个能够编程实现的结构,但是你应当确定团队正确使用你创建的工具。请他们不要忽视错误对话框,确信他们知道怎样搜集信息从而不会丢失已找到的bug等等。花时间来培训测试员、美工、策划是值得的。
1.1.11 预防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越早发现就越好。
1.1.12 结论
本文向你介绍了有效率地调试游戏所需的工具。调试有时候被形容为一门艺术,但那只是由于人们越有经验就做得越好。当你把五步调试法融会贯通,又学会了识别bug模式,并将自己的调试工具集成到游戏中,再形成自己在调试上的个人风格和绝招,很快地,你将熟练地有系统地追捕到并且消灭最困难的bug。最后再加上一点预防,我想你的游戏开发会一帆风顺,一个bug都没有也说不定。
1.1.13 致谢
感谢Scott Bilas和Jack Matthews,他们提了极好的建议,并为本文贡献了一些个人经验和智慧。人们看待调试有各自的角度,因此他们的意见在推敲本文建议的时候起了非常大的作用。
1.1.14 参考文献
[Dawson99] Dawson, Bruce, “Structured Exception Handling,” Game Developer Magazine (Jan 1999), pp. 52–54.
[Dawson01]
[Freeman-Hargis03] Freeman-Hargis, James, “The Statistics of Random Numbers,” AI Game Programming Wisdom 2,
[Laming03] Laming, Brett, “The Art of Surviving a Simulation Title,” AI Game Programming Wisdom 2,
[Lecky-Thompson00] Lecky-Thompson, Guy, “Predictable Random Numbers,” Game Programming Gems,
[McConnell93] McConnell, Steve, Code Complete: A Practical Handbook of Software Construction, Microsoft Press, 1993.
[Rabin
[Rabin00b] Rabin, Steve, “Squeezing More Out of Assert,” Game Programming Gems,
[Rabin02] Rabin, Steve, “Implementing a State Machine Language,” AI Game Programming Wisdom,
[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,








