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

时间:2016-12-14 05:38:04

标签: visual-studio debugging x86 breakpoints

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

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

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

1 个答案:

答案 0 :(得分:6)

这实际上是一个相当大而复杂的主题,它也是特定于架构的,所以我只针对这个答案,提供英特尔(兼容)x86微体系结构的常用方法的摘要。

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

此外,这并不仅限于Visual Studio。它的调试器当然可以按照我所描述的方式工作,但其他任何Windows调试器也是如此,例如Debugging Tools for Windows调试器(WinDbg,KD,CDB,NTSD等),GNU's GDBIDA's debugger,开源x64dbg,等等。

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

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

虽然软件断点使用处理器本身的功能,但它主要在软件中实现,因此名称。具体来说,中断#3(the assembly language instruction INT 3)提供断点中断。这可以放在可执行代码中的任何位置,当CPU在执行期间命中该指令时,它将陷阱。然后,调试器可以捕获此陷阱并执行它想要执行的任何操作。如果程序没有在调试器下运行,那么操作系统将处理陷阱;操作系统的默认处理程序只会终止程序。

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

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

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

    在C / C ++中,可以通过调用the DebugBreak API functionthe __debugbreak intrinsic或使用内联汇编插入INT 3指令来插入固定断点。在.NET代码中,您可以使用System.Diagnostics.Debugger.Break发出固定断点。

    在运行时,通过用单字节NOP instructionINT)替换单字节0xCC指令(0x90),可以轻松删除固定断点。 NOP是no-op的助记符:它只会导致处理器浪费一个循环而不做任何事情。

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

    所以它的作用是在请求的位置插入INT指令(0xCC),写出当前的指令。如果这是一个单字节指令(例如INC),则它只是被INT替换。如果这是一个多字节指令(大多数是),那么只有该指令的第一个字节被0xCC替换。然后原始指令变为无效,因为它已被部分覆盖。但那没关系,因为一旦处理器命中INT指令,它就会陷入并停止在这一点上执行。部分的,损坏的原始指令不会被击中。一旦调试器捕获到由INT指令触发的陷阱,并且"中断" in,它撤消内存中的修改,用原始指令的正确字节表示替换插入的0xCC字节。这样,当您从该点恢复执行时,代码是正确的,并且您不会反复敲击相同的断点。注意,所有这些修改都发生在存储在存储器中的二进制可执行文件的当前图像中;它直接在内存中修补,无需修改磁盘上的文件。 (这是使用专为调试器设计的ReadProcessMemoryWriteProcessMemory 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个,但DR4DR5DR6和{{1}相同实际上只有6个。)前4个调试寄存器(DR7DR0)存储存储器地址或I / O位置,其值可以使用特殊形式设置DR3指令。 MOV(相当于DR6)是一个包含标志的状态寄存器,DR4(相当于DR7)是一个控制寄存器。当相应地设置控制寄存器时,处理器尝试访问这四个位置之一将导致硬件断点(具体地,将引发DR5中断),然后可以由调试器捕获。同样,细节很复杂,可以在网上或Intel's technical manuals找到不同的地方,但不一定是为了获得高层次的理解。

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

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

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

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

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

最后,还有条件断点。这是您可以在一行代码上设置断点的地方,但是如果某个指定条件的计算结果为true,则要求调试器仅在那里中断。这些非常功能强大,但根据我的经验,开发人员很少使用它们。据我所知,这些是在正常的无条件断点下实现的。当命中断点时,调试器会自动评估条件。如果这是真的,那么它会在#34;为用户。如果为false,则继续执行,就好像断点从未被击中一样。条件断点没有硬件支持(超出上面讨论的数据断点支持),我不知道对条件断点(例如,操作系统提供的东西)的任何低级支持。当然,这就是为什么在断点上附加复杂条件会大大降低程序执行速度的原因!

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