本书由铁文手打整理,仅为方便个人查阅摘录
如喜欢本书,请购买正版
如果要寻找并消除错误,Visual C++调试器将是你最好的朋友。它有好几个著名的特性使得它很高效,也很好用,下面是我喜爱它的一些原因:
•Visual C++调试器完全嵌入到了Visual C++开发环境,允许用户直接从源代码窗口使用调试器进行设置断点和控制程序的指向。
•在进行调试时,源代码窗口通过数据标签显示变量值,这使得用户只需在源代码周围移动鼠标就可以得到大量的信息,而不用使用对话框查看变量。
•它有一个功能强大的观察窗口,不仅显示了变量和结构,还允许用户计算表达式、执行函数、选择显示格式。
•Visual C++调试器有个非常有用的变量窗口,能方便地显示当前语句或前面语句的变量。当你跳过一个函数时,它还显示返回值、当前函数的局部变量或者this指针指向的对象。变量窗口不像观察窗口一样,需要输入查看的变量。
•支持多种高级断点,例如,条件代码定位断点、数据断点以及消息断点。
•允许用户不需额外的工作就可以调试DLL。
•能让用户在不得不进行调试远程程序或用户不能安装Visual C++时,进行远程调试。
•支持即时(JIT)调试,为事后调试提供了方便。
•支持编辑继续(Edit and Continue)功能,减少了调试周期的时间和所花费的精力。
本章将介绍一些更高级的或者不是太明显的特性来帮助读者尽可能好地使用Visual C++调试器,我会假设读者已经有了基本的调试技术:你知道如何创建一个调试版本,如何在代码中设置断点,如何跳过或跳进代码,如何使用数据标签和观察窗口査看变量值,以及如何使用调用堆栈窗口。本章的目的不是介绍这些内容,而是介绍其他的一些技术。顺便说一句,如果你觉得你在这些基本技术上还是需要帮助,可以参看MSDN中的Visual C++用户指南。
使用Visual C++调试器的第一步是选择合适的编译与链接选项。表7.1列出了对跟踪错误最有用的一些编译选项,表7.2列出了最有用的链接选项。
在第2章“编写便于调试的C++代码”中提到,Visual C++编译器能发现使用未被初始化的局部变量的问题,发行版本中的这个功能比调试版本更强大一些,因为这个错误是在优化中检査的。要得到这些信息,你可以创建一个发行版本,并使用/W4编译选项,或者/O1、/O2、/Os、/Ot优化选项。
表7.1 调试错误的编译选项
编译选项 |
含义 |
/W4 |
在最髙的警告层次作编译(所有的版本都使用) |
/D "DEBUG" |
打开条件编译调试代码开关,例如,断言语句与跟踪语句(仅在调试版本中使用) |
/Gz |
有助于在调试版本中找到在发行版本中经常会出现的错误,包括未被初始化的自动(局部)变量、堆栈错误、不正确的函数原型(仅在调试版本中使用) |
/Od |
关闭优化开关,使得代码在调试器下更容易读懂(仅在调试版本中使用) |
/GF |
消除重复字符串,并将字符串放到只读内存中,从而避免它们被错误地修改。当/ZI编译选项(编辑继续的程序数据库)打开时,该选项自动被打开(明确地只能被发行版本使用) |
/ZI |
用调试符号和编辑继续信息创建程序数据库,从而减少调试周期的时间和所花费的精力(仅在调试版本中使用) |
/Zi |
创建调试符号的程序数据库(仅在发行版本中使用) |
表7.2调试错误的链接选项
链接选项 |
含义 |
/MAP:"Debug /ProgramName.map" |
创建一个映射文件 |
/MAPINFO:LINES |
在映射文件中添加行号信息 |
程序员经常会对调试版本与发行版本的区别很迷惑,特别是在调试版本可以运行、而发行版本不能运行时。让我们首先从调试版本与发行版本不同的编译选项开始,来看看这些不同到底是什么含义。
表7.3列出了特别针对调试版本的编译选项。表7.4列出了特别针对发行版本的编译选项,除了这些选项,还有其他多种编译选项(特别是/Fd、/Fo、/Fp)被用来管理Debug与Release目录下的输出文件。
表7.3调试版本的编译选项
链接选项 |
含义 |
/MDd,/MLd,或者/MTd |
使用调试版本的运行时刻函数库 |
/Od |
关闭优化开关 |
/D "_DEBUG" |
打开条件编译调试代码开关 |
/ZI |
创建编辑继续的程序数据库 |
/GZ |
在调试版本中捕获调试版本的错误 |
/Gm |
打开最小化重新链接开关,减少链接时间 |
表7.4发行版本的编译选项
编译选项 |
含义 |
/MD,/ML或者/MT |
使用发行版本的运行时刻函数库 |
/O1或者/O2 |
打开优化开关,使得程序会最小或者说速度会最快 |
/D "NDEBUG" |
关闭条件编译调试代码开关(具体说就是ANSI C的assert函数) |
/GF |
消除重复字符串,并将字符串放到只读内存中,从而避免它们被错误地修改 |
我发现这些编译选项最有趣的是那些没有的东西。明确地说,就是没有/Debug选项来指定是调试版本,也没有/Release选顶来指定是发行版本。实际上调试版本使用了一组选项来帮助你进行调试,而发行版本也使用了一组选项来产生高效的代码。在任何版本中,你可以选择任何一组选择(仅有的限制是一些选项与另一些选项不相容),所以你可以有一个带有调试符号、跟踪语句、断言语句的发行版本,只要你选择了这些选项。编译器并不关心这些,因为它丝毫也意识不到这两个版本之间的差异。
这避开了我们的问题:调试版本与发行版本之间到底有什么差异?从某种程度上讲,不同的版本其实是人头脑中的某种界定。毕竟,带有调试符号、没有优化的发行版本更像是一个调试版本。不同的版本是为了达到不同的目的,你可以选择你想的任何选项来实现这些目的。如果硬要我选择一个编译选项来最好地刻画这些版本的不同,我会选择优化。一个发行版本一般意味着某些类型的优化,然而一个调试版本意味着没有优化。例如,在本章的前面我提到Visual C++编译器能发现使用未被初始化的局部变量的错误,而发行版本中的这个功能比调试版本的要更强大一些。它们的不同在于优化,但你也完全可以有自己的见解。
一个发行版本一般意味着某些类型的优化,然而一个调试版本意味着没有优化。
下面让我们来更具体地看看每一个调试编译链接选项,并和发行版本相对比。
与C的运行时刻函数库链接的调试版本能帮助你进行调试。除了有调试符号,最重要的区别在于调试版本的运行时刻函数库使用了调试堆(Heap),调试堆在第9章“内存调试”中作了详细介绍,下面是对它的一些最重要的特性的一个概要总结。
•调试版本的运行时刻函数库对内存的分配作了跟踪,并允许用户检查内存泄漏。
•在刚分配的内存里写上0xCD的字节模式,这有助于发现使用末被初始化数据的错误。
•在被释放的内存里写上0xCD的字节模式,这有助子发现使用了被释放的内存。
•在缓冲区的两边分配了四字节的保护数据,并用0xFD的字节模式作初始化,来检查写内存的上溢出和下溢出。
•在每个内存分配的地方对源代码文件名和行号作了记录,这有助于用户在源代码中对内存分配进行定位。
在这些特性中,两种版本之间的一个明显不同处在于调试版本能发现多种内存错误,而发行版本不能。另一个重要的不同点是调试版本允许(并且报告出)对内存的写操作有四个宇节的上溢出和四个字节的下溢出,对程序不会有任何影晌,然而同样的错误在发行版本中就会导致内存破坏。
因为未被优化的汇编代码直接对应于源代码,所以比优化后的代码更容易读懂。所以当你调试代码时,调试器会以你预想的方式工作,而优化后的代码却可能会到处跳转,和你预想的很不一样,而且一些变量也因为优化去掉了。此外,未被优化的代码编译与链接会更快,从而会有更短的调试周期。
理论上讲,调试版本不会比发行版本更完善,所以一旦你的调试版本被调试通过,你可以确信你的发行版本至少可以运行得同样好。不幸的是,这个完美的理论因为优化而被打破了(对调试堆里保护字节的小范围内存覆盖也是一个原因,这在前面提到过),优化代码要求编译器作一些假设,并去除冗余;但有时这些假设是错误的,并且去掉的冗余也有可能隐藏错误。下面我将介绍与优化有关的一些问题。
在调试版本中,堆栈基址指针(EBP寄存器)在函数内部是一个常数,所以它可以用来寻找堆栈里的内容,具体地说,便是函数返回地址。然而,在发行版本中,堆栈基址指针也许被优化掉了。这种类型的优化被称为帧指针省略(FPO),堆栈基址寄存器被用作通用寄存器,而且省去了函数调用时对EBP寄存器的压入、弹出操作。如果想详细了解帧指针省略的更详细的信息,请参看第6章“在Windows中调试,与第9章“内存调试”。
帧指针的省略隐藏了函数原型不匹配的错误,这仅在调试版本中函数返回时会导致崩溃。
正如我在第5章“使用异常和返回值”中提到的,如果你的程序编译时采用了同步异常模式,异常处理程序可能会被优化掉,同步异常模式通过/GX或者/EH编译选项来设置,并且成为默认模式。使用异常模式时,Visual C++假设C++异常仅仅会被含有throw语句或者调用了一个函数(函数可能有一个throw语句)的代码产生。任何其他的代码不可能接收到C++异常,所以编译器在发行版本中将不必要的异常处理代码优化掉了,而在调试版本中没有这样做。因此,同步异常模式会阻止程序中的C++异常处理代码安全地捕获结构化的异常(由非法访问、被零除、堆栈溢出等等错误引起)。要捕获结构化的异常,你必须使用异步异常模式,这可以使用/Eha编译选项来设置。完全不懂得这种方式的程序员在发现有些异常处理代码在发行版本中消失时,会觉得特别奇怪。
volatile关键字告诉编译器一个特定的变量可能以一种编译器不知道的方式改变值,例如被操作系统修改,被硬件修改或者被一个并发运行的线程修改,所以编译器一定不能对这个变量作任何优化。结果是,一个volatile变量总是从它被存储的地方读进和写出,而不是为了优化而保存在寄存器中。调试版本没有作优化,所以事实上调试版本中的所有变量都是volatile的。相反,如果一个变量被错误地未设置成volatile,发行版本中便会有一个与优化相关的错误,如果你的程序是多线程的,那么程序出问题的可能性非常大,这将在第10章“调试多线程程序”中介绍。
事实上调试版本中的所有变量都是volatile的。
优化会去掉不必要的变量以及重复使用的变量。这可能会隐藏错误。看看下面的代码。
void StackAttack() {
int optimizedOut1, optimizedOut2;
TCHAR bugsText[16], *bugs = _T("This function has bugs!");
_tcscpy(bugsText, bugs);
}
在这个函数中,bugsText缓冲区只有16个字节长,对于接收bugs字符串来说太短了。不必要的变量optimizedOut1和optimizedOut2在调试版本中会保护堆栈内容不被破坏,但这些变量在发行版本中会被去掉。导致的结果是,缓冲区的溢出会破坏堆栈里的函数返回地址,从而在发行版本中会导致崩溃,而在调试版本中不会。
一般地,被优化掉的变量不会这么明显。本章后面提到的GetlnitializedPoint函数是一个更现实的例子。
发行版本也许真的有一个优化错误,这是由过度优化或者优化器本身的错误(这是大家所熟知的,特别是在Visual C++的早期版本中)引起的。然而,正如前面提到的一些优化问题所说的,如果你的程序没有被优化可能正常运行,而在优化后却崩溃了,这不一定是优化器的错误导致的。而且,优化器还可能发现代码中潜在的错误,而这些错误可能会被调试版本掩盖。
你可以通过以下方式来确定一个错误是否与优化相关。
•完全关掉优化。
•使用更安全的优化形式,例如对代码的大小作优化而不是对速度作优化。避免自定义的优化(例如假设没有别名,假设函数调用间别名有效),因为它们可能会优化得有点过头。
•选定某些文件关掉优化或者作更安全的优化。
•使用#pragma optimize对选定的代码关掉优化。
如果错误在这些措施下会消失,那么这个错误就是与优化相关的。记往,可能性最大的还是你的代码出了问题,而不是优化器自身。证实优化器错误的唯一可行方法是完全重新编译链接程序(检查是否有过时的代码没有用),检查反汇编代码是否有翻译错误。除了这种证明以外,将过失推到优化器上面仅仅是一个假设。
如果_DEBUG符号被定义了,Visual C++的C运行时刻函数库和NFC的调试代码才会被编译。当_DEBUG被定义时,运行时刻函数库包括_ASSERT、_ASSERTE、_RPTn、_RPTFn以及调试堆语句。MFC包括ASSERT、VERIFY、ASSERT_VALID、ASSERT_KINDOF、TRACE以及DEBUG_ONLY语句。MFC中起源于CObject的类还包括Dump和AssertValid函数,而且在起源于CView的类中,GetDocument函数是内联的,当NDEBUG符号没有被定义时,ANSI C的断言语句会被编译。
看看内联函数,有趣的是许多程序的调试版本将内联函数编泽成普通函数,使得调试器可以跟踪进函数的内部。这个技巧没有太大必要,因为Visual C++在调试版本中默认关掉了内联(具体地说,内联是被/Ob0编译选项关掉的,默认情况下这个选项是被选上的)。对于发行版本,内联能被/O1(大小最小)、/O2(速度最怏)、/Ox(完全优化)打开。
尽管C运行时刻函数库的版本由编译器选项决定,MFC可以使用_DEBUG符号来确定到底链接的是哪个版本。这是通过使用comment语句实现的,见下面的代码(取自Afx.h)。
#ifdef _AFXDLL
#ifndef _UNICODE
#ifdef _DEBUG
#pragma comment(lib, "mfc42d.lib")
#pragma comment(lib, "mfcs42d.lib")
#else
#pragma comment(lib, "mfc42.lib")
#pragma comment(lib, "mfcs42.lib")
#endif
#else
#ifdef _DEBUG
#pragma comment(lib, "mfc42ud.lib")
#pragma comment(lib, "mfcs42ud.lib")
#else
#pragma comment(lib, "mfc42u.lib")
#pragma comment(lib, "mfcs42u.lib")
#endif
#endif
#endif
这里,lib注释提供了链接器要寻找的函数库。注意,这个技术使你能够比使用工程设置(project settings)更好地控制函数库。
如果步骤是正确的,条件编译调试代码在调试版本和发行版本中都会有相同的表现。所以,应该注意的是,一定要确信任何条件编译代码中没有非调试程序代码,而且调试代码不会产生在何副作用,唯一的例外是MFC的VERIFY 宏,它允许用户将程序代码放到布尔表达式中。而且,断言语句和跟踪语句可能会改变多线程的程序执行情况,这是因为执行起来要花费很多时间的断言语句会改变线程的相对速度,而跟踪语句会对线程产生线性化的影响。
打开编辑继续(Edit and Continue)选项有一定的副作用,它会打开/GF编译选项,/GF编译选项会消除重复字符串,并将字符串放到只读内存。当发行版本也打开了/GF编译选项时,调试版本和发行版本应该有同样的执行效果。
/GZ编译选项会做下面的一些事情:
1.用0xCC字节模式初始化所有的自动变量,这使得使用未被初始化的指针会导致非法内存访问异常〈至少Windows 2000是这样的)。
2.当通过函数指针调用函数时,会通过检査堆栈指针来验证函数调用的匹配性。
3.在函数末尾检查堆栈指针,确认它没有被修改。
第2、3特性能帮助调试版本找出函数原型不匹配的错误,和发行版本中的FPO优化选项有大致相同的效果。
许多程序员认为Visual C++在调试版本中将自动变量初始化为0,而在发行版本中不是这样。这个信条可以理解成一种市井传说,因为它从来就不是正确的。实际上,仅仅在调试版本中将自动变量初始化为零是一个编译器做的最不合适的一件事。在前面我已提到,编译器是意识不到一个版本是不是调试版本,所以编译器根本就没有办法知道是否要进行这样的初始化。但是,如果/GZ编译选项(它与优化版本不相容)打开了,编译器会将所有的自动变量初始化为0xCC字节模式。
如果代码己经成功地编译了,打开最小化重新编连不应该对程序的调试版本的执行有影响。
假设你的程序有一个只在发行版本中才出现的错误。除非你是一个汇编语言高手,否则,你需要调试符号来提示你到底发生了什么。正如你知道的,事情并没有你事先想象的那么令人沮丧,因为你可以创建一个带有调试符号的发行版本,并直接使用调试器来调试它。尽管Visual C++的AppWizard默认情况下并没有为发行版本创建调试符号,你可以在工程设置中作一些修改来创建调试符号。做这些通常会使得发行版本的可执行文件小一些,但不会小很多(通常在100字节范围内),这主要是因为调试符号保存在程序的数据库文件(PDB)中,而不是在可执行文件自身。你可以在原有基础上为发行版本创建调试符号,或者完全创建一个新的编译链接设置,但从创建调试符号不会给程序带来很大影响的事实,我们能够得到Visual C++调试的黄金定律:最好为你的可执行程序创建调试符号,并将得到的PDB文件存档,即使程序属于发行版本。不幸的是,Visual C++没有将创建调试符号设置成默认的,而这会误导程序员,让他们认为发行版本不能创建调试符号。
为程序的某个版本创建调试符号,要对程序所对应的VC项目做如下的设罝:
1.打开工程设置对话框,在Settings for对话框中选择所需的版本(例如“Win32 Release”)。
2.在工程控制树里,通过单击根节点选择整个工程。
3.在C/C++标签里选择Common类。在调试信息里,如果是发行版本则选择Program Database,如果是调试版本则选择Program Database for Edit and Continue(注意,编辑继续选项与优化链接不相容,而且它还增大可执行文件的长度,从而不适合于发行版本)。
4.在Link标签里选择Debug类。然后选择Debug info和Mirosoft format选项。记住不要选择Seperate types选项,这样所有的调试信息才会被合并到单独的一个PDB文件中。另外,如果你需要做事后调试的映射文件时,记住要选择产生Generate MapfiIe选项。
5.对于发行版本,选择Link标签,在Project options对话框的最后加上“/OPT:REF”。这个选顶使得不被引用的函数和数据不会出现在可执行文件中,从而避免了文件无谓的增大。对于调试版本不要使用这个选项,因为它会关闭增量链接。
6.使用Rebuild AlI命令重新编译整个工程。
如果你发现带有调试符号的可执行文件比不带调试符号的可执行文件大许多,很有可能是你忘记了加上了/OPT:REF链接选项。
现在你就可以开始调试了。一旦你开始调试发行版本,你很快就会发现调试未被优化的代码有很多好处。首先,断点会比较难设置,这是因为源代码行号己经被重新组织,很难找到合法的断点行。问题在于调试器不是总能找到对应于一组优化指令的源代码。实际上,你会觉得从源代码上下文菜里里执行Go To Disassembly命令和在打开Source Annotation选项的反汇编代码窗口里设置断点反而会比较容易。你可以跟踪进入函数,但要注意,发行版本中针对内嵌函数和内部函数库的函数内联功能是打开的、而且你很可能没有发行版本的函数库(包括C运行时刻函数苦和MFC)的调试符号,所以跟踪进库函数不会有多大帮助。再一次,你会发现在某些情况下,为了进一步的控制调试,使用反汇编代码窗口进行代码的调试会比较容易。
令人高兴的是,最重要的调试工具——调用堆栈——仍然可以像往常一样工作,它通常需要调试器做一些特殊的处理,具休地说,FPO优化会消除通常用于搜索堆栈的堆栈帧指针,但是调试器还是能通过存储在PDB文件的特殊FPO调试信息构造调用堆栈,并找到函数返回地址。
调试发行版的最大的挑战是调试变量——或者更准确地说,就是调试过去曾是变量的东西。某些汇编语言对于调试发行版本确实会有好处(参看第6备的“汇编语言基础知识”)。让我们通过一简单的例子来看看这个问题。下面的函数返回一个未被初始化的POlNT对象。
POINT GetInitializedPoint() {
POINT pt;
pt.x = 13; // 0x0D
pt.y = 42; // 0x2A
return pt;
}
下面是对应该函数的未被优化的汇编代码,这更像是源代码的直接翻译,再加上标准的函数入口代码和清除(cleanup)代码。
push ebp ; save the stack base pointer
mov ebp, esp ; copy the stack pointer to the stack base
sub esp, 48h ; make room for local variables on stack
push ebx ; save the EBX, ESI, EDI registers
push esi
push edi
lea edi, [ebp - 48h] ; initialize the local variable with 0xCC
mov ecx, 12h
mov eax, 0CCCCCCCCh
rep stos dword ptr [edi]
mov dword ptr [dbp-8], 0Dh ; set x to 13
mov dword ptr [dbp-4], 2Ah ; set y to 42
mov eax, dword ptr [ebp-8] ; copy x to EAX
mov edx, dword ptr [ebp-4] ; copy y to EDX
pop edi ; restore the EBX, ESI, and EDI regirsters
pop esi
pop ebx
mv esp, ebp ; copy the stack base to the stack pointer
pop ebp ; restore the stack base pointer
ret
通读上面的代码,我们可以看到函数的入口代码对堆栈进行了设置,保存堆栈帧指针和当前堆栈指针,为POINT自动变量腾出空间,保存EBX、ESI、EDI寄存器。因为代码编译时带有/GZ选项,所以自动变量会被初始化为0xCC字节模式。然后将x数据成员设置为13,将y数据成员设置为42。最后,设置好函数返回值,从堆栈中恢复所有被保存的值,然后返回。按照约定,如果返回地址是64位的,低32位要保存在EAX寄存器中,高32位要保存在EDX寄存器中。特别要注意一下汇编代码是如何直接对应于源代码的,包括自动变量的使用。
下面让我们看看优化过的汇编代码。
mov eax, 0Dh ; set EAX to 13
mov edx, 2Ah ; set EDX to 42
ret
这些代码确实经过了很好的优化处理。它所做的只是将EAX寄存器设置为13,将EDX寄存器设置为42,然后返回,这绝对是该函数最少需要做的所有事情(而且,你还可以通过将函数设置为内嵌的来消除返回代码)。注意一下汇编代码是如何和原始源代码没有对应关系的,而且POINT变量也已经消除了。如果你尝试着显示pt,将会导致“CXX0017:Error:Symbol‘POINT’ not found”(CXX0017:错误:POINT符号没有找到)的错误。如同这个例子非常简单一样,真正知道到底发生了什么事的唯一途径便是调试汇编代码。
现在是提出这个非常重要的测试问题的适当时机了:如果你不能确信调试版本和发行版本有相同的执行行为,QA到底应使用哪个版本进行测试呢?记住,调试版本和发行版本是来自同一个源程序的但执行效果不同的程序。你不能假设这些版本运行起来是一样的,除非你能通过仔细的测试证明它们是一样的。
一些程序员认为只对发行版本进行测试就可以了,因为客户最后真正用的毕竟只是发行版本。最终,你必须对你发布的代码进行测试,所以发行版本是唯一真正起作用的版本。这个观点有一定的道理,促它忽视了调试版本的重要性。调试版本,从定义就可以看出,是帮助用户发现、定位、消除错误的。QA不对调试版本进行测试是一个严重的错误,因为它低估了本书所介绍的许多调试策略。例如,使用断言语句最根本的好处是自动发现许多运行时产生的错误。不对调试版本进行测试意味着测试员要手动地发现那些错误。而且,一旦错误被发现,使用调试版本进行跟踪,寻找原因比发行版本会容易得多。
显然,最好的解决方案是对这两个版本都进行测试。在开发进程的初期,几乎全部使用调试版本,然后,随着程序逐渐的趋于结束,逐步转向发行版本。同样很明显的一点是,如果在程序即将发布时,才对发行版本进行第一次测试是非常危险的一件事。
你需要对调试板本和发行版本都进行测试,你不能假设这些版本运行起来是一样的,除非你能通过仔细的测试证明它但是一样的。
如果你不是汇编语言高手(如今的时代,还有谁是呢?),调试符号对于调试的高效性非常重要。有好几种类型的调试符号和调试符号选项,所以,也就有了好几种调试符号问题。本节的目的是介绍不同类型的调试符号、调试符号选项以及如何处理调试符号问题。
程序数据库文件包含了Visual C++调试器所需的调试信息和程序信息。调试信息包含了变量的名字和类型、函数原型、源代码行号、类和结构的布局、FPO调试信息(重建堆栈顿)以及进行增量链接所需的偏息。此外,对于设置了Program Database for Edit and Continue选项的程序,程序数据库还要包舍执行编辑继续(Edit and Continue)所需的信息。这么多的内容使得你会发现PDB文件通常会非常大,而且往往比可执行文件还要大好多。
由于PDB文件是和可执行文件分离的,所以没有个很好的理由来说明为何不创建PDB文件,即使是发行版本的程序;但是,创建分离的文件对程序员来说确实是增加了额外的负担。具体地说,如果你要调试带有符号的程序,你必须将PDB文件和你发布的任何版本的源文件、可执行文件一起存档。如果不这样做,调试符号就没有任何价值了。很快你就会看到,绝大多数调试符号问题都和程序数据库文件的缺少、不同步有关。
如果你仔细查看了程序的Debug目录,你会发现实际上有两个PDB文件。如果可执行文件名为MyProject.exe,你会发现MyProject.pdb文件和Vcx0.pdb文件,这里的x是Visual C++的版本号。存在有两个文件的原因是Visual C++编译器在编译单个的源代码文件时,它需要将所有的数据类型信息存储在Vcx0.pdb中。编译器使用Vcx0.pdb的原因是,它在编泽单个源文件时,还不知道可执行文件的名字(这是正式的官方申明),所以这个文件会被工程中的所有目标文件共亨。当链接器创建可执行文件时,同时会创建单独的一个工程PDB文件,并将Vcx0.pdb里的全部调试信息包括进去,同时还增加其他的一些信息。
这意味着Vcx0.pdb文件是临时文件,并且可以被完全忽略吗?看起来好像是。还有一个阿题,Visual C++有一个不是非常重要的链接选项,称为Separate types(默认情况下它是打开的,在工程设置对话框的Link标签里的Debug类里可以找到)。这个选项在工程选项里是以/pdbtype:sept形式出现的。如果你选择了该选项,链接器不会将Vcx0.pdb里的信息合并到工程PDB文件里,所以要求程序员如果要很好地调试程序,必须同时有这两个PDB文件。当有多个时执行文件时,使用分离的PDB文件特别不可取,因为你需要将多个Vcx0.pdb文件拷贝到同一目录下(这是不可能的)。如果Visual C++调试器弹出了寻找符号的对话框,向你索要Vcx0.pdb文件,你应该知道你选择了Separate types选项。在这种情况下,你可以单击Cancel按钮,消除该对话框,并继续调试程序,但你就不能在调试器里看到任何数据类型信息了。
使用Separate types选项有一个好处,减轻了链接器在对工程进行编连时的工作量,并且最终生成的文件会占用较少的磁盘空间,但是,这个选项比较不好,最好不要用,除非你要在一个非常慢的计算机上链接非常大的工程。
如果你运行的是Windows 2000或者Windows NT,要很好地使用调试器必须首先安装系统调试符号。有了调试符号后,调试器才可以在调用堆栈里显示系统函数名。注意,Windows 98没有系统调试符号,但Visual C++提供了MFC和C运行时刻函数库DLL的符号。Customer Support Diagnostics光盘可以用来安装Windows 2000的调试符号。当你将光盘插入光驱时,安装程序会被自动启动。如果要在Win NT4.0里安装调试符号,首先找到Visual C++程序组,然后运行Windows NT符号安装程序。这两个安装程序都会将系统DBG文件从光盘上拷贝到\WinNT\Symbols\Dlls目录。
这里唯一的技巧是,只有在符号文件和已安装的可执行文件匹配时,调试器才会使用符号文件。在你安装了服务包后,很有可能出现不匹配。所以,每当你安装了服务包时,应该从服务包光盘上的Support\Debug目录下的系统符号拷贝出来以更新系统符号。如果你对系统符号的状态不太确信,可以在Visual C++调试器里运行程序。只要发现任何的符号不匹配,在输出窗口的Debug标签上就会有“Loaded 'EXAMPLE.DLL', no matching symbol information found”(EXAMPLE.DLL已装载,但没找到匹配的符号信息)。
这些DBG文件包含了主要为Windows系统调试器所用的调试信息,但它们同时也可以被Visual C++调试器使用。调试信息包括全局变量名、函数名以及FPO调试信息。DBG文件使用了COFF和CodeView文件格式,但Visual C++调试器仅仅使用了CodeView格式信息。
你也许会奇怪系统调试符号为什么没有使用PDB文件格式。原因是微软的Windows系统开发人员使用的调试工具(具体地说,就是WinDbg、NTSD/CDB以及KD调试器)和一般的人使用的不一样,所以他们也就使用了为这些调试工具而设计的不同的调试文件格式。系统层的调试和应用层的调试很不一样,所以Visual C++调试器对系统层的调试并不是非常理想。DBG文件相对于PDB文件的一个重要好处在于DBG文件格式不会经常变化。PDB文件格式随着Visual C++的每一个新版本的出现都会有所变化,因此异致文件和其他版本的不相容。为每一个最新的Visual C++版本提供不同的系统调试文件是不可行的。
如果你使用的是Visual C++调试器,很有可能你会为调试版本选择Program Database for Edit and continue选项,为发行版本选择Program Database选项。然而,在不平常的情况下,你可能还需要选择另外两个调试选项。第一个是C7 Compatible选项,它会把调试信息放到可执行文件的末尾,而不是生成一个单独的PDB文件。如果你实在不愿意生成分离的调试文件,这个选项是有一定意义的。第二个是Line Numbers Only选项,它只会把全局符号和行号信息放到可执行文件的末尾。如果你不愿生成分离的调试文件,而且希望时执行文件比较小,这个选项也是有一定意义的。这两个选项都会关掉增量链接,从而会很大程度地增加链接时间。顺便提一下,我想不出,实际操作中使用这两个选项的任何理由。
调试符号是墨菲法则在实际生活中的又一个例子,任何可能出现问题的事物都会出现问题。下面是你可能遇到的几种调试符号的问题。
•调试符号没有找到。
•调试符号找到了,但是与可执行文件不匹配。
•调试符号找到了,但是可执行文件在预想的目录下没有找到。
•匹配的调试符号找到了,但是它们和你所使用的Visual C++的版本不相容。
简而言之,如果要调试成功,你需要确信可执行文件、源代码文件、调试符号文件以及你所使用的Visual C++的版本都是匹配的,而且都能在预想的目录下找到。注意,当调试符号文件和可执行文件有相同的内部时间戳(time stamp)时,它们才被认为是匹配的,所以你不能通过查看文件日期来判断它但是否匹配。
要确信这些全部匹配,你需要将工程中的所有可执行文件、源代码以及PDB文件一起归档。如果你保存了不同版本的Visual C++编译器生成的代码,你还需要安装所有这些版本的编译器,这样,当你调试和这些版本匹配的程序时,就可以直接使用它们了。在这种情况下,你可能还希望将比较重要的与编译器版本相关的库文件也一起归档,例如Msvcrtd.dll和Mfc42d.dll,包括发行版本、调试版本以及它们的调试符号。所有的这些将保证所有的事物都是同步的。
你可以通过査看输出窗口的Debug标签来确定调试符号是否已成功装载了。如果调试符号被成功装载了,你会收到“Loaded symbols for‘C:\SOMEPATH\SOMEEXE.DLL’.”的消息。如果符号没有被装载,你会收到“Loaded 'EXAMPLE.DLL', no matching symbol information found”(EXAMPLE.DLL已装载,但没找到匹配的符号信息)。这时,或者是调试符号文件没有找到,或者是与可执行文件不匹配。不幸的是,这条错误信息没有给出进一步的关于这个问题的提示。既然错误信息给出了全路径名,而且调试符号文件应该和可执行文件在同一目录下,所以你可以通过查看该目录来很快地确定到底是哪种情况(从详细的技术方面讲,调试器会首先在保存可执行文件的路径下查找文件,然后使用Windows文件査找序列来査找文件,这在LoadLibary API函数的文挡里有详细的介绍。如果你总是将这些文件保存在一起,你的生活将变得容易得多)。如果有必要,你可以使用Dumpbin工具(在Vc\Bin下)来比较可执行文件和DBG文件的时间戳,命令行中要带有“/headers”选项,然后检査“timedatastamp”记录就可以了。
如果DLL是在程序编连时链接进程序的,那么当主可执行文件被装载时,调试器会自动将DLL的调试符号也装载进来。如果DLL是在运行时才链接的,DLL的符号就不会被自动装载,例如使用LoadLibrary API函数或者使用COM装载的DLL。结果是你不能在这些模块上设置断点。不过你可以通过预装载调试符号来解决这个问题,在工程设置对话框的Debug标签里的Additional DLLs类里加上所有在运行时才会装载的DLL就可以了。但是,要注意Windows自身有一套关于装载DLL的规则,并且注册表也决定了COM装载的模块,所以有可能程序会从与你指定的目录不同的目录下装载DLL,这会导致“Preloaded Symbols may not match”(预装载符号不匹配)的错误。在这种情况下,你或者修改Additional DLLs里指定的路径(如果正确的DLL被装载了),或者驱动被装载的DLL(如果错误的DLL被装载了),或者修正注册表(如果错误的COM组件被装载了)。
本节将介绍Visual C++调试窗口,并给出一些能帮助你更好地使用调试窗口的技巧。下面的这些特性是所有调试窗口共有的。
•当跟踪程序时,所有显示数据的窗口会将信变化的变量所对应的数据项设置为红色。如果继续跟踪程序,数据项又会恢复为黑色直至它的值又被改变。
•可编辑文本区域支持标准的剪切、复制、粘贴以及取消操作等编辑命令,而只读文本区域只支持复制命令。
•所有含有多列的窗口在双击列边缘的竖直分线时,将自动调整第一列的大小使得第一列里的内容能恰好显示。
•观察窗口、变量窗口以及调用堆栈窗口的上下文菜单里有一个Hexadecimal Display选项,它会影响输入,也会影响数据的显示。在其中一个窗口里改变该选项,所有窗口都会相应改变。
调试程序时,你可以使用观察(Watch)窗口监视变量和表达式。后面我将更详细地介绍观察窗口的表达式和格式化符号。你可以直接在观察窗口里输入表达式,也可以从源代码中、变量(Variable)窗口中、寄存器(Register)窗口中、内存(Memory)窗口中、以及调用堆栈(CallStack)窗口中将表达式拖过来。
当你监视一个单独的变量时,你可以在调试过程中在Value一栏修改该变量的值。这个技术对于进行实验性的调试和暂时修复错误非常有用。
和Visual C++文档相反的是,在观察窗口里没有Type这一列。不过你还是可以确定变量的基类型,选中一个变量,在上下文菜单里选择Properties命令,然后查看Type这个域就可以了。
本节对观察窗口的许多介绍对快速查看(Quick watch)对话框同样适用。例如,快速査看对话框也有改变变量值和自动改变大小的特性,但是不支持拖动、将最近改变的内容用红色表示以及Properties命令。
我本人不太使用快速查看对话框,而且它是模式的,看不出有什么是快速的。
变量(Variables)窗口有三个标签:Auto目标签显示了当前语句和前一条语句用到的变量;Locals标签显示当前函数的局部变量;this标签显示了this指针执行的对象。Auto标签也显示函数返回值,但它只在返回值是32位或更少时才有效。如果你仅仅是在跟踪代码,变量窗口可以让你看到什么正在进行,而不用频繁地将变量输入到观察窗口中。
变量窗口最有趣的特性是Context对话框,它允许你在调用堆栈的任何带有调试符号的点显示变量和相应的值。你也可以使用观察窗口和上下文操作符显示这些变量,但这个方法通常会花费更大的精力。如果你没有看到Context对话框,在上下文菜单里选择Toolbar选项即可。
你可以在调试过程中在Value一栏修改变量的值。这个技术对于进行试验性的调试和暂时修复错误非常有用。你可以通过下面的方法确定变量的基类型:选中一个变量,在上下文菜单里选择Properties命令,然后查看Type这个域就可以了(和文档相反的是,变量窗口没有Type栏)。
使用寄存器(Register)窗口可以监视CPU的寄存器、标志值以及浮点堆栈。你还可以使用寄存器窗口来修改寄存器的值,用鼠标单击值,或者使用Tab键切换到值单元,然后就可以输入你所想的值了。
显然,你不应该改变一个寄存器的值,除非你能确切知道你在做什么。即使你知道你在做什么,你也应该尽量避免使用这个窗口改变寄存器的值,因为通常你还可以通过应用另一种调试特性找到一种更简单、风险更小的方法来完成同样的任务。例如,你可以通过改变EIP寄存器来修改当前执行指令,但完成这个任务的更好的方法是使用Set Next Statement命令,它的好处在于当你试图做一些实在很愚蠢的事情时,它会警告你。
使用内存(Memory)窗口可以显示从一特定地址开始的虚拟内存。你还可以使用内存窗口来修改内存单元的值,用鼠标单击值,或者使用Tab键切换到值单元,然后就可以输入你所想的值了。你可以在上下文菜单里选择Byte Format、Short Hex Format、或者Long Hex Format来决定如何显示内存单元的值。
内存窗口最有趣的特性是Address框,它允许你指定从哪个虚拟内存地址开始显示。Address框接受虚拟内存地址(例如0x00400000)、变量(例如rect、*pRect或者*this)或者寄存器(例如ESP)。注意,对于变量你需要给出的是对象,而不是对象的指针。例如,你有一个指向矩形的指针,你应该输入解除参照的指针(例如*pRect)来得到正确的地址。如果你给出的是寄存器,虚拟内存地址将立即被寄存器的值替换,所以不要以为内存窗口显示的内容将随着寄存器的改变而改变。如果你没有看到Address框,在上下文菜选择Toolbar选项。另外,除了使用Address框,你还可以使用Edit菜单栏里的Go To命令。
你可以直接在内存窗口里输入地址,也可以从源代码中、观察窗口中、变量窗口中、寄存器窗口中以及调用堆栈窗口中将地址和变量拖过来。但要注意,拖动的目的地是内存数据列表,而不是Address框,这一点我发现很容妨混洧。另外,由于某些原因,你输入的地址对应的内存不一定在内存数据列表的顶端,也许在下面几行。所以,在你变得惊慌之前,仔细检査你输入的地址并确定它到底是从哪里开始的是一个不错的主意。
使用调用堆栈窗口可以显示引起当前源代码语句执行的一系列函数调用,当前函数在堆栈的顶端。你可以设罝上下文菜单里的Parameter Types和Parameter Values选项来决定调用堆栈如何显示。Parameter Types选项显示函数参数的数据类型,而Parameter Values选项显示函数参数的值。这两个选项默认情况不是打开的。
双击某个函数调用时,如果存在相应的源代码,将显示源代码窗口,否则将显示反汇编代码窗口。Go To Code命令有相同的功能。代码窗口用一个黄色的箭头显示当前执行语句,或者用一个绿色的箭头指向非活动堆栈帧的代码。你还可以从调用堆栈窗口执行Insert/Remove Breakpoint命令和Run to Cursor命令。所有这些命令都在上下文菜单里。
使用反汇编代码(Disassembly)窗口可以査看编译器生成的对应于源代码的汇编指令。你可以使用Edit菜单里的Go To命令显示特定代码地址处的汇编代码。
使用反汇编代码窗口很像使用只读的源代码窗口,但有了更多的控制。具体地说,你可以使用Insert/Remove Breakpoint命令设置断点,可以使用Set Next Statement命令改变程序的执行,还可以使用Show Next Statement命令来确定当前程序语句。通过设置Source Annotation和Code Bytes选项可以选择如何显示汇编代码。你还可以使用Go To Source命令显示相应的源代码。所有这些命令都在上下文菜单里。
观察窗口还有许多看不出来的功能(实际上所有这些功能之所以不能看出来是因为默认情况下该窗口是空白的,而且文档也不是很好)。在Name一栏除了可以输入变量外,你还可以输入简单的C++表达式,甚至可以调用函数。这些功能使得观察窗下非常酷。
在本节里,我将介绍如果使用观察窗口的表达式。使用观察窗口时,如果你不能确信该做什么,或者不知道什么可以工作,尽管去尝试好了。很有可能你会惊喜地发现它居然可以工作。
下面是观察窗口的求值规则:
•只有活动标签上的表达式才会被求值。
•每当调试器暂停时(断点、单步跟踪或者异常),或者活动标签被修改时,这些表达式会被求值。
•以从上往下的顺序对这些表达式求值。
这些求值规则可以让你比较有策略地使用这些标签。你可以将那些你希望一起査看的表达式集中在同一标签上。还有另外一种选择,你可以将那些属于同一个特定调试任务的表达式组织在同一标签上,这样,你就可以用一个标签来调试内存,另一个标签来调试变量,另一个来调试寄存器,等等。你也可以使用不同的标签进行变量赋值的切换。例如,假设你有一个全局变量Debug。你可以在一个标签上输入“Debug=1”,在另一标签上输入“Debug=0”,这两个标签的切换就会改变该变量的值。
观察窗口表达式可以包含以下的操作数:
•常数(十进制、十六进制、八进制、字符、浮点数,但不能是程序里的常数);
•程序变量;
•寄存器和伪寄存器(详情请参看下一节)。
它还包含以下的操作符:
•算术运算(+、-、*、/、%、++、--);
•赋值运算(=、+=、-=、*=、/=、%=、>>=、<<=、&=、^=、|=);
•关系运算(:==、!=、<、<=、>、>=);
•布尔运算(!、||、&&);
•位运算(~、>>、<<、&、|、^);
•地址运算〈&、*);
•数组运算([]);
•类/结构/联合运算(.、->);
•sizeof;
•C强制类型转换(只能有一层间接转换);
•函数调用。
你还可以使用括号,这不属于操作符号。注意,观察窗口不支持条件操作符(?:)和逗号操作符。
你可以使用赋值表达式来设置变量的值。例如,假设你要将x的值设为100,你可以将“x”以表达式的形式输入,然后在Value—栏改变它的值。另一祌方法是你直接以“x=100”作为表达式输入,这样每当这个表达式被求值时,x的值就会被重新设置。如果你经常要做这个赋值运算,这个方法也许会更方便一些。但是,你应该在使用完后将该赋值表达式删除或者改变活动观察窗目标签,因为如果不这样,该表达式将会继续被求值,从而可能产生令人不解的结果。
要慎用赋值表达式,因为它们会继续被求值,从而可能产生令人不解的结果。
对于常数,你应该注意的比较特殊的一个特性是,当你打开Hexadecimal Display选项(从任何调试窗口的上下文菜单)时,表达式里用到的常数都会被解释成十六进制。例如,如果你输入了表达式“myVvr+16”,加的将是0x16,而不是十进制的16。如果要使用十进制,必须加上“0n”前缀,如“myVar+0n16”。这一点对变量窗口同样适用。
观察窗口通过使用上下文操作符对表达式的范围进行限定,并解决不确定问题。上下文操作符的语法如下—所示。
{[function name], [source code File], [executable file]}
你要提供函数名、源代码文件、可执行文件,或者这3者的任意组合。两个逗号必须存在,除非源代码文件和可执行文件都被忽略了。而且,如果源代码文件名和可执行文件名里包含了逗号、空格或者大括号,你必须用引号将名字括起来。你可以使用“{*}”来指示当前上下文。
对于观察窗口表达式,通常你只需要在上下文操作符里提供一个单独的项。最常用的是,用可执行文件和函数调用来指示定义该函数的模块,例如,你可以用下面的语句来调用Visual C++运行时刻函数库的_CrtCheckMemory函数:
{,,msvcrtd.dll} _CrtCheckMemory()
给出源代码文件名可以解决不确定的全局变量。例如,假设你在好几个不同的源代码文件里定义了ErrorStatus静态全局变量,你可以用下面的语句来显示一个特定的变量:
{, MyFile.cpp} ErrorStatus
给出函数名可以解决不确定的自动变量和函数参数。例如,假设你有带有局部变量Count的函数Function1,它调用同样带有Count局部变量的函数Function2,你可以使用下面的语句来指定任何一个变量:
{Function1} Count
{Function2} Count
如果没有上下文操作符,当前上下文会把前面的任何上下文隐藏起来,这样就使得Function1里的Count变量在Function2里不可以被访问。
可以将上下文操作符应用到变量、函数、类型以及断点。对类型使用上下文操作符允许使用其他模块定义的数据类型进行强制数据类型转换。例如,如果你要将当前上下文里的指针变量pSomeObject强制类型转换到MyDll.dll里定义的CsomeType类型,你可以使用下面的语句:
{, , MyDll.dll}{CsomeType*){*}pSomeObject
对于观察窗口我比较喜欢的是调用程序函数的功能,尽管这个功能有一定的限制。在进行调试时,从观察窗口里调用函数并显示它的返回值或者看看它的副作用是非常有用的。例如,你可以调用一个调试函数,将某个对象的信息或者跟踪信息转储到输出窗口的Debug标签上。
你还可以应用这个功能来调用普通函数和成员函数,甚至包括私有成员函数。但是,每个表达式最多只能调用一个函数,并且你不能调用内嵌函数、构造函数以及析构函数。而且比较奇怪的是,你也不能调用Windows API函数,但如果你将它们包装在自己的程序函数里,你还是可以调用它们的。所有的断点都将被忽略,如果函数的执行超过了20秒或者抛出了未被捕获的异常,该函数将停止求值。函数被限制在当前线程里运行,并且不能创建线程,也不能使用其他线程,但该进程里的其他线程允许执行,并且可能会引起上下文的切换。
既然每当调试器暂停时,观察窗口里的表达式都要被求值一次,因此观察窗口里的函数将被频繁地调用。如果你只要调用函数一次,可以在快速査看对活框里输入该表达。
如果你收到了“CXX0017:Error:symbol “functionName not found”(CXX0017:错误:符号“函数名”没有找到)的错误信息,那么一定要使用上下文操作符。调用没有参数或者带有常数、寄存器参数的函数应该不会有任何问题,但调用带有变量参数的函数时,很容易让人不解。例如,你尝试着输入MFC函数afxDump,将this指针转储到输出窗口的Debug标签。
{ , , mfc42d.dll } afxDump(this)
这将导致“Error: Symbol 'this' not found”(错误:符号this没有找到)的错误信息。问题在于上下文操作符会应用到整个表达式(更准确的说是应用到表达式的剩佘部分),所以调试器将试图在mfc42d.dll里找到this指针,这当然不能正常工作。解决方法是使用“{*}”上下文操作符,它使用当前上下文。当然,下面的表达式也不能工作(否则这就太容易了)。
{ , , mfc42d.dll } afxDump({*}this)
这将导致“Error: Argument list does not match a function”(错误:参数列表和函数不匹配)的错误信息。要解决这个问题你需要将this指针进行强制类型转换,所以下面的表达式将以预想的方式工作。
{ , , mfc42d.dll } afxDump((const CObject*){*}this)
解决这个问题确实是一件很让人痛苦的事,但一旦你做成功了,还是很值得的。
如果你不能向观察窗口里的函数传递变量,很有可能你需要使用“{*}”上下文操作符来使用当前上下文。
在观察窗口的表达式里调用函数有一个问题,它们通常要会费很多时间来求值。如果你发现调试器在跟踪程序时要用很长时间,很有可能在观察窗口里有函数调用。一个简单的检査方法是关掉观察窗口再看看性能如何。如果调试器恢复正常,那么在观察窗口里一定有花费很长时间求值的函数调用。
输入一个不带有参数的函数名时(括号也要省硌),观察窗口会显示函数的虚拟内存地址和它的原型,这个功能有时会给你提供很大的方便。
Visual C++调试器的数据标签(Datatip)也支持和观察窗口表达式类似的表达式功能,但不支持下面的功能:
•寄存器和伪寄存器操作数;
•赋值操作符;
•函数调用:
•上下文操作符。
如果你想查看的表达式在屏幕上而且在当前执行范围内,你不用将它输入到观察窗口里。首先选择一个合法的表达式,将鼠标箭头移到表达式上,你就会在数据标签里看到该表达式和它的求值结果。例如,假设程序里有这样一段代码:
int fred, wilma, barney, betty;
...
fred = (wilma + barney + 4) / (betty + 8);
你可以选择任何合法的表达式,例如“wilma + barney”、“barney + 4”、“wilma + barney + 4”、“betty + 8”,并在数据标签里查看表达式的求值结果。
你可以用数据标签来显示源代码里在当前执行范围内的表达式。
顺便提一下,如果你要査看的源程里的表达式里含有函数调用或者赋值操作符,对表达式求值的最简单的方法是,或者将选择的表达式拖到观察窗口里,或者使用QuickWatch窗口命令。
正如我前面提到的,你可以在观察窗口表达式和内存窗口Address框里输入寄存器和伪寄存器。你也可以使用寄存器窗口来查看寄存器,但观察窗口的功能要更强大一些,因为你可以在表达式里使用寄存器。而且使用观察窗口避免了显示多余的调试窗口,从而一定程度上减少了窗口的混乱程度。
观察窗口和内存窗口支持表7.5列出的寄存器。这些名字都是“正式”寄存器名,但你可能会经常使用这些名字的变体。例如,对于EAX寄存器,你可能会用@eax、EAX、eax甚至Eax。而且,使用@前缀可以避免和变量名产生冲突的可能性,所以当你有一个名为eax的变量时,可以使用@eax。
观察窗口还支持表7.6列出的伪寄存器。内存窗口虽然也识别这些伪寄存器,但它们不会有任何意义。@ERR伪寄存器对于在观察窗口里监视GetLastError值会很有用。你可以输入“@ERR, hr”来査看WIn32错误码对应的文本(同样地,你可以输入“@eax, hr”来监视COM HRESULTS)。@CLK伪寄存器可以用来执行简单的侧写(profiling)操作(如果要进一步了解调试器的侧写功能,请参看第8章“基本调试技术”)。@TIB伪寄存器显示TIB地址。TIB包含了许多有用信息,例如异常结构、结构化的异常处理程序链(TLS)数组。Matt Pietrek的《Under the Hood》(1996年5月)介绍了TIB的结构以及它的有用之处。
表7.5观察窗口和内存窗口支持的寄存器
寄存器 |
用法 |
@EAX |
通用寄存器。记录函数返回值 |
@EBX |
通用寄存器 |
@ECX |
通用寄存器,记录指向对象的this指针 |
@EDX |
通用寄存器,记录64位函数返回值的高端字 |
@ESI |
内存移动和比较操作的源操作数 |
@EDI |
内存移动和比较操作的目标操作数 |
@EIP |
指令指针(当前执行代码的位置) |
@ESP |
栈指针(当前栈顶的位置) |
@EBP |
栈基址指针(当前栈顶帧的低端) |
@EFL |
记录比较、算术操作的符号标志 |
@CS |
代码段 |
@SS |
堆栈段 |
@DS |
数据段 |
@ES |
附加段 |
@FS |
另一个附加段,用来指向TIB |
@GS |
又一个附加段 |
表7.6 观察窗口使用的伪寄存器
伪寄存器 |
用法 |
@ERR |
显示当前线程的APl函数GetLastError返回的最新错误码 |
@CLK |
显示以微秒来计算的累计时间 |
@TIB |
显示TIB的地址 |
要在观察窗口里显示于TIB结构,首先把下面的代码添加到程序里:
#ifdef _DEBUG
#inlcude "tib.h"
PTIB pTIB;
#endif
Tib.h头和PTIB结构可以参看Pietrek的文章。现在就可以在观察窗口里将pTIB赋值给@TIB并查看pTIB了,你还可以进一步研究TIB,如图7.1所示。
图7.1带有TIB的观察窗口
观察窗口通常以十六进制来显示指针,但你也可以通过上下文菜单里的Hexadecimal Display选项来选择用十进制还是用十六进制来显示表达式。这个显示选项也影响变量窗口、调用堆栈窗口,快速观察窗口以及源代码数据标签使用的格式。在很多情况下,使用全局的设置是不够的。例如如果你希望能同时用十进制和十六进制来显示变量就不可以。你还可能希望使用其他格式来显示表达式,例如字符串、字符甚至是内存单元的内容。你可以使用表7.7列出的格式化符号来为每个表达式单独地控制显示格式。
符号 |
格式 |
例子 |
输出 |
d或者i |
有符号十进制整数 |
-42,d |
-42 |
u |
无符号十进制整数 |
42,u |
42 |
o |
无符号八进制整数 |
42,o |
052 |
x |
十六进制整数 |
42,x |
0X0000002a |
X |
十六进制整数 |
42,X |
OX0000002A |
l或者h |
为d、i、u、o、x、X显示前缀 |
42,hx |
0X002a |
f |
有符号浮点数 |
1.5,f |
1.500000 |
e |
有符号科学表示法 |
1.5,e |
1.500000e+000 |
g |
压缩的有符号浮点数 |
l5,g |
1.5 |
c |
字符 |
42,c |
'*' |
s |
ANSI字符串 |
"bugs",s |
"bugs" |
su |
Unicode字符串 |
"bugs",su |
"bugs" |
st |
默认字符串类型(ANSI或者Unicode) |
"bugs",st |
"bugs" |
hr |
HRESULT和Win32错误码 |
0X06,hr |
The |
wm |
Windows消息号 |
0X01,wm |
WM_CREATE |
[digits] |
显示数组元素 |
s,5 |
Displays first 5 iterms |
使用格式化符号的方法是,首先输入你要查看的表达式,然后是逗号和格式化符号。“st”格式化符号的结果由选项对话框里Debug标签上的Display Unicode strings选项的设置来决定。如果你正在调试Unicode程序,你当然要将这个选项打开。
调试Unicode程序时,一定要把Display Unicode string选项设上,并在字符串上使用“st”格式化符号。
你也许己经注意到观察窗口仅仅将指针展开一层,所以如果指针指向有多个元素的数组,你只能看到第一个元素。你也许也己经注意到仅能查看第一个元素不是特别有用,另一方面,你可以将数组展开来查看所有元素,如果元素比较多时,展开的时间会比较长(注意,尽管指针和数组比较相似,它们并不相同。数组是指向固定数目对象的指针,而指针指向的对象数目是不确定的)。这个问题使得格式化数组元素的功能显得非常有用。例如,考虑下面的代码:
char *s1 = _T("This program has bugs!");
CString s2 = _T("This program has bugs!");
你可以在观察窗口里输入“s1, 5”来显示char字符串的前5个字符。类似地,你可以输入“s2.m_pchData, 5”来显示CString的前5个字符。你还可以使用指针运算表达式来显示子字符串。例如,输入“(sl + 17), 5”和“(s2.m_pchData + 17), 5”可以显示“bugs!”。但是观察窗口显示的下标总是从零开始,它并不关心表达式里指定的偏移,所以你必须做一些算术操作来得到实际的数组下标。
如果指针栺向有多个元素的数组,你可以使用数组元素格式化来显示数组的前几个元素,你也可以使用指针表达式来显示子数组。
在观察窗口里,你可以使用表7.8列出的格式化符号来改变内存单元内容显示的格式。这些格式化符号允许你在观察窗口里而不是在内存窗口里显示内存单元内容。
符号 |
格式 |
Ma |
64个ANSI宇符 |
M或者mb |
十六进制表示的16个字节,然后是16个ANSI字符 |
Mu |
十六进制表示的&个字,然后是8个Unicode卞符 |
Mw |
8个字 |
Md |
4个双字 |
Mq |
4个四字 |
你可以使用表7.9列出的操作符来显示寄存器、变量或者虚拟内存地址指向的内存单元内容。例如,如果要显示ESP寄存器指向的双字,使用表达式“DW(ESP)”。你还可以将这些操作符和格式化符号一起使用,所以“DW(ESP), u”会以无符号十六进制整数的形式来显示ESP寄存器指向的双字。你可以使用小写字母的操作符并省略括号,所以“dw esp, u”也是合法的。
操作符 |
输出 |
BY(exp) |
输出exp指向的字节的内容 |
WO(exp) |
输出exp指化的字的内容 |
DW(exp) |
输出exp指向的双字的内容 |
观察窗口格式化符号功能强大而且方便使用,但有时它们的功能还不是足够的强大,也不是足够的方便。具体地说,它们不能识别数据类型,所以你必须手动输入格式化符号;它们不能对类和数据结构进行处理;它们只能在观察窗口里工作而不能在变量窗口和源代码数据标签里工作。
使用Autoexp.dat控制对象和数据结构的显示以及Step Into命令进行控制。
幸运的是,虽然显示Windows API、MFC以及ATL对象和数据结构的规则没有绑定到Visual C++,但它们在Autoexp.dat(在Conmmon\Msedv\Bin)里有定义。所以,你可以按照文档在文件头里增加新的规则或者修改已有的规则。这些规则就可以被观察窗口、变量窗口以及源代码窗口使用。每当指向Go命令时,Autoexp.dat文件会被重新装载,所以使得规则的试验很容易进行。另外,你可以使用Autoexp.dat文件对Step lnto命令进行控制,防止跟踪进了特定的函数,或者因为你没有相应的源代码,或者是因为对它们的跟踪只是浪费时间。
一个小小的警告:从技术上讲,Autoexp.dat文件没有经过“文档化”,所以你不能指望它会提供永远的支持。但是,这是我目前所看到的文档最多但未经“文档化”的功能,所以我想这种情况很快就会改变。
Autoexp.dat文件里的AutoExpand部分定义了类和数据结构的显示规则。这此规则使用了下面的格式:
Type = [text]
在这个格式里,type是类型的名字,text是对要显示的数据成员的描述,member是类或者结构的成员的名字,format是可选的观察窗口的烙式化符号。注意,相等符号(=)、尖括,(<>)以及逗号要按照规定写,而方括号([])表示可选项。你可以使用<, t>格式来显示对象的最底层派生类型的名字。如果一个类没有相应的规则,那么将检査它的基类,看是否有匹配的规则。下面是一些例:
[AutoExpand]
CPoint =x=
CRect =top=
CWnd =<,t> hWnd=
图12显示了观察窗口中这些类型是如何显示的。
如果变量没有按照你预想的方式展开,首先仔细检査类型的规则,然后仔细检查变量类型,确信该变量确实是你要查看的。在观察窗口里查看变量类型的方法如下,从上下文菜单里执行Properties命令,然后检査Type域就可以了。注意,调试器是意识不到typedefs的,所以调试器会将LPCTSTR处理成const char*,将BSTR处理成unsigned short*。
图7.2带有Autoexp.dat定义的类和数据结构的观察窗口
当你使用Step Into命令跟踪进入函数时,Visual C++调试器会使用下列规则:
•如果函数在程序数据库里,而且调试器能找到源代码,调试器会进入源代码。
•如果函数在程序数据库里,但是调试器找不到源代码,调试器会弹出寻找源代码对话框向用户征求源代码路径。如果你给出了合法的、正确的路径,调试器将进入源代码,否则,调试器会进入汇编代码。
•如果函数不在程序数据库里,调试器会执行Step Over命令。
很多时候,这正是你所希望的执行过程。但在一些情况下,这个方法也会有点讨厌,或者因为你没有源代码,或者因为跟踪进代码,这都会浪费时间。下面是一个典型的例子。
int TraceFunction(const CString &s);
int APIENTRY WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstarice, LPSTR lpCmdLine, int nCmdShow) {
TraceFunction(_T("This is a case for execution control。"));
...
}
在这个例子里,如果你使用Step Into命令进入TraceFunction,你会首先进入CString的构造函数,它会将字符串转换成CString对象。当你重复做了几次后,最简单的解决方法是使用Step Out命令退出构造函数。如果你跟踪进TraceFunction已经好多次了,频繁地跟踪跳出CString的构造函数会很烦人。你可以使用下面的方法解决这个问题,在Autoexp.dat里加进下面的语句,并且重新启动Visual C++(和AutoExpand规则不同的是,Execution Control要求Visual C++重新启动才能生效。
[ExecutionControl]
CString::CString NoStepInto
下面是ExecutionControl的几种不同形式
[ExecutionControl]
RandomFunction= NoStepInto ; Don't step into this function
CRandom::RandomMethod=NoStepEnfo; Don't step into this member function
CRandom::*= NoStepInto ; Don't step into any functions of this class
ATL::*= NoStepInto ; Don't step into any ATL functions
让我们来看综合了前面所学知识的一个例子。假设你已经为下面的类创建了调试数据库:
enum BugType { CrashBug, MajorBug, MinorBug, UsabilityBug, SetupBug, DooBug};
class CBug {
public:
BugType m_Type;
CString m_Description;
};
class CProgramBug : public CBug {
public:
CString m_File;
int m_LineNumber;
};
如果你在观察窗口、变量窗口或者源代码数据标签里查看CBug或者CProgramBug变量时,你会发现变量以{...}的方式显示,这没有任何作用。现在让我们在AutoExp.dat的AutoExpand部分加上下面的语句。
CBug =<,t> BugType=
Description=
CProgramBug =<,t> BugType=
Description=
File=
注意,如果要正确地显示CString,你必须正确地显示m_pchData成员。而且,我使用的是“st”格式化符号来正确显示Unicode字符串,而不是使用“s”。保存AutoExp.dat文件后,就可以执行Go命令了。图7.3说明了变量是如何被显示的。
图7.3定义在AutoExp.dat里的类的数据标签
有效地使用断点对调试的髙效性有很大的作用。我们可以想象一下,如果没有了断点,使用调试器的唯一方法是在程序里一步一步跟踪。直到你发现了问题,或者让程序执行下去,直到它崩溃。断点实际上是允许你向调试器描述环境,并让调试器设置好程序状态的一个机制。理想状态下,处于断点时,你要做的只是检査调用堆栈,再跟踪少数几条代码,检查少数几个变量,解决问题。
当然,你肯定对Insert/Remove Breakpoint命令很熟悉,它允许用户在源代码(在源代码窗口里)特定的行或者特定的一条汇编指令(在反汇编窗口里)处设置断点。你应该也对Run to Cursor命令很熟悉,它会设置一个临时的断点,并当程序执行到那里时,自动将断点清除。Step Over、Step Into、Step Out以及Break Execution命令都是通过设置临时断点来工作的。另外,Visual C++还在断点对话框里提供了丰富的设置高级断点的功能,在Edit菜申里执行Breakpoint命令可以打开对话框。你可以设置下列类型的断点:
•代码定位断点:你可以设置无条件代码定位断点,也可以设置基于表达式或者基于代码执行次数的条件代码定位断点。
•数据断点:你可以设置基于表达式的条件数据断点。对于数组和结构,你还可以指定查看的数组元素数或者结构元素数。
•消息断点:你可以设置消息断点,该断点在某个特定的窗口函数接收到某个特定的消息时有效。
功能真的是非常强大。记住,取代这些断点的方法是单步(singlestep)调试代码,所以,这些功能还是很值得学习和使用的。
当程序正在运行时,这三种断点都比较容易设置,因为当你输入断点后,Visual C++会立即检查这些断点是否应发生。相反,如果程序不在运行,Visual C++会允许你输入语法上正确的高级断点。所以,当程序不运行时,用Step Over命令启动程序,然后设罝高级断点。
高级断点在程序运行时比较容易设置。
断点对话框里有两种表达式:断点条件表达式和高级断点表达式。
断点条件表达式用来确定调试器是否要暂停在该断点,表达式可以是一个布尔表达式,当表达式成真时,调试器会暂停;表达式也可以是非布尔表达式,从上次断点到达时起,如果表达式的值变了,调试器也会暂停。
断点条件表达式能包含下列的操作数:
•常数(十进制、十六进制、八进制、字符、浮点数,但不能是程序里的常数)
•程序变量;
•寄存器和伪寄存器。
它还包含以下的操作符:
•算术运算(+、-、*、/、%);
•关系运算(:==、!=、<、<=、>、>=);
•布尔运算(!、||、&&);
•位运算(~、>>、<<、&、|、^);
•地址运算〈&、*);
•数组运算([]);
•类/结构/联合运算(.、->);
•sizeof;
•C强制类型转换(只能有一层间接转换);
•内存内容(DW()、WO()、BY())。
你还可以使用括号,但它不会被算作操作符号和观察窗口表达式不同的是,断点条件表达式不支持赋值操作和函数调用。而且,对于结构和数组的比较,一次只能比较一个元素。例如,当字符串s含有“bugs”时程序暂停,你应该使用下面的表达式:
s[0] == 'b' && s[1] == 'u' && s[2] == 'g' && s[0] == 's'
Visual C++使用髙级断点表迖式来表示内部断点。在断点对话框的底部你可以看到Breakpoints列表中的表达式,你也可以自己在Location标签的Break at框里,或者在Data标签的Enter the expression to be evaluated框里自己输入表达式。
这些表达式和观察窗口表达式使用相同的上下文操作符,还带有源代码行号、变量名、调用函数名、虚内存地址或者语句标签,下面是一些例子。
a line number:
{[function], [source], [executable]}.100
a variable name:
{[function], [source], [executable]} MyVariable
a function name:
{[function], [source], [executable]} MyFunction
a functionn name:
{[function], [source], [executable]} CMyClass::MyFunction
a memory addres:
{[function], [source], [executable]} 0x00401234
a statement label:
{[function], [source], [executable]} Barney
如果你在Break at框或者Enter the expression to be evaluated框里输入的表达式使用了程序当前执行范围里的变量,调试器会自动添加正确的上下文操作符。否则,你必须自己添加上下文操作符。
代码定位断点是最常用的断点。你可以设置下面几种类型的代码断点。
•源代码行数断点;
•代码虚拟内存地址断点;
•函数名断点;
•语句标签断点。
上下文操作符允许用户在函数名处设置断点,但有时这还不能提供足够的信息,因为C++函数数的重载(overload)功能允许多个函数有同样的名字。对于重载的函数,Visual C++会弹出Resolve Ambiguity(解决不确定)对话框让用户选择所想的函数,见图7.4所示。
图7.4 Visual C++ Resolve Ambiguilty(解决不确定)对话框
你可以设置条件代码定位断点,当相应的布尔表达式成真时有效,或者非布尔表达式当值改变时有效。例如,假设你的程序有了大小为123字节的内存泄漏,而你希望在内存分配时査看调用堆栈。因为内存块的大小比较特殊,通过使用条件断点可以很容易地捕获到它。首先在DbgHeap.c的_malloc_dbg函数的第一行设置无条件代码定位断点。然后用下面的方法将它改为条件的,在断点对话框里选择Location标签,在Breakpoints列表里选择Dbgheap.c的断点,单击Condition按钮。在条件断点(Breakpoint Condition)对话框的Enter the expression to be evaluated框里输入“nSize==123”,nSize是_malloc_dbg的参数,用来确定要分配的内存大小(图7.5)。
图7.5用于代码定位断点的条件断点对话框
你也可以设置条件代码断点,当代码被执行到的次数到达特定的数时,断点有效。例如,如果你有一个当循环循环了100次才发生的错误,你可以在循环处设置断点,并跳过前99次的循环。这里有一个有趣的技巧,如果你不知道错误发生前断点被执行到的次数,你可以先指定一个比较大的跳过次数(例如10000),然后执行程序。当错误发生时,通过查看断点对话框可以知道余下的跳过断点的次数(例如8765),如图7.6所示。然后徐就可以将条件断点改为跳过初始次数减去余下的次数,再减去1(例如,10000 - 8765 - 1 = 1234)。
图7.6显示了跳过断点的剰余次数的断点对话框
不幸的是,Visual C++里如果有两件事凑在一起,将会使得这个技巧很难实现。第一个是,跳过断点的次数在文本的最后,但是你不能将文本水平移动,因此很难(如果不是不可能)看到次数。第二个是,当你编辑断点时,断点对话框通常在上下文操作符的参数前加上全路径。所以,显示的将是
{, MyProgram.cpp, }.888
而不是
{, C:\Projects\MyProgram\MyProgram.cpp, }.888
这使得问题变得更糟。在这种情况下,我建议消除路径,这样你才能读到Breakpoints列表里的内容。
在Windows 2000里,你可以在Windows的 API函数里设置断点。代码定位断点通过修改内存里的代码来正常工作,即将带有断点指令的第一个字节替换为int 3指令,从而可以进入调试器。如果你试图在Windows 98里在Windows API函数里设置断点,你实际上一起修改了(也就是破坏了)操作系统。你还可以在Windows 2000里设置系统调用断点,因为Windows 2000支持写时复制(copy-on-write)的刷新,这样就只会使得对当前进程的修改是局部的。
可以在Windows 2000里设置系统调用断点。
执行下面的步骤可以设置系统代码断点。我们以API函数MessageBox为例。
1.确定包含 API函数的模炔。你可以将Win32api.csv文件(在Vc\lib下)作为所有Windows API函数的路标,例如,命令行
findstr MessageBox Win32api.csv
会指出MessageBox在User32.dll里。
2.确定该模块对应的调试符号已装载。运行程序,检查输出窗口的Debug标签。如果调试符号被成功的装载了,你会看到消息“Loaded Symbols for ‘C:\WINNT\SYSTEM32\USER32.DLL’.”。否则,你会收到消息“Loaded ‘C:\WEWT\SySTEM32\USER32.DLL’,no matching symbol information found”。
3.确定真正的函数名。如果调试符号被装载了,使用命令
dumpbin -symbols user32.dbg I findstr MessageBox
它会返回全部的混合名字“_MessageBoxA@16”。如果调试符号没有被装载,使用命令
dumpbin -expotts user32.dll | findstr MessageBox
它会返回“MessageBoxA”。注意,“MessageBox”只会被预处理器看到,它会将名字转换为“MessageBoxA”或者“MessageBoxW”,这里“A”代表ANSI而“W”代表宽字符或者Unicode字符。
4.在断点对话框里设置断点。如果调试符号被装载了,输入
{, , user32.dll} _MessageBoxA@16
如果调试符号没有被装载,输入
{, , user32.dll} MessageBoxA
如果调试符号没有被装载,你还需要在选项对话框的Debug标签里设置Load COFF&Exports选项。这个选项允许你在没有调试符号的情况下,在输出函数上设置断点。
如果你没有findstr.exe工具,可以在Windows资源包里找到。另外,你也可以使用Visual C++的Find in Files命令。
通过跟踪特定的代码能很好地隔离很多错误,但如果跟踪数据的变化,有些错误会被隔离得更好。例如,通过跟踪导致破坏的内存会使得调试内存破坏非常容易。在这些情况下,数据断点会令人难以置信的有用。
数据断点非常好,尽量使用它!
你可以设置条件数据断点,当某个布尔表达式成真时有效,或者当某个非布尔表达式的值改变时有效。对于数组和结构,你可以指定要监视的数组元素或结构成员。如果你指定了某个指针指向的内存,你可以输入指针指向的内存处要查看的字节数。
和代码定位断点不一样,在一些情况下你需要谨慎使用数据断点,因为对它们的监控可能会花费很长的处理时间。所以,它们可能会使得程序在调试器里运行得非常慢,即使是在非常快的计算机上。下面是一些能尽可能有效的使用数据断点的技巧:
•一次要尽可能少地使用数据断点。Intel处理器分配了四个调试寄存器专门用于数据断点。有效使用数据断点的关键在于要尽可能好地利用调试寄存器,所以,要尽量使得数据断点能在四个32位的寄存器里求值,并且尽量只查看数组和结构里的一个元素。
•关掉观察窗口,或者删除所有没必要的观察窗口表达式,特别是那些有函数调用的表达式。
•一般情况下不要使用数据断点,除非你必须要使用它们。例如,在你启动程序之前,在断点对话框里关掉所有的数据断点,在你所知道的需要使用数据断点的地方设罝代码定位断点,执行程序,直到它运行到了断点,这时再打开数据断点。尽管这样需要你做一些额外的工作,但程序的运行会快很多。注意,你可以在程序正在运行时关闭断点(如果你忘了关掉断点)。
•基于变量的数据断点很慢,而基于数据成员的数据断点更慢。基于虚拟内存地址的数据断点运行效率会很高,因为它们只需使用一个调试寄存器,所以你可以通过设置基于变量地址的数据断点来提供性能。一个进程内的虚拟内存地址也是与上下文无关的,所以断点会在所有范围内有效。例如,假设你希望在count为0时程序暂停,并且count的地址为0x00631234(在观察窗口里输入&count很容易得到这个地址)。这样我们可以使用“*(int*)0x00631234==0”,而不用“count==0”。注意,使用等价的表达式“DW(0x00631234)==0”会慢很多。而且,变量地址并不经常是随机的(特别是在程序的开始);所以如果一个堆栈变量或堆变量在一次运行中有一个确定的地址,很有可能它在以后的运行中还会有同样的地址。但是,堆栈地址会被频繁地重用,所以基于堆栈地址的断点应该只在一个单独的函数里使用。
•避免在调用了调试堆诊断函数的代码里使用数据断点,例如_CrtCheckMemroy和_CrtDumpMemoryLeaks,将这两者结合在一起会使得你的程序就像蠕虫在爬动一般。如果有必要的话,可以暂时删除这些诊断函数。
利用调试寄存器来提高数据断点的性能。尽量设置基于含有变量虚拟内存地址的表达式的数据断点。
消息断点使得当某个特定窗口函数接收到某个特定消息时程序暂停。如果要在一个窗口里监视多个消息,使用多个消息断点就可以了。
这个功能听起来很有用,但很可能你会只使用代码定位断点。因为对于基于Windows API的程序,很容易在处理特定消息的代码处设置断点。类似地,对于基于MFC的程序也很容易在处理相应的消息处理函数处设置断点。注意,你不能直接在MFC程序里使用消息断点,因为这些断点要求你提供一个基于C的窗口过程。所以,你只能在全局的C窗口过程里设置消息断点,如图7.7所示。然而,在全局窗口过程里使用消息断点不是很可行的一种办法,例如,AfxWndProc函数,因为,它会被MFC程序里的许多窗口使用。
图7.7基于全局MFC窗口过程的消息断点
断点并不是总能正常工作,并且它们的错误信息也并不总是非常的清楚。下面针对一些最常见的断点问题给出了解决方法。
初学者一般会使用符号断点,这时一定要确定调试符号和可执行文件、源代码是匹配的,这本章的前面已介绍过。没有匹配的符号时,调试器就不会知道可执行文件里的行号、函数、变量以及语句标签,所以如果打开了Load COFF&Exports选项,你唯一可以设置的断点只能基于虚拟内存地址或者输出函数。
如果符号是匹配的,但你还是不能在断点对话框里输入断点表达式,可以检查断点表达式,看看是否出现了下面的问题:
•表达式的类型错了,或者操作数不对。
•表达式需要上下文操作符号。
•上下文操作符里的信息错误,或者是函数、源代码、可执行文件名的次序有误,或者是把逗号放错了位置。
这些常见的断点问题会导致比较“可怕”的消息出现,如图7.8所示。
图7.8 “可怕”的“一个或者多个断点不能披设置”消息框
要真正分析清楚这个消息,了解Visual C++如何设置断点会对此有所帮助。Visual C++将存储断点信息,这些信息显示在Breakpoint对话框底部的Breakpoints列表中。当程序被装载进来后,调试器会检查一遍断点列表,在程序数据库里査找必要的信息,并设置上断点。如果这些断点里有任何一个不能被设置,Visual C++会弹出这个消息框。唯一的例外是工程设置对话框的Debug标签的Additional DLLS类里列出的模块里设置的一组断点。这些模块里的断点会被认为是虚拟的,只有在DLL被动态装载进来后,调试器才会真正将断点设置上。
在上面介绍的工作流程中,Visual C++会因为下列的原因设置断点失败:
•没有找到匹配的调试符号。一定要确定所有设有断点的模块的调试符号已经装载进来了。
•上下文操作符里的模块、文件或者函数在程序的数据库里和Additional DLLS表里没有找到。一定要将 API函数LoadLibrary和COM装载的模块加到Additional DLLS表里使得断点有效。
•一个需要使用上下文操作符的断点却没有使用上下文操作符,所以断点没法找到。
•代码定位断点使用了并没有指向合法代码的源代码行号,这说明源代码和可执行文件并不匹配。如果你在Visual C++的外面修改了代码,并改动了行号,基于行号的断点也许就会变成无效。在这种情况下,你应该重新编连程序,并且重新设置断点。另一个原因是你在调试优化过的代码,而源代码这时并不直接映射到可执行文件。解决这个问题的一个方法是基于函数名设置断点,而不是基于行号。
•基于虚拟内存地址的断点没有指向合法代码。很可能你需要重新设置或者删除断点。
一旦碰到了图7.8所示的消息框,最好的办法是打开断点对话框,确定Breakpoint列表里的哪些断点被取消了(它们不会被检查),然后对它们进行修正。你可以使用Edit Code按钮为有问题的代码定位断点、定位相关的源程序代码。
使用Visual C++调试器进行程序调试有三种基本的不同方法。
•从调试器里直接运行程序。
•以独立的形式运行程序,当程序因为未被处理的异常而崩溃时,使用即时(Just_in_time,JIT)调试方式进行调试。
•以独立的形式运行程序,并将调试器附加到进程上。
通常当你开发程序时,最简单的方法是直接从调试器里运行程序。这样,当你需要査看程序运行情况时,可以随时进入调试器。因为从Visual C++装载被调试的程序非常快,所以开发过程中在调试器外面运行程序不会有什么好处(但是,我也看到在一种情况下被调试的程序会装载得很慢。这种情况是Visual C++工作台——DSW文件——包含了许多大的工程,时间会花费在Visual C++需要确定哪些文件需要被重新编译链接上。解决方法很简单,只打开需要调试的主要可执行文件所在的工程——DSP文件——就可以了)。
一般地,人们比较喜欢直接从调试器运行程序。但有些情况下,程序在调试器里运行正常,而单独运行时就会崩溃。而且,测试者不可能从调试器运行程序,在这些情况下,跟踪错误的最好方法是使用即时调试(当然,在测试者的计算机上进行即时调试,必须确定他已经安装了Visual C++)。在Tools(工具)菜单里执行Option命令,就可以打开即时调试功能。在选项对话框里的Debug标签里将Just_in_time Debugging选项设置上即可。现在,如果程序因为未被处理的异常而崩溃时,你就可以将程序装载到调试器了,如果是Windows 98崩溃对话框,就单击Debug按钮,如果是Windows 2000崩溃对话框,就单击CanceJ按钮。
尽管即时调试非常有用,但它还是有一个严重的问题——它并不是总能奏效,问题在于即时调试代码处在崩溃进程里。所以,对于真正的完全崩溃,单击Debug或Cancel按钮会导致启动调试器失败或死亡蓝屏的出现。在这些情况下、最好的替代方法是将调试器附加到已经在运行的程序上,方法是在Link菜单里执行Start Debug->Attach to Process命令。执行这个命令后,你就可以从Attach to Process对话框里选择要调试的进程了。你可以在程序崩溃前将调试器附加到进程(这是大家所希望的),并且,如果你关掉了即时调试,你还是可以将调试器附加到一个己崩溃的进程。如果这个方法还是不能奏效,你可以使用下面的命令行将调试器附加上去:
Msdev.exe -p
你可以很容易地从Windows 2000的任务管理器的进程标签里得到进程的ID。实际上,你可以直接从任务管理器将调试器附加到进程上,方法是首先从列表里选择要调试的进程,然后从上下文菜单里执行调试命。
即时调试允许将任何一个调试器附加到己崩溃的程序上,而不仅仅是Visual C++调试器。在Windows 2000里,你可以通过査看HKEY_LOCAL_MACHlNE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug里的调试器值来确定到底使用的是哪个调试器。在Windows 98里,即时调试器信息存储在Win.ini文件AeDebug部分的调试器关键字里。在任何一种情况下,如果要使用Visual C++的调试器,值应设置成下面的形式:
"c:\ProgramFiles\VisualStudio\Common\MSDev\Bin\MSdcv.exe" -p %ld -e %ld
你会发现这个值被设置为使用Dr. Watson,在很多情况下这并不是你所想要的。要恢复这个值的方法很简单,打开选项对话框,选择Debug标签,然后选择Just_in_time Debugging复选框。使用Dr. Watson进行事后调试有一定的作用,但它是你最后不得己才做的选择。调试活的进程总比调试死的进程要好一些。
Visual C++支持远程调试,它在某些情况下特别有用。远程调试允许用户在一台计算机(远程计算机)上运行程序和一个小的调试监视器,并在另一台计算机(主机)下运行调试器,通过TCP/IP进行通信。因为远程调试存在的唯一标志是Build菜单里的Debugger Remote Connection命令,所以它也特别容易被忽视。在这一节里,我的主要目的只是让你知道这个功能确实存在。
VC开发环境之所以提供远程调试的能力,是因为有些情况下单机调试会让你崩溃掉。比如,调试GUI程序的WM_PAINT消息,因为要单步调试,所以调试器会对界面的重绘产生副作用(heisenberg不确定性原理)。当然还有些别的情况也适用,比如程序在测试环境运行的好好的,但是在客户那行为总是异常,这时候如果可以TCP远程连接上去维护的话,就能通过远程调试的特性在出现状况的系统环境中排错~
下面来说一下具体的做法。先明确下概念,远程调试嘛,自然是两个机器之间调试。程序运行在目标机器上,调试器运行在本机。当然,目标机器上还是要有少许辅助程序才能跟本机的调试器connect上,以便通讯。一般来说,只需要copy四个文件到目标机器上就行了:MSVCMON.EXE、DM.DLL、TLN0T.DLL和MSDIS110.DLL。这四个文件都能在VC6目录的Common\MSDEV98\Bin目录下面找到。copy过去之后,运行msvcom.exe,看下图片~
有个Settings的按钮,不用管。直接点Connect就行了~
接着看看本机这边调试器的设置。首先设置好远程调试开关,在Build菜单下有个Debuger Remote Connecting的子菜单,点之。出现个窗口,默认是在Local项,我们要选的是Network(TCP/IP),然后点设定。会弹出一个对话框,输入目标机器的ip或者机器名,最后点0K就行了。
接下来把工程打开,设置最后一步。假设生成的可执行程序名为RemoteDebug.exe,在目标机器上的路径为d:\Prj\Remote.exe,那么,在本机的Project Settings里面,选择Debug页面的Remote executable path and file name下面的编辑框中输入目标机器中程序的路径:d:\Prj\RemoteDebug.exe。注意,这里写的是从目标机器的角度所看到的路径。
然后编译一下程序,把新编译出来的RemoteDebug.exe复制到目标机器的d:\Prj下面,就可以在本机像平常一样调试了。
要注意的事项:
1.要求本机与目标机器上的版本要完全一样才行。
2.在本机设置远程调试路径时一定要填目标机器上看到的路径,而不是本机看到的网络路径。
3.调试开始时,会提示些符号信息的东东,都确定就行了
4.远程调试的设置是全局设置,跟项目无关。实际上,上面提到本机调试器设置时都没打开工程。所以,当不需要远程调试时,要从Build菜单下面的Debuger Remote Connecting的子菜单设置回Local模式。否则每次都会问你要远程的信息噢
下面是远程调试非常有用的一些情况。
•对错误的调试与海森堡不确定性原理有关。这时,调试器的存在会打乱你正在调试的程序的执行情况,例如窗口的绘制、窗口的激活或者输入焦点。
•调试全屏程序,例如游戏、屏保等。
•调试客户/服务程序,例如DCOM、MTS/COM+或者SQL服务器。
•调试出现在客户或者测试者那里的错误,而且你没法在自己的计算机上重现这些错误。
•在没有Visual C++的环境里进行调试。
•有些计算机是专门用于某种应用系统的,或者有些计算机的系统配置出了问题,这时,在这些计算机上就不能安装开发环境,因为这样会破坏系统的配置。在这样的计算机上进行调试就要用到远程调试。
远程调试非常容易安装,也非常容易使用。它仅仅需要七个文件,所以它对远程计算机的影响会非常小。使用Visual C++进行远程调试的步骤在《MSDN Visual C++ Programmer's Guide》的“Debugging Remote Application》里有详细的介绍。下面是能对你有所帮助的几条提示。
•如果你在局域网里进行远程调试,并且也没有连接到Intemet,但调试器还是会弹出拨号连接对话框。这时只要单击取消就可以了,你没有必要进行在线的远程调试。
•调试器会要求你输入远程可执行文件路径和文件名,这是远程计算机所看到的可执行文件的路径。对于复杂的工程,尽管你可以将可执行文件从本地机拷贝到远程机器,但保证所有文件都保持同步还是有一定困难的。对不同步的可执行文件进行调试简直就是对时间的浪费,这会使得人非常沮丧。解决这个问题的一个方法是,完全共享开发程序所在的驱动器,并为本地机上的可执行文件取一个符合通用命名标准的文件名。另一个解决方法是,使用Windows浏览器的映射网络驱动器功能在远程机上将开发程序所在的驱动器映射到一个驱动器字符(例如X:),然后以驱动器字符来定位可执行文件所在的路径。这个技巧会给你带来很大的方便,但它对性能会有所损耗,因为远程机必须通过网络访问本地机上的可执行文件。如果性能代价太高,可以在工程设置对话框的Post-build step里加上一段script,将可执行文件拷贝到远程机。
•远程调试器链接设置是全局性的,并不是针对某个工程的。所以当你结束远程调试后,要手动恢复本地调试的设置。所以,在结束远程调试后,如果意外地看到了Remote Executoble Path and File Name对话框,不要觉得很奇怪。
也许使用需要编译的语言开发程序的最大的缺点就是你必须编译它。如果你曾经使用过解释语言编写程序,你会明白我的意思,例如,在Visual Basic里,当调试器正在运行时,你可以对程序作修改,而且它们马上就会生效,从而极大地简化了调试过程。Visual C++提供的编辑继续(Edit and Continue)功能(Visual C++ 6.0引入)使你同时能拥有编译执行和解释执行的好处。在编辑继续的支持下,你可以在调试程序时,甚至在程序正在运行时修改程序,并且通过进入调试器使得修改生效(在这两种情况下,都会有所限制)。然后你就可以继续调试了,从而避免了标准的调试周期,结束程序的执行,重新编译链接,最后启动程序使之到达原来的运行状态。编辑继续功能是通过修改内存里的程序映像来实现的,而不是通过修改文件。使用编辑继续功能可以极大地提高你的调试效率。
编辑调试功能的唯一缺点是,你正在调试程序时,不小心在源程序里敲进了一些字符,而当你还没意识到什么发生了时,修改已经生效了。在这种情况下,编辑调试功能确实显得有点太容易使用了。从这个角度看,编辑调试功能确实要花些时间来熟悉。
在默认条件下,Visual C++6.0以及后面的版本生成的程序调试版本的编辑继续功能是打开的。下面是打开编辑继续功能的具体步骤。
1.打开工程设置对话框,在Setting for框里选择调试版本(例如“Win32 Debug”)。正如前面提到的,编辑继续功能与发行版本是不兼容的。
2.在工程树形控制器里,单击根节点,选择整个工程。
3.在C/C++标签里选择General类。在Debug info里,选择Program Database for Edit and Continue。同样地,要保证所有的优化选项被关掉了。
4.在Link(链接)标签里选择Customize类,选择Link incrementally选项。
5.使用Rebuild alI命令将整个工程重新编译。
6.这一步是可选的。打开选项对话框,选择Debug标签,选择Debug commands invoke Edit and Continue选项,选择了这个选项后就不用在Debug菜单里执行Apply Code Changes命令了。
编辑继续功能需要获取存储在PDB文件里的特殊信息来使得代码的修改对调试器有效。如果被修改文件对应的这些信息不在PDB文件里,编辑继续功能就不会执行,而且在调试过程中对代码的任何修改都会导致图7.9对话框的出现。
图7.9 Visual C++的“—个或者多个文件已经过时”消息对话框
编辑继续功能的使用是非常简单的。只需修改源代码,然后执行Apply Code Changes命令就可以了。如果你选择了Debug commands lnvoke Edit and Continue选项,你甚至不用调用Apply code Changes命令,因为当你在Debug菜单里执行Go、Run、Run to Cursor、Step Into、Step Over、Step Out、或者Step Info Specific Function命令时,这个命令会自动被执行。
这里唯一的技巧是对代码的修改有时会导致当前的执行语句被修改。编辑继续功能会尽量将执行点设置正确,但结果并不能保证总是正确的。图7.10里的消息对话框会提醒你执行被修改了。你应该验证当前的执行点是正确的,如果需要的话,你还需要使用Set Next Statement命令。
图7.10 Visual C++的“执行点被修改了"消息框
编辑继续功能适用的修改类型会有所限制。不过,令人高兴的是,当你对程序做了编辑继续功能不支持的修改时,输出窗口里的错误消息会提醒你。下面的修改是调试期间不支持的:
•对资源文件的修改。
•对只读文件里代码的修改,
•对优化代码的修改。
•对处理异常代码的修改。
•对数据类型的修改,包括类、结构、联合以及枚举。
•增加新的数据类型。
•对函数原型的修改,包括函数名、参数、调用协议以及返回值。
•函数的删除。
•对全局或者静态代码的修改。
•对不是局部编连的可执行文件的修改。
此外,在活动(active)函数里增加新的变量时,还有一个总的变量长度不超过64字节的限制。活动函数就是调用堆栈里的任何函数——当前执行函数或者它的调用者。对于当前不在调用堆栈里的函数没有这样的限制。
这些技巧会让你更好地使用编辑继续功能。
•将编辑继续功能与Set Next Statement命令联合起来使用功能会非常强大。如果在调试代码时发现了错误,修改代码,使得修改有效,然后使用Set Next Statement使程序从修改前的代码处继续执行。这样,你就可以很快地修复错误,使得修改有效,然后继续执行,好像错误从没发生似的。
•你也可以在程序正在执行时修改代码。你可以直接修改代码,并执行Apply Code Changes命令就可以了,而不用进入调试器、修改代码、使得修改有效、然后继续执行程序。这样不是更简单了吗?
•编辑继续功能并不只限于当前已经装载的工程。你可以对所有编连成可以编辑继续的可执行代码作修改,一般地,如果你可以调试代码,那么你就可以对它执行编辑继续命令。这条规则适用于编连时打开编辑继续选项的可执行文件、动态链接库、以及COM组件。同样地,对代码做了修改后,只用执行Apply Code Changes命令就可以了。
•对于执行了pre_link和post_build步骤的工程,编辑继续功能有一个很大的问题。pre_link和post_build步骤在工程设置对话框里设置。典型的post_build歩骤包括拷贝可执行文件和重定位、绑定。问题在于当你停止调试时,被修改了的可执行文件会被自动重新链接,但不会执行这些步骤,这通常会导致生成非法版本。在这种情况下,执行Build命令没有任何效果,因为Visual C++会认为工程是最新的。最好的解决方法是当你结束调试时,不让编辑继续功能执行自动重新链接,从而你就可以直接从Visual C++重新链接程序了。你可以通过运行注册表编辑器并将下面的注册关键字设为零,以避免编辑继续功能执行重新链接。
HKEY_CURRENT_USER\Softerware\Microsoft\DevStudio\X.0\Debug\ENCRelink
当Visual C++正在执行时不要做这个修改,因为这不会生效。
......