使用Microsoft visual Studio和Rational Purify进行运行时调试(二)

作者: Goran Begic, Technical Marketing Engineer, Development Solutions, IBM Rational

翻译: wyingquan#hotmail.com      2006-02-29

调试——修复缺陷过程中最慢且代价最高的一步——是大型软件开发过程的一个重要组成部分。并且相信任何一个调试人员都会告诉你,定位引起缺陷的真正原因是一项艰巨的任务;并且修复一个缺陷比发现一个缺陷容易得多。在本文第一部分,我将向您介绍Microsoft Visual Studio程序开发环境并且讨论使用它的编译器如何进行初步调试。在第二部分中我将介绍如何使用Microsoft Visual Studio调试器和Rational Purify进行运行时调试。有关本文中使用的示例可以参考本文中示例程序的说明和本文内容进行调试环境的搭建。
使用运行时调试器
假设你已经使用Microsoft Visual C++编写了如文章提到得一些代码,想要看看它是否能够正确运行。按照文中描述步骤设置该工程,并且尽量按照预先设计好的步骤来进行。
第一次使用编译器运行程序时,程序运行正常,但是这并不意味着它没有bug。实际上你也知道(或许你并不知道),即使程序在你的机器上能够正常运行,它可能仍然包含错误。加入它在特定的机器上以特定的顺序运行,它仍有可能出现错误。因此,是该找一个更高级的工具来帮助你进行调试了:调试器。Microsoft Visual studio开发环境同时提供了一个强有力调试器,可使你广泛地洞察程序内部结构、结构体中的数据、寄存器中的内容甚至是汇编指令。
使用这个调试器遇到的一个问题是,你必须告诉它什么时候暂停执行。可是如果不知道该什么时候查看比较好该怎么办,程序执行的速度是很快的?最简单的方式是使用just-in-time(JIT)即时调试功能。
JIT调试
JIT调试可以把调试器绑定到一个崩溃的程序上。它可以让用户看到程序在崩溃而被“被冻结”时的快照。不幸的是,要看懂引起程序崩溃的信息是比较困难的,而且在使用JIT调试引起崩溃的主要原因时可能已经跃过了该位置。即使是这样,JIT有时也会帮你很大的忙,所以在这里我将讲述以下如何使用它来调试一个臭虫成灾的C++程序。
本文用到的示例是一个简单的命令行应用程序,包含一个名为Bears的类和两个函数,这两个函数对MyBear对象进行操作。该程序含有若干严重的错误,在下面的介绍中我将一个个地进行调试。
首先编译运行该程序,你将会看到图1那样的结果。看起来太让人难过了!怎么搞的?这程序怎么就不行了呢?
如果你想在程序崩溃时进行调试,当调试器与崩溃的进程绑定开始运行时你将看到如图2显示的结果。我比较喜欢在任何时候都显示寄存器窗口和堆栈调用窗口。它们各自显示了当时寄存器中的内容和已经执行了的函数列表。你可以在主菜单上选择View->Debug Windows菜单选择其它你想要显示的调试窗口。
在程序崩溃前最后调用的一个函数是C Run-Time Library (msvcrt.dll)中的。由于我没有使用Debug版的库,所以找不到函数名而没有在堆栈上显示。如果使用Debug版的C Run-Time Library,你将看到更多如图3中显示的信息。
最后调用的函数是strcat(),它的确是mcvcrt.dll中的。你可以从跟踪信息和堆栈调用信息中看到程序最后执行的操作是创建Bear对象。
让我们再来看看调试器给我们提供的其它有用的信息。寄存器EDI的内容是0XCCCCCCCC。这个寄存器是用来进行内存比较和移动的。所显示的16进制值是该Visual Studio实例用来自动初始化所有本地变量的。这也意味着程序可能设法使用一个未被初始化的变量。在屏幕下方的变量窗口中可以看到变量pBearFriend包含上述值。那么是这个原因引起程序崩溃吗?
是的,毫无疑问!看一看源码就可以知道编写代码的人忘记了初始化变量m_pBearFriend的值。而在拷贝粘贴时把变量m_pBearName初始化了两次。可以查看bear.cpp文件的4549行。
  m_pBearName = new char[strlen(pName)+1];
  strcpy(m_pBearName, pName);
  m_pBearName = new char[strlen(pFriend)+1];    //Copy-paste error!
// m_pBearFriend = new char[strlen(pFriend)+1];  //Correct allocation
  strcpy(m_pBearFriend, pFriend);
呵呵,这也太简单了吧!让我们继续来调试这个程序吧。可以把第47行注释掉,删除掉那行正确代码前的注释符号来纠正这个错误。然后rebuild一下在调试状态下重新运行。
嗯?L怎么又出现了一个让人恶心的错误消息?如图4,这次系统提示“Debug Error!”。正如我前面提到的,Debug版的C Run-Time library使用的是Debug版的内存分配函数,它分配了额外一处内存用于报告新分配的内存块越界的情况。这恰恰是产生问题的地方。你可以单击忽略按钮,程序将完整地执行。注意:如果你使用release版的Microsoft C Run-time Library进行程序链接的话将不会显示这个错误。那么如何找出导致问题的原因呢?
在程序终止时,你会看到另外一种信息——这次它时一个警告信息(如图5
0x80000003 EXCEPTION_BREAKPOINT
这个消息是在调用HeapFree()时出现的。
既然我们提到过断点,那我将要介绍一下这个几乎在任何调试工具中包含的重要手段。
什么是断点?
断点是调试器最常用的手段之一。你可以在Visual Studio编辑中选择一行源码然后按F9来设置断点;用来标记调试程序时在这个位置暂停执行。如果设法让程序在正确的时间暂停,你将能够查看内存中读写的内容。这在程序真正运行时是很难完成的,相对于臃肿的项目来说更加复杂。
从汇编的级别上来说,断点是一个插入到代码中的1个字节的指令(0xCC)。当进程运行到0xCC时,将它解释为一个特殊的,优先级高的中断,进程在这个位置暂停执行。除此之外,还把当前的指令(两个寄存器中的内容)保存下来,这样当用户决定从断点处继续执行时这些值能够被重新加载,程序也接着执行。
断点应该设置在什么地方呢?在本例中,在程序崩溃时Debug build给你提供了一些线索,根据这些可以查找bug的位置。堆栈调用窗口按系统调用和执行的顺序显示了函数列表和各自的参数。并且程序最后一次执行的指令显示在堆栈的顶端。通常,堆栈窗口会显示系统函数而不是用户函数。这是因为在执行用户函数是调用系统函数的过程。本例中MyBear对象的用户自定义析构函数调用了run-time函数free()来释放该对象使用的内存,实质上是在内部是通过调用Debug版的函数来释放内存。
正如图6所示,在你调用MyBear对象的析构函数时发生的错误。堆栈窗口顶部的函数是用于释放内存的Debug版的函数:
delete(m_pBearName);
delete(m_pBearFriend);
delete(m_pBearHobby);
在这里我们设置一些断点,设置在该值被初始化之前的行上(bear.cpp的第52行),和调用delete()函数删除m_pBearHobby变量的行上(Bear.cpp的第30行)。
设置了断点以后,rebuild一下并且以调试方式运行。这时程序恰好停在设置了断点的位置(第52行)。Visual Studio调试器主窗口中以相同的顺序显示了最后一次关闭时你使用过的多个窗口(图7)。主窗口左侧的窗口显示的是源码和设置了断点的代码行。右侧堆栈调用窗口显示了最后一次暂停前最后执行的方法——MyBear对象的构造函数。
这次,我将使用Debug Momory窗口。它在窗口的下半部分显示。在Memory窗口的地址栏,输入存储在m_pBearHobby指针执向的值。内存块就定位在了m_pBearHobby所指向的值的位置。在这里显示为0xCD字样。此外,有4个字节标记着分配内存结束和同时被编译的由“Debug”版的内存分配程序创建的“安全区域”(显示为0xFD字样)。Debug版的内存分配程序“占用sprays”了已分配的内存块,然而没有初始化,这块内存显示为0xCD,数组周围的边界区域显示为oxFD
F5键继续在调试器中执行程序。图8显示了在数组指针m_pBearHbby被初始化以后的将看到的情况。
在主窗口底部的变量窗口中,你可以看到已创建对象的成员变量的列表。m_BearHobby变量现在指向了程序早已分配给它的“Philosophy”字符串。内存窗口中也显示了你之前在相同的位置设置的字符串的副本。可以明确地定位到Philosophy字符串在内存中的存储位置。
如果你细心计算已分配字节数的化,就会看到Philosophy包含10个字母(也就是10个字节),字符串的末尾——字符串终止符(0x00)——在字符串后面的安全边界区域。如果是Release版的程序,在数据之间将没有额外的区域,程序实际上可能会为了程序的执行用这些地方来以未知的推理写入一些有用的数据。幸运的是bears示例不是一个临界任务程序。
断点是强者(知道在哪里设置断点)手中的得力工具。你在本程序中看到的所有的断点设置是最简单、最直截了当的。你也能够以更高级的方式操作断点。在Microsoft Visual Studio主窗口中按ALT+F9打开断点设置窗口,这里显示了所有断点的位置,表达式,变量和消息发生的条件。甚至可以设置条件表达式断点,尽管设置正确的条件并非那么容易。
例如:你可以使用高级断点设置让调试器在一个指针变量存储是值发生改变时暂停。这将有助于让你检查获得的所有指针指向的值并且让你在指针指向非法地址的地方加入适当的代码。
现在,你能够通过在源文件bear.cpp的第50行上分配更多的字节来纠正错误然后rebuild程序。现在程序正确执行了,那么这也就意味着程序中没有错误了吗?如果这样认为,那你就错了。
使用自动化的运行时调试器
到现在为止,你已经通过运行Debug版的程序发现并修复了一些错误,但你仍然不知道这个小程序是否没有bug了。事实上我们没有时间来为每种可能发生的情况给程序设置断点来查看它运行时内部的情况。但是你仍然必须确定软件是否已经准备好分发给客户了。在这种情况下,最保险的做法是使用一种高级调试工具,最好让它能够为你执行这个时间紧迫的运行时检查过程。
市面上有许多类似的工具。其中许多使用源码作为程序执行时获取信息的主要来源,但是下面我将使用的工具更像是一个自动版本的调试器,即使在没有源码的情况下也可以运行。它依赖于程序的符号调试信息,检查每一处内存分配和函数的参数,是在程序运行时检测内存泄漏的。程序需要按下集成在Microsoft Visual Studio中的一个按钮才可以进行测试,这样你就可以运用你对Visual C++编译器和Visual studio调试器的知识进行分析做出合理的判断。报告会呈现给你精确的错误位置和对应的内存分配的位置,以及所有使用一般方法和调试器难以发现的问题。这里我也故意保留了程序中前面已经解释过的错误,因为这是我首先如何定位的——通过在Rational Purify中运行示例。
9显示了报告的第一个标签。报告显示即时你纠正了第一个错误,程序中仍然有两处错误和一处发生内存泄漏。
尽管这不是一个关键业务程序,你应该推断这些错误的来源和它们是否真实存在。我将在下面逐一分析。
列表中第一个错误被标记为ABW。它是Array Bounds Write的缩写,表明程序向数组写入内容时下标越界了。在这种情况,它通过在代码中的正确的位置设置的断点和观察在内存中初始化变量值来标记出错误。我不得不承认我首先使用了上述报告来定位错误,接着我使用了它提供的信息来设置断点来调试程序(如图10)。
展开错误信息,Rational Purify会显示出发生错误时调用的堆栈信息。它同时也指出了代码中发生错误的位置。这和通过调试器在堆栈调用窗口、内存窗口、变量窗口和源码窗口中看得到的信息是同一类型的。单独使用Microsoft Visual Studio和集成使用Rational Purify最大的不同之处在于后者不需要设置断点或使用JIT调试器。Purify直接指出了内存问题和发生问题的原因,这更有利于开发人员修复错误。
Purify报告的错误列表中还剩下什么问题呢?对了,下一个问题是Array Bound Read,它也是由于下标越界引起的问题。然而这回应用程序尝试读取数组地址以外的内存。图11中,有问题的源码出现在“计算”bears数量得函数中。最后一刻我决定在这里加一个“特写”,它并不是很明显。这对于当在调试器中测试应用程序时,标注这个未被测试的问题非常重要,尽管使用Debug版的C RunTime库。
尽管整数数组为两个元素分配了内存,数量是从不存在得数组元素中获得的。这个值下标越界了,在调试版本下它具有一种模式,这种模式通常用来标识分配4个字节数组的界限。边界值是相同的大小:4个字节。数据的“第三个元素”为十六进制值:0XFDFDFDFD,这恰巧是程序中显示得值。勿庸置疑每次运行时population的值都是一样的。
对于Release版本来说,模样就大不相同了:0xABABABAB(不再是边界的值了)。最可怕的是编译器并没有告诉你它使用了什么值。这种问题最难定位,因为程序看起来是正确运行的。因此除非你在发布前进行彻底的测试,有一个好机会,但是直到一个不适当的时间(当程序安装到客户的机器上时)你才会知道这些问题。
在图12中,你可以看到数据的头两个元素被标红了并且该模式被程序理解为一个无效的数字。有趣的是这个值应该代表bearspopulation,在你改变了Microsoft C Run-Time后就会改变,或者如果你使用Purify来检测这种错误,在这种情况下population的值将是0xAEAEAEAE。程序不会崩溃或者引起系统错误,但是代表beaspopulations的数值是错误的,它是基于内存模式在该位置读到的内容。最大的population是跟Rational Purify字节模型I相关联的。
内存泄漏
Purify报出的最后一个错误信息是MLKMemory Leak)。内存泄漏是最难检测和恼人的,有其是对一个要运行很长的时间而中间又不能重起的程序。这样的程序会吞掉所有的内存资源最后导致机器上的所有进程都停止。
MLK信息将会在对上所有内存中或在已分配的内存块中没有被指到的情况下显示。这样的内存区域不会被程序用到,但是他们也不返还给操作系统,持续的内存泄漏可能在即使有G级内存的机器上引起严重的问题。
可以使用Debug版运行时库来检查堆上的内存泄漏(MSDN中有相应的帮助)。Purify以一种比较巧妙的方式来解决这个问题:泄漏检查,它在程序终止前使用,以类似于Java垃圾收集器一样的方式工作。代替了内存释放时,Purify显示泄漏内存块详细的报告(图13)。
这个报告是正确的,给pName分配了内存却一直没有释放它。被用来释放内存的代码行被注释掉了,但是也有可能是被忘记了。
类似Rational Purify一样的工具带来许多的好处之一是它不仅有发现内存泄漏的能力,而且能够收集使用中的内存的信息,分配给程序使用的堆中含有的无效的指针。甚至可以给受控制运行的进程分配内存块,如果程序使用较大块内存的花,最终结果将会是性能下降。例如,操作系统可能开始使用指针指向的交换区空间或者甚至可是完全在内存外运行。在我定义条件时:有指针指向的内存快,或者在内存块中有指针指向的位置,不会被当作是内存泄漏。然而,你也应该控制它们,甚至如果它们的大小可能会影响测试时程序的性能-或者甚至可能机器上运行了其它的程序。
例如,如果在使用new()分配内存时声明变量为静态变量,Purify将会把这块内存报告为Memory In Use(MIU)
static char* pName = new char[MyBear.getLength()+1];
在你改正完图14中所有的错误后,你可以认定这个程序时没有bug的,可以发布了。使用一个自动化的调试工具将会提供一些你需要知道的有关开发中的程序的额外的信息。你应该把它作为一个构造一个可发布版本的程序的主要标准。
擅长调试腾出更多的时间用户设计开发
一个好的项目计划,风险保障和一系列合适的工具使开发者的生活方式更加简单,并能够腾出更多的时间进行研究或实现新的特征。你可以把基本的工具例如编译器或调试器用于易于使用来调试aids和把它们结合起来进行运行时的错误检测,就相我在文中讲述的一样。
无论技术和你使用的工具是否达到你的最终目标――生产高质量的软件——应牢记调试的目的始终的相同的:发现被测试程序“谎言之下到底是什么”(危机四伏J)。
 
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章