利用 Managed Debugging Assistants,让 CLR 来发现错误

发布日期: 2006-5-30 | 更新日期: 2006-5-30

本文讨论:

MDA 的概念及其在调试中的作用

使用 MDA 的通用方案

针对托管和非托管调试启用 MDA

本文使用了以下技术:
.NET Framework 和 Visual Studio 2005

*

本页内容

PInvokeStackImbalance
PInvokeStackImbalance

LoadFromContext
LoadFromContext

CallbackOnCollectedDelegate
CallbackOnCollectedDelegate

GcUnmanagedToManaged
GcUnmanagedToManaged

InvalidFunctionPointerInDelegate
InvalidFunctionPointerInDelegate

AsynchronousThreadAbort
AsynchronousThreadAbort

StreamWriterBufferedDataLost
StreamWriterBufferedDataLost

PInvokeLog
PInvokeLog

启用MDA
启用MDA

小结
小结

断言是非常有价值的开发工具,它使您能够在运行时测试特定的情况。通常,您可能需要测试绝不会发生的情况("如果该情况发生,一定是出错了"),有时您需要验证确实发生了特定情况("如果该情况没有发生,一定是出错了")。由于这些断言并不能充当用户的"眼睛",因此,通常它们只编译为调试版本或者生成一个包含有关此类问题的信息日志文件。

这对于应用程序开发人员而言非常好,但对于库的开发人员而言却不然。使用库的应用程序开发人员可受益于提供这些警告的库,然而此类功能通常是侵入性的。库供应商通常不使用断言,而是选择引发可以进入应用程序级进行处理的异常。但异常并不适合在所有情况下都成为断言的替代方案。

例如,请考虑公共语言运行库 (CLR) 如何加载程序集。程序集可以加载到以下两个"上下文"中:Load 上下文和 LoadFrom 上下文。使用 LoadFrom 上下文通常可引起与序列化、强制转换和程序集依赖项解决方案有关的错误和意外行为。任何时候当程序集加载到 LoadFrom 上下文中时,您都不会希望引发异常,但您可能希望收到某种调试时的通知 - 说明正在发生的情况,并且希望能够轻松打开或关闭这种通知。这使您能够查看出现的情况,并确定它是否为合法的使用,或者您依赖的某些代码是否带来意外的副作用。进入 Managed Debugging Assistants(托管调试助手,MDA)。

MDA 是 CLR 以及基类库 (BCL) 中的断言(探针),它们可以打开或关闭。当启用时,它们提供关于 CLR 当前的运行时状态以及开发人员无法访问的事件的信息。其中的一些信息甚至可以修改运行时行为,从而帮助公开很难发现的错误。Microsoft® .NET Framework 2.0 包括 42 种这样的 MDA,其中的一些 MDA 较有用,但它们都是功能非常强大的调试助手,有助于您在运行时跟踪一些真正严重的问题。

当前可用的 MDA 分为三类,如图 1 中的 Type 列所示。信息类的 MDA 提供关于 CLR 当前状态的数据,默认情况下不启用。行为类的 MDA 默认情况下也不启用。但这些 MDA 并不仅仅是为开发人员提供信息,而是在尝试突出显示开发人员代码中具体错误的同时,修改 CLR 的行为。检测 MDA 直接识别正在发生的某种错误情况。一些检测 MDA 是很容易启用的,这主要取决于环境。如图 2 所示,Managed Debugger 目录中的检测 MDA 可从 Visual Studio® 2005 中的 Exceptions 对话框选择。默认情况下,Managed Debugger 目录的一个子集将在 Exceptions 对话框中接受检查,其结果将使 Visual Studio 2005 在遇到 Exception Assistant 对话框时终止调试。但在非托管调试器中运行时,默认情况只启用两个检测 MDA。

image

图 2 .NET Framework 2.0 中的 MDA

您可能会发现某些 MDA 更加有用。我认为,开发的起点应该是在 Exceptions 对话框中打开 Managed Debugger 目录中的所有 MDA,仅当实际确定它们产生妨碍时对其禁用。特别是当进行任何种类的互操作工作时,这些 MDA 有助于发现可能长时间未引起注意的小问题。实际上,正如 Mike Stall 在他的网络日记中提到的,在 .NET Framework 2.0 中引入 MDA 其实是 .NET Framework 小组成员的自助行为。MDA 可以捕获许多问题,这些问题从另一方面反映出框架中的明显错误,即使错误的征兆只是一系列多米诺骨牌中的一粒(由开发人员代码中早期所出现问题引起的多米诺连锁反应)。

我有几个很好的 MDA,下面我将一一描述。

PInvokeStackImbalance

要进行任何 P/Invoke 工作,MDA 是必需的。当 CLR 检测到一个 P/Invoke 调用之后的堆栈深度与预期的堆栈深度不匹配时,将激活它。请考虑从 kernel32.dll 导出的一个简单方法,您可通过该方法编写一个互操作签名:

BOOL Beep(DWORD dwFreq, DWORD dwDuration);

有效的 P/Invoke 声明如下所示:

[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool Beep(int frequency, int duration);

但如果由于粗心或误解了本机类型和托管类型之间的关系,您在无意中将代码声明如下,应该这么办呢:

[DllImport("kernel32.dll", SetLastError=true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool Beep(long frequency, long duration);

如果将原本是 int 的参数误用为 long,调用方传入目标方法的数据将比它实际预期的多出 8 个字节(两个 64 位值而不是两个 32 位值)。Beep 函数利用 StdCall 调用约定,这意味着被调用方负责从堆栈移除参数。因此,被调用方将从堆栈移除 8 字节(它预期为两个 32 位的值),这样将导致堆栈不平衡(错误提供了额外的 8 字节)。当该函数返回到 CLR 时,将引发 PInvokeStackImbalance MDA,从而产生如图 3 所示的对话框。

image

图 3 PInvokeStackImbalance MDA 激活

当然,PInvokeStackImbalance 的世界也不是完全充满了阳光。CLR 中的实时编译器 (JIT) 实现了通过 P/Invoke 调用本机函数的若干种方法(从非常快的内联技术到较慢的解析技术)。JIT 根据多种因素选择要使用的技术,这些因素包括 P/Invoke 声明的复杂性。遗憾的是,启用 PInvokeStackImbalance MDA 时,则强制 P/Invoke 调用使用较慢的技术。在基于 C# 和 Visual Basic® 的典型应用程序中,P/Invoke 的使用相对而言并不频繁,因此 MDA 提供的价值超过了性能上的损失。但是,进行大量互操作的应用程序(例如,用 C++ 编写以利用 It Just Works 互操作的应用程序)在启用 MDA 的调试器中运行时可体会到性能明显降低。请参阅 Mike Stall 的网络日记

返回页首返回页首

LoadFromContext

我在前面描述的情况(跟踪加载到 LoadFrom 上下文的程序集)并非假设。LoadFromContext MDA 在任何程序集加载到 LoadFrom 上下文的任意时刻向您发出警告,从而可以更轻松地识别那些表示无效转换异常、反序列化失败等内容的错误。如果完全可能,最好将程序集安装到 Global Assembly Cache 或 ApplicationBase 目录下,这样您就能够在显式加载程序集时使用 Assembly.Load。

返回页首返回页首

CallbackOnCollectedDelegate

通常,互操作工作需要 Windows® 回调托管代码,该过程也称为反向 P/Invoke。这通常通过将一个委托传递给一个 P/Invoke 方法实现。在后台,CLR 创建一个指向 thunk 的指针,该 thunk(在被调用时)进而调用该委托。在非托管环境中,该函数指针是一个常量,而且正确使用它的非托管代码希望该目标始终存在。但当托管代码不再需要该委托时,垃圾回收器可自由回收它(回收该委托也将释放相关的 thunk),这将导致非托管代码中的函数指针失效。当非托管代码之后尝试调用该无效的函数指针时,通常会发生访问冲突。这样不好。

为防止出现这种情况,只要非托管代码可能需要调用它,该委托就必须保持活动状态。该工作的一部分由您来完成。例如,获取从 kernel32.dll 公开的 EnumWindows 函数。EnumWindows 接受一个回调函数作为参数,并将针对找到的每个顶级窗口调用该函数。所有这些回调都发生在 EnumWindows 调用期间。对于此类情况,CLR 可以确保在调用该方法的过程中不收集传入 P/Invoke 方法的委托。但如果非托管代码缓存提供给它的函数指针副本,该怎么办呢?稍后,当托管 P/Invoke 调用完成时,非托管代码可能尝试回调该函数指针,这时该委托可能就不是活动的。

有关该操作的具体示例,请考虑图 4 中的代码(错误很多)。使用低级的键盘窗口挂钩,该代码截获发送到任何窗口的所有 WM_KEYDOWN 消息并输出相应的键。当您运行该应用程序时,它很可能显示为工作,至少在一小段时间内是这样。但在某一时刻,垃圾回收器将运行并且它会发现传入 SetWindowsHookEx P/Invoke 方法的 LowLevelKeyboardProc 委托实例在任何位置都不再引用,而且 GC 将回收它(如果您不喜欢等待,可以在对 Application.Run 的调用之前插入一个对 GC.Collect 的调用,以便强制该委托立即移除)。之后,当 Windows 下一次尝试使用该挂钩时,应用程序几乎一定会"死得很惨",而且原因可能不明。

让 CallbackOnCollectedDelegate 来解围!启用该 MDA 时,如果对委托进行垃圾回收,将不会删除 thunk。相反,修改 thunk 以便激活 CallbackOnCollectedDelegate MDA,这样您就可以确切知道进程为什么会"死亡"(请参阅图 5)。请注意,CLR 只保留一定数量修改的 thunk,而该数值可使用 MDA 配置文件进行配置(稍后将详细介绍),尽管您很少需要更改该数量。

image

图 5 PInvokeStackImbalance MDA 激活

当 CallbackOnCollectedDelegate 帮助识别该问题后,您需要修复它。典型的解决方案是:使用 GC.KeepAlive 来确保特定实例保持活动状态一段时间,或者使它成为一个类的成员变量,它自身将在足够长的时间内保持活动状态。

返回页首返回页首

GcUnmanagedToManaged

在关于 CallbackOnCollectedDelegate 的讨论中,我曾提过通过在建立挂钩后(此时已进行了所有对该委托的引用)调用 GC.Collect 来可轻松遏制该错误。这当然是一个解决方案但它也需要对代码进行修改,MDA 的最大好处之一是无需代码修,即可调试帮助和错误检测。这就是 GcUnmanagedToManaged MDA 的用处所在。分成行为类别时,该 MDA 不生成任何输出;相反,只要一个线程从非托管代码转换为托管代码,它都会引起 GC 执行一个集合。因此,如果除 CallbackOnCollectedDelegate 之外还启用了 MDA,则只要非托管代码尝试调用委托,垃圾回收都将首先执行。如果有大量回调时(导致大量额外的回收),可以引起较高的性能开销,但它也可以极大地缩短发生损坏(在本例中,丢弃对委托的所有引用)与 CallbackOnCollectedDelegate MDA 向您发出问题警告之间的时间。所有这些(未对代码进行修改)均是我的所编书籍种的成功所在。

返回页首返回页首

InvalidFunctionPointerInDelegate

对于 .NET Framework 1.x 中功能的一个常见问题是:对动态目标进行 P/Invoke 调用的功能,因为目标函数的名称、签名或位置在编译时是未知的。该功能在 .NET Framework 2.0 中通过 Marshal 类上的一个新方法 (GetDelegateForFunctionPointer) 启用。它接受两个参数:目标函数的 IntPtr 地址,以及针对该函数进行实例化的委托的 Type(该委托的签名应该与目标函数兼容)。

如果将一个无效函数指针传递给 Marshal.GetDelegateForFunctionPointer,会发生什么情况?它很乐意为您创建委托,但调用该委托时,您会明显地感觉到具有访问冲突,甚至更糟。为帮助您解决此类问题,当无效函数指针传入 GetDelegateForFunctionPointer 时,InvalidFunctionPointerInDelegate MDA 将向您发出警告。考虑以下代码:

EventHandler ev = Marshal.GetDelegateForFunctionPointer(
    (IntPtr)0x12345678, typeof(EventHandler));
ev(null, EventArgs.Empty);

运行代码时,Exception Assistant 在第一行向您发出以下消息的警告:

Invalid function pointer 0x12345678 was passed into
the runtime to be converted to a delegate. 
Passing in invalid function pointers to be converted
to delegates can cause crashes, corruption or data loss.

要结束它,如图 1 所示,该 MDA 默认情况下在 Visual Studio 中启用,您甚至无需打开它就可以利用它。自动保护。

返回页首返回页首

AsynchronousThreadAbort

.NET Matters 专栏的老读者们可能还记得我在前几期专栏中演示了如何使用 Thread.Abort 方法。在一个版本中,它用于实现方法超时(请参阅".NET Matters: StringStream, Methods with Timeouts"),然而在另一个版本中,它帮助实现支持取消的 ThreadPool 的包装(请参阅".NET Matters: Abortable Thread Pool")。在这两期专栏中,我详述了使用该技术的危险所在,而且在我的关于 .NET Framework 中的可靠性的文章中(请参阅 "High Availability: Keep Your Code Running with the Reliability Features of the .NET Framework"),我详细介绍了它可能引起的问题,并介绍了改进托管应用程序可靠性的多种方式。

简言之,除非 Thread.Abort 用于中止当前线程 (Thread.CurrentThread.Abort),否则尽量避免使用它。为帮助代码使用 Thread.Abort 时发出警告,CLR 提供了 AsynchronousThreadAbort MDA。与 LoadFromContext MDA 类似,AsynchronousThreadAbort MDA 并不指明代码中存在问题,但它会让您知道正在使用一些危险内容,从而帮助您找出所遇问题的原因。请考虑下面的简单程序:

class Program {
    public static void Main(string[] args) {
        Thread t = new Thread(delegate() {
            while (true) { Thread.Sleep(500); }
        });
        t.Start();
        t.Abort();
        Console.WriteLine("Done");
    }
}

该代码分离一个新线程并立即中止它。如果启用 AsynchronousThreadAbort MDA(一个简单任务,由于它是 Managed Debugger 类别的一部分,因此可以从 Visual Studio 中的 Exceptions 对话框启用),倘若提供了具有以下文本的 Exception Assistant(虽然几乎使用不同的线程 ID),Visual Studio 将在中止线程的那一行中断:

User code running on thread 4408 has attempted to abort thread 4956.
       This may result in corrupt state or resource leaks if the thread being 
       aborted was in the middle of an operation that modifies global state or uses native resources. 
       Aborting threads other than the currently running thread is strongly discouraged.

MDA 确切告诉您已中断的线程以及正在中断的线程,当(通过调试器)访问该操作中涉及的所有线程时,这是一个非常有用的功能。

返回页首返回页首

StreamWriterBufferedDataLost

MDA 的另一个优势是,StreamWriterBufferedDataLost 会警告您:错误地关闭 StreamWriter 将导致数据丢失。文件 I/O 的开销很大,因此为了最小化 I/O 操作的数量,StreamWriter 将缓冲 I/O 操作的数据。调用 StreamWriter.Write 时,传递的数据可能(或不可能)立即写入基础文件中,这主要取决于要编写的数据量以及已经编写的数据量。在某个时刻,StreamWriter 确定它已经接收了足够的数据,而且它应该写入该文件中,此时它进行实际的 I/O。到时候,它将要编写的数据存储到一个内部缓冲区中。如果您忘记关闭 StreamWriter,StreamWriter 的缓冲区中可能包含一些未刷新的数据,而且该数据绝不会使其进入输出文件中。如果启用 StreamWriterBufferedDataLost MDA,当它检测到其缓冲区中有未刷新的数据时,该 StreamWriter 的 finalizer 将激活 MDA,此时 Exception Assistant 将显示如图 6 所示的信息。请注意,MDA 不仅通知您发生的情况,而且还提供完整的堆栈跟踪,该跟踪说明在对其数据已被放弃的 StreamWriter 进行实例化的位置。这样做有什么好处?

返回页首返回页首

PInvokeLog

想知道您的应用程序正在进行什么 P/Invoke 调用吗?当启用时,将在第一次调用每个 P/Invoke 声明时激发 PInvokeLog MDA,从而使您确切知道执行了哪些非托管函数以及哪些托管 P/Invoke 签名引起该操作发生。但遗憾的是,您在 Visual Studio 中并不能访问该信息的全部。至此,我已经介绍了 MDA 如何实际将信息报告给调试器。对于 .NET Framework 2.0,ICorDebug 接口已经扩展为支持 MDA,特别是通过 ICorDebugManagedCallback2 及其 MDANotification 方法。MDANotification 提供具有 ICorDebugMDA 实例的侦听器,从而提供关于引发的 MDA 的有用信息,包括一个 XML 描述。对于 CallbackOnCollectedDelegate,该 XML 输出将如下所示:

<mda:msg xmlns:mda="http://schemas.microsoft.com/CLR/2004/10/mda">
  <!-- 
     A callback was made on a garbage collected delegate of type
     "test!InterceptKeys+LowLevelKeyboardProc::Invoke". This may 
     cause application crashes, corruption and data loss. When 
     passing delegates to unmanaged code, they must be kept alive 
     by the managed application until it is guaranteed that
     they will never be called.
   -->
  <mda:callbackOnCollectedDelegateMsg break="true">
    <delegate name="test!InterceptKeys+LowLevelKeyboardProc::Invoke"/>
  </mda:callbackOnCollectedDelegateMsg>
</mda:msg>

如果将该输出与图 5 的输出对比,您会发现 Visual Studio 正在用 Exception Assistant 显示 MDA 的输出的内部文本注释。对于 PInvokeLog,MDA 的 XML 输出如下所示:

<mda:msg xmlns:mda="http://schemas.microsoft.com/CLR/2004/10/mda">
  <mda:pInvokeLogMsg>
    <method name="test!InterceptKeys::CallNextHookEx"/>
    <dllImport dllName="C:\WINDOWS\system32\USER32.dll" 
        entryPoint="CallNextHookEx"/>
  </mda:pInvokeLogMsg>
</mda:msg>

图 4 的 InterceptKeys.CallNextHookEx P/Invoke 时生成的输出。遗憾的是,Visual Studio 不显示整个 XML 输出,只显示前面示例中所显示的内部文本。如果您需要该信息的全部,则需要使用显示完整 XML 的调试器,例如,Windbg。当然,Windbg 不像 Visual Studio 那样具有 Exceptions 对话框,因此,您需要以稍微不同的方式启用 PInvokeLog MDA。

返回页首返回页首

启用MDA

有若干种方式来启用 MDA。如果 MDA 属于 Managed Debugger 类别(请参阅图 1),而且如果您要使用 Visual Studio,则应该采用 Exceptions 对话框。但是,这只介绍了这些情况的一部分。MDA 能够以其他两种方式启用:通过注册表和通过环境变量,这两种方式可以与配置文件结合进行。通过注册表启用 MDA 涉及到将 HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework 键上的 MDA 值设置为 1,正如您可以使用包括以下代码的 .reg 文件所进行的操作一样:

Windows Registry Editor Version 5.00
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\.NETFramework]
"MDA"="1"

这会通知 CLR 查找与具有名称 AppName.exe.mda.config(其中 AppName.exe 是要调试的应用程序的名称)的应用程序处在同一目录中的 MDA 配置文件。该文件的 XML 内容列出了您要针对该应用程序启用的所有 MDA。因此,例如,一个启用了 8 个 MDA(迄今为止我已经描述过的)的配置文件将如下所示(请注意,XML 对 MDA 的名称区分大小写,而且该 MDA 必须按字母顺序列出)。

<?xml version="1.0" encoding="UTF-8" ?>
<mdaConfig>
  <assistants>
    <asynchronousThreadAbort />
    <callbackOnCollectedDelegate />
    <gcUnmanagedToManaged />
    <invalidFunctionPointerInDelegate />
    <loadFromContext />
    <pInvokeLog />
    <pInvokeStackImbalance />
    <streamWriterBufferedDataLost />
  </assistants>
</mdaConfig>

一些 MDA 获取额外的参数(您可以在 MSDN 库中找到记录的这些参数),事实上,必须对一些 MDA 配置后才能使用。作为一个可以配置但不是必须配置的 MDA 示例,请考虑针对前面讨论过的 CallbackOnCollectedDelegate MDA 的配置项:

<mdaConfig>
      <assistants>
        <callbackOnCollectedDelegate listSize="1500" />
      </assistants>
    </mdaConfig>

callbackOnCollectedDelegate 元素上的 listSize 属性通知 CLR 为 MDA 保留多少放弃的互操作 thunk,在本例中是 1500(该值必须大于 50 小于 2000,默认值是 1000)。另一方面,PinvokeLog 必须使用附加信息进行配置才能完全有用。需要通知 PInvokeLog 哪些目标 DLL 具有提供的输出:

<mdaConfig>
  <assistants>
    <pInvokeLog>
      <filter>
        <match dllName="user32.dll"/>
        <match dllName="kernel32.dll"/>
      </filter>
    </pInvokeLog>
  </assistants>
</mdaConfig>

在注册表中启用 MDA 是全局性的,而且将影响运行的所有托管应用程序。相反,对于许多情况而言,您可能发现使用环境变量更易于启用 MDA。与注册表键类似,COMPLUS_MDA 环境变量可以设置为 1,从而强制执行应用程序以使用相关的配置文件:

set COMPLUS_MDA=1

但是,除非需要使用特定的参数配置特定的 MDA,否则您可以避免使用配置文件,而是将环境变量设置为您想启用的 MDA 的由分号分隔的列表。例如,以下命令将启用 GCUnmanagedToManaged 和 CallbackOnCollectedDelegate MDA:

set COMPLUS_MDA=GCUnmanagedToManaged;CallbackOnCollectedDelegate

设置该变量之后,可从命令行编译并执行图 4 中的代码:

    csc /debug /o- BuggyCode.cs
    BuggyCode.exe

一旦 BuggyCode.exe 截获您的下一次按键操作,就可为您提供标准调试器附加对话框(请参见图 7),该对话框从 CallbackOnCollectedDelegate MDA 的调用衍生而来。

image

图 3 MDA 的实时调试

您现在可以使用 Windbg 从 PinvokeLog 查看输出。此外,使用图 4 中的示例以及我前面显示的 PInvokeLog 配置文件(它应该命名为 BuggyCode.exe.mda.config 并与 BuggyCode.exe 位于同一目录中),您能够按以下方式执行它:

    set COMPLUS_MDA=1
    Windbg BuggyCode.exe

只要 Windbg 加载并且您能继续执行,就会看到图 8 中显示的输出,包含来自 PInvokeLog MDA 的大量 XML 代码片段。

image

图 8 使用 WinDbg 查看 PInvokeLog 输出

如果要使用具有 MDA(如 PInvokeLog)的 Visual Studio 也可以,但需要进行一些额外的工作,而且虽然当 MDA 激发时将通知您,但您不会获取提供的关于 MDA 的任何附加信息(例如,在 PinvokeLog 的情况中,DLL 导入信息)。首先进入 Visual Studio 中的 Exceptions 对话框,并在 Managed Debugging Assistants 下使用一个您感兴趣的 MDA 名称添加一个项。确保已在注册表中启用了 MDA,而且已经正确创建了包括您感兴趣的 MDA 在内的配置文件。此外,一些 MDA(例如,PinvokeLog)仅当启用混合模式的调试时在 Visual Studio 中激发。完成所有这些步骤并执行我的程序之后,每当激发 PInvokeLog MDA 时,您就会看到图 9 中显示的对话框。

image

图 9 Visual Studio 中的 PInvokeLog MDA

返回页首返回页首

小结

有关 MDA 的一个常见问题是:开发人员是否可以添加自己的 MDA。遗憾的是,答案是否定的,至少在 .NET Framework 2.0 中不行。将来的 Framework 版本将允许该功能;但目前,使用当前所提供的支持级别是最好的选择。请关注 MDA;我想您将因为它而兴奋不已。

Stephen Toub 是 MSDN Magazine 的技术编辑。

转到原英文页面

posted @ 2009-10-26 20:20  pursue  阅读(1545)  评论(0)    收藏  举报