如何在Visual Studio中添加运行时断点



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

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

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

这实际上是一个相当大和复杂的主题,它也是特定于架构的,所以我在这个答案中只旨在总结英特尔(和兼容)x86 微架构的常见方法。

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

此外,这不仅限于Visual Studio。它的调试器当然以我将描述的方式工作,但任何其他Windows调试器也是如此,如Windows调试器的调试工具(WinDbg,KD,CDB,NTSD等),GNU的GDB,IDA的调试器,开源x64dbg等。


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

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


虽然软件断点使用处理器本身的功能,但它主要在软件中实现,因此得名。具体来说,中断 #3(汇编语言指令INT 3)提供断点中断。这可以放置在可执行代码中的任何位置,当 CPU 在执行过程中点击此指令时,它将捕获。然后,调试器可以捕获此陷阱并执行它想要执行的任何操作。如果程序未在调试器下运行,则操作系统将处理陷阱;操作系统的默认处理程序将简单地终止程序。

INT 3指令有两种可能的编码。也许最合乎逻辑的编码是0xCD 0x03,其中0xCD表示INT0x03指定"参数"或要触发的中断的编号。但是,由于断点非常重要,英特尔的设计人员还为INT 3添加了特殊情况表示形式 - 单字节操作码0xCC

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

  • 如果是固定断点,则调试器可以在编译时将此INT指令插入到代码中。然后,每次你达到那个点时,它都会执行该指令并中断。

    在 C/C++ 中,可以通过调用DebugBreakAPI 函数、__debugbreak内部函数或使用内联程序集插入INT 3指令来插入固定断点。在 .NET 代码中,可以使用System.Diagnostics.Debugger.Break发出固定断点。

    在运行时,可以通过将单字节INT指令(0xCC)替换为单字节NOP指令(0x90)来轻松删除固定断点。NOP是无操作的助记符:它只会导致处理器浪费一个周期而不做任何事情。

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

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

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

    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中断),然后调试器可以捕获该断点。同样,细节很复杂,可以在网上或英特尔的技术手册中找到,但没有必要获得高层次的理解。

这些特殊调试寄存器的好处是,它们提供了一种无需修改代码即可实现数据断点的方法!但是,有两个严重的限制。首先,只有四个可能的位置,所以如果没有很多聪明,你只能有四个断点。其次,调试寄存器是特权资源,访问和操作它们的指令只能在环 0(本质上是内核模式)执行。尝试以任何其他权限级别(例如在环 3 中,实际上是用户模式)读取或写入这些寄存器将导致一般保护错误。因此,Visual Studio 调试器必须跳过一些障碍才能使用这些。我相信它首先挂起线程,然后调用SetThreadContextAPI 函数(导致内部切换到内核模式)来操作寄存器的内容。最后,它恢复线程。这些调试寄存器非常强大,可用于为包含数据的内存位置设置读/写断点,以及为包含代码的内存位置设置执行断点。

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

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

  • 内存访问断点的一个技巧是使用保护页。这涉及更改包含要PAGE_GUARD感兴趣的数据的虚拟内存页面的保护级别,这意味着后续访问该页面(读取或写入)的尝试将引发保护页冲突异常。然后,调试器可以捕获此异常,验证它是否在访问感兴趣的内存地址时发生,并将其作为断点处理。然后,当您恢复执行时,调试器会安排页面访问成功,再次重置PAGE_GUARD标志,然后继续。这就是 OllyDBG 实现其对内存访问断点的支持的方式。我不知道Visual Studio的调试器是否使用了这个技巧。

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


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


如果你对更多细节感兴趣(好像这个答案还不够长!),你可以看看Tarik Soulami的Inside Windows调试。看起来它包含相关信息,尽管我还没有阅读它,所以我不能毫不掩饰地推荐它。(它在我的亚马逊愿望清单上!

最新更新