在 Visual Studio 中添加 运行 时间断点如何工作?

How does adding a run-time breakpoint in Visual Studio work?

当我在 运行 时间内向某些 C# 代码添加断点时,断点被命中。这实际上是如何发生的?

我想说的是运行在debug模式下,Visual Studio有代码块的引用,在运行的时候加断点,就是一旦在编译代码中调用该引用,就会激活。

这是一个正确的假设吗?如果是这样,您能否提供有关其工作原理的更多详细信息?

这实际上是一个相当大和复杂的话题,而且它也是特定于体系结构的,所以我在这个答案中的目的只是提供一个关于英特尔(和兼容的)x86 微体系结构的常见方法的总结。

好消息是,它是 语言 独立的,因此调试器将以相同的方式工作,无论是调试 VB.NET、C# 还是 C++ 代码.之所以如此,是因为所有代码最终都会编译(无论是静态[,像C++一样提前编译还是使用JIT 编译器,如 .NET]) 或动态地 [ 例如 ,通过 运行-time 解释器]) 到可以由处理器本机执行的目标代码。调试器最终使用的是本机代码。

此外,这不限于Visual Studio。它的调试器确实按照我将描述的方式工作,但任何其他 Windows 调试器也是如此,例如 Debugging Tools for Windows debuggers (WinDbg, KD, CDB, NTSD, etc.), GNU's GDB, IDA's debugger, the open-source x64dbg,等等。


让我们从一个简单的定义开始——什么是断点?它只是一种允许暂停执行的机制,以便您可以进行进一步的分析,无论是检查调用堆栈、打印变量的值、修改内存或寄存器的内容,甚至修改代码本身。

在 x86 架构上,有几种实现断点的基本方法。它们可以分为两大类软件断点和硬件断点。


尽管软件断点使用处理器本身的功能,但它主要在软件中实现,因此得名。具体来说,中断 #3 (the assembly language instruction INT 3) 提供断点中断。这可以放在可执行代码的任何地方,当 CPU 在执行过程中命中这条指令时,它会陷入陷阱。然后调试器可以捕获这个陷阱并做任何它想做的事。如果程序未 运行ning 在调试器下,则操作系统将处理陷阱; OS 的默认处理程序将简单地终止程序。

INT 3 指令有两种可能的编码。也许最合乎逻辑的编码是 0xCD 0x03,其中 0xCD 表示 INT,而 0x03 指定 "argument",或要触发的中断号。然而,由于断点非常重要,Intel 的设计人员还为 INT 3 添加了一种特殊情况表示——单字节操作码 0xCC.

这是一个单字节指令的好处是它可以毫不费力地插入程序中的几乎任何地方。从概念上讲,这很简单,但 实际上 的工作方式有些棘手。基本上,有两种选择:

  • 如果是fixed断点,那么调试器可以在代码编译时插入这条INT指令。然后,每次你碰到那个点,它就会执行那个指令并中断。

    在 C/C++ 中,可以通过调用 the DebugBreak API function, with the __debugbreak intrinsic, or using inline assembly to insert an INT 3 instruction. In .NET code, you would use System.Diagnostics.Debugger.Break 来插入固定断点以发出固定断点。

    在运行时,可以通过用一个字节NOP instruction0x90)。 NOP 是 no-op 的助记符:它只会导致处理器浪费一个周期而不做任何事情。

  • 但是如果它是一个动态断点,那么事情就会变得更加复杂。调试器必须修改内存中的二进制文件并插入 INT 指令。但是它要插入哪里呢?即使在调试版本中,编译器也无法合理地在每条指令之间插入 NOP,并且它事先不知道您可能要插入断点的位置,因此不会有 space在代码中的任意位置插入一个单字节 INT 指令。

    所以它所做的是在请求的位置插入 INT 指令 (0xCC),覆盖当前存在的任何指令。如果这是一条单字节指令(例如 INC),则只需将其替换为 INT。如果这是一条多字节指令(大多数是),那么只有该指令的第一个字节被替换为 0xCC。原始指令随后变为 无效 因为它已被部分覆盖。但这没关系,因为一旦处理器命中 INT 指令,它就会陷入并恰好在该点停止执行。部分损坏的原始指令将不会被命中。一旦调试器捕获到由 INT 指令和 "breaks" 触发的陷阱,它就会 撤消 内存中的修改,替换插入的 0xCC 字节具有原始指令的正确字节表示。这样,当您从该点恢复执行时,代码是正确的,您不会一遍又一遍地遇到同一个断点。请注意,所有这些修改都发生在内存中存储的二进制可执行文件的当前映像上;它直接在内存中进行修补,而无需修改磁盘上的文件。 (这是使用专为调试器设计的 ReadProcessMemory and WriteProcessMemory API 函数完成的。)

    这是机器代码,显示了原始字节和汇编语言助记符:

    31 C0             xor  eax, eax     ; clear EAX register to 0
    BA 02 00 00 00    mov  edx, 2       ; set EDX register to 2
    01 D0             add  eax, edx     ; add EDX to EAX
    C3                ret               ; return, with result in EAX
    

    如果我们在添加值的源代码行(反汇编中的 ADD 指令)设置断点,ADD 指令的第一个字节(0x01) 将被替换为 0xCC,剩下的字节将成为无意义的垃圾:

    31 C0             xor  eax, eax     ; clear EAX register to 0
    BA 02 00 00 00    mov  edx, 2       ; set EDX register to 2
    CC                int  3            ; BREAKPOINT!
    D0                ???               ; meaningless garbage, never executed
    C3                ret               ; also meaningless garbage from CPU's perspective
    

希望您能够理解所有这些,因为这实际上是最简单的案例。软件断点是您 时间使用的断点。调试器的许多最常用功能都是使用软件断点实现的,包括单步执行调用、执行所有代码直至特定点,以及 运行 函数结束。在幕后,所有这些都使用了一个临时软件断点,该断点会在第一次被击中时自动删除。


然而,还有一种更复杂、更强大的方法可以在处理器的直接协助下设置断点。这些被称为硬件断点。 x86指令集提供了6个特殊的调试寄存器。 (它们被称为 DB0DB7,建议总共有 8 个,但是 DR4DR5DR6DR7,所以实际上只有 6 个。)前 4 个调试寄存器(DR0DR3)存储内存地址或 I/O 位置,其值可以使用特殊设置MOV 指令的形式。 DR6(相当于DR4)是一个包含标志的状态寄存器,DR7(相当于DR5)是一个控制寄存器。当相应地设置控制寄存器时,处理器尝试访问这四个位置之一将导致硬件断点(具体来说,将引发 INT 1 中断),然后可以被调试器捕获。同样,细节很复杂,可以在网上或 Intel's technical manuals 中的各个地方找到,但不是获得高级理解所必需的。

这些特殊调试寄存器的好处在于它们提供了一种无需修改代码即可实现数据断点的方法!但是,有两个严重的局限性。首先,只有四个可能的位置,所以如果不够聪明,您只能使用四个断点。其次,调试寄存器是特权资源,访问和操作它们的指令只能在 ring 0(本质上是内核模式)上执行。尝试在任何其他 privilege level (such as in ring 3, which is effectively user mode) will cause a general protection fault. Therefore, the Visual Studio debugger has to jump through some hoops to use these. I believe that it first suspends the thread and then calls the SetThreadContext API function 处读取或写入这些寄存器(这会导致内部切换到内核模式)以操纵寄存器的内容。最后,它恢复线程。这些调试寄存器 非常 强大,可以为包含数据的内存位置设置 read/write 断点,也可以为包含代码的内存位置设置执行断点。

但是,如果您需要超过 4 个,或者遇到其他一些限制,那么这些硬件提供的调试寄存器将无法工作。 Visual Studio 调试器必须有一些其他的、更通用的方法来实现数据断点。事实上,这就是为什么在调试器下 运行 设置大量断点会减慢程序执行速度的原因。

这里有各种各样的技巧,我对不同的闭源调试器到底使用了哪些技巧知之甚少。你几乎可以肯定地通过逆向工程或更仔细的观察来发现,也许有人比我更了解这一点。但我将简要总结一些我知道的技巧:

  • 内存访问断点的一个技巧是使用 guard pages. This involves changing the protection level of the virtual-memory page that contains the data of interest to PAGE_GUARD, meaning that subsequent attempts to access that page (either read or write) will raise a guard page violation exception. The debugger can then catch this exception, verify that it occurred upon access to the memory address of interest, and process it as a breakpoint. Then, when you resume execution, the debugger arranges for the page access to succeed, resets the PAGE_GUARD flag again, and continues. This is how OllyDBG 实现其对内存访问断点的支持。我不知道Visual Studio的调试器是否使用了这个技巧。

  • 另一个技巧是使用单步执行支持。基本上,调试器在 x86 EFLAGS 寄存器中设置陷阱标志 (TF)。这导致 CPU 在执行每条指令之前陷入陷阱(它通过引发 INT 1 异常来实现,就像我们在上面看到的使用调试寄存器时一样)。然后调试器捕获这个陷阱,并决定它是否应该继续执行。


最后还有条件断点。这是您可以在一行代码上设置断点的地方,但要求调试器仅在特定指定条件的计算结果为真时才在此处中断。这些 非常 强大,但根据我的经验,开发人员很少使用它们。据我所知,这些是作为正常的无条件断点在后台实现的。当遇到断点时,调试器会自动评估条件。如果为真,则为用户"breaks in"。如果为 false,它将继续执行,就像从未命中断点一样。没有对条件断点的硬件支持(除了上面讨论的数据断点支持),而且我不知道对条件断点有任何较低级别的支持(例如,由操作系统提供的东西系统)。当然,这就是为什么在断点上附加复杂的条件会显着降低程序的执行速度!


如果您对更多细节感兴趣(好像这个答案还不够长!),您可以查看 Tarik Soulami's Inside Windows Debugging。貌似里面有相关资料,虽然我还没有看过所以不能毫不掩饰地推荐。 (它在我的亚马逊愿望清单上!)