CLR 调试体系结构

公共语言运行时 (CLR) 调试 API 专门用作操作系统内核的一部分。 在非托管代码中,当程序生成异常时,内核将暂停执行进程,并使用 Win32 调试 API 将异常信息传递给调试器。 CLR 调试 API 可以为托管代码提供相同功能。 当托管代码生成异常时,CLR 调试 API 将暂停执行进程,并将异常信息传递给调试器。

本主题描述 CLR 调试 API 何时以及如何进行工作以及它提供哪些服务。

进程体系结构

CLR 调试 API 包括以下两个主要组件:

  • 调试 DLL,始终加载到与正在调试的程序相同的进程中。 运行时控制器负责与 CLR 进行通信并对正在运行托管代码的线程进行执行控制和检查。

  • 调试器接口,加载到与正在调试的程序不同的进程中。 调试器接口负责代表调试器与运行时控制器进行通信。 它还负责处理来自正在调试的进程的 Win32 调试事件,要么处理这些事件,要么将这些事件传递给非托管代码调试器。 调试器接口是 CLR 调试 API 中唯一具有公开 API 的部件。

CLR 调试 API 不支持跨计算机或跨进程的远程使用;也就是说,使用该 API 的调试器必须从其自己的进程内执行此操作,如下面的 API 体系结构示意图所示。 此图显示了 CLR 调试 API 的不同组件所在的位置以及它们与 CLR 和调试器的交互方式。

CLR 调试 API 体系结构

CLR 调试体系结构

托管代码调试器

可以构建一个只支持托管代码的调试器。 通过使用“软附加”机制,CLR 调试 API 使这种调试器能够根据需要附加到进程。 软附加到进程的调试器随后可以从该进程中分离出来。

线程同步

CLR 调试 API 具有与进程体系结构有关的相互冲突的要求。 一方面,将调试逻辑与正在调试的程序保持在相同进程中的原因很多,而且也很有说服力。 例如,数据结构复杂,经常要通过函数而不是通过固定内存布局来处理它们。 直接调用函数(而不是从进程外尝试对数据结构进行解码)要容易得多。 将调试逻辑保持在同一进程中的另一个原因是消除了进程间的通信开销,从而提高了性能。 最后,CLR 调试的一项重要功能就是能够在调试对象所在的进程中运行用户代码,很明显,这需要与调试对象进程进行一些协作。

另一方面,CLR 调试必须与非托管代码调试共存,后者只能从外部进程中正确执行。 此外,进程外调试器比进程内调试器更加安全,因为在进程外调试器中最大程度地减小了调试器的操作与调试对象进程之间的相互影响。

由于存在这些相互冲突的要求,所以 CLR 调试 API 会将各种方法的一些内容组合在一起。 主要的调试接口位于进程外,并且与本机 Win32 调试服务共存。 但是,CLR 调试 API 添加了与调试对象进程同步的功能以便能够在用户进程中安全地运行代码。 为了执行此同步操作,API 将与操作系统和 CLR 进行协作以便在进程中的所有线程不会中断操作的位置处暂停这些线程并使运行时处于不相干的状态。 然后,调试器可以在特殊的线程中运行代码,该线程可以检查运行时的状态并根据需要调用用户代码。

当托管代码执行断点指令或生成异常时,将通知运行时控制器。 此组件将确定正在执行托管代码的线程以及正在执行非托管代码的线程。 通常,在运行托管代码的线程达到可以安全挂起的状态之前,将允许这些线程继续执行。 例如,它们可能必须完成正在进行的垃圾回收。 当托管代码线程达到安全状态时,所有线程都将被挂起。 然后,调试器接口会通知调试器已经收到了断点或异常。

当非托管代码执行断点指令或生成异常时,调试器接口组件将通过 Win32 调试 API 接收通知。 此通知将传递给非托管调试器。 如果调试器确定需要执行同步(例如,为了能够检查托管代码堆栈帧),则调试器接口必须首先重新启动停止的调试对象进程,然后通知运行时控制器执行同步。 然后,当同步已完成时将会通知调试器接口。 此同步对于非托管调试器是透明的。

在同步进程期间,不得执行生成断点指令或异常的线程。 为了便于执行此规定,调试器接口在线程的筛选器链中放置了一个特殊的异常筛选器来控制线程。 当重新启动线程时,线程将进入异常筛选器,异常筛选器会将线程交给运行时控制器控制。 该继续处理异常(或该取消异常)时,筛选器会将控制权返还给线程的常规异常筛选器链或者返回正确的结果以继续执行。

在极少数情况下,生成本机异常的线程可能拥有重要的锁,只有先打开这些锁,然后才能完成运行时同步。 (通常,这些锁将为低级别库锁,例如 malloc 堆上的锁。)在这些情况下,同步操作一定会超时并且将会失败。 这也将导致需要同步的某些操作失败。

进程中的帮助器线程

每个 CLR 进程中只使用一个调试器帮助器线程以确保 CLR 调试 API 正常运行。 此帮助器线程负责处理由调试 API 提供的许多检查服务,以及在某些情况下协助线程同步。 您可以使用 ICorDebugProcess::GetHelperThreadID 方法识别帮助器线程。

与 JIT 编译器交互

为了使调试器能够调试实时 (JIT) 编译的代码,CLR 调试 API 必须能够将 Microsoft 中间语言 (MSIL) 版本的函数中的信息映射到本机版本的函数中。 此信息包括代码中的序列点以及局部变量位置信息。 在 .NET Framework 1.0 和 1.1 版中,只有当运行时处于调试模式时,才会产生此信息。 在 .NET Framework 2.0 中,始终会产生此信息。

另外,可以对 JIT 编译的代码进行高度优化。 优化(例如公共子表达式消除、函数内联展开、循环展开、代码检查等)可能会导致函数的 MSIL 代码与被调用执行的本机代码之间的相互关系丢失。 因此,这些主动代码优化方法将会严重影响 JIT 编译器提供正确映射信息的能力。 所以,在调试模式下运行运行时时,JIT 编译器将不会执行某些优化。 此限制使调试器能够准确地确定所有局部变量和参数的源行映射和位置。

调试模式

CLR 调试 API 提供了以下两种特殊的调试模式:

  • “编辑并继续”模式。 在此情况下,运行时将以不同方式运行以便以后能够更改代码。 这是因为某些运行时数据结构的布局必须不同以便支持“编辑并继续”。 因为这对性能有负面影响,所以除非想使用“编辑并继续”功能,否则请不要使用此模式。

  • 调试模式。 此模式使 JIT 编译器能够忽略优化。 因此,它可以使执行的本机代码与高级语言源代码更加匹配。 除非需要,否则请不要使用此模式,因为这种模式也对性能有负面影响。

如果在“编辑并继续”模式外调试程序,则不支持“编辑并继续”功能。 如果在调试模式外调试程序,则将仍然支持大多数调试功能,但优化可能会引起异常行为。 例如,单步执行看来像是在方法中的行与行之间随机跳转,内联方法可能未在堆栈跟踪中出现。

如果调试器在运行时初始化自己之前获取了进程控制权,则调试器可以通过 CLR 调试 API 以编程方式启用“编辑并继续”模式和调试模式。 这足以能够达到许多目的。 但是,附加到已经运行了一段时间(例如在 JIT 调试期间)的进程的调试器将无法启动这些模式。

为了帮助处理这些问题,可以独立于调试器在 JIT 模式或调试模式下运行程序。 有关启用调试的方法的信息,请参见调试、跟踪和分析

JIT 优化可能使应用程序的可调试性降低。 CLR 调试 API 使用经过优化的 JIT 编译代码来启用堆栈帧和局部变量检查。 单步执行虽然受支持,但可能不精确。 您可以运行一个程序来指示 JIT 编译器禁用所有 JIT 优化以产生可调试的代码。 有关详细信息,请参见令映像更易于调试

请参见

其他资源

CLR 调试概述

调试(非托管 API 参考)