本文分为两部分,在此我们先来学习一些基本的使用
Visual Studio
调试
Win32
应用程序的基础知识。
作者: Goran Begic, Technical Marketing Engineer, Development Solutions, IBM Rational
翻译:wyingquan # hotmail.com 2006-02-09
图
1: Visual Studio
调试器窗口
每当提及我们为提高软件质量做了多少工作时,开发人员总会拍胸脯保证没有问题。然而,你要永远记住一个不争的事实:程序中将始终存在bug。毕竟,程序是人设计的,那就当然不会没有bug的。因此,调试——修复缺陷这一最耗时且最昂贵的过程——在大型软件开发过程中始终占据一席之地。广而言之,调试也包括各种各样的编程技术,它能使开发人员能够预见程序中潜在的问题。由于开发过程的高度复杂,调试工作就变得更加繁琐。实际上,为了全面掌控整个调试活动,你就必须关注整个开发过程。正如一些“调试人员”将要告诉你的,定位真正的引起产生缺陷的原因是所有工作中最困难的;修复代码的缺陷是调试过程中最简单的一步而已。本文的第一部分将向您介绍Microsoft Visual Studio程序开发环境并将讨论使用Microsoft Visual Studio编译器进行最基本的程序调试方法。
形形色色的
bug
准确的来说,什么是bug呢?如果你的应用程序安装在任何机器上都会崩溃,你就会知道程序肯定有bug。但是以下这种情况呢?通过自己的测试发现程序运行良好,就自豪地发布了它。但不久后,一些重要的客户反馈了一些令人为难的“问题”,这些问题仅在一些机器上的某种配置情况下出现。这种问题我们当然也要把它称为bug。
实际上,我们把许多不同类型的问题都称为bug。数据被破坏是其中级别最高的,但是应用程序由于设计缺陷或者甚至界面设计混乱也会导致用户操作上的一些不方便(即不符合用户的习惯或不符合约定俗成)。例如:你是否在Microsoft Outlook中使用快捷键Ctrl+F,弹出的不是预期的查找窗口,而是转发窗口(在几乎所有的应用程序中,Ctrl+F快捷键都会调出查找窗口)。
调试方法和技术
时下流行的一个口号叫“防御性的编程”。这是一个由许多技术和策略组成的用于编写代码的方法,有助于早期发现错误。例如,编写巧妙的代码,使用所有开发人员都理解和认可的符号(变量名称)编写代码,这样有助于减少bug。此外,防御性的编程技术还包含了由程序语言提供的大量非常有用的宏定义和函数,这些都有助于你在程序执行期间检查重要的事件。
即使在度量软件质量期间你也应该进行程序调试。在有了过硬的质量保证的同时,也让项目管理人员能够为系统后期开发做出决策。
为了避免由于代码中的bug引起发布时间的延期,你应该在整个开发周期中尽可能早地开始调试。例如:最理想的是在项目计划时就开始——在为第一个原型整理需求时。在早期的原型中加入过多的特征可能会引起缺陷的增加,并且会导致在修复这些特征上花费大量的人力和时间。花费在调试一个有过多特征的软件上的时间要远远多于花在一个简单而又强壮的程序上的时间。
你也应当使所有项目组之间保持一致的编程环境,一致的文档和代码——使用版本控制软件——并且对修改后的程序进行“冒烟测试”。如果你正好文档化了源码的所有改变,那么就会发现一般新的bug都出现在最近修改过的代码中,关注于这些变化会大大地减少花费在问题修复上的时间。
在我前面提及到过,不管多么仔细或者维护你的代码,甚至你的程序看上去运行正确,但实际情况是:程序中还会包含bug。软件开发工具一般都会把这些考虑进去,并且包含了有助于尽量消除软件缺陷。
那么开发人员需要的基本工具有哪些呢?至少需要四个:
·
一个编辑器
·
一个编译器
·
一个调试器
·
一个自动化的运行时调试器
如果没有这些工具,你不可能开发一个成功的应用程序。实际上自动化运行时调试器是最近才加入到以上列表中的,它的主要任务是在程序运行期间定位错误,这些程序在运行时可能很难手工完成——即使对一些开发高手。
Visual Studio
程序开发环境
在本文中,我将会全面地透视使用Mocrosoft Visual C++编译器构建的一段代码的运行时调试。当然,市面上也有许多其它类型的编译器,有些甚至时免费提供的,但是我选择这个工具的原因时因为它最有可能是你已经安装的。它并不会快速生成代码,也不是没有bug,它也不是最遵循ANSI C++标准的编译器,但是它被广泛地用于C++ Win32平台上的软件开发。
我将会以介绍一些可能用到的术语和工具来开始。尽管这些对Visual C++开发人员来说已经相当的熟悉,但对那些在UNIX上或者使用Java的人来说可能比较陌生。
Visual Studio
集成开发环境(IDE)
许多应用程序使用此环境进行开发,它由编辑器和一些菜单、工具栏组成,通过它们来调用编译器和连接器、调试器等。也可能使用其它的编辑器编写的代码而通过命令行方式调用Visual C++编译器和连接器。
VC++
工程
一个Visual C++工程就是一个文件夹,该文件夹中保存着用来构建应用程序的所有文件。它是在你选择了所开发程序的类型后程序自动生成的。你也可以自己新建一个空的工程然后再单独编写代码。当你构建应用程序时,默认情况下它会生成在工程文件夹的子文件夹中。同时默认情况下有种编译类型:Debug和Release。Debug版包含了一些有助于调试程序的额外的信息,Release版只包含了最基本的信息,用来发布。Release版的程序在大小和速度上进行了优化,并且不会把一些程序源码信息暴露给最终用户。工程定义文件保存在扩展名.dsp的文件中,工作区信息保存在扩展名为.dsw的文件中。
调试器
用来控制程序运行,使用户能够浏览程序的每个执行步骤、检查程序使用的所有变量、所有的内存分配和处理器的寄存器中的内容。因为即使是一个相当简单的应用程序也可能包含成千上万条机器指令,如果要浏览这些指令的话你必须熟知这些机器码,通过它们能够一直检查应用程序。你可以使用调试器(这里指的是Visual Studio调试器)在源码中设置断点,从而检查程序在断点位置的运行情况。
调试器窗口
在Visual Studio Debuger中运行一个程序时,它会打开一些默认的窗口;您也可以根据需要打开其它一些调试窗口。图1显示了我在使用Visual Studio Debugger时经常打开的窗口。
Visual Studio Debugger
的主窗口是标准的编辑窗口。箭头指向了当前应用程序执行到的位置。如果源文件中设置的断点不能够到达,那么调试器将以raw机器码的形式显示汇编窗口。
在Visual Studil Debugger界面的右方,你可以看到寄存器窗口和堆栈调用窗口:
·
“
寄存器”窗口显示寄存器内容。可以用来查看CPU寄存器的名称及其内容,如果在执行程序过程中将“寄存器”窗口始终打开,则在代码执行时会看到寄存器值的变化。最近更改过的值显示为红色。
·
“
调用堆栈”窗口使您可以查看调用堆栈上的函数名、参数类型和参数值。仅当正在调试的程序处于中断状态时,才显示调用堆栈信息。
在调用堆栈窗口之下是“内存”窗口。显示了指定地址指向的虚拟内存的内容。你可以在窗口该窗口的地址栏中输入您想查看的地址从而查看它所指向的内容。你也可以从源码窗口或其它调试窗口中拖动变量或地址到该窗口中查看所对应指向的内容。
最后介绍的是变量窗口和监视窗口:
·
变量窗口包含三个页签。自动:当调试本地应用程序和逐过程进行函数调用 (F10) 时,“自动窗口”将显示这个函数以及可能由这个函数调用的所有函数的返回值。局部变量:包含当前范围内所有局部变量的名称、值以及类型。This (Me):显示 this 指针指向的对象的名称、值以及类型。可以直接键入变量名或从源码和调试窗口中直接托拽变量到该窗口来显示它们的值。
·
监视窗口。可以使用“监视”窗口计算变量和表达式并保留结果。还可以使用“监视”窗口编辑变量或寄存器的值。同样也可以直接键入变量名或从源码和调试窗口中直接托拽变量到该窗口来显示它们的值。
使用编译器进行调试
编译器用来把源码编译成机器码。然而编译器的功能不仅仅是这些,它同时可以用来检测报告在编译过程中发现的各种错误和一些跟静态内存分配的潜在的问题。例如:如果你注意到了编译器的警告级别的话,你也可以通过选择正确的警告级别设置来避免一些不必要的麻烦。几乎每种编译器都会检测出语法错误,但是即使代码中没有语法错误也并不意味着代码中不存在错误。让我们来看下面的例子:
PLeftEdge = new char(strlen(pLeft) + 1);
strcpy(pLeftEdge, pLeft);
pLeftEdge = new char(strlen(pRight) + 1);
strcpy(pRightEdge, pRight);
如果你在使用strcpy()函数前未检查它的参数--或者大量的使用copy/paste函数――或许这种bug将来就会折磨你。编译器不会认为这样的代码是有错误的,因为它的语法是正确的。运行含有这种代码的程序问题很快就会暴露出来,因为问题的根本在于:你使用了一个未初始化的字符串作为函数stecpy(pRightEdge,pRight)的参数。在后面我们还会用到这个例子,届时我们再调试一个存在相似问题的程序。
但是,编译器在一些情况下也会有很大用处。接下来详细介绍一下使用Microsoft C++ 编译器构建的代码的实时调试方法。
Visual C++
调试设置
Visual C++
提供了两种默认的构建设置:Release(发行版)和Debug(调试版)。两种构建配置使用同样的编译器,你也可以任意设置两种配置的参数。对C++来说,修改了某些选项后他们还是属于发行版和调试版。因此可能有人会问,那么发行版和调试版有什么不同?调试版配置包含了有助于调试程序的信息,而发行版的配置旨在提高程序的性能。因为大多数程序员更倾向于测试一个接近发布的程序,所以这里我主要介绍一些有助于调试发行版的相关知识,并会解释发行版和调试版的不同之处。
符号调试信息
发行版和调试版最主要的不同之处是调试版的配置默认情况下会创建符号调试信息。如果你相调试发行版的程序,必须确保编译器和连接器的选项中设置了创建符号调试信息的相关参数。符号信息文件被保存在一个单独的文件或代码段中,包含了从汇编指令到源码的一系列信息。如果没有这些信息,那调试只能在汇编级别上进行;尽管某些人可能会读懂汇编代码,但这显然不便于进行调试的。
符号调试信息有多种类型。Microsoft编译器默认的类型是so-called PDB文件,使用参数/Zi或者/ZI创建(使用这个参数创建的信息具有“Edit and Continue”的特性)。
PDB
文件默认保存在Debug目录下,名称同执行文件名,扩展名是.pdb。需要注意的是默认情况下Visual C++ 6编译器会创建一个名为VC60.pdb的文件,它是在编译期间生成的,创建这个文件的原因是,编译器不知道将要产生的可执行文件的名字。在连接器激活之前,所需要的.obj文件并未确定,所以这些信息暂时保存在这个文件中。默认情况下连接器不会将它合并到projectname.pdb文件中,除非你设置了让他们合并。
Visual Studio
中的连接器生成pdb的默认选项为/PDBTYPE:SEPT。你也可以在发行版和调试版配置中改为/PDBTYPE:CON。CON表示“统一的”。如果通过Visual Studio的DEBUG设置中改变这些设置的话连接中的设置不会跟着改变。我想如果使用设置生成单独的PDB文件会在性能上有一些优势,但是你完全可以忽略这点,因为当今的机器配置都已经相当的高了。如果你想在别的机器上调试你的程序,那么包含符号调试信息就显得相当的重要——并且如果你使用了其它的测试工具的话也必须提供所需的符号调试信息。为了保持完整性,PDB文件必须和执行文件相匹配;必须放到同一个目录下,并且不要使用我上面提及的连接选项。
另外可选的生成符号调试的类型是兼容C7(使用编译选项/Z7,将产生一个.obj文件和一个.exe文件,并带有调试器使用的行号和全部的符号调试信息)和“Line numbers only”(使用编译选项/Zd,修改.obj文件或可执行文件的翻译,以使其只包含全局和外部符号以及行号信息,但不包含符号调试信息)。选择兼容C7类型生成的调试信息将包含在执行文件中并包含行号和全部的符号调试信息。这样兼容了DOS下的CodeView格式。“Line numbers only”不包含符号调试信息。
重定位信息
重定位信息,或称“relocs”,是保存在二进制可执行文件中的一个包含定位信息的表格式信息。这些信息服务于所有地址信息,当执行模块加载到内存中得一个基址时其它地址由连接器设置。如果执行模块被加载到预定的基址,那么重定位信息就是没用的。像Rational Purify这样的运行时调试工具使用这些信息来对模块进行“插装”,由于Purify经常不得不重定位内存中插装后的模块(如果原本位置不能再被使用得话)。项目连接设置选项/FIXED:NO,提供了强制建立重定位模块信息的功能。发布版设置中未设置此选项,你必须手工在连接设置对话框中加入它。
优化编译
优化的目的是使创建的目标代码变小(使用内存小)或者执行速度加快。为了达到这个目的,编译器对汇编代码做了各种优化。例如,去除掉一些多余的代码,去除多余的表达式,优化循环和使用内联函数等。
如果是为了调试的目的的话那么应该关闭发布版和调试版中的所有优化选项。优化编译的代码会使调试变得更加困难。同时也意味着当你在调试优化编译的程序时设置断点要比调试一个未优化版本的程序困难的多的多。
连接时使用
debug
版运行时库
默认情况下调试版本的构建设置在连接阶段包含调试版的C运行时库。这会对查找bug有所帮助,因为这样一来使用了调试版的动态内存分配。例如,增加位模式来标识已经被分配的内存,并且这些信息有助于检测是否发生越界访问。它们也占用了一定的内存,位于每一处新分配内存块的后面,因此被称作为“守护字节”,用于检查越界访问。
调试版的内存分配函数为malloc_dbg和heap_alloc_dbg。这样所有对的malloc()和new()调用都将解释为调试版的函数。内存释放函数free()和delete()都解释为调试版的函数free_dbg。
未被初始化的内存的内容为0xCD,在内存分配结构边界上的内存的内容为0xFD,空闲内存的内容为0xDD。
在发布版的构建配置中使用调试版的运行时库也将会使构建成为调试版的构建版本。而且如果你有商业的运行时调试工具的话基本上就用不到它。因此在构建发布版的调试程序时使用动态连接运行时库时非常重要的(默认编译选项是/MD)。
警告级别
Visual Studio
的默认警告级别是Level 3(编译选项/W3)。该级别将报告例如在函数原型执行前调用的信息。如果是处于调试的目的的话,可以修改警告级别为/W4,这样有助于检测所有未初始化的局部变量和虽然已经初始化但未被使用的变量。因为缺少对Visual C++编译器的了解,这些都是部分有用的。部分原因是由于/W4级别下也产生了大量的并非真正的警告信息(即它会误报),这样就会导致难于定位潜在的问题。然而,如果你把所有的警告信息都当作错误看待,那么你写的代码当然会比默认级别下编写的代码存在的缺陷更少。/W4级别的另一个好处是可以预防断言失败。
在
Debug
版本中捕获
Release
版本的错误
当使用/GZ选项时,Visual C++将自动初始化所有局部变量(使用值0xCCCCCCCC),检查函数指针调用堆栈的合法性,并检查调用堆栈的合法性。调试版的默认设置中默认启用了该选项,你也可以在发布版的配置中手工设置。
运行时调试:都是与内存相关的
当你要执行你的程序时,它首先会被加载到内存。实际上,因为Windows使用了内存映射机制,执行文件只有部分在需要的时候才被换入内存。另外Windows系统使用便携式格式文档,从结构上来看也跟他们在内存中的映象一致。
堆和栈
当程序启动时,使用内存页来保存程序所使用的所有静态的和动态的数据。每个进程都至少使用了两种类型的内存区域:
·
栈,或静态数据块,是保存所有自动变量的内存区。在
Win32
平台,每个线程都有它自己的静态内存区域。主程序线程所使用的堆的大小是在编译期间就已经确定了的,默认情况下它的值为
1MB
。栈的大小也可以使用连接选项
/STACK:reserve[,commit]
选项指定。也可以在模块定义文件中使用
STACKSIZE
语句覆盖这个值,或者使用
EDITBIN.EXT
工具直接修改二进制可执行文件。
·
堆,或称自由存储区,是虚拟内存中使用不受约束的区域,使用句柄来标识,并且使用范围仅受可用虚拟内存的限制。对上的动态结构和句柄是在运行时分配的。
每个进程都至少拥有一个默认的堆,但有的进程可能有许多的动态堆。程序运行期间通过堆API函数从堆上分配内存块。进程创建的默认的堆是私有的,并且不能被其它进程使用。默认堆的内存保留区域和提交区的大小是在连接期间已经确定了的。你可以改变默认堆的大小(1MB),使用连接选项/HEAP:reserve[,commit]。同样也可以使用EDITBIN工具修改已经编译连接好的二进制执行文件。
堆的分配函数有三种类型:
·
GlobalAlloc/GlobalFree and LocalAlloc/LocalFree
用于在默认堆上分配
/
回收内存
·
COM Imalloc allocator
(
CoTaskMemAlloc/CoTaskMenFree
),用于默认堆上的内存分配
·
C
运行时内存分配
API
——
new()/delete()
和
malloc()/free()
,用于
C
运行时在私有堆上的内存分配
/
回收
此外Win32 API中的VirtualAlloc()和VirtualFree()函数用于虚拟内存页的分配。你可以在Win32应用程序中直接调用这两个函数,但是一般这样的函数是用不着的,除非你想一次性分配一大块内存。
这仅仅是调试最艰难一步的开始——控制和调试动态分配结构。然而很显然第一步是:必须让程序运行。我将在本文的第2部分讲解这一部分。