第4章使用跟踪语句
在20世纪70年代中期,C编程语言作为一种低层、多用途的编程语言出现。我并不知道第一个有意义的C语言程序是什么样的,但我可以信誓旦旦地说它是有bug(错误)的,而且作者肯定是使用printf命令行语句来对那些bug进行调试的。同样地,Microsoft Windows在80年代中期作为IBM个人计算机的图形用户接口出现,我同样不知道第一个有意义的Windows程序是什么样的,但我也可以很肯定地说它也是有bug的,而且这些bug是利用API消息框语句进行调试的。
就目前来讲,使用跟踪语句来调试Windows程序是相当于printf和消息框语句调试工具的一种新的方式,它们之间唯一重要区别是显示方式不同。跟踪语句是一种相当原始的调试设备,是向目前发展趋势的一种极大的挑战。它的简易、灵活和功能强大使其成为最混乱的编程环境中的调试器的优先选择之一。
跟踪语句可使程序执行,并使程序员可对可变值进行查看,它们提供了一个用于观察的程序,并且独立于一个交互式的调试器,但是最具特色的是它们常用于对调试器提供的的信息进行补充。下面是一个典型的跟综语句:
TRACE(_T("Warning (FunctionName):Object %s not found.\n"), objectName);
这个语句显示了代码中的一个特定事件,在这今例子中,程序试图找到一个对象,但是没有找到。在Visual C++中,跟踪消息通常输出到输出窗口中的Debug标签。它们也可以重新输出到一个文件中。
跟踪语句的特性如下:
•跟踪语句用于报告代码中重要的运行事件。这种事件不—定是bug,但有可能是(一些确定的bug事件使用断言(assertion)报告)。这种事件有可能是一个bug的某些征兆,或者是用于跟踪调试一个bug的某些有用信息。除了跟踪程序执行,跟踪语句同样用于转储可变值和调用栈。
•跟踪语句的编译通常是有条件的,并只存在于调试版本中,而在发行版本中不被编译。具体说来,OutputDebugString API函数、MFC AfxOutputDebugString宏和MFC AfxDumpStack函数在所有版本中均被编译,但是其他所有的跟踪语句仅在定义了_DEBUG符号时才被编译。因为这些有条件编译的跟踪语句在发行版本中没有定义,你可以不受任何执行限制地积极使用它们。
•跟踪语句不能包含程序代码或对程序代码有间接的影响作用,因此在发行版本中,程序也有类似的限制。跟踪语句不能修改程序变量或调用那些修改程序变量的函数。
•跟踪语句的目的是向程序员提供信息,而不是向用户。它们并不能代替错误信息输出给用户。
跟踪语句也是调试语句,它可以执行程序,并且在运行中程序员可以查看变量。
理论上讲,如果你使用了一个新式的交互式调试器,跟踪语句并不是必须的,但是它们对于査看恃别重要的代码还是非常有用的,例如进程的启动和终止、函数失败处理、程序警告信息和一个不能预料的动作。跟踪语句在交互式调试器不能正常工作时尤为有用,例如当调试遇到其他装置(如DCOM、进程或者编程语言(如C++和Visual Basic)时,与调试设备代码没有用户接口时,当远程调试时。跟踪语句对于调试那些使用交互式调试器很难调试的程序动作是很有效的,这些很难调试的程序动作通常是因为海森堡不确定原理造成的(参见第1草1.8节中“理解海森堡不确定原理”),例如窗口激活和鼠标移动消息。最后,如果使用交互式调试器需要太多工作时,跟踪语句也是很有帮助的,例如当跟踪一个特殊的Windows消息集合或监控异常处理时。
为了更充分理解跟踪语句,我们必须把它们和断言做一番比较,这一点非常重要。尽管它们的特性和执行非常相似,它们的用途却截然不同。跟踪语句和断言的区别如下:
*跟踪语句是无条件的。断言是有条件的布尔语句,而跟踪语句却总可以执行。
*跟踪语句不直接显示出bug。断言用于显示出bug,而跟踪语句用于显示程序执行和变量值。
*跟踪语句可以被随便地忽略,默认情况下,断言打断程序的执行,并弹出一个消息框等待用户或程序员的响应;跟踪语句则将信息输出到调试窗口或文件中,于是很容易被程序员忽略。这种特点使得跟踪语句对于整体上程序的检验和程序的警告是非常理想的。一个很好的类比是:断言对编译器错误,跟踪语句对编译器警告。
尽管断言和跟踪语句有一些相似性,它们用于报告的信息类型是完全不同的,因此它们不能被视为可互相替换的。
4.1跟踪语句的类型
Visual C++提供了跟踪语句的几种类型。在本节中,我列出了由Windows API,ANSI C++运行时刻函数库、Visual C++的C运行时刻函数库和MFC与ATL应用程序框架提供的跟踪语句。
Windows的跟踪语句
你可以使用的最基本的跟踪语句是Windows的OutputDebugString API函数:
void OutputDebugString(LPCTSTR traceText);
如果运行调试器,这个函数将traceText输出到输出窗口的Debug标签中,若调试器没有运行,这个函数将什么也不做,这个函数不是带有条件编译的,因此它在调试版本和发行版本中都可以使用。它是Windows的一部分,因此它一直是有效的。这使得这个函数适用于在程序启动和结束过程中的跟踪。与此相反,Visual C++的C运行时刻函数库和MFC跟踪语句并不适宜跟踪程序启动和结束,因为它们仅在加载了相关的DLL之后才有效。
如果你只想在调试版本中使用OutputDebugString,可以使用下面的宏来实现:
#ifdef _DBBUG
#define OutputTraceString(text) OutputDebugString(text)
#else // _DEBUG
#define OutputTraceString(text) ((void)(0))
#endif
ANSI C++运行时刻函数库跟踪
ANSI C++运行时刻函数库中并没有跟踪语句,但是它有用于跟踪的标准字符模式的输出流,具体的有:C语言的stderr流和C++语言的cerr和clog流。这些流中的其中两个使用尽可能少的缓冲区以减少持续不断写文件的需要次数。具体说来,stderr是不需要缓冲区的;cerr使用单位缓冲,在每次插入操作后,字符将存入文件中;clog使用完全缓冲。因为还没有写到文件中的缓冲输出,在程序出错崩溃后会完全丢失,因此在调试过程中使用最小缓冲是很重要的。
要注意的是因为这些标准输出流并不是针对图形用户接口设计的,因此它们在一般的Windows程序中没有任何效果,但是可以在控制台基础上的Windows程序中使用。因为这些重要的原则,在本章中,我没有对这些流进行更进一步的讨沦。
VisuaI C++的C运行时刻函数库跟踪语句
ANSI C运行时刻函数库没有跟踪语句,但是Visual C++的C运行时刻函数库有跟踪语句。你可以使用_RPTn或_RPTFn调试报告宏〈前面的下划线表示这些宏是Visual C++特有的)。这需要你在程序中引用crtdbg.h,并利用C运行时刻函数库链接:
_RPT0(reportType, format);
_RPT1(reportType, format, arg1);
_RPT2(reportType, fonnat, arg1, arg2);
_RPT3(reportType, format, arg1, arg2, arg3);
_RPT4(reportType, format, argl, arg2, arg3, arg4);
_RPTF0(reportType, format);
_RPTF1(reportType, format, arg1);
_RPTF2(reportType, fonnat, arg1, arg2);
_RPTF3(reportType, format, arg1, arg2, arg3);
_RPTF4(reportType, format, argl, arg2, arg3, arg4);
这些宏使用printf类型的字符串格式和一个格式参数的可变数字,其中宏名中这个新增加的数字,表示参数的数目。参数reportType表示报告的类型,报告类型选项为_CRT_WARN、_CRT_ERROR和_CRT_ASSERT,其中_CRT_WARN是用于跟踪语句的。_RPTFn宏报告了源码文件名和调用这些宏的行号,因此你应该在源码的信息有意义的情况下使用这些形式,我想,这应该是大部分的情况下。这些宏是有条件编译的,只能存在于调成版本中。有趣的是,_RPTn宏和OutputDebugString不一样,它不支持Unicode。
默认情况下,如果运行了调试器,这些宏将格式化的文本输出到输出窗口的Debug标签;如果调试器没有运行,它们将什么都不做。然而你可以使用_CrtSetReportMode函数改变默认输出设置。你可以使用函数_CrtSetReportMode使跟踪消息输出到调试器的输出窗口、文件、消息框或它们的组合。函数_CrtSetReportFile可以指定将报告输出到哪个文件中。存储到文件中的跟踪消息组成了一个有效的日志文件,它在调试服务器代码时尤为有用。图4.1表示与重定向到一个消息框时而显示的跟踪消息。你肯定并不想将太多跟踪消息全部用消息框的方式显示出来,因为它们很快会变得非常烦人的。
MFC的跟踪语句
如果你使用MFC,你可以使用TRACE和AfxOutputDebugString宏、CObject::Dump虚函数和AfxDumpStack。AfxOutputDebugString宏和AfxDumpStack函数可以在所有的版本中编译,而其他的函数则只能在调试版本中编译。
TRACE宏有以下形式:
_TRACE(reportType, format);
_TRACE0(reportType, format, arg1);
_TRACE1(reportType, fonnat, arg1, arg2);
_TRACE2(reportType, format, arg1, arg2, arg3);
_ TRACE3(reportType, format, argl, arg2, arg3, arg4);
和宏_RPTn和_RPTFn一样,TRACE宏使用printf类型格式的字符串和一个格式参数的可变数字。TRACEn类的宏需要你指定参数的数目。而TRACE宏允许使用任何数目的参数,如下所示:
TRACE(_T("window rect l:%d r:%d t:%d b:%d\n"), rect.left,
rect.right, rect.top, rect.bottom);
一般说来,我推荐你使用TRACEn宏而不要使用TRACE宏,在MFC源码中,这一点尤其受到限制。其中的差别在于当使用TRACE宏时,你需要使用_T宏来格式化参数以正确地解决Unicode的校正(如上面的例子所示),反之,在TRACEn类型的宏中,你不必使用_T宏。因为其中差别并不是很明显,你也可以随你自已高兴使用这些格式。
具有讽刺意味的是,尽管MFC提供了一个CString类来处理实际中任何长度的字符串,但实际在内部这些跟踪函数使用一个512字符大小的固定缓冲区。假如TRACE宏的参数需要大于512字符的文本缓冲区,这会导致一个断言失败。
使用于Tracer实用程序
通过改变Afx.ini文件的Diagnostics小节中TraceEnabled的设置,TRACE宏的输出设置可以被设定和取消。默认设置下,MFC跟踪输出功能是打开的。但是要注意,改变这个值的最标准的方法是运行Visual C++的Tracer实用程序,如图4.2所示,而不要直接编辑文件。选择“Enable tracing”选项,打开跟踪输出功能。
图4.2 Visual C++的Tracer应用程序
如果你没有看见TRACE宏的输出,很可能是跟踪的功能被取消了,运行Tracer实用程序,修改设置将跟踪功能打开。
如你所见,MFC本身还提供了许多其他的跟踪选项来控制跟踪消息的输出。例如,如果你选择了选项“Main message dispatch”,MFC使用下面的代码跟踪所有的窗口:
// from Wincore.cpp
#ifdef _DEBUG
if(afxTraceFlags & traceWinMsg)
_AfxTraceMsg(_T("Wndpro"), &pThreadState->m_lastSentMsg);
#endif
这些选项非常有用,例如,你可以使用WM_COMMAND dispatch选项通过MFC命令消息的路线进行跟踪。理解这些选项的实际功能的最简单的方法是把它们全部选中,然后看结果。不过,一定要记住,Tracer只影响MFC跟踪语句的输出。
使用AfxOutputDebugString
AfxOutputDebugString宏使用和OutputDebugString—样的语法,但是在内部,在调试版本中,它由_RPT0宏实现,在发行版本中。它由OutputDebugString实现。它通过将Unicode字符转变成单字节字符的方法也能处理Unicode。不考虑Unicode字符串的处理,AfxOutputDebugString宏在调试版本中有如下的定义:
#define AfxOutputDebugString(lpsz) _RPT0(_CRT_WARN, lpgz)
这使得AfxOutputDebugString成为一个在MFC程序中使用Visual C++的C运行时刻函数库跟踪语句的好方法。
使用CObject::Dump
到目前为止,我提供的所有跟踪语句主要是用来跟踪程序执行。CObject类有一个转储虚函数,因此所有继承CObject的类都可以通过重载这个函数,输出它们的值。例如,你可以使用如下所示的语句来输出CObject派生类pObject的值:
#ifdef _DERUG
AfxDump(pObject);
pObject->Dump(afxDump);
afxDump << pObject;
#endif
其中afxDump是一个预定义的全局量CDumpContext。注意CDumpContext对最一般的内建数据类型及CObject的指针和引用,支持插入操作符(<<)。有几个不是CObject派生的类也有定义的插入操作符,具体的有CPoint、CSize、CRect、CString、CTime和CTimeSpan。因此,在这三个转储语句中,插入操作符的形式是多样的,因为你可以用附加的文本和其他的数据补充输出,例如:
afxDunip << _T("Warning:This object doesn't seem right!\n") << pObject;
如第7章所示,利用Visual C++调试器调试,你能做的一件很酷的事就是从watch窗口中调用程序函数,例如,你可以在watch窗口中通过输入以下的表达式来直接从调试器转储一个对象:
{ , , mfc42d.dll} AfxDump((const CObject*){*}this)(PS:参考7.7中的上下文操作符)
想要知道更多的关于Watch窗口表达式的各种作用,参见第7章。
对于有很多数据的复杂对象,例如集合,你很可能希望使用CDumpContext::GetDepth函数来控制要输出的数据的数目。按照MFC的惯例,转储虚函数在深度(depth)被设置为零时做一个空的转储,而在深度大于零时做个有深度的转储,,例如,CObList类如下语句所示实行它的转储函数:
void CObList::Dump(CDumpCotext& dc) const {
CObject::Dump(dc);
dc << "with" << m_nCount << "elements";
if(dc.GetDepth() > 0) {
POSITION pos = GetHeadPosition();
whiIe(pos != NULL)
dc << "\n\t" << GetNext(pos);
}
dc << "\n";
}
利用转储虚函数做什么,在很大程度上依赖于你的跟踪语句的策略。如果你只要使用交互式调试器调试,而只使用跟踪语句来获得补充的信息,你很可能并不需要很频警地转储对象。另一方面如果你使用跟踪语句代替交互式调试器,则你很可能经常地转储对象。然而,正如第9章“内存调试”中所讨论的,你可以建立起调试堆(debug heap),因此使用转储虚函数可以报告CObject内存中的漏洞,使得内存中的漏洞可以更好地被跟踪,这种能力为你自已的类补充转储虚函数提供了更多的动力。
使用AfxDump
从前面的例子可以知道,afxDump是MFC中相当于cerr流的跟踪语句,所以你可以直读向它输出跟踪消息。和输入输出流一样,它具有类型安全和可扩展的优势。不同于输入输出流的是它没有可移植性。你可以将其他类型的MFC跟踪语句转送到一个非MFC的程序,仅仅需要你定义合适的宏,而使用插入操作符则需要你重写所有的跟踪语句。这使得afxDump和插入操作符组合具有了不同类型的跟踪语句的最小的可移植性。
TRACE宏由afxDump实现,afxDump由AfxOutputDebugString实现,而AfxOutputDebugString在调试版本中由_RPT0宏实现。这表示,你可以使用_CrtSetReportMode函数将这些跟踪消息输出到调试器的输出窗口中,也可以输出到文件(使用_CrtSetReportFile设定一个指定的文件),还可以输出到消息框中,或输出到它们的任意组合。你还可以使用下面的方法直接将afxDump重定向:
#ifdef _DEBUG
CFile dumpFile; // must be a global variable
dumpFile.Open(_T("dump.log"), CFile::modeWrite | CFile::modeCreate);
afxDump.m_pFile = &dumpFile;
#endif
或者,你可以使用这种方法创建另一个全局的CDumpContext对象(例如afxFileDump),并在你想把特定的跟踪消息输出到文件中时,使用这个对象。
使用AfxDumpStack
你可以使用AfxDumpStack函数来输出一个调用栈:
void AFXAPI AfxDuunpStack(DWORD dwTarget = AFX_STACK_DUMP_TARGET_DEFAULT);
其中,参数dwTarget用来决定在调试和发行版本中输出到什么地方。可以输出到TRACE宏、OutputDebugString或剪贴板。你也很可能希望使用AFX_STACK_DUMP_TARGET_TRACE选项,它的含义是在调试版本中输出到TRACE宏,而在发行版本中没有输出。换句话说,如你希望能在发行版本中跟踪输出,你可以使用AFX_STACK_DUMP_TARGET_ODS选项。要注意的是,这个函数要想正常工作,必须在路径中有Imagehlp.dll文件。
如果你使用这个函数,必须确定修改你的工程的链接设置,你可以使用Microsoft的格式和一般对象文件格式(Common Object File Format, COFF)的调试信息(从工程设置对话框中,在Link标签中选择Debug Category,然后选择Both formats调试信息选项)。COFF调试信息在调用栈中得到调试符号是必需的。显然,发行版本的调用栈没有调试符号,除非你将COFF格式的调试信息包含进去,而一般人不会这样做,因为不这样做可以保持执行空间不会很大.
如果使用AfxDumpStack函数,就需要使用Microsoft格式和COFF格式的调试信息链接调试构件。
如果你不使用MFC,但又想输出调用栈,你可以使用StackWalk APl写下你自己的栈转储函数。使用StackWalk有一定的困难,因此如果你想使用这个函数,你可以参考MFC的AfxDumpStack的源代码(在Mfc\DumpStack.cpp)中来査看它是如何运行的。
ATL跟踪语句
ATL支持跟踪语句,最基本的一种类型是AtlTrace函数:
inline void _cdecl AtlTrace(LPCTSTR format, ...);
ATLTRACE(format, ...);
一如往常,这个函数使用printf类型的字符串格式和格式参数的可变数字。和MFC TRACE宏一样,它使用一个512字节固定大小的缓冲区,而且如果它的参数需要一个大于512字节的文本缓冲区,会导致一个出错的断言。实际上,它是使用API函数OutputDebugString实现的,因此它的输出不可能改变到其他目标。另外,这个函数可以在所有版本中编译,但它在发行版本中没有任何效果,这是因为它将被编译成一个空函数。因此,ATLTRACE宏通常放在和它具有相同动作的适当的地方,当然,它只存在于调试版本中时,情况又另当别论了。
ATL跟踪语句的另一个选择AtlTrace2函数如下所示:
inline void _cdecl AtlTrace2(DWORD category, UINT level, LPCTSTR format, ...);
ATLTRACE2(category, level, format, ...);
这个函数增加了一个参数跟踪类别(category)(例如,atlTraceCOM、atlTraceWindowing和atlTraceControl)和一个参数严格级别(severity level)。ATL程序通常有大量的跟踪信息、类别(category)和级别参数允许你只显示自己真正感兴趣的跟踪消息,而把其他的无用信息过滤掉。类别值是位掩码,你可以设定不止一个类别的跟踪语句,只要将类别位掩码相“或”’即可得到复合的掩码。严格级别的值可以是任何值,但系统只接受0到4之间的值,0级指最严格的级别。ATL自身使用介于0到2的级别值。和AtlTrace函数类似,AtlTrace2在发行版本中被编译成一个空函数,因此ATLTRACE2宏通常用在适当的地方。在ATL自身内部跟踪中专门使用这个宏。
使用ATL_TRACE_CATEGORY和ATL_TRACE_LEVEL
通过定义ATL_TRACE_CATEGORY和ATL_TRACE_LEVEL预处理常量,可以决定显示哪种AtlTrace2消息,默认情况下,显示所有种类,不过你可以使用atlTrace位掩码定义ATL_TRACE_CATEGORY常数过滤类别。注意,你可以通过把这些掩码值相“或”,获得新值以选择显示多个种类。例如,要显示所有的COM方让和引用次数的跟踪消息,可以使用以下的矩义:
*#define ATL_TRACE_CATEGORY (atITraceCOM丨atITraceRefCount)
通过定义ATL_TRACE_LEVEL,你可以设定输出的最小的严格级别。只有在默认情况下,才输出最严格的警告级别,因此大多数情况下,你可以把这个值设定为你能接受的最不严格的警告级别(也就是最高位的级别数)。例如要查看除去最不严格的标准级别的其他所有级别,可以做以下的定义:
#define ATL_TRACE_LEVEL 3
如果在StdAfx.h文件的顶端(在#include<atlbase.h>之前)定义这些符号,你就可以为整个程序设定类别和严格级别。不过注意AtlTrace2函数是内联的,因此你可以为一个单独的文件重新定义这些值,甚至为了更精确的控制,你可以为一段代码重新定义。
使用_ATL_DEBUG_QI和_ATL_DEBUG_lNTERFACES
在StdAfx_h文件顶端(在#include<atlcom.h>之前)定义预处理。常量_ATL_DEBUG_QI就可以在跟踪QueryInterfaces调用时使用内部支持。这种设置通过显示类名、显示要查询的接口名和显示查洵是否成功,可以设定跟踪所有QueryInterface的调用。
类似地,在StdAfx.h文件顶端(在#incIude<atlcom.h>之前)定义_ATL_DEBUG_INTERFACES预处理常量,而不要使用_ATL_DEBUG_QI就可以在跟踪AddRef和Release调用时使用内部支持。这种设置通过显示当前引用数、类名和引用的接口名,可以设定跟踪所有的AddRef和ReIease调用。
Visual C++消息Pragma
消息Pragma实际上是一个编译时的跟踪语句,你可以使用它来警告在预处理过程中发现的潜在的编连(buiId)问题。下面是一个典型的例子:
#if(WINVER >= 0x0500
#pragma message("NOTE:WINVER has been defined as 0x0500")
#pragma message("greater which enables features that require")
#pragma message ("Windows 2000 or Windows 98.")
#endif
消息Pragma是非常有用的,尤其是在复杂编连中。然而,如果你要检测一种特定的问题,而不是潜在的问题,使用#error预处理来代替打断编译会更直接一些。
跟踪语句的源代码
现在来看一看跟踪语句的源代码。下面是Visual C++的C运行时刻函数库的_RPT4和_RPTF4宏的断言源代码:
// from Crtdbg.cpp
......
当然,其他的_RPTn宏有类似的实现过程,只不过参数少了一些。如你所见,_RPT宏的实现和一ASSERT宏的实现几乎是完全相同的,唯一的重要区别就是断言在有字符串的地方使用了布尔表达式。
下面是\MFC使用的跟踪语句的源代码:
// from Afx.h
......
注意,在MFC TRACE宏中的一个缺点是AfxTrace函数使用一个512字符固定大小的缓冲区,这使得它在跟踪长字符串时是无用的。如果栈空间很充裕的话,这种限制是非常烦人的。
下面是ATL使用的跟踪语句的源代码:
// from Atlbase.h
......
AtlTrace函数的实现和AtlTrace2的实现实际上是一样的,只除了它没有过滤类别和严格级别。和AfxTrace函数类似,AtlTrace函数使用512字节固定大小的缓冲区。和AfxTrace不一样的是,AtlTrace函数使用OutputDebugString输出,因此它们的输出不能改变目标。
4.2 自定义的跟踪语句
尽管有好几种跟踪语句可供选择的类型,你如果发现没有一种标准的Visual C++跟踪语句可以正确地完成任务,你可能希望定义自己的跟踪语句。创建自定义跟踪语句一般有如下几种因素:
•利用Visual C++程序的不同类型来提供方便,例如Windows API、MFC和ATL程序。
•输出源代码文件名和行号。
•将非要求的跟踪语句过滤掉,并可以不重新编译即改变过滤条件。
•能够将跟踪消息输出重定位到调试器的输出窗口、一个文件、消息框或者以上几种的任意组合,最好使用_CrtSetReportMode和_CrtSetReportFile函数。
•处理任意长度的文本。
_RPTFn宏除了过滤之外,拥有所有的能力,因此要实现这样的一个自定义跟踪语句的最简单的方法就是在这些宏中加入过滤器。下面是一个可能的实现方法:
int CategoryFilter = 0xffffffff;
int DetailLevelFilter = 0xffffffff;
#ifdef _DEBUG
#define OutpuTraceString(category, detailLevel, text) \
do
{
if(((category) & CategoryFilter) && ((detailLevel) <= DetailLevelFilter)) \
_RPTF0(_CRT_WARN, text);
} while(0)
#else // _DEBUG
#define OutpuTraceString(category, detailLevel, text) ((void)0)
#endif // _DEBUG
自定义的OutputTraceString宏加入过滤器后和AtlTrace2基本类似,只是,它使用的是变量而不是预处理的常量,因此你可以在程序运行过程中使用代码或者调试器改变过滤器。为了更加灵活,你可以从注册表的INI文件中读取初始的过滤器的值。将它写成函数,并处理printf类型的参数,就可以更进一步改善这种方法。在每一个输出的字符串中加入时间片(timestamp)、进程ID号和线程ID号,就能调试多线程程序。
4.3跟踪语句策略
我认为正确的使用跟踪语句需要策略。和断言相比较,跟踪语句是非常简单的(你只是简单地输出一个诊断字符串),那么会出什么错呢?跟踪语句的问题就在于太好的东西太多了。尽管一个单独的跟踪语句很少出错,但一个程序输出的跟踪消息越多,它们的效率就越低。设想一下,你正在和几个程序员一起完成一个工程,而每个程序员加入了很多跟踪语句来帮助调试各种各样的问题。更有甚者,设想一下问题解决之后,这些跟踪语句仍然留在程序之中。最初这样的做法没有任何问题,但是后来程序将会输出成百上千的跟踪消息,而几乎所有的消息都是无用的。这些跟踪消息中的大部分是为了解决程序中的问题提供信息,而这些问题早己被解决了。让程序输出这么多无用的信息,这显然会降低程序的效率,而且在其中寻找那些有用的跟踪消息也很困难。如果一条很重要的警告和其他几百条无关紧要的消息混合在一起,你将很难注意到它。与此相反,如果你在一个非常小的小草堆中找一根针,你可以很容易地找到它。
跟踪语句的有效使用需要一个策略,太多的跟踪消息会降低它们的有效性。
如下是两个基本的跟踪语句策略:
•使用跟踪语句补充由交互式调试器提供的信息。
•使用跟踪语句代替交互式调试器。
我把第一种策略称为调试器补充策略(Debugger Supplement Strategy)。在跟踪语句很少的情况下,这种策略非常有效。我把第二种策略称为调试器代替策略(Debugger Replacement Strategy)。它在跟踪语句很多的情况厂非常有效,但是跟踪语句需要过滤,这样你才可以将精力集中在你真正感兴趣的部分。在一个程序中,你可以将这些策略结合起来,你可以使用调试器补充策略在总体上调试,只在交互式调试器没有效果的情况下使用调试器代替策略。
调试器补充策略
调试器补充策略其实和你的家庭医生使用的诊断策略是相同的。不管你是不是去诊所,你的家庭医生都会做一套标准的综合诊断(例如给你测量体温和测量血压),而不考虑你的问题,然后针对你的特殊的疾病做一些特殊的诊断(例如照X光、抽血检验和喉咙检査)。综合诊断一直都是很有用的,因为它可以在检查数据有异常时对你的疾病做出新的解释。一旦你的疚病治愈,综合诊断还是会继续下去,而特殊珍断就不会了。在这一点上,特殊诊断好象就没什么价值了。
综合诊断
如果你主要使用交互式调试器时,调试器补充策略会很有用,因为交互式调试器可以很容易地跟踪代码并査看程序执行情况和变量的变化。可靠的综合诊断(“至关重要的症状”)可以帮助你对程序的实际运行有一个很好的理解。类似的综合判断包括:
•程序过早的终止;
•动态链接库(DLL)的加载和卸载;
•进程的启动和终止:
•线程的启动和终止:
•异常处理;
•函数失败;
•错误但仍有用的函数输入;
•程序警告:
•不支持的属性或模式:
•未预期的程序动作。
所有这些综合诊断使得你能查看程序中这些最引人注意的事件。如果这些综合诊断中的任何一个出错,你肯定很想了解它的详情——特别是在你解决问题的时候。例如,怎样调试一个在加载时就终止的程序呢?在这样的例子中,这个程序很可能还没有运行到WinMain,因此想用在调试器中设置断点的方式解决这个问题也就不那么容易了。这个问题其实可以很容易找到,解决方法就是在所有程序过早终止的代码部分加上跟踪语句,这些跟踪语句可以给出终止的原因和源代码的文件名以及终止的行号。
使用综合诊断跟踪语句能让你查看程序中最引人注意的事件。
比如一个跟踪错误函数输入的例子,假设你调用一个函数,这个函数为一个HTML控件指定了一个文件。HTML文本即使有几个标签错误(tagging error),它通常还是有用的,因此标签错误通常不会导致函数失败,甚至都不会有出错信息。大多数情况下你希望接受这个控件并在可能的情况下显示这个文本,但是有时候你也会希望知道这个文本是有错的。跟踪语句可以使这类回馈可见,所以是一种很好的解决方法。注意,代码中存在带有标签错误的HTML文本有可能是一个程序错误,但情况也不是完全如此的。它也有可能是文件或用户输入的一个错误结果,因此修正标签并不适合。
对于一个跟踪函数失败的例子来说。函数失败有很多可能,不一定就是程序出了毛病。如果执行函数所需要的资源被用光了或者没有找到,这个函数也可能失败。假如用户输入不当,函数也有可能失败,这些问题有可能是程序的错误,但显然它们也有可能不是。这一点再次说明,利用跟踪语句报告这些问题非常合适,但使用断言并不合适。
特殊诊断
有时候你可能跟踪一个特殊的问题,使用交互式调试器很难发现这个问题。在这种情况下,你为了找出问题原因所在,经常使用某种二进制搜索,或者使用跟踪语句来帮助决定如何解决。这些特殊诊断的跟踪类型包括:
•调用特殊函数时跟踪。
•跟踪程序运行的某个特殊部分,例如程序的初始化。
•跟踪某个特殊的变量值或状态。
•为某条特殊的消息跟踪消息的发送。
使用特殊诊断跟踪语句来解决一个特殊的问题。
用一个实际的调试例子可以很好地说明如何使用跟踪语句解决特殊问题。我开发了一个控件,它可以使用纯文本或HTML显示文本。程序由Windows API代码和MFC代码组合而成。这个控件在Windows 98下运行良好,但决定文本大小的代码部分在Windows 2000下会导致一个断言错误。下面是出错部分的代码:
textExtent = pDC->GetTextExtent(token);
CDC:: GetTextExtent函数是GetTextExtentPoint32 API函数的包装形式,并且如果GetTextExtentPoint32失败的话,它会输出一条断言。在调试器中检査token的值,会显示它是个无效字符串。设备上下文指针的值看上去也是无效的,但是同时它又不应该是无效的,因为它可以调用函数,只是函数失败了(那么是什么原因使函数调用失败呢)。有趣的是,附近那些执行其他图形设备接口操作的代码使用同样的设备上下文就没有问题。因为你无权访问设备上下文的内部数据结构,所以你不可能使用调试器通过直接检査设备上下文来发现是什么地方出错。现在我知道的全部情况就是设备上下文有时是有效的,有时是无效的。重新浏览一遍源代码也不能发现任何明显的问题。因为直接使用调试器我没有办法找到问题所在,那我正好使用跟踪。
我想确定的第一件事就是CDC设备上下文指针或它的内部HDC处理是否由于某种原因遭到破坏,然后能够跟踪找到它第一次遭到破坏的地方。我使用如下的跟踪语句达成了目标:
_RPTF2(_CRT_WARN, "TEMP: CDC: %X, HDC: %X\n", pDC, pDC->m_hAttribDC);
将这条跟踪语句放在使用设备上下文的相关代码之前和之后,我确定了这些值没有被改变。这种测试检查出了简单的内存破坏。有些情况是设备上下文内部出错。(注意除了使用跟踪语句,我还可以使用数据断点寻找内存破坏,参见第7章的叙述。)
然后我进一步想看是哪一个设备上下文遭到破坏,我使用了下面的跟踪语句:
_RPTF0((GetObjectType(pDC->m_hAttribDC) == OBJ_DC) ?
"TEMP: The DC is valid\n" : "TEMP: The DC is corrupted\n");
GetObjectType函数在检査一个GDI对象的有效性之后会返回这个对象的类型值。寻找那些说明设备上下文在大多数情况下有效但只在调用了一个特定函数之后变为无效的相关代码,将上面这条跟踪语句放在这些代码之中。下面是哪个函数的摘录:
void DrawControlText(HDC hDC, LPCSTR text, int textLength, RECT* pRect, int style)
{
CRect extentRect(*pRect);
if(IsHTMLText(text))
{
CDC dc;
CPtrList textList, formatList;
int lines;
dc.Attach(hDC);
ScanHTMLText(text, textList, style);
FormatHTMLPage(&dc, extentRect, textList, formatList, lines);
if(style & DT_CALCRECT)
{
ClearHTMLTextList(textList);
ClearHTMLList(formatList);
*pRect = extentRect;
return;
}
...
}
else
...
}
如上面代码所示,如果文本是HTML文本并且参数style表示计算文本的矩形值(通过调用GetTextExtent在Format HTML Page中决定),那么函数将pRect设置为结果的扩展矩形,并立即返同。在返回之前,函数并没有调用dc.Detach(),因此变量dc一出作用域,输入的设备hDC也被破坏。函数CDC::Detach的目的就是为了防止破坏(是的,这是程序中一个错误,但是它涉及到这么多的代码以致于问题并不像上面摘录的代码显示的一样明显。而这些代码在Windows 98下为什么运行良好那就不得而知了。
为特殊诊断而使用跟踪语句中最重要的一点是它们是暂时性的。我在这些跟踪语句的文本前面加上了“TEMP:”可以清楚的显示这一点(然而更好的办法是,你可以使用“TEMP(错误ID):”将跟踪语句和一个特定的错误报告联系在一起)。问题一旦解决,你应该将这些临时的跟踪语句删除,以免将来留下许多无用的诊断。你不用在程序的错误解决之后马上将这些跟踪语句删除,因为这个错误的痕迹有可能还存在,或者也许还有类似的问题可以使用相同的诊断语句。但是一旦你非常确定这个问题和所有类似的问题删除之后,你应该将这些跟踪语句删除。
一旦你确定问题已经解决,将用于特殊诊断的跟踪语句删除。
调试器代替模式
交互式调试器在大多数程序中都是非常有用的工具,它能够找到大部分的问题,但在有些情形下,使用交互式调试器就不是很好的选择。这些情形可能包括:
•调试服务器;
•跨机器调试(如DCOM):
•跨程序设计语言调试遇到编程语句(如C++和Visual Basic);
•跨进程调试遇到进程;
•调试线程;
•远程调试;
•遇到那些因为海森堡不确定原理而很难调试的程序动作。
如果需要的话,使用垠踪语句解决问题,而不要使用调试器。
在这些情况下,最好的做法经常是使用跟踪语句调试。在类似的情况下,使用跟踪语句非常有效,因为它们不需要调试器,它们可以比手上输入产生的信息输出更多的信息,而且更重要的是它们不影响程序的执行,因此可以避过那些和海森堡不确定原理有关的问题(如第10章“调试多线程程序”所示,跟踪语句的输出是线性的,因此使用跟踪语句可以影响线程之间的相关时间。因此,有跟踪语句的多线程程序运行时和调试器运行时会有所不同)。注意,这些跟踪语句和刚刚讨论过的特殊诊断不同,尽管它们是通用的,因此不能将它们从程序中删除。
在调试器替代模式中成功的第一个关键就是在使用交互式调试器不尽如意时,使用跟踪语句输出尽可能多的问题类型的信息。例如,基于事务的服务器通常对事务升始、事务结束和事务出错的时机和原因进行跟踪。因为这样很可能会出现大量的信息,因此跟踪成功的第二个关键就是能够对这些跟踪消息进行过滤,使得你可以将注意力集中在你真正感兴趣的部分。使用前面介绍的AtlTrace2函数和用户定义的OutputTraceString宏作为过滤机制均可达到理想的目的。
既然ATL跟踪语句支持过滤,那么调试器替代模式的最好例子很可能就是ATL自身。MFC用于创建具有全部用户接口的Windows程序,而ATL则用于创建COM组件。通常情况下,这些组件提供某种服务并且很少或根本没有用户接口。这些COM组件可以使用多线程、多进程和多种编程语言程序。它们还可以使用DCOM通过多台机器分发。
通过对源代码的分析,可以注意到很有趣的一点:MFC对每一条跟踪语句有超过一打的断言语句,ATL对每一条跟踪语句的断言不超过两条。这些跟踪语句用于跟踪重要事件的执行(例如,获得或设置属性值、关闭窗口和创建数据库过程)和用于在函数失败时跟踪。在很多返回E_FAIL、E_OUTOFMEMORY、E_INVALIDARG或E_UNEXPECTED结果代码的情况下,跟踪消息可以使这些情况可见。你可以设想一下如果使用交互式调试器,你需要在每个函数的失败部分郜设一个断点,这简直是不可能的。
4.4 各种技巧
下面一节讨论一些技巧,它可以帮助你更好地使用跟踪语句。
使用和DBWIN类似的实用程序
Visual C++的16位版本有一个便利的实用程序名为DBWIN,它可以让你独立于调试器査看跟踪语句。这个实用程序在远程调试时特别有用,因为它允许用户找到你的程序中的跟踪语句并把它发送给你。不好的一点是Windows处理内部的OutputDebugString的方法改变了许多,以至于原来的DBWIN应用程序不能用于32位程序。但幸好现在有一些32位的应用程序提供了类似的功能。其中最好的一个程序就是从系统内部得到的DebugView,他在Wjndows 2000和Windows 98下都能使用。你可以在www.sys-internals.com下载这个程序。但要当心,这个程序好象不能处理AfxDumpStack的输出。
考虑提供一个重定向输出设置
使用DBWIN实用程序的另一个更好的替代方法是让使用调试版本的用户可以将跟踪消息重定向输出到一个文件中,这一点你可以使用Visual C++的C运行时刻函数库_RPTn、_RPTFn宏和MFC TRACE宏做到。你可以在INI文件或注册表中加入对程序的跟踪语句里定向输出设置,这样就可以如你所愿地让用户重定向输出跟踪消息。
处理长字符串
MFC TRACE宏和ATL ATLTRACE宏使用一个512字符固定大小的缓冲区。一般情况下,这样并不会出什么问题,但是有些时候,你需要存储非常长的字符串,例如SQL语句。正如我前面提到的,一个解决办法是使用自定义跟踪语句。不过,你也可以使用标准跟踪语句转储长字符串。使用MFC时,最简单的方法是使用afxDump和插入操作符直接跟踪:
TRACE(longString); //asserts if _tcslen(longstring) > 511
#ifdef _DEBUG
afxDump << longString; // doesn't assert for long strings
#endif
在使用ATL时,最简单的方法是使用OutputDebugString直接跟踪:
ATLTRACE(longString); //asserts if _tcslen(longstring) > 511
#ifdef _DEBUG
OutputDebugString(longString); // doesn't assert for long strings
#endif
如上面的两个例子所示,你只能在调试版本中条件编译这些跟踪语句。
处理大量的跟踪输出
Visual C++输出窗口的缓冲区是有大小限制的,因此如果跟踪消息数据产生的速度超过输出窗口处理的速度,那么消息会塞满缓冲区,数据就会丢失。要避免这个问题有一个很简单的方法,那就是在输出大量数据的代码段例如对象转储函数时,调用Sleep API函数(例如SIeep(100))。
产生调试报告
_RPTn宏又称调试报告(debug report)宏。我发现可以使用跟踪语句作为创建调试报告的一种方法,也就是说跟踪信息的表示非常重要。你可以使用标签字符显示消息行之间的关系,这样可以提高跟踪消息的可读性,类似地,你还可以使用前缀指明跟踪消息的类型,这样可以提高跟踪消息的可检索性。例如,你可以在所有补充说明函数失败的跟踪消息前加上前缀:“Error(函数名):”,在所有警告的消息前加上前缀:“Warning(函数名):”等等。使用这种方法,你可以使用类似于Visual C++的Find in Files命令的文本处理工具过滤掉你并不感兴趣的跟踪消息。
输出独立行,而且不要忘了新行(newline)字符
跟踪语句和printf语句很相似,它们的输出都附加在当前行后。因此,你可以用多行跟踪语句构成一行文本,而不必用中间字符串格式化文术,如下面语句所示:
OutputDebugString(_T("The variable name is "));
OutputDebugString(variableName);
OutputDebugString(_T("\n));
事实证明这种方法很糟糕,其中有两个原因。第一个原因就是,这个方法在单线程程序中很有用,但在多线程程序中,它的输出会混乱不清。在多个线程中,OutputDebugString的输出是线性的,因此它能够保证正确输出的是一条语句,而不是多条语句。这种方法的第二个问题就是一些类似于DBWIN的应用程序会在每一个输出的字符串之前加上前缀时间片(timestamp)、进程和线程号,这样会使分成小片的字符串更加混乱。
而且,记住要创建一个新行,你必须输出一个新行字符。这种思想是很简单,但我发现在跟踪语句使用这个字符常常会出错。
别忘了检查跟踪语句
跟踪语句令人高兴的一点就是它们可以使程序中那些最引人注意的部分一目了然,而且这些跟踪语句还不是特别的显眼,可以简单地略去不看;但也有不好的地方就是跟踪消息太容易被忽略了。实际上,跟踪消息这么容易被忽略,你会很容易习惯于根本不去看这些消息。尽管程序中的跟踪消息并不是对每一种类型的错误都有用,但你还是应当养成这样的习惯:每当你的程序中有错误而你想得到更多信息的时候,你应该去査看一下跟踪消息。很多时候,那些额外的信息就在跟踪消息中——你所要做的就是去看一看。
4.5推荐阅读
......