UML软件工程组织

.NET 中的断言和跟踪
John Robbins
本页内容
调试器 调试器
条件编译 条件编译
跟踪和 TraceSwitch 跟踪和 TraceSwitch
断言 断言
TraceListener — Listeners 正在侦听 TraceListener — Listeners 正在侦听
BugslayerTraceListener 的用法和实现 BugslayerTraceListener 的用法和实现
更多新功能 更多新功能
小结 小结

因为 Microsoft 已经发布了 Visual Studio .NET Beta 1,所以许多读者已经开始深入研究 .NET。但不要搞错了 — .NET 是一种全新的平台。关于诸如 ASP .NET 和 ADO .NET 之类的关键功能,已经进行了大量的宣传。但对于我而言,.NET 的最大优点之一在于它解决了所有编程问题中最棘手的一个问题 — 内存损坏和泄漏。

借助于公共语言运行库 (CLR) 来照管指针和内存管理,您可以集中精力来解决用户的问题,而不是浪费时间去寻找内存损坏。此外,Microsoft 最终具有了一个用于访问系统的全新系统范围的编程模型。一致的面向对象的基类库 (BCL) 也可以消除大量的错误。

难道这就意味着没有撰写 Bugslayer 专栏文章的需要了吗?在 Visual Studio 能够实现您的想法之前,仍然会出现很多问题,如逻辑错误,以及对系统、性能和可伸缩性的误解。

本月,我打算帮助您开始进行正确的 .NET 开发。您可能会回想起来,有效的旧断言非常符合我的心意。当我第一次开始学习 .NET 时,我感到若有所失,因为我所钟爱的 ASSERT 和 TRACE 宏不见了。因此,我希望向您说明如何在 .NET 中进行断言和跟踪。为此,我首先需要讨论一下 .NET SDK 中提供的调试器。然后,我将讨论断言和条件编译,并为您提供一个比 BCL 所提供的更好的断言工具。

本月专栏中的所有代码都是用 .NET SDK Beta 1 开发的,因此不需要使用完整的 Visual Studio .NET 来编译和运行。除非 Microsoft 在 Beta 2 中更改了接口,否则这些代码将继续正常工作。您应该了解的关于 .NET SDK 的一件事情就是它极为稳定;我从 PDC 版本开始就一直在使用它,并且没有出现过任何问题。

调试器

通常,调试就是调试。换句话说,无论您使用何种操作系统,总是要设置断点、转储内存并执行其他一些常见操作。对于那些具有 Win32®, 背景的开发人员而言,.NET 调试有一项主要功能略有不同。您可以在正在运行的 .NET 进程中随意附加和分离(没错,就是分离)调试器!现在,调试正在运行的服务器应用程序应该比以前更加容易。

.NET SDK 包含两个调试器:CORDBG.EXE 和 DBGURT.EXE。其中,DBGURT.EXE 是较好的 GUI,CORDBG.EXE 是基于控制台的版本,它具有几项附加功能,但比较难使用。CORDBG.EXE 中神奇的 ? 命令非常重要,因为您可以使用它来获取有关所有命令的帮助。通过在 ? 后面输入命令名称,您可以获得有关单个命令的更多帮助。通常,如果您曾经使用过 WinDBG 之类的调试器,则应该能够推测出大多数命令。但是,我还是打算向您说明 CORDBG.EXE 中我觉得有趣的几个命令。

我希望引起您注意的第一个命令是 wt,它会逐句通过应用程序,并打印所调用的每个托管方法的调用树。我发现使用 wt 来了解各种系统类之间的调用关系非常有用。有时,wt 命令会显示许多没有用处的信息,但查看一下 BCL 中谁对谁执行了什么操作却是非常有用的。wt 命令能够从调试器的当前代码行开始到相应方法的结尾为止,来跟踪程序流。


图 1 中的简单程序演示了 wt 命令。一旦您开始对程序执行 CORDBG.EXE,将从 Main 中调用 Foo 的代码行开始。如果您键入“wt”,则输出将如下所示:

(cordbg) wt
       1        HappyAppy::Main
       6        HappyAppy::Foo
       6        HappyAppy::Bar
      10        HappyAppy::Baz
       7        HappyAppy::Bar
       7        HappyAppy::Foo
       2        HappyAppy::Main

      39 instructions total

并且您可以看到 Foo 所调用的每个元素的调用图。在执行 wt 命令之后,指令指针将指向对 Foo 的调用之后的代码行。


另一个有用的命令是 f (funceval),它使您能够调用您的类外部的方法。对于非静态方法,请记住传递“this”作为第一个参数。还有一组使您能够创建对象的命令,但是我尚未猜测出它们的工作方式。


CORDBG.EXE 使用起来有一点儿麻烦,而 DBGURT 则像梦一样美妙。从我去年七月在 PDC 收集的信息来看,DBGURT 与 Visual Studio .NET 使用相同的代码基,因此您可以领略一下最终的 Visual Studio .NET 调试器的样子。只是从 .NET SDK 中的小型预览看来,我确实很喜欢我所看到的内容。首先,Visual C++ 6.0 调试器中应该可以停靠的对话框(如 Modules、Threads 和 Breakpoints)最终还是可停靠的,从而使调试器更加易于使用。此外,为了防止所有这些可停靠的窗口由于占据太多的屏幕区域而要求使用 35 英寸的监视器,调试器具有一个非常直观的模式,在该模式中,多个停靠窗口可以共享同一个带有模拟标签的窗口区域。图 2 显示了各种可停靠窗口(如 Modules、Threads 和 Breakpoints 显示),所有窗口都共享屏幕底部的区域。

并非调试器 UI 所承诺的每件事情都存在于 SDK 的 Beta 1 版本中。具体说来,DBGURT 缺少 C# 的语法着色和某些类型的断点。但是,DBGURT 中有足够的功能可让您获得 .NET 调试乐趣。DBGURT 有几个新的跳过计数位置断点非常有用。现在,当跳过计数恰好等于特定数字、或者特定数字的倍数、或者大于或等于特定数字时,您将中断运行。

既然我已经讨论了调试器的要点,那么现在我要转而讨论一下条件编译,因为离开了它,跟踪和断言将不复存在!


条件编译

.NET SDK 中的所有三个语言编译器(C#、Visual Basic 和 C++)都支持标准的 #ifdef...#endif 风格的条件编译。但是,C# 和 Visual Basic 现在具有一个非常好的自定义属性,它可通过另一种方式来支持条件编译。在声明 C# 或 Visual Basic 方法时,您可以使用该条件属性来确定该方法何时可以调用。该条件属性的优点是:调用方在调用方法时不必执行任何额外的工作。如果设置了 C# 的 #define 所指定的条件属性中的编译指令,或者设置了 C# 与 Visual Basic 的 /D: 编译器选项所指定的条件属性中的编译指令,则将进行方法调用。否则,编译器不会生成该调用所需的 Microsoft 中间语言 (MSIL)。

下面的程序显示了 C# 示例中条件属性的用法:

using System ;

class HappyAppy
{

    [conditional ( "DEBUG" )]
    public static void DebugOnlyMethod ( )
    {
        Console.WriteLine ( "DEBUG is active!!" ) ;
    }

    public static void Main ( )
    {
        DebugOnlyMethod ( ) ;
    }
}
以下是用 Visual Basic 编写的同一示例:
imports System
imports System.Diagnostics
Public Module HappyAppy
    Sub  DebugOnlyMethod ( )
        Console.WriteLine ( "DEBUG is active!!")
    End Sub
    Sub Main
        DebugOnlyMethod ( )
    End Sub
End Module

只有当您在编译时定义了 DEBUG,DebugOnlyMethod 才会在这两个示例中出现。这种做法的优点是在进行 DebugOnlyMethod 调用时,无须使用大量的 #ifdef...#endif。如果您尚未明白条件属性是如何工作的,我建议您运行一下前面的程序。正像您开始看到的那样,C# 和 Visual Basic 中的条件编译功能使得使用大量断言的卓越编程实践变得十分轻松愉快。

跟踪和 TraceSwitch


BCL 提供了两个完全相同的类来处理跟踪和断言:Trace 和 Debug,这两个类都来自 System.Diagnostics 命名空间。有趣的是,这两个类具有完全相同的属性和方法,但它们并不是从对方派生的,也不是从 Object 以外的任何基类派生的。这两个类之间的观念是:当您定义 DEBUG 类时,Debug 类是活动的;而当您定义 TRACE 时,Trace 类是活动的。根据文档资料,Microsoft 希望您对调试版本使用 DEBUG,而对所有版本使用 TRACE。.NET 的面向网络管理员的一个功能是您可以实地启用轻型诊断跟踪,因此您应该始终定义 TRACE。但是,像以前操作系统中的跟踪一样,如果您不遵循严格的格式设置并且只输出帮助您了解程序流所需的最少内容,则输出可能会过多。


Trace 和 Debug 类上的跟踪方法包括 Write、WriteIf、WriteLine 和 WriteLineIf。Write 和 WriteLine 之间的唯一区别是 WriteLine 会在输出的结尾放置一个回车符和一个换行符。WriteIf 和 WriteLineIf 仅当第一个参数计算为真时才会执行跟踪。这可以为您提供条件跟踪功能。

尽管这听起来不错,但使用 WriteIf 和 WriteLineIf 或许并不是一个好主意。您能看出下面的代码片段有什么错误吗?

Debug.WriteLineIf ( ShowTrace                                
                    "Num = " + Num + " value out of range!"  ) ;

问题是在调用 Debug.WriteLineIf 之前对将要显示的字符串执行完全计算和生成。这意味着在每次执行该行时,即使 ShowTrace 为假,您仍然具有参数生成的所有系统开销。与此不同,您应该做的是在进行调用之前使用正常的条件检查,就像下面的代码片段一样。

if ( true == bShowTrace )
{
     Debug.WriteLine ("Num = " + Num + " value out of range!" ) ; 
}

此处,在条件计算为真并且您将执行跟踪之前,将避免字符串参数生成的系统开销。缺点在于您必须执行更多的键入操作。

因为跟踪是实地查找问题的一种好方法,所以 Microsoft 在 System.Diagnostics 中添加了另一个类来帮助您确定跟踪级别:TraceSwitch。TraceSwitch 是一个简单的条件类,它使得跟踪各个程序集、模块和类变得更加容易。TraceSwitch 的目的是使您可以轻松地确定跟踪级别,以便您的代码能够即时生成适当的输出。可以通过 TraceSwitch 类的属性来确定跟踪级别,因为如果设置的级别恰当,这些属性将全部返回真。图 3显示了跟踪级别和它们的值。

创建和使用 TraceSwitch 的过程有一些琐碎。下面的代码片段说明了 TraceSwitch 的创建和使用。我使用 WriteLineIf 压缩了该代码片段。

public static void Main ( )
{
    TraceSwitch TheSwitch = new TraceSwitch ( 
        "SwitchyTheSwitch", "Example Switch"  );
    Trace.WriteLineIf ( TheSwitch.TraceError ,
                        "Error tracing is on!" ) ;
    Trace.WriteLineIf ( TheSwitch.TraceWarning ,
                        "Warning tracing is on!" ) ;
    Trace.WriteLineIf ( TheSwitch.TraceInfo ,
                       "Info tracing is on!" ) ;
    Trace.WriteLineIf ( TheSwitch.TraceVerbose ,
                        "VerboseSwitching is on!" ) ;
}

此时,您可能很想知道如何设置跟踪级别。TraceSwitch 构造函数采用了两个参数:开关名称和开关说明。重要的值是开关名称,因为您必须使用确切的字符串来设置跟踪级别。第一种设置开关的方法是为 HKLM\SOFTWARE\Microsoft\COMPlus\Switches 中的所有开关使用全局注册表项。只须创建一个与开关名称匹配的 DWORD 值,并将数字设置为图 3 中指定的相应数字。

另一种设置特定跟踪级别的方法是使用环境变量,即 _Switch_ 后面跟开关名称。以前面的代码片段为例,环境变量应该是 _Switch_SwitchyTheSwitch。将环境变量设置为您希望看到的跟踪级别。请记住,任何环境变量都将重写注册表设置。

将所有应用程序的跟踪开关放入单个环境变量中的思想看起来好像是一起等待发生的事故。我可以很容易地看到各个企业之间发生命名冲突的非常真实的可能性。我感觉如果有一种可以从输入文件指定跟踪开关的方法,那么将会好得多。在本月的 Bugslayer 代码中,我创建了一个名为 BugslayerTraceSwitch 的派生自 TraceSwitch 的类(所有相关代码都可以在本文顶部的链接处找到)。BugslayerTraceSwitch 的构造函数还采用了第三个参数,即用于读取跟踪级别设置的文件。假设您传入构造函数的文件名具有足够的信息,以便它能够被找到。BugslayerTraceSwitch 是我的 Bugslayer 程序集的一部分,因此您只须将 Bugslayer 作为导入文件予以包括。文件格式非常简单,如下面的代码片段所示。

; The format is =
HappyAppyClassSwitch=4
Switcheroo=0

请注意,带有分号的行被视为注释行。

既然您已经了解了如何使用 .NET 中的跟踪功能,下面让我们讨论一下输出的去向。默认情况下,跟踪输出除了经历传统的 Win32 OutputDebugString 调用以外,还会去往附加的调试器。请记住,.NET 不是基于 Win32 的传统应用程序,因此调试器输出将不同于您习惯看到的内容。在本专栏的后面,我将详细讨论输出。

在本部分的开头,我提到过 Trace 和 Debug 类都具有跟踪方法。但您应该使用哪个方法呢?我决定只使用 Trace 来进行跟踪。这样,我在进行跟踪时就有了一致的方式,而不必在键入跟踪语句之前进行一番考虑。既然您已经了解了跟踪的工作方式,下面让我们转而讨论一下 .NET 中的断言是如何工作的。

断言

正像我在前面提到的那样,Trace 和 Debug 类都具有 Assert 方法。我只使用 Debug 类中的 Assert。这些方法是完全相同的,但我不希望在运行应用程序的过程中弹出意外的消息框,因此我坚持使用 Debug 版本。默认的断言消息框如图 4 所示。请注意,.NET 断言附带了现成的堆栈审核(带有填充源和行查找)。



bugslayerfig04

图 4 Debug 断言




尽管 C++ ASSERT 宏使用起来更加容易一些,但 C# 中的 Debug.Assert 方法也不错;重载的 Assert 方法只是采用了不同的参数。第一个方法采用单个 Boolean 条件;第二个方法采用一个 Boolean 条件和一个消息字符串;最后一个方法采用一个 Boolean 条件、一个消息字符串和一个详细的消息字符串。使用 .NET 中的 Assert 意味着您必须比传统的 C++ ASSERT 完成更多的键入操作。因为没有 .NET 宏,所以为了在断言消息框中显示断言字符串,您需要自己传入字符串。下面的代码片段显示了全部三种类型的 C# Assert 的工作方式。

Debug.Assert ( i > 3 ) ;
Debug.Assert ( i > 3 , "i > 3" ) ;
Debug.Assert ( i > 3 , "i > 3" , "This means I got a bad parameter") ;

当然,因为 C# 支持条件属性,所以您只需定义 DEBUG 来启用断言代码。对于 Visual Basic,您需要用真正的条件编译来环绕各个断言,如下所示:

#If DEBUG Then
Debug.Assert ( i > 3 )
#End If

正像我在前面提到的那样,如果代码以交互方式运行,则 .NET 中的断言将显示在消息框中。我在 .NET 中捣鼓了一下,发现可以将所有应用程序的断言全局性地重新定向到文件中。但使用这些技术需要您自担风险。此外,除了您用于进行开发的计算机以外,绝对不要在其他任何计算机上使用它们。有了这个前提,下面是需要遵循的步骤。在 HKLM\Software\Microsoft\ComPlus 中,您需要添加两个 DWORD 值(NoGuiOnAssert 和 LogToFile)以及一个字符串值 (LogFile)。将 NoGuiOnAssert 设置为 1 以禁用消息框。将 LogToFile 设置为 1 以启用将日志记录到文件的功能。将 LogFile 设置为所有断言输出应该前往的完整名称和路径。但是,在更改全局设置之前,您应该知道如何通过 TraceListener 更好地控制断言输出。

TraceListener — Listeners 正在侦听

跟踪和断言在 .NET 中是唯一的,因为控制输出相当容易。Trace 和 Debug 类都有一个成员 Listeners,它是 TraceListener 对象的数组。TraceListener 发送跟踪和断言的输出。正如您能够想象的那样,您可以有一个用于将输出发送到 OutputDebugString 的跟踪侦听器和一个用于将输出发送到文件的跟踪侦听器。Trace 和 Debug 类的功能是逐个枚举 Listeners 数组中的 TraceListener 类,并让每个类处理输出。这可以让您添加或减少输出。默认的 TraceListener (DefaultTraceListener) 通过它的 Write 和 WriteLine 方法将跟踪输出发送到 OutputDebugString,以及所有附加调试器的 Log 方法。如果用户以交互方式登录,则 DefaultTraceListener 将通过它的 Fail 方法将所有断言发送到消息框。


BCL 附带了一些预定义的 TraceListener,您可以将它们添加到 Listeners 数组中以作为附加的输出手段。第一个是 EventLogTraceListener 类,它可将输出发送到指定的事件日志。第二个是 TextWriterTraceListener,它可将输出定向到 TextWriter 或 Stream,如 FileStream 的 Console.Out 函数。下面的代码显示了如何将一个 TextWriterTraceListener 添加到链中。

Debug.Listeners.Add(new TextWriterTraceListener ( "Trace.Log" ) );

BugslayerTraceListener 的用法和实现


能够随意替换跟踪输出是一个有趣的主意。但是,从实际的观点出发,我宁愿有一个 TraceListener。首先,我可以从应用程序中的任意位置来控制它。如果有多个独立的 TraceListener,则对其中的每一个进行控制会变得更加困难。其次,一个 TraceListener 可以处理整个应用程序的所有输出需要。因此,我编写了 BugslayerTraceListener 以便简化我的工作。它是所有 TraceListener 的完全插入替换。跟踪输出可以去往多个位置(附加的调试器、文件以及标准的 OutputDebugString 调用)的任意组合。断言除了可以去往消息框和事件日志以外,还可以去往上述所有位置。将 BugslayerTraceListener 添加到 Debug 或 Trace 对象的过程非常简单。

Debug.Listeners.Remove ( "Default" ) ;
BugslayerTraceListener btl = new BugslayerTraceListener ( ) ;
Debug.Listeners.Add ( btl ) ;

您需要做的一件事情是删除 DefaultTraceListener,以便 BugslayerTraceListener 可以控制输出。

如果您查看 BugslayerTraceListener 本身的代码,则没有多少令人激动的内容。有趣的部分位于 BUGSLAYERWIN32.CS 中(请参见图 5)。我需要确保 BugslayerTraceListener 在弹出消息框之前还要检查一下是否有交互式用户。这要求我用特殊的结构调入 Win32 API。通常,从托管代码中调用 Win32 API 会令人感到困惑。如果您需要将某些代码提到 .NET 中,我希望 BUGSLAYERWIN32.CS 可以为您提供一些有关如何做到这一点的提示。

更多新功能

非托管 Visual C++ 的编译器和链接器看上去似乎有一些有趣的新功能。在进行了关于 .NET 的所有讨论以后,传统 Visual C++ 中的新功能将获得较少的关注。借助于显著改进的调试器和新的编译器标志,Visual C++ .NET 将是对已安装 C/C++ 代码基的所有用户的强制性升级。我通过阅读 .NET SDK 文档资料中的 Visual C++ Compiler Reference 来了解这些标志。

我最喜欢的新标志是 CL.EXE 用于进行运行时错误检查的 /RTC。它所检查的一些错误包括局部内存上溢和下溢、未初始化的内存访问和数据截断。需要记住的是这些检查在运行时发生。CL.EXE 和 /LTCG(链接时代码生成)的新 /GL(全程序优化)标志提供了前所未有的程序优化级别。最有趣的优化是跨模块内联,即在模块中内联函数(即使该函数是在另一个模块中定义的)。对 x86 CPU 的另一个优化是自定义调用约定,它将允许编译器和链接器在函数调用之间使用寄存器传递参数。CL.EXE /GS(生成安全检查)选项将插入代码,以检查是否存在使返回地址冲出堆栈的缓冲区溢出。启用 /GS 以后,任何试图接管您的程序的病毒或欺诈代码都将弹出一个消息框,并立即终止进程。

最后一个有趣的新标志 /PDBSTRIPPED 是一个 LINK.EXE 标志,它将只使用公共符号和框架指针优化 (FPO) 数据生成第二个 PDB 文件。这样,您就可以将第二个 PDB 文件发送给您的客户,以便您可以实地从 Dr. Watson 日志中获取完整的调用堆栈和信息。总的来说,Visual C++ .NET 中的确有一些非常好的新功能,因此我已经迫不及待地要将现有代码迁移过去。

小结

我希望这一有关在 .NET 上进行调试的简介将使您的开发工作变得容易一些。具体说来,BugslayerTraceListener 应该使您的跟踪和断言变得更加容易。在考察 .NET 时,请不要忘记在老地方寻找新功能。并且,应该始终通过从一开始就编写较好的诊断代码,来使您的工作变得更加轻松。

因为我认为每个人都应该坦白他们的错误,所以我必须承认在我的 2000 年 12 月刊专栏的 Smooth Working Set 实用工具中有一个小错误。令人非常难堪的是,我在 SWSFILE.CPP 的 CFileBase::AppendToDataBuffer 中有一个重新分配问题。更新后的代码如图 6所示。感谢 Eric Patey 和 Richard Cooper 报告了这一问题。


技巧 41(来自 Ted Yu):在您的 2000 年 4 月刊专栏中,您抱怨 STL 的 bsearch 函数不返回值。下面是一个能够返回与找到的值相对应的迭代器的 bsearch 函数。如果找不到该值,则该函数将返回 end()。

template inline _FI bsearch ( _FI _F , _FI _L , const _Ty& _V )
{
     _FI _I = lower_bound(_F, _L, _V);
     if (_I == _L || _V < *_I) 
     {
          return _L ;
     }
     return ( _I ) ; 
}

技巧 42(来自 Patrick Gautschi):Microsoft 已经发布了一组有趣的工具,以帮助用户跟踪名为 UMDH(用户模式转储堆)的内存泄漏。您可以从 How to Use Umdh.exe to Find Memory Leaks 下载这些工具。请确保阅读有关如何使用它们的整篇知识库文章。

John Robbins 是 Wintellect 的创始人之一,该公司是一家专门致力于 Windows 和 COM 编程的软件咨询、教育和开发公司。他是 Debugging Applications (Microsoft Press, 2000) 一书的作者。要联系 John,请访问 http://www.wintellect.com

 

 


 

版权所有:UML软件工程组织