调试
这是痛苦的事:
看着你自己的烦忧,并且知道
不是别人、而是你自己一人所致
——索福克勒斯:《埃阿斯》
自从14世纪以来,bug(虫子、臭虫)一词就一直被用于描述“恐怖的东西”。COBOL的发明者,海军少将Grace Hopper博士据信观察到了第一只计算机bug——真的是一只虫子,一只在早期计算机系统的继电器里抓到的蛾子。在被要求解释机器为何未按期望运转时,有一位技术人员报告说,“有一只虫子在系统里”,并且负责地把它——翅膀及其他所有部分——粘在了日志簿里。
遗憾的是,在我们的系统里仍然有“bug”,虽然不是会飞的那种。但与以前相比,14世纪的含义——可怕的东西——现在也许更为适用。软件缺陷以各种各样的方式表现自己,从被误解的需求到编码错误。糟糕的是,现代计算机系统仍然局限于做你告诉它的事情,而不一定是你想要它做的事情。
没有人能写出完美的软件,所以调试肯定要占用你大量时间。让我们来看一看调试所涉及的一些问题,以及一些用于找出难以捉摸的虫子的一般策略。
调试的心理学
对于许多开发者,调试本身是一个敏感、感性的话题。你可能会遇到抵赖、推诿、蹩脚的借口、甚或是无动于衷,而不是把它当做要解决的难题发起进攻。
要接受事实:调试就是解决问题,要据此发起进攻。
发现了他人的bug之后,你可以花费时间和精力去指责让人厌恶的肇事者。在有些工作环境中,这是文化的一部分,并且可能是“疏通剂”。但是,在技术竞技场上,你应该专注于修正问题,而不是发出指责。
|
提示24 |
|
|
Fix the Problem, Not the Blame |
|
bug是你的过错还是别人的过错,并不是真的很有关系。它仍然是你的问题。
调试的思维方式
最容易欺骗的人是一个人自己。
——Edward Bulwer-Lytton, The Disowned
在你开始调试之前,选择恰当的思维方式十分重要。你须要关闭每天用于保护自我(ego)的许多防卫措施,忘掉你可能面临的任何项目压力,并让自己放松下来。最重要的是,记住调试的第一准则:
|
提示25 |
|
|
Don’t Panic |
|
人很容易恐慌,特别是如果你正面临最后期限的到来、或是正在设法找出bug的原因,有一个神经质的老板或客户在你的脖子后面喘气。但非常重要的事情是,要后退一步,实际思考什么可能造成你认为表征了bug的那些症状。
如果你目睹bug或见到bug报告时的第一反应是“那不可能”,你就完全错了。一个脑细胞都不要浪费在以“但那不可能发生”起头的思路上,因为很明显,那不仅可能,而且已经发生了。
在调试时小心“近视”。要抵制只修正你看到的症状的急迫愿望:更有可能的情况是,实际的故障离你正在观察的地方可能还有几步远,并且可能涉及许多其他的相关事物。要总是设法找出问题的根源,而不只是问题的特定表现。
从何处开始
在开始查看bug之前,要确保你是在能够成功编译的代码上工作——没有警告。我们例行公事地把编译器警告级设得尽可能高。把时间浪费在设法找出编译器能够为你找出的问题上没有意义!我们需要专注于手上更困难的问题。
在设法解决任何问题时,你需要搜集所有的相关数据。糟糕的是,bug报告不是精密科学。你很容易被巧合误导,而你不能承受把时间浪费在对巧合进行调试上。你首先需要在观察中做到准确。
bug报告的准确性在经过第三方之手时会进一步降低——实际上你可能需要观察报告bug的用户的操作,以获取足够程度的细节。
Andy曾经参与过一个大型图形应用的开发。快要发布时,测试人员报告说,每次他们用特定的画笔画线,应用都会崩溃。负责该应用的程序员争辩说,这个画笔没有任何问题;他试过用它绘图,它工作得很好。几天里这样的对话来回进行,大家的情绪急速上升。
最后,我们让他们坐到同一个房间里。测试人员选了画笔工具,从右上角到左下角画了一条线。应用程序炸了。“噢”,程序员用很小的声音说。他随后像绵羊一样承认,他在测试时只测试了从左下角画到右上角的情况,没有暴露出这个bug。
这个故事有两个要点:
l 你也许需要与报告bug的用户面谈,以搜集比最初给你的数据更多的数据。
l 人工合成的测试(比如那个程序员只从下画到上)不能足够地演练(exercise)应用。你必须既强硬地测试边界条件,又测试现实中的最终用户的使用模式。你需要系统地进行这样的测试(参见无情的测试,237页)。
测试策略
一旦你认为你知道了在发生什么,就到了找出程序认为在发生什么的时候了。
|
再现bug(reproduction,亦有“繁殖”之意——译注) 不,我们的bug不会真的繁殖(尽管其中有一些可能已经到了合法的生育年龄)。我们谈论的是另一种“再现”。 开始修正bug的最佳途径是让其可再现。毕竟,如果你不能再现它,你又怎么知道它已经被修正了呢? 但我们想要的不是能够通过长长的步骤再现的bug;我们要的是能够通过一条命令再现的bug。如果你必须通过15个步骤才能到达bug显露的地方,修正bug就会困难得多。有时候,强迫你自己隔离显示出bug的环境,你甚至会洞见到它的修正方法。 要了解沿着这些思路延伸的其他想法,参见无处不在的自动化(230页)。 |
使你的数据可视化
常常,要认识程序在做什么——或是要做什么——最容易的途径是好好看一看它操作的数据。最简单的例子是直截了当的“variable name = data value”方法,这可以作为打印文本、也可以作为GUI对话框或列表中的字段实现。
但通过使用允许你“使数据及其所有的相互关系可视化”的调试器,你可以深入得多地获得对你的数据的洞察。有一些调试器能够通过虚拟现实场景把你的数据表示为3D立交图,或是表示为3D波形图,或是就表示为简单的结构图(如下一页的图3.2所示)。在单步跟踪程序的过程中,当你一直在追猎的bug突然跳到你面前时,这样的图远胜于千言万语。
即使你的调试器对可视化数据的支持有限,你仍然自己进行可视化——或是通过手工方式,用纸和笔,或是用外部的绘图程序。
DDD调试器有一些可视化能力,并且可以自由获取(参见[URL 19])。有趣的是,DDD能与多种语言一起工作,包括Ada、C、C++、Fortran、Java、Modula、Pascal、
|
图3.2 一个循环链表的调试器示例图。箭头表示指向节点的指针
|
Perl以及Python(显然是正交的设计)。
跟踪
调试器通常会聚焦于程序现在的状态。有时你需要更多的东西——你需要观察程序或数据结构随时间变化的状态。查看栈踪迹(stack trace)只能告诉你,你是怎样直接到达这里的。它无法告诉你,在此调用链之前你在做什么,特别是在基于事件的系统中。
跟踪语句把小诊断消息打印到屏幕上或文件中,说明像“到了这里”和“x的值 = 2”这样的事情。与IDE风格的调试器相比,这是一种原始的技术,但在诊断调试器无法诊断的一些错误种类时却特别有效。在时间本身是一项因素的任何系统中,跟踪都具有难以估量的价值:并发进程、实时系统、还有基于事件的应用。
你可以使用跟踪语句“钻入”代码。也就是,你可以在沿着调用树下降时增加跟踪语句。
跟踪消息应该采用规范、一致的格式:你可能会想自动解析它们。例如,如果你需要跟踪资源泄漏(比如未配平(unbalanced)的open/close),你可以把每一次open和每一次close 记录在日志文件中。通过用Perl处理该日志文件,你可以轻松地确定
|
坏变量?检查它们的邻居 有时你检查一个变量,希望看到一个小整数值,得到的却是像0x6e69614d这样的东西。在你卷起袖子、郑重其事地开始调试之前,先快速地查看一下这个坏变量周围的内存。这常常能带给你线索。在我们的例子中,把周边的内存作为字符进行检查得到的是: 20333231 6e69614d 2c745320 746f4e0a 1 2 3 M a i n S t , \n N o t 2c6e776f 2058580a 31323433 00000a33 o w n , \n x x 3 4 2 1 3\n\0\0 看上去像是有人把街道地址“喷”到了我们的计数器上。现在我们知道该去查看什么地方了。 |
有问题的open是在哪里发生的。
橡皮鸭
找到问题的原因的一种非常简单、却又特别有用的技术是向别人解释它。他应该越过你的肩膀看着屏幕,不断点头(像澡盆里上下晃动的橡皮鸭)。他们一个字也不需要说;你只是一步步解释代码要做什么,常常就能让问题从屏幕上跳出来,宣布自己的存在。
这听起来很简单,但在向他人解释问题时,你必须明确地陈述那些你在自己检查代码时想当然的事情。因为必须详细描述这些假定中的一部分,你可能会突然获得对问题的新洞见。
消除过程
在大多数项目中,你调试的代码可能是你和你们团队的其他成员编写的应用代码、第三方产品(数据库、连接性、图形库、专用通信或算法,等等)、以及平台环境(操作系统、系统库、编译器)的混合物。
bug有可能存在于OS、编译器、或是第三方产品中——但这不应该是你的第一想法。有大得多的可能性的是,bug存在于正在开发的应用代码中。与假定库本身出了问题相比,假定应用代码对库的调用不正确通常更有好处。即使问题确实应归于第三方,在提交bug报告之前,你也必须先消除你的代码中的bug。
我们参加过一个项目的开发,有位高级工程师确信select系统调用在Solaris上有问题。再多的劝说或逻辑也无法改变他的想法(这台机器上的所有其他网络应用都工作良好这一事实也一样无济于事)。他花了数周时间编写绕开这一问题的代码,因为某种奇怪的原因,却好像并没有解决问题。当最后被迫坐下来、阅读关于select的文档时,他在几分钟之内就发现并纠正了问题。现在每当有人开始因为很可能是我们自己的故障而抱怨系统时,我们就会使用“select没有问题”作为温和的提醒。
|
提示26 |
|
|
“Select” Isn’t Broken |
|
记住,如果你看到马蹄印,要想到马,而不是斑马。OS很可能没有问题。数据库也很可能情况良好。
如果你“只改动了一样东西”,系统就停止了工作,那样东西很可能就需要对此负责——直接地或间接地,不管那看起来有多牵强。有时被改动的东西在你的控制之外:OS的新版本、编译器、数据库或是其他第三方软件都可能会毁坏先前的正确代码。可能会出现新的bug。你先前已绕开的bug得到了修正,却破坏了用于绕开它的代码。API变了,功能变了;简而言之,这是全新的球赛,你必须在这些新的条件下重新测试系统。所以在考虑升级时要紧盯着进度表;你可能会想等到下一次发布之后再升级。
但是,如果没有显而易见的地方让你着手查看,你总是可以依靠好用的老式二分查找。看症状是否出现在代码中的两个远端之一,然后看中间。如果问题出现了,则臭虫位于起点与中点之间;否则,它就在中点与终点之间。以这种方式,你可以让范围越来越小,直到最终确定问题所在。
造成惊讶的要素
在发现某个bug让你吃惊时(也许你在用我们听不到的声音咕哝说:“那不可能。”),你必须重新评估你确信不疑的“事实”。在那个链表例程中——你知道它坚固耐用,不可能是这个bug的原因——你是否测试了所有边界条件?另外一段代码你已经用了好几年——它不可能还有bug。可能吗?
当然可能。某样东西出错时,你感到吃惊的程度与你对正在运行的代码的信任及信心成正比。这就是为什么,在面对“让人吃惊”的故障时,你必须意识到你的一个或更多的假设是错的。不要因为你“知道”它能工作而轻易放过与bug有牵连的例程或代码。证明它。用这些数据、这些边界条件、在这个语境中证明它。
|
提示27 |
|
|
Don’t Assume it – Prove It |
|
当你遇到让人吃惊的bug时,除了只是修正它而外,你还需要确定先前为什么没有找出这个故障。考虑你是否需要改进单元测试或其他测试,以让它们有能力找出这个故障。
还有,如果bug是一些坏数据的结果,这些数据在造成爆发之前传播通过了若干层面,看一看在这些例程中进行更好的参数检查是否能更早地隔离它(分别参见120页与122页的关于早崩溃及断言的讨论)。
在你对其进行处理的同时,代码中是否有任何其他地方容易受这同一个bug的影响?现在就是找出并修正它们的时机。确保无论发生什么,你都知道它是否会再次发生。
如果修正这个bug需要很长时间,问问你自己为什么。你是否可以做点什么,让下一次修正这个bug变得更容易?也许你可以内建更好的测试挂钩,或是编写日志文件分析器。
最后,如果bug是某人的错误假定的结果,与整个团队一起讨论这个问题。如果一个人有误解,那么许多人可能也有。
去做所有这些事情,下一次你就将很有希望不再吃惊。
调试检查列表
l 正在报告的问题是底层bug的直接结果,还是只是症状?
l bug真的在编译器里?在OS里?或者是在你的代码里?
l 如果你向同事详细解释这个问题,你会说什么?
l 如果可疑代码通过了单元测试,测试是否足够完整?如果你用该数据运行单元测试,会发生什么?
l 造成这个bug的条件是否存在于系统中的其他任何地方?
相关内容:
l 断言式编程,122页
l 靠巧合编程,172页
l 无处不在的自动化,230页
l 无情的测试,237页
挑战
l 调试已经够有挑战性了。








