Windows程序调试----第一部分 调试策略----第1章 调试的过程

时间:2021-06-17 21:58:22

第一部分调试策略

1章调试的过程

    虽然可能存在无数种错误,与此对应,潜在的也存在无数种调试策略,但是大多数的错误还是可以通过普通的调试过程来消除的。本章就来介绍这些过程。

1.1错误的调试五步曲

    首先让我们来看一下一个不太有效的调试过程。像Elisabeth Kuble-Ross在她的书《On Death and Dying》中提到的悲哀五步曲那样,低效率调试过程的五步曲是:否认、愤怒、交涉、沮丧、容忍。我们来分析一下这些阶段,以便找到调试过程中避免这些情绪的方法。

    1. 否认。程序人员拒绝承认错误的存在,或者否认这些错误是由他的代码引起的。症状:“这种错误这么简单,不可能存在”,或者“这显然是Windows或编译器引起的错误。”

    2. 愤怒。程序员对那些发现他的错误的人怒气相向。症状:“那些测试人员为什么总是挑我的毛病,他们为什么不去看看系统其他部分的错误呢”,或者“那些搞测试的家伙们就不能做点更好的事情吗?不要总找这些微小的问题。他们显然不知道什么是真正的错误”。

    3. 交涉。寻找错误几乎令程序员绝望。他开始发狂地许诺将来改掉一切坏的编程习惯。症状:“如果我能找到这个错误,我保证从今以后经常使用断言语句。”当然,一旦错误找到了,这种承诺就不会遵守了。

    4. 沮丧。程序员开始感到沮丧,甚至考虑更换工作了。症状:“我本应该做个写技术资料的作者,至少他们不用调试任何东西。”

    5. 容忍。程序员开始接受这样一个事实,就是他根本不可能消除这个错误。症状:“OK,我们还是把它记录在案,然后说这是一个小特色。存在一些未决的小问题并不是那么糟糕的事情,至少它会激发用户去升级。”

    这几步叙述了一个失败的调试过程,其中每一歩都在缩小程序员消除错误的能力。对那些帮助你寻找错误的人发火是更具破坏性的。不幸的是,这个过程正是调试技术不高的程序员经常采用的,结果当然是除了悲伤什么也不会得到。

    尽量在你的调试过程中避免这五种情绪。

1.2正确的调试五步曲

    好啦,下面介绍高效率的调试五步曲,看一看正确的调试是应该怎样进行的。

    1. 确定错误的存在。有人(程序员,测试员,或用户)确定存在某个错误。错误可能是通过测试、运行调试代码、使用开发工具或者检査源代码等等而发现的。

    2. 收集错误信息。报告错误存在的人员和程序员一起,收集一些附加信息,有助于分析错误。可以通过运行程序、检查源代码或使用调试工具来收集这些信息。附加信息包括:错误是否可以重现,重现错误需要怎样做,重现错误需要的数据和程序设置,重现错误需要的系统和软件配置。

    3. 分析错误信息。通过分析错误信息,程序员可以确定代码中的问题和问题的裉源,以及要消除错误应该对源代码进行什么修改。

    4. 消除错误。程序员通过修改源代码来消除错误。

    5. 修改的验证。程序员需要验证新的代码消除了错误并且没有产生新的错误,并需要检查那些可能产生类似错误的相关代码。

    本章的剩余部分分析了每一步的细节,介绍了一些可以用于提高调试效率的小技巧。

1.3      确定错误的存在

    正如在简介中定义的,错误就是实现上的小缺陷,而测试是积极地检测错误的过程。于是,大多数错误都是通过程序员的测试、内部质量保证测试人员和α测试员的测试或者外部β测试员的测试发现的。我坚持认为,获得无错误软件的最有效的方法,就是由程序员来尽力发现自己的错误。不幸的是,错误常常会从这个过程中溜掉,所以错误也常常会由最终用户发现。如果自己没有找到错误,程序员经常会收到存在错误的通知,即错误报告单。

    除测试之外,错误还可以直接通过调试过程检测到,调试过程包括通过代码来预防和掲示错误。你可以充分利用C++编译器的优点,避免那些常常是错误来源的语言陷阱(参考第2章“编写便于调试的C++代码”),这样可以预防错误。最普通的调试技术是使用断言语句(参考第3章“使用断言”)、跟踪语句(参考第4章“使用跟踪语句”)、异常(参考第5章“使用异常和返回值”)、检测资源泄漏的方法(参考第9章“内存调试”)揭示错误。当然,你也可以直接通过检査源代码来发现错误。

1.4      收集错误信息

    发现错误是调试过程最关键的一步,但检测到错误只是类似于发现了冰山的一角:你看到的常常不是整个情况,还有许多是藏在底下的。为了成功地消除错误,通常需要收集足够多的信息。当然,你所需要的信息的数暈,取决于错误本身的特点。比如,你可能需要为调试程序错误而准备很多的信息,却几乎不需要什么信息就可以调试用户界面的错误;取得准确的错误信息是成功而高效地调试的关键

    系统崩溃是最难分析的一类错误,因此它们也就需要最多的信息。因此,本节重点介绍了崩溃错误的信息收集。你可以据此调整所需信息的数量,以适应那些简单的错误。

    测试人员提供的信息(错误报告)

    如果其他人在你的程序里找到了一个错误,你常常会收到某种错误报告单,以此来获得错误信息。分析不同种类的错误还需要各种不同数目的信息,这就要求,除了为测试人员提供错误报告表单,还需要为如何填写错误报告表单提供详细的说明。毕竟测试人员没有义务对调试需要哪种特定的信息来进行推敲。

    你需要提醒你的测试人员,他们需要额外地在错误报告表单里提供有用的信息。很多测试人员错误地认为他们唯一的作用就是发现错误。其实,他们的作用除了发现错误,还要为程序员重现错误和最终消除错误提供足够的信息。错误检测出来后并没有被立即消除是一种很大的时间浪费。含糊的错误报告,如“在我单击它后程序崩溃了”,或者“程序偶尔会有奇怪的表现”,这些通常都不能帮助正确地消除错误。

    错误重现

    为非平凡的错误准备错误报告的第一步,就是测试人员必须重现错误,考虑错误出现和不出现的特定环境(在这里,所谓的非平凡错误,就是那些不稳定重现的错误。平凡的错误常常是很容易重现的)。测试人员应该使得错误像它第一次出现的那样重现,然后寻找可以重现错误的最简单的测试用例,这个最简单的测试用例常常不是错误首次出现的那个测试例子。测试人员应该试验各种各样的简单的测试用例,来观察不同的现象。也就是,尽可能查找那些类似的测试用例,而这些用例并不会导致错误发生。这些信息将帮助程序员明白在什么样的特定环境下会产生错误。

    这些信息量看来很大,大得以至于一份错误报告容纳不了,尤其是来自客户的错误报告。所以,当你不能收到所有这些信息时不用惊奇。但是,测试人员必须在重现错误的过程里起到积极作用。毕竟,如果测试人员都不能在发现错误之后立即重现它,程序员就更不可能了。错误报告里用于重现错误的信息是很重要的,完全值得仔细推敲。

    测试人员必须理解检测到错误后立即重现错误的重要性。对于重现错误来说,刚发现错误的时候是最合适的时间,没有比这再合适的了。测试人员重现错误的时间拖得越久,忘掉的可能性就越大。如果你的测试人员在重现错误的过程中总是遇到困难,你可能就需要向他们推荐使用录像带来记录他们确切的试验步骤了。

    错误报告表单的信息

    准备一份错误报告表单是获得良好的错误信息的必由之路。错误报告表单至少应该包含下列信息:

l  当天的日期。

l  测试者的名字、公司、和联系信息。

l  程序名和版本号。很多时候还需要相关的动态链接库的版本。

l  系统配置信息。提供硬件和系统软件的配置信息,包括Windows的版本和service pack的版本。

l  错误类型。错误类型有:系统崩溃、程序崩溃、程序失效、可用性问题、安装错误、文档和帮助方面的问题、产品问题以及建议。

l  问题描述。描述观察到的错误,以及其他相关信息。比如,有用户窗口界面的程序,应该对出现错误的窗口以及屏幕上显示的信息进行描述,包栝详细的错误框信息、断言语句失败的消息框内容或者Windows崩溃的对话框信息。

l  重现错误的步骤。包括错误重现所需的程序设置和数据的描述。如果问题是不可重现的或反复无常的,应该有注释说明。

l  添加附件的详细说明。附件可以包括:屏幕快照、Dr.Watson文件、测试数据文件等等。

    有趣的是,虽然严重程度从来就不是测试人员决定的,大多数错误报告中却都有“严重程度”这样一个条目。我不愿意使用“严重程度”,但只有少数人同意这个观点。常常看到大多数时间都浪费在争论问题的严重程度上。越是简单的错误,就越容易引起争论。而这类简单的错误,大多是很容易修正的。当然,这个方法可以使未决的错误简单而且容易管理,但这个方法只有在错误列表很小时比较有效。一旦有数百的,甚至数千的未决错误,这个方法就不实用了。我认为只需简单地决定错误是否需要关注就好了。这是另一个方法,如果测试人员觉得对于这个错误在下一个版本中是否得到修订并不关心,就可以选择不关注。如果选择了关注,那就没有理由不立即消除它了。

    错误表单的详细说明

    错误表单需要详细的说明书,用于指导测试人员正确填写。这些说明可以帮助测试人员在字面意思之外,更好地理解你想要的信息。好的说明书应该包括以下内容:

l  列出你想要的特定信息,按错误类型分开说明。比如,什么情况下你需要重现错误的步骤,什么情况下你需要知道系统的详细信息。

l  鼓励测试人员找到重现错误的最小测试用例。需要指明,这些不仅仅有助于程序员调试错误,而且也就只需要测试人员在错误报告里写入少量的其他相关信息了。

l  就如何提供细节信息提出建议,并给出好的和坏的例子。至少,重现错误的步骤里面应该包括,测试人员想执行的仟务、特定的数据和程序选项的选择以及引起这个问题的特定程序。比如,如果一个错误是由一个命令引起的,那这个命令是如何提出的呢?是通过菜单条,工具条,设备菜单,还是键盘?是否指出这些细节对定位错误的帮助有很大不同。但是,指出这些细节并不意味着报告就要冗长,如果测试人员能够使用简洁的一句话来描述清楚这些问题,那是最好的了。

l  就如何重现错误提供建议。描述在一发现错误时你希望测试人员做什么。然后就如何重现错误提出指导。比如,测试人兄应该重启程序,然后重新来一次吗?或者测试人员应该重启Windows,然后重新来一次吗?

l  就如何对付程序崩溃等很少接触到的意外情况而提供详细的指导。至少,测试人员应该详细记录这种崩溃的类型(比如访问违例、堆栈溢出、除零错)和出错的地址,如果程序是在Windows 98的环境下崩溃的,测试人员应该单击“详细信息”按钮。拷贝细节信息(不是人工的,而是使用“全选”和“复制”按钮),将这些文字加入到错误报吿中。

l  就出现断言失败时应做的工作提出指导。测试人员应该写下消息框中的所有内容吗,他们应该选择哪一个按钮?通常,为了得到更多的信息,你应该建议测试人员选择“忽略”,继续运行程序直到系统崩溃或出现同一行的相同断言失败信息为止。

l  就如何使用Windows 98Windows 2000下的Dr.Watson提出详细的说明。详细说明你要使用的Dr.Watson的选项,问题描述框里应该填写的内容以及如何将Dr.Watson的日志文件附加到错误报告中去。对Windows 98的用户来说,需要说明如何在启动组里加入Dr.Watson的启动快捷方式,这样就可以在启动时自动装载Dr.Watson了。对Windows 2000 的用户来说,需要指出是否要求附加一个压缩文件Used.dmp,它也是Dr.Watson产生的。除非你有更好的理由,否则你最好指示用户在Dr.Watson启动的情况下进行测试。注意不同版本的Dr.Watson有不同的行为和不同的参数(关于使用Dr.Watson的描述请参见第6章“在Windows中调试”)

l  对Windows 2000崩溃出现蓝屏(又叫死亡蓝屏)时的情况,给出详尽的说明。他们是否应该至少写下屏幕上出现的东西,是否应该记录下其他信息吗,是否应该附上MinidumpMemory.dmp文件?你可以通过系统控制面板的设置,使得发生这种情况时系统自动将蓝屏上的信息记录在MinidumpMemory.dmp文件中。单击“高级”标签,然后单击“启动和故障恢复”,接着选择“小内存转储(64KB)”或者“完全内存转储”。这样就可以在蓝屏重启后得到一个转储内存的文件。通常,可以选择小内存转储,完全内存转储文件非常大,因为它们是逐字的转储内存(虽然已经做了一些压缩)。同样地,你也可以通过www.sysinternals.com上的BlueSave实用工具来记录蓝屏上的信息。

l  至少给出一个正确的填写错误报告单的例子。注意一个有趣的问题,有时候测试人员会写出可能的解决方案,而不是碰到的问题。如果将可能的解决方案附加在问题后面,将是有利的,相反,如果不描述柯题而是汇报解决方案,那就是有害的了。比如,测试人员没有汇报某个小部件不正常工作,而是写下了他们认为应该如何更正这个问题。尤其是当建议的解决方案并不是太好或者潜在的问题不是那么明显时。这种方法会导致混乱。你可以制定错误报告表单,将它的重点放在汇报问题上,通过这种方让来阻止这个情况的产生。错误报告表单应该包括详细的问题描述部分,还应该包括一些说明,明确地表示你对错误更感兴趣而不是对错误解决方案感兴趣。

    确定错误报告表单的重点是放在汇报问题上。不鼓励测试人员汇报可能的解决方案。

    问题术语

    一个最不利于调试可行性的问题是,你常常浪费很多小时追踪一个并不存在的错误。这种情况的产生有很多原因,但是典型的情况是用户在错误报告中使用不正确的术语的结果。

    术语问题在很多情况下可能导致追踪不存在的错误。比如,一些测试人员将出现各种看起来不正常的对话框叫做崩溃(他们也可能使用其他字眼,如陷阱、炸弹、缺陷等)。有的测试人员可能会将断言消息框或者任何其他的带有停止标记和中止、重试、忽略按钮的消息框,叫做崩溃。失败的断言确切来说不是崩溃,它们的调试方法完全不同。一些测试人员搞不清硬盘空间和内存。他们可能会汇报一些类似于“我运行这个程序,用光了内存”,这里他们可能通过“内存”来表达“磁盘空间”。

    下面介绍一个我最近碰到的事情,来说明为什么这种混淆能够导致时间的浪费。有人报告了一个崩溃,我进行远程调试,增加了一些用于提供附加信息的断言语句,请测试人只再运行一次,测试员说程序还是崩溃了。这完全出乎我的预料。幸运的是,我没有恐慌。然后我请测试人员读下屏幕上的信息,他读道:测试断言失败。

    对这种情况的解决方法是首先确定测试人员汇报的是不是屏幕上的确切的显示、如果你希望和测试人员保持长期的联系,最好花力气培训一下他们的术语问题。但是,在你希望和鼓励测试人员提供确切的错误报告的同时,你必须意识到他们有时候不会这样。因此,最终的解决方案是,意识到测试人员不是像你一样的计算机学者,他们的错误报巧中很有可能有误导的术语。如果你不能肯定错误报告中的语言,不要迟疑,直接向测试人员询问就可以了。

    注意测试人员可能在错误报告中使用令人误解的术语。

    编程人员提供的信息

    当你拿到错误报告之后,需要通过重现错误来收集附加信息。验证错误报告,填写遗漏的信息,尽量减少重现错误的步数,寻找诊断错误的线索。你可能还需要回顾源代码,或者使用调试工具运行程序,来寻找附加的线索。如果不能重现错误,你可以要求测试人员增加信息,重新提交错误报告。如果可能,你可以请测试人员在你面前重现错误,或者通过电话完成这个工作。

1.5分析错误信息

    完成收集错误信息之后,就是分析它,决定需要修改那些代码的时候了。这个分析的步骤中,你需要挖掘除了错误之外附加的四种相关代码信息。要挖掘的佰息有:

l  错误代码。测试人员看到的错误,也就是最后一步报告的情况。

l  症兆代码。可能导致观察到的失败的细节代码。

l  原因代码。症兆的潜在原因。

l  解决代码。需要更改的,可以消除错误而不引入新错误的明确的代码。

    人们所说的隔绝错误,其实就是获得这些信息的过程。

    像你了解的那样,从错误本身收集信息到最终的代码修改,有好几个步骤。对于简单的问题,症兆代码、原因代码和解决代码常常是相同的,或明显相关的。对于那些非常隐伏的错误,症兆只是问题的载体,那三类代码常常是没什么明显的关系。这种情况下,如果你只试图通过修改症兆代码来消除错误,那么真正的错误仍然存在,你只是遮掩了症兆而已。为了真正地消除错误,必须跟踪进入代码,决定那些引起错误的真正原因。在这种情况下,发现真正的错误原因比发现错误本身要难很多。

    问题的症兆只是最简单的错误的原因。对于比较难的错误,必须跟踪进去寻找内在原因。

    举一个典型的例子,如某个程序崩溃的原因是,将空指针作为参数传递给一个过程。这个错误可能是访问违例。症兆代码可能会是接受无效参数的函数,但这并不是原因。必须找出为什么这个指针被错误地赋值为空。如果是一个简单的错误,可能是因为忘记了对指针赋值,或者对错误的变量赋了值。如果是一个复杂的错误,可能已经正确地对指针进行了赋值,但是在问题函数调用之前,这个指针被改变了。这个意外的改变可能是由于调用了一个函数,它无心地改变了指针的值,或者内存覆盖了。

    逬行分析

    有两个方法用于进行错误信息的分析:使用调试器,或者使用你的头脑。

    使用调试器

    Visual C++的调试器是容易使用的、高效的、多产的工具。这样,使用调试器就是分析大多数错误的首选。除了少数例外(常常与海森堡不确定原理相关,这在本章的后面会介绍),使用调试器是最佳方案,通过直接观察就可以了解到底发生了什么。你可以设置断点,跟踪症兆代码、原因代码和解决代码,观察执行流(特别是调用栈),检査变量的值,检査线程信息(对多线程程序来说)。有关使用Visual C++调试器的详细介绍将在第7章“使用Visual C++调试器调试”。

    使用调试器,通过观察就了解代码做了些什么。

    使用你的头脑

    Glenford Myers的《The Art of Software Testing》一书中,他表示他更喜欢仔细思考错误产生的原因,而不是使用调试器。他说:“这种粗野的方法的主要问题在于它们忽略了思考的过程......一个优秀的程序调试人员应该是不需要走进计算机就能正确地指出大多数错误,”显然,Myers说这些话时他还没有在Windows下面用C++编过程序。使用这些技术的现代程序开发太复杂,不使用调试器很难分析。所以,对太多数错误来说,使用调试器还是首选。

    一些错误可能很难在调试器中重现。对于这种错误,更可行的方法是,借助于调试工具,如断言、跟踪和日志文件,补充一些可供推断的迹象,使用头脑,仔细考虑问题所在

    使用调试器的最大问题在于,你会变得很依赖调试器。特别是在面对那些难以在调试器中重现的错误,或者,更甚者根本不能重现的错误时,这个问题就会很重要。在这种情况下,试图在调试器中跟踪错误出现的原因非常困难。这时,另一个选择就是用你的头脑,仔细考虑问题所在。毕竟,你知道源代码,并且知道它应该如何工作,所以当然可以在头脑中运行它们,判断这些代码真实的执行情况。相对于使用调试器目击错误的产生,通过迹象推断来重构错误场景更加有效。当然,你也可以借助于调试工具,如断言、跟踪和日志文件,补充一些可供推断的迹象。

    即使你在使用调试器,动脑也很重要。要成功地从症状推测出起因和解决方案,需要知道代码是怎样运行的。如果你不理解代码,那么在第一次出现错误时,正确地识别它几乎是不可能的。

    得出结论

    尽管简单错误的解决方法只是通过检查,或通过简单的推理。但是对于那些复杂的错误,可能常常会用到一些逻辑推理来得出正确的(至少是有道理的)结论。在本节中,我介绍两种传统的逻辑推理方式;演绎推理和归纳推理。同样也会介绍几种谜题的解决策略,可以有效地帮助解决复杂的调试难题。

    演绎推理

    演绎推理出的断言是从正确的前提肯定可以推得的结论。比如,有这样一个前提“怫蒙特的冬天常常很冷”,如果你冬天在佛蒙特,你可以推论出天气一定很冷。如果前提是真的,结论一定是真的。实际上,在演绎推理中,结论只是一个前提更细节的陈述。

    演绎推理经常在从一般推理到特殊时使用。你可以使用演绎推理,将现存的普遍的关于世界的知识(基于事实或规则)应用到特殊情况,得出结论或作出预测。在调试领域,你可以运用错误、Windows, C++MFCATL等等的信息来得出结论。比如,发现一个整数除零的异常,一定是运行了整数除法的一段代码,分母是零。相反地,如果你没有发现整数除零的异常,那分母一定没有零。虽然这些单独的结论几乎没有什么意义,但足如果将几个从源代码和错误本身得到的这种类似的演绎推理结合起来,就能够对跟踪问题有很大的帮助。

    排除的过程

    排除的过程是在演绎推理的基础上得出结论的一个众所周知的方法。排除的过程有以下几步:

    1. 收集有关的事实。

    2. 确定可能的解释。

    3. 排除与事实不一致的解释。

    4. 精炼剩下的解释。

    5. 用经验来验证剩下的解释。

    6. 假设剩下的解释是真的。

    排除的过程就是演绎的一种形式,因为它是从事实得出结论的。这个过程可能看着挺熟悉,它就是大多的犯罪推理家所使用的问题解决技术,如歇洛克*福尔摩斯。福尔摩斯的作者柯幽道尔曾说过;“当你排除所有的不可能后,剩下来的那些,虽然不大可能,但或许是对的。”

    如果一个错误只有几种可能的解释,即使不使用调试器,排除过程也能帮助你迅速的确定问题。

    归纳推理

    演绎推理的问题在于,所有的结论都是基于已存在的知识的,因此你不能得到新知识。归纳推理允许在实验的帮助下得出新知识,这就是它被科学家广泛使用的原因。归纳推理出的断言是从正确的前提可能得出的结论。比如,有个前提是“佛蒙特的冬天常常很冷”,那么如果在冬天,如果有个地方很冷,你可以推出这里可能是佛蒙特。这个结论不能由演绎推理得到,但是它能在实验的帮助下推出。实验越是确定性的,结论的正确性越是可以保记。继续这个例子,如果你驾车顺着高速公路前进,看见了许多山脉,而且大多数地段都是绿色的(佛蒙特是绿色山脉洲),意识到沿路两边没有广告牌(在佛蒙特,广告牌是违法的),那就有很大的可能性你是在佛蒙特了 (前提是,你在美国)。但是注意到如果只用演绎推理,由前提得出这个结论是没有道理的。

    归纳推理经常用于从特殊推出一般。使用归纳推理从特殊前提(基于事实,规则或实验)得到普遍的知识,从而可以得出结论或作出预测。在调试领域,你可以将从特殊实例得出的知识推广到普通实例中。比如,使用Spy++工具观察到正常工作的对话框在创建时收到一个消息,可以归纳出,所有正常工作的对话框在创建时大概都会收到同样的消息。此外,注意到这个结论有可能是错误的。归纳是一个强有力的工具,可以用于创造那些不存在的知识。调试程序时,特别是在技术文档很少的情况下,这是个非常有用的方法。

    科学方法

    科学方法是一个基于归纳推理得出结论的有名的方法。科学方法使用下面几个步骤:

    1. 观察事实,寻找模式。

    2. 提出一个可以验证的假设,这个假设解释了上面的观察事实。

    3. 在假设的基础上,预言还没有观察到的新事实。

    4. 做实验观察这个新事实。

    5. 如果观察到了新的亊实,预言可能是正确的。如果没有观察到新事实,修改预言来解释这个新的事实,转3。或者,也可以舍弃这个预言,转2

    科学方法是一种归纳推理的形式,因为它从特殊前提得到了普遍的知识,在调试的时候你归纳的事实常常是特殊的。比如,假设在调试时发现一个变量显示了一个错误的值。你的假设就是,如果变量为正确的值,错误就不会发生了。于是做个试验,在调试器中更改变量的值,观察结果。如果错误消失了,证明你的假设是正确的。相反,如果错误仍然存在,可能这个变量的值不是错误的原因。或者存在另外的错误。虽然科学研究的典型情况是使用科学方法做出单个假设,但是在调试中,你常常会发现,如果存在多个错误的话,同时做出多个假设也是可以的。

    构造假设

    当使用科学方法或其他方法构造假设时,你对问题域(问题目标、源代码、Windows C++MFC ATL等等)理解得越好,你对于构造好的假设的能力就越高。幸运的是,使用调试器观察代码的执行情况得出的错误假设,很可能比仅仅通过检查源代码得出的假设正确性好。所以,在调试器中进行分析往往能够快速地验证或反驳一个假设,而不需要整个调试周期。

    对问题域有一个较好的理解,有助于你做出较好的假设。

    演绎推理与归纳推理——用哪一个

    决定使用哪种推理方式的主要因素是手头上问题的特点,单独使用哪一种方式都不是最好的。虽然基于演绎推理的结论被认为是正确的,而基于归纳推理的结论是很可能的,但在现实中并没有这么大区别。演绎推理的问题在于它必须基于一个正确的前提,现实中,特别是在调试领域,这种前提是很少的。

    解决难题的策略

    你当然希望自己的结论是合理的,但是如果对下一步怎么做都不知道的话,任何逻辑推理都是没有用处的。分析错误常常像解谜题一样,所以用于解谜的方法也适用于错误分析。这些方法常有助于创造性思维。下面列出一些我认为在调试中很有帮助的策略。

l  简化问题。将问题缩减为相关信息,注意不要被那些不相关的信息误导。寻找可以重现错误的最简单的测试用例,寻找可以重现错误的最简单的代码片断,清除那些非本质的东西。

l  不要忽略显而易见的东西。听说过这样一件事情,一个能力很强的程序员去诊断某位用户的软驱驱动程序的问题。他花了一个多小时的时间,让用户运行诊断程序、跟踪设备驱动程序代码、制作内存映象等等。然后他让用户检查一下软驱的数据线,是否连接的很好。结果是数据线有一部分没有插好,用户不能连接数据线,而驱动程序却是正常工作的。教训:不要忽略显而易见的东西。首先实验最简单的事情。另一个教训:不要太聪明。用你的脑袋。但千方不要用过了

l  小心错误的假定,,好的逻辑谜题常常会误导你得出个错误的假定,这样使得正确的解决方法看起来也像是错误的,或者,将你的注意力引向那些显而易见的但是错误的解决方法。通常这忡情况的解决方法就是意识到做了一个错误的假定。比如,假设你发现了一个错误只会发生在一台计算机上,其他的计算机上不会发生。你可能认为是计算机的配置或是硬件的原因导致了这个问题。但是,也可能是产生问题的程序本身的设置问题,或者使用了不同的数据。另一个例子,假设你更改了代码,然后就出现了一个错误。你可能会认为错误是这段新修改的代码带来的,然后浪费你的脑力努力去寻找。但是,这个错误也可能是早就存在的,新更改的代码只不过是暴露了它而

l  从其他的角度看问题,有时候很容易陷入一个错误的视角来看问题。用创造性的思维解决问题:试用些新的或不平常的方法。有很多逻辑谜题的解决要点就是从另一个角度看问题。

l  从相反的方面去做。如果你所做的没什么作用,那么试着从相反的方面去做。比如,如果不能指出问题是什么,试着指出问题不是什么。使用排除的方法,或二分法查找的方法。

l  注意,什么信息也收集不到,这种情况本身就是一种信息。有时候没有任何信息就是足以用于解决问题的消息。如果你期望发生的亊情没有发生,这其实也是告诉了你某种信息。有许多逻辑谜题就是从信息的缺少或盼望的事情没有发生而入手解决的。

l  不要偏向某种解决方法。虽然有些解决方案可能比其他的更常用些,但是如果你把自己所有常用的解决方案都排除后,情况就完全不同了。所以,不要发现特定的某种方法,而是寻找所有与事实相符的方法。如果你专注于某种自己喜欢的方法,这样你会看不到其他可能的选择。一旦你迷失了,就说明问题根本不在那里,否则早就找到了。

l  考虑使用例子。有些问题太复杂了,普通的思考根本想不透,或者有些问题很抽象,很难抓住。这时候,最好的方法就是使用例子,试用一些例子和边界条件。比如,很经典的一个问题“丨的时候就出问题”,这个只用抽象的思考恐怕很难发现,但是一旦试验一个例子,就迎刃而解了。

l  休息一下。有时候如果休息一下,或者暂时想些别的事情,会使你的脑子更请醒。盯一会空白处会很有效。特别是在长时间考虑一个很简单的问题时,这个方法更有效。休息可以让你从一个新的角度考虑问题。

l  要坚持。休息是很有帮助的,但很多谜题就是让你在就要解决前的最后一刻放弃它们。同样,很难的错误也是这样。我经常发现,在我打算放弃的付候,如果再坚持会儿,试验些新的方法,问题就能解决了。有些问题需要很辛苦的工作才能解决。

    我觉得这些策略常常能帮助我脱离困境,其他的技术将在第12章“非常规策略”中介绍。

1.6消除错误

    如果很仔细地分析过了,从源代码中消除错误就是相对简单的事情。但是无论何时你修改源代码来消除错误,特别是在你对代码不是特别熟悉时,一定要注明“所属权”,即是你修改了这些代码。考虑这样一种可能性,下一步验证修改时,你发现陷入了这样的困境;这个问题并没有解决,或者更改带来了新的比原来更糟的问题。在这种情况下,需要回到代码原来的样子,重新开始。

    修改可能是错误的,这个可能性意味着需要备份原来的代码。如果使用源码控制系统,只要所有的文件都登记过了,就可以回到原来的状态,所以应当养成在验证危险的错误修改前登记文件的习惯。如果没有使用源码控制系统,或者因为某个文件不满足登记的条件,不能登记,这时候就需要为所有要被修改的文件制作备份。最简单的方法就是创建一个源码目录的临时备份。如果工程有很多文件,最好压缩成一个文档。总之,保证你能在必要的时候得到原来的代码。

    不要处于危险中。在冒险验证修改时,一定要备份。

1.7修改的验证

    最后一个调试中的挑战就是修改的验证了,必须保证错误被成功地消除了。具体地,需要验证这些:

l  源问题消除了。

l  没有引入新的问题。

l  程序中类似的问题不存在了,

    使用文件比较工具找出源文件修改的地方,再看一遍。然后使用调试工具,在代码修改的地方设置断点,观察执行情况。

    验证错误有没有被成功消除的方法就是重新执行一遍以前重现错误的步骤,观察现象。如果没有重现,可以假设问题消除了。但是,这种方法只能用于细微问题的更正。一个安全的并且比较实用的方法(至少会浪费比较少的时间)就是使用文件比较工具,如Visual C++ 里的WinDiff,找出哪此代码改变了。你需要仔细地再看一遍这些改变的地方,保证它们都是有意义的。然后设置断点,运行程序,观察更改的代码的运行情况。如果是按照你希望的那样执行的,问题就解决了,就可以保证修改是成功的,反之,需要做更多的工作。

    虽然这个验证的方法看起来有很大的工作量,其实不然。 WinDiff工具可以比较目录,所以如果你创建了一个源代码目录的备份,就可以一次找到所有的改变了,观察更改代码的执行过程可能需要额外的一些时间。实际上,所有这些步骤中,设置断点算是最难的了。

    回顾代码的过程也是有效的,用于保证不会引入其他的新错误。理想情况下,测试人员也需要验证下错误是否成功消除了,执行一些以前的测试,看看有没有产生新的错误。

    找到一个错误后不要停下来。如果你找到了一个,就有可能找到更多。从这方面讲,软件错误就像虫子—样。由于类似的代码很可能有类似的错误,你应该注意寻找相似的代码。首先察看与问题代码相近的地方,然后使用Visual C++里的“Find in Files”这个命令,寻找相似的变量名、过程名或其他语言特征,甚至可以査找注释。然后浏览一下类似的地方会不会有问题。

    一旦找到一个错误,就可能找到更多。类似的代码可能含有类似的错误。

1.8巧妙地而不是艰苦地调试

    调试是艰苦的工作,但它并不是总这样。这里有些技巧,可以帮助你更容易地调试。

    尽早地修复错误

    尽早地修复错误,是指一旦你发现它们或得到了错误报告,就立刻修复错误。这个方法有以下的优点。

    •当错误被很好地理解了,并且重现了,就开始调试。错误信息和重现信息都会随着时间逐渐失效的。

    •修复的过程也能帮助你更好地理解错误。尽早地获悉错误当然可以使你以后不再会犯同样的错误。

    •保留一个短的未知问题列表使得你能够比较容易地诊断其他错误,这是因为程序员不会总是碰到已知问题。这样也有助于判断一个修改是否引入了新的错误。

    •保留一个短的未知问题列表使得程序更容易测试,这是因为测试人员不需要汇报同一个错误的不同现象。为同一个错误准备许多错误报告单是不必要的。

    •保留一个短的未知问题列表使得工程更容易管理,这是因为工程的正确状态比较容易确定。

    简而言之,保留一个短的未知问题列表,会使软件开发过程更多产。错误并不是葡萄酒,不会随着年龄的增长变得更好,所以应当尽早地消除错误。

    并且,在测试程序的过程中要相信你看到的。如果有什么东西看起来像个错误,那它就是错误。当你看到它有点像错误时,不要否定,就当它是错误的,立即解决它。把它留在那里是不会自动消失的。

    首先尽可能阻止错误发生

    在连编程序时,充分利用编译器、链接器以及语言中揭示错误的功能,是可以阻止错误发生的。这些方法包括;

    •编译时不使周*别的警告信息。

    •使用类型安全的链接。

    •使用精确的数据类型而不是普遍类型(void*)

    •使用C++语言的功能而不是预处理。

    •使用类型安全的基于C++的类型转换来代替使用C的普通类型转换。

    这些技术会在下一章里详细讨论。

    从错误中学习如何预防将来会产生的错误

    这个检测和消除错误的过程为你提供了一个很好的机会,从错误中学习,就知道如何才能预防将来会产生的错误。Glenford Myers在《The Art of Software Testing》和Steve Maguire 在《Writing Solid Code》中提到:一显你发现错误,问自己几个问题:

    •错误是如何产生的?

    •如何预防这个错误?

    •如何更早地检测到这个错误?

    •如何自动地检测到这个错误?

    •在将来如何预防类似的错误?

    发现任何一个错误都是学习新东西的机会(虽然不用为了学习经验而专门编写错误代码,但这种学习的目的就是为了预防错误),错误并不是经常随机出现的,是有模式可寻的。这些模式就为提高你的编码技术和缩短软件开发过程创造了方法,通过提问自己这些问题,想出实用的方法,你可以学到如何编写更好更稳定的软件。

    假定它有错误

    假没你写了一个函数用于执行某个任务,刚刚结束了编码,开始编译和链接。现在需要做些什么?从下面的选项你会选择什么:

    a. 在头脑中运行遍代码,尽可能地发现问题。

    b. 使用调试器执行一遍代码,检查它的行为,包括边界条件和错误处理。

    c. 就让它执行,希望得到正确答案。

    d. ab

    e. 一个也不对。

    如果你喜欢冒险,无疑会选择C。你很快就会发现,代码编译和链接并不能保证它正确的共作。运行代码,观察它的运行,同样也不能证明什么。要想知道代码的真正运行情况,必须细心地在头脑中和调试器中回顾一遍。使用调试器,你还可以改变变量的值和执行路径,来测试边界条件和错误处理。

    我认为,你并不需要测试新代码来判断它是否含有错误,就把它当作有问题的来处理。代码越复杂,有问题的可能性越大。有时,我会冒险,让它执行,快速察看代码的行为(或者,可能是错误行为)。但是,我这样做不是因为我觉得它没有错误,相反,我假定它是不正确的,我只是想看看会出现什么失败的现象。特别是在复杂的代码中,这样做能让我知道下面需要在调试器中看些什么。

    并不需要测试新代码来判断它是否含有错误,就把它当作有问题的来处理。

    这种方法看起来需要很多工作,但是另外一种情况会用更多的时间。在头脑和调试器中回顾一遍代码,相对于写代码来说,只需要很短的一段时间。所以这个方法整个的工作量并不是那么大。

    增量开发

    如果使用增量开发的技术,调试过程就很简单了。就是说,一次只增加一个重要的功能,或做一个重要的改变,然后在继续开发之前验证它。这样,如果问题产生了,就与你改变的那部分代码相关。如果你一次改变了许多地方,就需要花很多工作来区分和诊断错误的原因了。

    不要玩谴贲游戏

    正如前面我们在五个错误的调试阶段中的“否认阶段”指出的那样,某些程序员在对待他们的错误上,太急于谴责编程语言、编译器或者Windows本身了。这些程序员似乎有这样一种天赋,每周发现一个编译器的错误。他们还发现同事们犯了许多错误。不知何故,错误绝对不是他们的过失。虽然这些可能是错误的原因,但是更有礼貌和有用的态度是,假定问题是自己的代码产生的,只有在发现有力的证据表明问题不出在你那里时,才能怀疑这些。在那之前,不要困窘,只怀疑自己。注意,经过一个快速检查找不到代码中的错误,并不是什么有力的证据。

    先不要责备你的同事和工具,首先要怀疑自己的代码。

    这并不是说Windows和编译器的错误不会产生。确实有时候会这样。在VisuaI C++中有数以百计的错误(MSDN中记录在案的就有400多个),在Windows中有数以千计的错误。《Windows Developer's Journal》每月有一个专栏,叫做“本月中发现的错误”。比如编译器,如果你使用新的或晦涩的语言功能,就可以很容易地发现问题。当你从其他的C++编译器转过来时,就特别容易发现编译器的错误,因为不同的编译器在不同的语言功能的处理上有困难。但是在普通情况下,由于编译器或操作系统的问题而出现错误,这个可能性是很小的。问题更有可能是因为你的代码。比如,考虑下面这段代码,它要将对话框设置的一个值赋给m_Size变量:

    CSizeDialog dialog;

    dialog.m_Size = m_Size;

    if (dialog.DoModal() == IDOK) {

        int tempVarl, LempVai 2, tempVar3 , tempVar4,

        m_Size = dialog. m_Size;

        // use temp variables here

    }

    if (newBlock) {

        // value of m_Size appears to be reset here

    }

    这段代码看起来很简单,但是不管用户从对话框输入什么样的值,m_Size变量的值就是不改变。调试器显示,程序一进入第二个if模块,m_Size的值就被重置了。编译器的错误?不是,在变量tempVar4的后面有一个逗号,应该是个分号。所以变量m_Size在第一个lf模块中就成了局部变量,它屏蔽了同样名字的类成员变量。

    理解海森堡不确定原理

    海森堡不确定原理首先被物理学家沃那_海森堡发现,他是量了力学的创始人。原理是这样的:“位置越精确,当时的动量就越不精确。”更实用一点的解释就是,你观察得越精确,就越扰乱你的测量。这个原理指出,对某种现象的绝对的观察和测量是不可能的。这样就导致不确定性。

    这个原理也与调试有关。在调试器的交互环境中运行程序。显然会改变程序的行为,由于调试器的设计者很努力地去避免它,所以大多数情况下都没有问题。但是,在有关窗口的调试中就困难了,这是因为调试器的存在扰乱了某些行为,包括:

l  窗目的拖动(特别是,当窗目的拖动与调试器的激活时刻重眷时)

l  窗目的激活;

l  输入焦点;

l  键盘输入:

l  鼠标输入、移动和捕获;

l  内存分配;

l  线程同步。

    调试操作改变了这些活动的结果。比如,考虑一下调试WM_MOUSEMOVE消息的困难。如果你在处理这个消息的代码上设置一个断点,在相应的窗口中移动鼠标时调试器就会被激活。当然你需要移动鼠标操作调试器,这样就会使得以后的WM_MOUSEMOVE消息和没有调试器时的不相同。因此。WM_MOUSEMOVE消息很难调试。在第8章“基本调试技术”中列出了调试这类活动的一些技术。跟踪语句(见第4)在绕过海森卑不确定原理时也很有效。

    必须知道在调试器的交互环境中运行程序会改变程序的行为。

    通过RTFM了解你的工具

    如果你在使用复杂的没有很好理解的技术开发程序,我可以保证,代码中会有许多错误。比如,如果你不理解paint消息如何创建、窗口的确认机制如何运行、图形设备接口 (GDI)如何工作,恐怕很难正确处理paint消息。在学习一种新的技术时,可以通过纯粹的努力——阅读文档来减少错误的数量。保证“阅读详细的手册”(read the fine manual, RTFM),了解你的工具。不要在还不明白在做什么的时候就去编程序。

    通过RTFC了解问题域

    同样地,如果你在调试根本不理解的代码,你对源代码的修改很有可能是错的。当修改代码时,保证你已经很好地理解了程序在做什么以及代码如何实现。如果你不能断定,和那些明白的人就修改的地方讨论一下。保证“阅读详细的代码”,了解你在做什么。不要通过试验和出错来进行调试。

    使用保守的维护方式

    虽然在开发过程的实现阶段,花费了很大力气来调试,但是在维护阶段,同样需要大量的调试工作。在维护代码过程中,保证使用保守的维护方式。即,在维护代码过程中自我约束,不要加入新的错误,但是在必须更改代码时,尽量改进代码。总结一下,保守维护的规则如下:

    1. 如果不是错误代码,一定不要修改。

    2. 如果是错误代码,修改并这改善。

    第一条规则要求,只有在真正存在问题的地方你才能修改代码。不要只是因为不喜欢它们的样子而进行哪怕很小地修改。不管多么的丑陋,也不要改变变量名、过程名、代码格式等等这些正确的代码。千万不用动它们,抵制这种冲动,如果必须,将你的手放在座位下面。如果你修改了,即使看起来非常细小的一个改变,也可能会有引入新的问题的危险。危险可能很小,但是危险带来的麻烦可不少,因为你最好遵守前面的一个技巧:假定所有的新代码都有错误。除非你愿意详细地测试它们,而这是在维护阶段很少做的一个事情,否则不要更改任何代码。

    这样做是有原因的,尤其是,特殊目的的代码常常没有记录。做了一个看起来没有任何害处的改变,就很有可能带来问题,特别是在你不完全理解的时候(记录特殊的代码可以减少这种危险)。考虑下面这个MFC窗口的paint函数。

    void CMyWindow1::OnPaint () {

      CPaintDC dc (this); // necessary code that appears unnecessary

    }

    voi d CMyWindow2::OnPaint () {

      CPaintDC dc[Lhis)

      CPen grayPen (PS_SOLID, 1, CetSysCo:or(COL0R_3DSHADOW));

      dc.SelectObject (&grayPen );

      // do some drawing with the pen

      dc.SelectStockObject(BLACK_PEN); // necessary code that

                                   // appears unnecessary

    这两个paint函数都是正确的,但是看起来它们都有不必需的代码。第一个OnPaint函数,声明了一个绘图设备上下文,但是没有使用。但是,绘图设备上下文的析构函数有另一个作用,即确认更新范围,从而阻止Windows发送进一步的paint消息。如果你删除了这个“没用”的代码,程序就会陷入一次次响应相同paint消息的循环中。第二个OnPaint函数,设备上下文在退出过程之前仅仅选择了一个黑笔对象,而没有使用。如果你删除了这个“没用”的代码,除了一个GDI的资源泄漏外,程序会像以前那样运行。这是因为Windows不允许程序删除正在使用的GDI对象,选择一个库存的对象就是删除使用对象的很普通的方法。

    当然,如果真的需要修改代码时,一切都不同了,这时第二条规则就要起作用了。如果必须修改代码,尽量使代码比以前改善。不要仅仅是修补程序,如果能改善的话,尽量让代码看起来比较干净,容易理解。如果代码很糟糕,几乎不能修改,那就完全重写(注意这些代码可能很干净,但是可能有错误)。反正也要测试这些代码,只要你明白自己在做些什么,改善那些必须修改的代码没有任何坏处,使用这种方法,随着时间的过去,代码的质量会缓慢改善。相反,如果不是用这种方法,代码的质量合慢慢下降,很可能引入别的问题。

    采取负责的态度

    在本章的开始,我在错误的五个调试阶段的介绍中,列出了一个无效的调试过程。所有这些令人讨厌的行为都是从这个问题滋生的;对待调试采用一种幼稚的态度。他们表现出了缺乏对质量的关心,逃避程序员的“开发出正确代码”这个最终责任。不检查就认为他们的代码没有问题,对测试人员找到错误感到厌烦,所有这些行为都破坏了这个责任。

    我们理想的目标是开发出无错误软件,但是由于很多现实的问题使得这个目的很难达到。有些人认为最好忽略现实情况,假装我们例行公事地开发了无错误软件。但我不同意。任何东西都可以改善,这是妄想。如果坚信无错误软件是可能的却不愿意宣布你自己的软件是无错误的,你就是妄想。假定代码中含有问题常常比假设问题不存在要有益得多。

    开发无错误软件的最好的方法就是釆取负责的态度。如果你写下了代码,它是你的,你必须对它负责任。预防、检测、消除尽可能多的错误就是你的工作。其他人也可能发现或更正你的问题,但这并不能改变任何事情。不能依赖这个。如果错误产生了,就是你的过失。开发无错误软件是不太可能的,但是如果对自己的工作釆取负责的态度,开发几乎无错误的软件就是可能的了。

    当有人在你的代码中发现错误时,应该用什么态度?我觉得答案依赖于问题本身。如果有人在我的代码中发现了显而易见的错误,很简单的测试就可以发现,我会很尴尬,尽力以后不会重复同样的错误。在这种情况下,我犯了个很傻的错误,没有对我的代码负责任。另一方面,如果有人在我的代码中找到一个很难发现的错误,我根本就不会困窘。虽然我更愿意自己找到这个错误,并且我努力去找了,现实是有时候我可能会让一些错误从我的指缝里溜掉。我只需要尽力保证以后不会再发生就可以了。

    在这一章中列出了许多种类的错误,大多数是通过各种比较标准的过程就可以消除的。随着对调试越来越有经验,你会得到对这一个过程的更深入的理解,创造出适合自己的调试技术。本书剩下的部分就是这一章中介绍的过程和技巧的细节介绍。

1.9推荐阅读

    Badger, Teny M. Puzzles and Games in Logic and Reasoning. Mineola, NY:Dover 1996

    一个很好的逻辑与空间谜题的收集,可以帮助你练习逻辑推理、创造性思维以及解决难题的能力。

    Kernighan, Brian W.and Rob Pike,The Practice of Programming. Reading, MA: Addison-Wesley, 1999.

    5章“Debugging”,给出了广泛但是简略的调试过程的改观,明显地倾向于使用脑力而不是使用调试器。第6章“Testing”,也值得一读,它讲述了一些使用断言和防御性的编程,这些在我看来是与调试相关的。

    Maguire, Steve. Writing Solid Code: Microsoft Techniques for Developing BugFree C Programs. Redmond, WA: Microsoft Press, 1993

    Maguire在这本书中介绍了处理错误的系统以及调试过程。值得注意的思想有:程序员有责任发现和修复自己代码中的错误;错误应该一发现就着手修复,这时候等待只是浪费时间;修复错误是一种消极的方式,可以督促懒情的程序员检查他们的程序:维护一个小数目的错误列表可以很容易地确定工程的状态。

    McConnell, Steve. Code Complete: A Practical Handbook of Software Construction. Redmond, WA: Microsoft Press, 1996

    26章“Debugging”,介绍了另外一种调试过程的解释,并且完成了一个完整的例子,是对调试过程的一个很不错的总结。

    Myers, Glenford J. The Art af Software Testing. New York: Wiley, 1979

    7章“Debugging”,这本经典的测试方面的书籍较好地介绍了调试过程的概观。它的介绍很有见地,虽然这么长时间了,现在来说仍是很出色的。这一章集中介绍了使用头脑来消除错误,特别是使用归纳和演绎过程。虽然我并不同意他的关于避免使用调试器和程序员不应该测试自己的代码的观点,这章中剩下来的部分还是充满了有用的观点。

    PirsigRobert M.Zen and the Art of Motorcycle Maintenance: An Inquiry into Values. New York; William Morrow,1974

    这本书通过使用机动车保养的实例,介绍了逻辑、价值和质量的哲学研究。虽然这看起来和Windows程序的调试似乎没什么关系,但是几乎所有的调试过程的高层次都被介绍到了,包括极好的归纳逻辑、演绎逻辑和科学方法的解释。如果你的调试过程有些类似前面提到的错误的五个的步骤,最好仔细地阅读一下。

    Rosenberg, Jonathan B. How Debuggers Work: Algorithms, Data Structures, and Architecture. New York; Wiley, 1996

    1章“Introduction,介绍了调试器设计的基木原则,包括如何避免海森堡不确定原理的问题。

    Voti Oech, A Whack on the Side of the Head: How You Can Be More Creative. New York: Warner Book, 1998,

    一木介绍如何开发创造件思维的书籍。它介绍了十种有效的技术,用于打开惯性思维的限制,提高你解谜题的能力。