本章回答了很多Windows下调试程序的常见问题,主要是基本的调试技术。所以它看起来可能有点像Windows调试常见问题解答,其中很多是我在调试新闻组里见到的问题。
请釆取以下步骤:
•重定位你的程序的可执行代码以防止虚拟地址空间冲突。关于虚拟地址空间冲突和重定位的讨论见第6章“在Windows中调试”。
•为程序的可执行代码创建MAP文件,并存档。MAP文件的创建和使用见第6章。
•在程序的可执行文件中创建调试符(即使是在发行版中),并存档PDB文件。请注意不要使用链接选项Seperate types。PDB文件的创逮和使用见第7章“使用Visual C++调试器调试”。
•将程序的工程、源代码和可执行文件存档。
•为用户创建一个提交错误报告的机制,包括一个错误报告表单和指示。错误指示应该推荐用户安装系统调试符号,将屏冪上的内容全部拷贝下来,并提交Dr. Watson关于此错误的日志文件。错误报告表单和指令见第1章“调试的过程”。
你可以使用 API函数IsDebuggerPresent将发行版本中的调试代码关闭(注意:Windows 95不支持这个函数)。例如,下面这段代码仅当程序在调试器中运行才会影响性能:
i[(IsDebuggerPresent())
PerformTimeConsumingDebugCheck();
用户一般都不会启动调试器,因此一个更好的方法是通过INI文件或注册表设置将发行版本中的调试代码关闭。代码如下所示:
BOOL ReleaseDebug = GetPrivateProfileInt(_T("MyApp"), _T("Debug"), FALSE, _T("MyApp.ini"));
...
if(ReleaseDebug)
PerformTimeConsumingDebugCheck ();
你可以使用以下几种方法之一:
•创建调试数据。创建特殊的调试数据,消除所有与问题无关的冗余数据。
•从程序中临时过滤数据。如果创建调试数据不太现实,你可以在程序中过滤掉所有无关的数据从而隔离问题。例如,如果你的程序显示一大堆数据,但只有当数据的X坐标为42时才会发生问题,你可以采取如下措施将问题隔离出来:
#ifdef DEBUG_FILTER_DATA
//filter data only when DEBUG_FILTER_DATA is defined
if(data[i] != 42)
continue;
#endif
•使用一个全局变量动态地激活调试代码。典型情况下,调试代码或者全部工作,或者全部不工作。因为它的状态是在编译时刻由预处理器决定的。但有的时候,我们希望当程序的状态设置成“再现错误”时能动态地激活调试代码。例如,你可以定义一个全局变量Debug,通过代码,INI文件、注册表设置或者调试器(可以使用Watch窗口修改值)控制它的取值。
#ifdef _DEBUG
BOOL Debug = FALSE; // define global debug variable
#endif
...
#ifdef _DEBUG
// filter data using aglobal variable, change Debug value from Watch window
if(Debug && data[i].x != 42)
continue;
#endif
•使用键盘动态地激活调试代码。有的时候我们希望用键盘动态地控制调试代码,例如某几个键的组合。例如,你可以借助 API函数GetAsyncKeyState检查键盘的状态,使程序仅当Control键按下的时候激活调试代码。
#ifdef _DEBUG
// filter data only when the Contrl key is pressed
if(GetAsyKeyState(VK_CONTRL) < 0) && data[i].x != 42)
continue;
#endif
C预处理器是一个伟大的东西,但是它的行为无论是从源代码还是从调试器的角度都完全不可见。例如,假设你多次用到了重载的operator new函数,预处理器会重定义new以帮助你动态地调试内存分配。但是如果你的程序不使用你定义的operator new版本,甚至不对它进行编译,这时候就很难使用你的源代码进行调试,因为我们很难知道预处理器到底做了些什么。
你可以使用以下几种技术检查预处理器的输出:在Project Setting对话框里选择整个工程,然后单击C/C++标签。在Project Options输入框中,在设置的最后添加编译选项/P。现在,重新build你觉得有问题的文件,例如bogus.cpp,你会发现bogus.i出现在工程文件夹里,而不是在Debug或Relese文件夹里。然后你就可以用任何种文本编辑器察看这个文件。预处理器文件可能会很大,因为文件的开始部分都是预处理器产生的垃圾,所以你可以直接跳到文件的最后,这才是你的程序代码所在的地方。然后你就可以搜索这个文件,看有关的预处理器符号到底是怎么定义的。
看完之后,记得把/P编译选项从工程设置中去掉。设罝了这个选项,编译器会把预处理器输出的结果放在目标文件列表里,所以必须把这个选项去掉才能编连你的工程。
你当然不希望将未经优化的代码犮布出去,因为它太不“优”了。你可以尝试优化程序的代码大小,这种方法安全得多,而且同样有效,因为小的代码一般也是快的代码。如果问题不存在了而且性能可以接受,你就成功了。否则,优化其中某些文件的性能。或者,如果你知道优化时哪个函数出的问题,可以按照你希望的方式对程序进行优化,然后用#pragm optimize对这个出问题的函数进行细致的优化。如果你想了解更多关于优化的内容,请阅读Matt Pietrek的著作:“Remove Fatty Deposits from Your Applications Using Our 32-bit Liposuction Tools”。
请记住一点:虽然Visual C++的优化工具可能存在问题,但更有可能是在你的代码中存在问题,然后被优化工具暴露出来了。但是,如果你不能在代码中找出与优化有关的错误的根源,使用上述技巧是最合适的。
重启Windows,把工程的Debug和Re!ease文件夹都删掉,从头编连程序。如果这样还不能解决问题,请看第12章“非常规策略”。
使用Debug菜单下的Break命令。Windows 2000下,如果程序有输入请求,可以使用F12键中断程序;然后检査窗口的调用栈,或者单步跟踪代码找到死循环的发生原因。
如果消息框是在一个单线程程序中出现的,使用Debug菜单下的Break命令或者在Windows 2000下使用F12键中断程序,然后检查窗口的调用栈找出消息框产生的原因。
显然,这是Windows 2000使用F12键进行调试中断的一个不利的影响。一种解决的方法是当定义了_DEBUG的时候在程序中将这个功能重新指定到其他键,如Alt+F12、Shift+F12等。如果是Windows 2000(但不能是Win NT4.0),你还可以在注册表中修改调试中断键盘控制。例如,要将调试中断改成使用ScrollLock键,可以将HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AeDebug中UserDebuggerHotKey的值设置成145(ScrollLock键的虚拟键代码)。必须重启Windows修改才能生效。
你可以指令你的程序进行profiling(如果确实需要的话不妨如此),但很多时候你只需要在调试器中运行程序就可以知道性能不好的原因所在。在性能不好的时候用几次Break命令,并检查调用堆栈窗口就可以找到原因。如果性能问题只限制在小段代码内,随机中断时碰到这段代码的概率就会很大。
你可以借助调试器进简单的profiling。
如果你希望进一步知道执行某一段特定的代码需要多长时间,可以在Watch窗口中输入“@CLK”,这是一个调试伪寄存器,用来显示时间(以微秒为单位)。或者你也可以在Watch窗口中输入“@CLK/1000得到以毫秒为单位的时间,这样可能看起来更方便些。还可以加上观察窗口的格式符号“,d”,以保证输出是十进制格式的。如果想看到调试器中每一步消耗的时间,而不是累加时间,在观察窗口中紧跟着“@CLK,d”输入“@CLK=d”(注意是在之后不是在之前),如图8.1所示。
图8.1 在观察窗口中使用@CLK伪寄存器得到代码的profiling
在调试的时候不小心越过了感兴趣的代码是经常发生的事。这时你一般不需要从头开始执仃程序,即使你关心的代码在整个程序运行过程中只执行一次。Visual C++调试器的确没有类似Step Back的命令来撤销Step Into和Step Over命令,但是它提供了Set Next Statement命令,允许用户在源代码窗口中选择一条语句,设定成下一条执行的语句。也可以在disassembly窗口中选择指令,用Set Next Statement设置为下一条“语句”。我们以下面这段代码为例,说明Set Next Statement命令的作用:
int ReallyBadNews() {
int value = 0;
POINT *pPoint = 0;
pPoint->x = 4; // will crash here with an access violation
pPoint->y = 5; // will crash here with an access violation
value = pPoint->x * pPoint->y;
return value;
}
int BadNew() {
int value;
{
int value2 = 40, value3;
value3 = ReallyBadNew();
value = value2 / value3; // will crash here with integer divide by zero
}
return value;
}
如果你在单步跟踪BadNews函数时使用的是Step Over命令,就会发现在运行ReallyBadNews函数时发生一个未处理的非法访问异常,导致程序崩溃。显然,产生这个错误是因为pPoint没有初始化指向一个合法的POINT结构。在ReallyBadNews中,你可以使用Set Next Statement命令指定继续执行函数中的其他任意一个语句,即使此时程序已经因为未处理的异常而崩溃了。一旦你回到ReallyBadNews,你也可以使用Set Next Statement命令继续执行该函数的任意语句。在这两个函数中,你都可以重新执行任何代码,而不会导致崩溃,只要代码本身没有严重的副作用导致程序不能运行。唯一不可以做的是使用Set Next Statement命令将控制点从一个函数移到另一个函数,否则就会出现如图8.2的消息框。必须在消息框中选择“Cancel”,因为继续执行会破坏堆栈。
图8.2 当试图使用Set Next Statement命令将程序执行的控制点从一个函数移到另一个函数时出现的消息框
你可能对此感觉奇怪:为什么在发生了未处理的异常之后还能够继续运行程序?这是因为在调试器中运行程序时,Visual C++自己处理了异常,所以只要使用了Set Next Statement命令避开有问题的代码,就可以继续执行。你可以在ReallyBadNews函数中设置为“return;下一条语句]”。典型地,这意味着你可以在源代码发生未处理的异常后继续运行程序。但是,如果在一个Windows API函数或库函数中发生了未处理的异常,你就不太可能在同一函数中继续执行,所以程序就很可能不得不终止了。
你可能会觉得奇怪,为什么可以自由地跳入/跳出一个块(BadNews函数中花括号中间的部分)。要理解其中的原因,就必须知道Set Next Statement命令到底做了些什么。很简单,它做的事就像它的名字所说的;通过修改指令指针寄存器(EIP)的值设置下一条将被执行的指令。它不修改堆栈,不执行任何代码,不创建或释放任何变量,也不做其他任何事情。要注意的是局部的自动变量,编译器在函数入口点就为该函数中定义的所有自动变量分配了空间,而不是在相应的块的入口点才分配。所以,虽然变量value2和value3看起来都只存在于块内部,但分配给它们的堆栈空间在整个函数的生存期内都有效。块的入门点的唯一动作是自动变量的初始化。在本例中,value2被初始化成40。
如果涉及到对象,问题就更复杂了。如果变量value2和value3是对象,进入块的时候会调用它们的构造函数,离开的时候调用其析构函数。检查Disassembly窗口中的代码可以明显地看到这一点。显然,构造函数和析构函数的不匹配不是一件好事情,所以当使用Set Next Statement命令涉及到对象的创建和解构时要格外小心。
—旦你熟悉了Set Next Statement命令,你会发现它真的很有用,它可以使你的调试过程效率更高。如果你不经常使用Set Next Statement,说明你用得还不太对。不过,要时刻记住:当你使用Set Next Statement的时候,你实际上执行的是不同于实际程序的另一个程序,这样的程序执行最好只看成是一次实验。这意味着即使程序崩溃、泄露资源或者发生了其他错误,你也不必人担心,因为真实的程序执行起来会是另外一个效果。另外,调试版本基本上是对程序代码的直接翻译,而发行版本一般不是。因此,在发行版本下在Disassembly窗口中使用Set Next Statement命令通常会取得较好的结果。
使用Set Next Statement命令使你的调试过程效率更高。
你可以使用Set Next Statement命令,有时或者还需要在观察窗口中调整一些有关的变量的值,来创造特定的错误条件,从而调试你的错误处理代码。例如,考虑如下的代码:
BOOL CMyApp::InitInstance() {
try {
m_pMainWnd = new CMyMainWnd;
m_pMainWnd->ShowWindow(m_nCmdShow);
m_pMainWnd->???
return TRUE;
}
catch(CMemoryException *e) {
e->ReportError();
e->Delete();
return FALSE;
}
}
一般宋说,operator new几乎不可能因为内存不足而失败,这使得我们的错误处理代码很难测试或调试。虽然这个函数中处理内存不足错误的代码相当简单,你可能需要在这个函数之外的地方调试这个错误的处理机制,看程序是否能正确地处理FALSE的返回值。值得注意的是,你不能仅仅靠使用Set Next Statement命令设置异常处理代码中的语句为下一条指令来测试这段异常处理代码(即使你跳过ReportError和Delete函数),因为不允许用户像跳入一个块那样跳入一个异常处理代码段。调试异常处理代码的最简单的方法是Step Into跟踪进入operator new的函数体,本例中是MFC的版本(AFXMEM.CPP):
void* __cdecl operator new(size_t nSize, int nType, LPCSTR lpszFileName, int nLine)
{
#ifdef _AFX_NO_DEBUG_CRT
UNUSED_ALWAYS(nType);
UNUSED_ALWAYS(lpszFileName);
UNUSED_ALWAYS(nLine);
return ::operator new(nSize);
#else
void* pResult;
#ifdef _AFXDLL
_PNH pfnNewHandler = _pfnUninitialized;
#endif
for (;;)
{
pResult = _malloc_dbg(nSize, nType, lpszFileName, nLine);
if (pResult != NULL)
return pResult;
#ifdef _AFXDLL
if (pfnNewHandler == _pfnUninitialized)
{
AFX_MODULE_THREAD_STATE* pState = AfxGetModuleThreadState();
pfnNewHandler = pState->m_pfnNewHandler;
}
if (pfnNewHandler == NULL || (*pfnNewHandler)(nSize) == 0)
break;
#else
if (_afxNewHandler == NULL || (*_afxNewHandler)(nSize) == 0)
break;
#endif
}
return pResult;
#endif
}
现在,你可以产生一个内存不足的异常了——一种方法是在_malloc_dbg之后设置pResult为0,这样做会泄露内存,但是在这个测试中不会引起大的问题;或者你可以使用Set Next Statement命令跳过_malloc_dbg及其后的两个语句。
使用Set Next Statement命令,并在观察窗口中改变相关变量的值,调试不太可能执行到的代码。
这里介绍的原理适用于一般情况:通过Set Next Statement命令和在观察窗口中改变变量的值这两种手段的结合,你可以轻松地测试/调试一般情况下很难执行到的代码。
你可以直接在调试器中检査函数的返回值,所以你不必为观察返回值而专门在代码中加个临时变量并重新编译。如果返回值不大于32位,它会被直接放在EAX寄存器中,你可以在寄存器窗口中察看,或者在观察窗口中键入“@EAX”„如果返回值长为64位,其低32位会放在EAX寄存器中,高32位放在EDX寄存器中。如果返回值长度大于64位,会在EAX寄存器中放入指向返回值的指针,可以通过在观察窗口中进行类型转换(例如,若某函数返回一个CRect,则可以键入“(CRect*)@EAX”显示结果),或在内存窗口Address栏中直接键入“EAX”查看返回值。
类似地,如果一个Windows API函数调用失败,你可以在调试器的观察窗口中直接键入"@ERR",检查GetLastErro的值。这是一个调试器伪寄存器,用来存放最近—次错误的代码。你可以使用格式符号“,hr”翻译这个错误代,例如“@ERR,hr”。
线程退出代码为-1不是一个错误,所以不必为之担心。Windows自身会创建退出代码为-1的线程,例如当你的程中显示某一个Windows通用对话框时。
激活即时(JIT)调试能够帮助你在调试器中检查这个问题。要激活JIT调试,在Tools菜单中选择Options命今,在Options对话框中选择Debug标签,选择Just-in-Time Debugging选项。
你可以使用存储器窗口査看堆栈的内容。显示存储器窗口,然后或者在Address栏中键入“ESP”显示堆栈指针,或者键入“EBP”显示堆栈的基址指针。然后在存储器窗口的上下文菜单中选择Long Hex Format显示选项,这个选项可以使得堆栈的内容更易阅读。
正如第1章中所说,窗口的画图代码是调试过程中海森堡不确定原理的一个最好的例子——正是调试器的存在干扰了你所要调试的东西。特别地,如果你正在画的窗口被正在激活的调试器覆盖,这个画图窗口就会被使无效,也就是说,调试器的激活影响了窗口的画图方式——如果它可以被画的话。
这个问题的解决方法是阻止窗口的重叠。你可以适当地对窗口进行布局,使程序和调试器窗口在屏幕上相邻显示。使用你的系统所能支持的最高的屏幕设置也许能对你有所帮助。如果没有足够的空间来避免所有的重叠,你至少应该使正在被调试的窗口不被调试器覆盖。可能你还不得不调整一下Visual C++的工具栏,以使得调试能正常进行。如果你的程序只能在最大化的窗口下运行(游戏和屏幕保护程序就是两个很好的例子),你必须在调试版本中将程序设定成允许以Restored窗口(即不是最大化的)方式运行。类似地,如果正在被调试的窗口具有WS_EX_TOPMOST属性,这个属性在调试版本编连时必须去掉。
另外一种选择是使用远程调试,即被调试的程序和调试器运行在网络中的不同计算机上。关于如何使用Visual C++进行远程调试的步骤请见MSDN的《Visual C++ Programmer's Guide》的“Debugging Remote Applications”部分。
如果你需要调试很多画图代码,可能最好的选择是再装一块显卡和一台显示器。这样你可以在一个显示器上运行程序,在另一台上运行调试器。这个解决方案可能要贵一些,但更灵活,而且耗费的精力最少。
最后,注意GDI会将GDI函数调用累积起来,放在一个批处理文件中,然后一次处理一个批处理文件,而不是一次执行一个调用。这一技术可以大大地提高画图效率,但是它也会使得画图代码难以调试,因为你不能看到每行代码的执行结果。你可以在调试版本中关掉批处理功能,为此只要在线程初始化时加入如下代码即可:
#ifdef _DEBUG
GdiSetBatchLimit(1); // disable thread GDI batch processing
#endif
不必要的闪烁是窗口画图最难调试的地方之一。当窗口的背景在不需要的时候被擦掉,就会造成不必要的闪烁。这个问题很难调试,因为Windows的基于消息的特性使我们很难确定到底是哪行代码发出了不必要的擦除背景的消息。
我总结了一些可能导致这个问题的源代码的情况,这可能比使用调试工具要简单一点:
•不适当的UpdateWindow调用。Windows给paint消息指定比较低的优先级,以防止窗口不必要的重画。但是,一个程序可以使用 API函数UpdateWindow,迫使窗口立即被重绘。不适当的UpdateWindow调用会导致不必要的重画。
•调用InvalidateRect而不指定更新矩形。 API函数InvalidateRect允许用户指定更新矩形,使得重画只限于需要重画的区域;可以传递一个空指针给InvalidateRect函数来更新整个窗口,但是这样做画图需要更长的时间,结果是不必要的闪烁和低速的画图。
•调用InvalidateRect,而将擦除背景参数不适当地设置为真。如果背景不需要重画,你可以将InvalidateRect函数中檫除背景的参数设置为false。注意:MFC将这个擦除背景的参数默认设置为true。
•不适当地使用CS_HREDRAW和CS_VREDRAW窗口风格。仅当客户区的大小改变需要重画整个窗口时,才需要设置这两种窗口风格。如果窗口中的某些元素需要居中放置,这是必要的:但是大多数的窗口不需要居中排列任何东西,所以没有必要使用这类风格。MFC默认使用的就是这两种风格,所以如果你使用的是MFC,最好在你自己的类的窗口构造函数中去掉这两个属性。
鼠标处理的代码是调试过程中海森堡不确定原理的又一个典型例子。鼠标处理代码很难调试。因为被激活的调试器干扰了鼠标消息的队列。
调试WM_MOUSEMOVE消息尤其困难,因为一旦你在一个鼠标移动消息的处理函数中设置了断点,当鼠标一进入被调试窗口调试器就会立刻中断。而大部分情况下,你希望在鼠标移动到窗口的特定位置或在特殊的环境下才发生中断。我发现本章前面部分介绍过的GetAsyncKeyState调试技术对于解决鼠标移动消息的调试特别管用。请看下面这段代码:
CMyWnd::OnMouseMove(UINT nFlags, CPoint point) {
#ifdef _DEBUG
if(GetAsyncKeyState(VK_CONTROL) < 0) {
int bogus = 0; // set brakpoint here to break when control key is down
}
#endif
...
}
如果你在给bogus赋值的代码上设置了一个断点,仅当你按下Control键时,才会进入鼠标移动消息处理函数。
调试WM_LBUTTONDOWN和WM_LBUTTONUP消息也是一个问题,因为在WM_LBUTTONDOWN消息处理函数中设置一个断点很可能会导致WM_LBUTTONUP消息被调试器吸收(eat)。绕开这个问题的一个办法是在调试器中一直保持鼠标按下的状态,只使用键盘控制调试器。一旦程序重新获得了输入焦点,你就可以释放鼠标按钮了。
与消息有关的问题很难在调试器中调试,因为调试器给你的信息很少。在不同的消息处理函数中设置断点,你可以知道收到了哪条消息。以及消息的参数是什么,在调试器中,你看不到消息的来源,也看小到消息是由 API函数SendMessage还是PostMessage发的,因为消息传递机制禁止这些信息在调用堆栈中出现。也很难通过调试器看到接收到的消息及其顺序的整体画面。
调试消息的最好力案是使用Visual C++提供的Spy++工具。Spy++允许程序员查看窗口、消息、进程和线程。要控制并调试消息,首先选择Spy菜单中的Message命令:这个命令将显示一个消息选项对话框。在Windows标签下,你可以使用Windows FInder Tool选择你要监视的窗口;然后在Messages标签下选择你想监视的消息,这一步很重要,因为Windows会传递成千上万个不同的消息给一个窗口,你还可以通过Output标签选择希望的消息信息的显示格式,注意,你可以选择解码消息的参数和返回值,这将使得结果更容易阅读。你可以选择将输出结果重定向到一个日志文件中,这样你就可以以最小化窗口的模式运行Spy++,以防止它干扰你正在调试的程序,从而避免海森堡不确定原理的问题。
图8.3显示了典型的Spy++消息输出,假定使用默认的输出选项,第一栏显示的是Spy++的输出行号,第二栏显示接受消息的窗口的句柄。第三栏中的“S”表示消息是用SendMessage发出的,“P”代表消息是由PostMessage发出的,“R”是消息句柄的返回值。第四栏给出解码后的消息名,其他的信息还有解码后的消息参数或返回值。
图8.3使用Spy++调试与消息有关的问题
你可以使用Spy++观察发送给你的程序的消息,也可以看看发送到其他程序的消息,观察两者行为上的差异。一般只需要关心消息的行为,而消息的顺序仅供参考。一般情况下,最好避免在你的代码中假定消息的顺序,因为在不同的Windows版本中消息的顺序可能会有所不同。消息的顺序也可能被程序中的 API函数PeekMessage的调用影响,这个函数可以以下同于消息到达时的顺序处理消息队列中的消息。但是,如果在Windows文档中定义了某种消息顺序,你就可以利用这类特定的消息顺序信息。例如,Windows API文档中规定:WM_NCDESTROY是一个窗口被销毁之前发出的最后一条消息,MFC的设计中就利用了这一信息。
调试工具提示(tooltip)代码的问题是:一旦你把一切东西建立起来了,Windows自己会管理工具提示的显示。如果工具提示显示得不正确,或者根本就不显示,你得不到任何有关的信息,因为所有有关的代码都在Windows内部。调试工具提示的技巧是指定LPSTR_TEXTCALLBACK,使用回调来获得工具提示的文本,即使这个文本是静态的,不需要回调。使用这个方法,然后你可以在回调代码中设罝断点,确认工具提示正以你所期望的工作方式工作。例如,你可以把鼠标移动到预期的位置,并查看回调函数是否被调用、其参数是否正确,从而验证工具提示的矩形框是否在正确的位置。
这个原则适用于一般情况。回调允许程序员比较方便地调试,因为它允许你进入Windows,查看它在做什么。
要调试这个问题,你必须首先了解一个事实:程序的启动代码不一定是程序中的第一行要运行的代码。因此,如果你把断点设置在程序的第一行启动代码上(Windows API程序的WinMain函数或MFC程序的CWinApp::initlnstance),你的程序仍可能在到达这一行代码之前就崩溃了。
要调试这样的崩溃,了解Windows可执行文件的载入过程可能会对你有所帮助。这里我们简要地看一下这个过程:
•Windows为主线程创建默认的堆、栈和线程局部存储空间。
•Windows将可执行文件的主体和它的所有DLL载入虚拟存储空间,如果有必要,DLL会被重定位。
•Windows解释所有的函数和数据引入符号。
•对所有DLL_PROCESS_ATTACH符号中带有该文件的DLL,Window调用其DllMain函数。
•Windows调用C的运行时刻函数库中的WinMainCRTStartup启动代码。
•运行时刻函数库分解命令行,并设罝环境变量。
•运行时刻函数库初始化自己。
•运疔时刻函数中为主可执行文件和所有DLL创建全局和静态变量。
•运行时刻函数中调用程序的WinMain函数。对于MFC程序,WinMain立即调用AfxWinMain。该函数调用AfxWinlnit初始化MFC自身,然后调用CWinApp::InitInstance初始化应用程。
在这些步骤中,构造全局和静态对象是最容易导致崩溃的一步,因为在对象的构造函数中可能存在错误。DllMain函数中的代码也可能产生问题,尤其是当这些函数的调用发生在程序被完全载入之前的时候。你可以通过在调试器中检查调用堆栈窗口来定位这样的问题。寻找可执行文件并将它们载入到虚拟存储空间这一过程中发生的所有问题,Windows都会自动报错。关于Windows 2000的程序载入过程的详细信息,参考Matt Pietrek的《Under the Hood》。
程序退出的过程是启动过程的一个镜像,因此,程序退出的代码也不一定是程序中要运行的最后一行代码。所以,如果你在程序退出代码的最后一行上设置断点(Windows API函数的WinMain函数,或MFC的CWinApp::ExitInstance函数),程序仍然可能在这些代码执行之后崩溃。在退出的过程中,最容易导致崩溃的是在析构全局和静态对象时,或者调用带有DLL_PROCESS_DETACH符号的DllMain时。你可以通过在调试器中检查调用堆栈窗口来定位这样的问题。
如果一个函数在堆栈中的返回地址被破坏了,该函数就会在返回的时候崩溃。以下是最容易导致返回地址破坏的原因:
•写自动变量越界。如果试图写—个自动变量数组时越界,可能破坏返回地址。因为栈在内存中是向下增长的,试图写自动变量时越界就会破坏最后一个压入栈中的数据,例如函数的返回地址。这个问题在调试版本和发行版本中都有可能发生。
•函数原型不匹配。如果函数的参数不匹配,或者调用函数与被调用函数之间的调用规则不匹配,都会造成对堆栈的破坏,而且很可能破坏函数的返回地址。这个问题在调试版本中不会出现,因为调试版本使用堆栈基址指针寄存器访问函数的返回地址,但在优化使用FPO的发行版本中可能发生。
这两种堆栈破坏的问题将在第9章“内存调试”中进一步讨论。
以下是最常见的特定于MFC的错误:
•使用错误的函数原型处理用户定义消息。使用错误的函数原型处理用户定义消息可能是MFC程序中最常见的错误了。在MFC中会发生这个问题是因为ON_MESSAGE和相关的宏转换了输入函数,因此即使存在不匹配,编译器也不会提醒你。一般来说,调试这类错误的最简单的方法就是使用编译选项/GZ编译你的调试版本。不幸的是,目前的MFC在调试版本中不使用这一选项。注意:正确的函数原型是这样的:
afx_msg LRESULT OnMyMessage(WPARAM wParam, LPARAM lParam);
这个问题在第9章中还将详细讨沦。
•保存指向临时MFC对象的指针。MFC试图给用户一个假象,即Wimtows是一个C++的面向对象的操作系统。因此它把Windows API函数返回的窗口句柄都封装成一个MFC对象。每个MFC线程都包含对象的两个映射图:一个是永久对象列表,这个列表中的对象一直存在,直到程序显式地将之销毁:另一个是临时对象列表,一旦程序的消息循环有空闲,MFC就会把这种对象销毁。任一列表都允许MFC将Windows的句柄转换成C++的对象指针。
例如,如果你调用CWnd::GetParent获得一个MFC窗口对象的父窗口,有可能此时父窗口已经不是一个永久的MFC对象了,这时候MFC就要创建一个临时对象。GetParent函数的实现如下;
_AFXWIN_INLINE CWnd* CWnd::GetParent() const {
ASSERT(::IsWindow(m_hWnd));
return CWnd::FromHandle(::GetParent(m_hWnd));
}
CWnd::FromHandle函数会先在永久对象列表中寻找需要的句柄,然后在临时对象队列中寻找。如果在这两个地方都没有找到,就会自己创建一个Cwn对象,加到临时对象列表中,然后返回一个指向该对象的指针。这个机制工作相当正常,除非你试图保存这个CWnd对象。当处理下一个消息时,一个临时对象很可能已经变得非法了,这样就会产生错误,而旦很难发现。
如果你要保存一个指向你没有显式创建的对象的指针,你必须非常小心。任何一个从调用了FromHandle的函数返回的对象都不能跨消息地保存,这样的函数包括CWnd的函数GetDlgItem、GetFocus、GetWindow、GetActiveWindow、FindWindow、GetOwner、GetFont、GetMemu、GetDC。为了将一个此类函数返回的对象转化成永久对象,你必须首先分配一个MFC对象,然后使用Attach函数将该对象和窗口句柄联系起来,如下列代码所示:
CWnd* GetPermanentParent(CWnd* pWnd) {
ASSERT_VALIDE(pWnd);
HANDLE hParent = ::GetParent(pWnd->GetSafeHwnd());
CWnd* pParent = new CWnd;
pParent->Attach(hParent);
return pParent;
}
任何一个从调用了Fromhand]e的函数返回的对象都不能跨消息地保存。
当传递对象时使用了消息或跨了线程,一定要传递底层的窗口句柄,而不是MFC对象。如果想更多的信息,清参考《MSDN Technical Nate TN003》的“Mapping of Window Handles to Object”。
•忘了在消息映射中加入消息处理函数。为了防止对象的vtable过于庞大,MFC使用消息映射而不是虚函数,来处理大多数的窗口消息。刚入门的MFC程序员习惯于使用MFC类向导模板来建立消息处理代码。而有经验的程序员经常手工输入代码。如果你定义了一个消息处理函数,但是忘记在消息映射中增加相应的条目,那么这个消息处理函数不会被调用,而且编译器和链接器不会报错。如果你觉得你定义的消息处理代码很奇怪地从来没有被执行,请检查你的消息映射。
•不正确地创建或销毁CFrameWnd和CV[ew的派生对象。CFrameWnd和CView对象都被设计成自清理。这一点在PostNcDestroy的实现中可以看到:
void CFrameWnd::PostNcDestroy()
{
// default for frame windows is to allocate them on the heap
// the default post-cleanup is to 'delete this'
// never explicitly call delete on a CFrameWnd, use
// DestroyWindow instead
delete this;
}
这些自清理对象在接受到WM_NCDESTROY消息——这是窗口接到的最后一个消息——时就会删除自已。这个设计暗示了两个信息:一是这些对象必须在堆上创建,因为你永远不会希望删除一个栈中的对象;第二点是要删除这样的窗口对象必须调用 API函数DestroyWindow,因为显式地调用delete会造成两次删除同一对象。如果需要更多的信息,请参考《MSDN Technical Note TN017》的(Destroying Window Objects)。
MFC TRACE宏的输出可以被关闭或打开,这是通过Afx.ini文件中Diagnostic区的TraceEnabled设置实现的。MFC的跟踪输出默认设置为打开。不过你最好不要直接编辑这个文件来修改这个值,标准的方法是运行Visual C++的Tracer工具,确定其中的EnableTracing选项被选中。
DiLascia, Paul. "Meandering Through the Maze of MFC Message and Command Routing", Microsoft Systems Journal, July 1995.
详细地阐释了MFC的消息路由。如果你要调试MFC程序中的消息问题,这是一本经典之作。
...