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

Posted

技术标签:

【中文标题】在 Visual Studio 中添加运行时断点如何工作?【英文标题】:How does adding a run-time breakpoint in Visual Studio work? 【发布时间】:2016-12-14 05:38:04 【问题描述】:

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

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

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

【问题讨论】:

是的,即使您的应用程序已经在运行,新添加的断点也会被触发(如果这是您所指的“运行时”)。您还可以在运行时更改断点的条件。但我不知道它是如何详细工作的,希望有人解释一下。 “当我在运行时向某些 C# 代码添加断点时它仍然有效”,“它仍然有效”是什么意思?你的意思是断点被命中还是仅仅意味着你可以成功添加断点? @JackZhai-MSFT 我们已经知道您可以成功添加断点(如 Marson Mao 所述),我的问题是它实际上是如何被命中的。我已经编辑了问题。 它修补内存中的二进制文件,在代码中的该位置添加一条int 3 指令,这会在执行该指令时立即导致陷阱。然后陷阱被调试器捕获。实际操作相当复杂。为什么您需要了解其工作原理? @CodyGray 只是对这里使用的概念感到好奇。前几天我想知道它是如何工作的,但在网上找不到任何解释它的资源。如果您可以将其表述为答案并为其他可能感兴趣的人指出资源 - 或者此类问题不属于该网站的范围,那就太好了? 【参考方案1】:

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

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

此外,这不仅限于 Visual Studio。它的调试器当然可以按照我将要描述的方式工作,但任何其他 Windows 调试器也是如此,例如 Debugging Tools for Windows 调试器(WinDbg、KD、CDB、NTSD 等)、GNU's GDB、IDA's debugger、open -sourcex64dbg,等等。


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

在 x86 架构上,可以通过多种基本方式实现断点。它们可以分为软件断点和硬件断点两大类。


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

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

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

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

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

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

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

所以它的作用是在请求的位置插入 INT 指令 (0xCC),覆盖当前存在的任何指令。如果这是一个单字节指令(例如INC),那么它会被简单地替换为INT。如果这是一条多字节指令(大多数都是),那么只有该指令的第一个字节被 0xCC 替换。然后原始指令变为无效,因为它已被部分覆盖。但这没关系,因为一旦处理器命中INT 指令,它就会在该点捕获并停止执行。部分的、损坏的、原始的指令不会被命中。一旦调试器捕获到由INT 指令触发的陷阱并“中断”,它撤消内存中的修改,将插入的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)的第一个字节将被替换为 @ 987654373@,把剩下的字节当作无意义的垃圾:

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个,但DR4DR5DR6DR6DR7相同,所以实际上只有6个。) 4 个调试寄存器(DR0DR3)存储内存地址或 I/O 位置,其值可以使用特殊形式的 MOV 指令设置。 DR6(相当于DR4)是一个包含标志的状态寄存器,DR7(相当于DR5)是一个控制寄存器。当相应地设置控制寄存器时,处理器尝试访问这四个位置之一将导致硬件断点(具体而言,将引发INT 1 中断),然后可以被调试器捕获。同样,细节很复杂,可以在网上或Intel's technical manuals的各个地方找到,但没有必要获得高层次的理解。

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

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

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

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

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


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


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

【讨论】:

这真的是我读过的最有见地的答案之一。谢谢。

以上是关于在 Visual Studio 中添加运行时断点如何工作?的主要内容,如果未能解决你的问题,请参考以下文章

使用 Visual Studio Code 的 Mocha 断点

在 Visual Studio 中跟踪运行时值

Visual Studio 2010 C# 调试器不会在新断点处停止

在 Visual Studio 2017 中使用断点时随机 C++ 执行流程?

断点设置但尚未绑定在Visual Studio中

Visual Studio调试效率技巧