购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

1.4 技巧和注意事项

在大多数情况下,使用调试器是直观的,无须了解太多的细节。只要调试器所依赖的所有内容都处于正确和良好的状态,它就能完美地工作。然而,有时候小小的问题就能带来一整天的麻烦。当调试器在我们需要的时候不工作,那将会是极度令人沮丧的。更糟糕的是,它可能给出“错误”的信息,导致我们得出错误的结论。当我们花费大量时间去追究一个错误的根源,最终却发现最基本的假设是错误的,这将会很令人挫败。在很多情况下,我们不应该错怪调试器本身,因为通常是我们自己的误解导致了这些困扰。

调试器会抱怨任何它不喜欢的事情。例如,源代码文件的时间戳比二进制文件晚,这可能意味着源代码已经被修改了;或者库文件的校验和与核心转储文件中指示的库文件不一致。如果我们忽视这些抱怨,调试器就可能像出了问题一样,例如程序不会在设定的断点处停止、不能捕获变量被意外修改的时刻、调用栈是混乱的,等等。

另一方面,调试器具有许多工程师不了解的强大功能。在大多数情况下,我们只使用了所有功能中的一小部分,以处理常见的调试需求。但是,如果我们能花更多的时间去学习调试器的高级功能,就会得到相应的回报。这将帮助我们更有效地进行调试,并解决那些偶尔会遇到的复杂问题。

1.4.1 特殊的调试符号

在之前的章节中,我们探讨了调试符号以及如何在调试过程中使用这些信息。作为功能的扩展,我们可以在需要的时候向调试器添加更多的调试符号。这在已知某个变量的具体类型但却无法打印该变量的情况下非常有用。

调试器无法理解变量的原因,主要是缺乏对应变量的调试符号。这在系统库、第三方库或遗留的二进制文件中并不罕见,因为这些库的符号可能被部分或完全剥离,或者在某些情况下,它们在编译时就没有生成调试符号。

解决这种问题的方法有两种:一种是重新编译并生成包含我们所需的调试符号的新库文件;另一种方法是使用Python扩展CDB(将在第9章中介绍)。当调试器成功加载新库文件的符号后,我们就能更好地调试这些二进制文件。下面,让我们通过一个第三方库的数据结构例子来看看具体的操作过程。

考虑一种情况——想要打印第三方库管理的一系列自由的内存块。这需要使用在头文件mm_type.h中声明的以下数据结构:

首先,编译上述文件,生成带有所有调试符号的目标文件:

     gcc -g -c -fPIC -o mm_symbol.o mm_symbol.c

然后,把这个目标文件加入调试会话中,这样就可以得到数据结构FreeBlock的类型符号。GDB命令add-symbol-file可以从下面显示的目标文件中读取额外的调试符号:

在这里,地址参数0x3f68700000并不重要。输入的文件通常是共享库,但也可以是目标文件。我们可以通过这种方式加入更多需要的符号。

这种方法为用户在使用调试器解释数据时提供了更大的灵活性。但请注意,这只能提供额外的类型信息,无法替换在原始二进制文件生成过程中确定的其他调试符号,如行号或变量位置。

可能使用最频繁的调试器功能就是断点设置,比如函数断点或者源代码行断点。然而在许多情况下,一个简单的断点可能是不够的。例如,当怀疑变量可能被错误修改时,常见的做法是在相关的地方设置多个断点或者在频繁执行的代码处设置断点。这可能导致另外一个问题,那就是断点会多次触发,这可能非常烦琐甚至不切实际,我们甚至会因此错过期盼的关键时刻。这是因为我们需要在许多合法的状态中找到有错误的那一个,而人的注意力是有限的。

一种常见的解决方案是设置条件断点,即将特定的条件表达式与断点关联起来。当达到断点时,调试器计算这个表达式,如果计算结果为真,那么程序会停下来等待用户的操作;如果计算结果为假,那么程序会继续运行。

读者应该意识到条件断点的性能损耗。即使看起来程序在达到断点时(条件表达式为假)没有停下来,实际上程序还是每次都会在达到断点时停止,并在计算表达式后由调试器恢复运行。如果这种开销过大,例如在频繁调用的函数中设置断点,可能导致程序明显变慢,我们必须找到一种更快的方式来检查数据。例如,通过函数拦截可以避免调试器的介入(可参看第6章获取更多细节)。

实际上有许多新颖的断点条件表达式,这是反映开发者经验水平的很好例子。以下是一些条件断点的例子:

     (gdb)ignore 1 100
     (gdb)break foo if index==5
     (gdb)break *0x12345678 if GetRefCount(this)==0

· 第一条命令告诉GDB在它停止程序之前忽略断点100次。

· 第二条命令在变量index为5的条件下在函数foo入口处停止。

· 最后一条命令在指令地址0x12345678处设置断点,并附加条件,即函数GetRefCount返回值为0,这种情况需要调试器调用一个函数来计算表达式。

断点可以设置在代码中,也可以设置在数据对象上。后者被称为监测点,或者数据断点。程序bug通常与特定的数据对象相关,并通过对这个对象的访问表现出来。在代码中设置断点的目的是允许我们检查可能错误更改数据的指令的程序状态。这种方法的一个明显不足之处是,它侧重于代码而非数据。被监视的代码可能会处理很多数据对象,大部分时间这些处理都是合法且正确的,除了可能出错的那个。因此,当怀疑的是特定的数据对象时,这种方法的覆盖范围太广,以至于无法有效地进行调试。

如果能在正确的数据对象上设置监测点,那我们就有更大的机会找到问题所在。当有太多可能错误修改数据对象的地方时,监测点是适用的。在这些情况下,代码断点可能无法提供太多帮助,因为它会过于频繁地停止程序,而这些停止并没有提供太多有用的信息。每当被监控的数据对象被覆写或读取时,监测点会根据其模式来停止程序。因此,当我们知道某个数据对象是程序失败的关键,但不清楚它何时和如何被修改为无效状态时,这种方式是最有效的。监测点是一个强大的功能,它通过关注数据引用来定位程序失败。

在大多数情况下,设置断点和监测点都很直观。但是,如果调试器的介入对问题的复现产生了显著影响,那么就需要仔细考虑。

断点和监测点使用不同的机制实现。如前面所述,调试器是通过将指定位置的指令替换为短陷阱指令来设置断点,原来的指令代码被保存在缓冲区中。当程序执行到陷阱指令,也就是达到断点时,内核会停止程序运行并通知调试器,后者从等待中醒来,显示所跟踪程序的状态,然后等待用户的下一条命令。如果用户选择继续运行,调试器会使用原来的代码替换陷阱指令,恢复程序运行。

监测点不能用指令断点的方法实现,因为数据对象是不可执行的。所以它的实现是要么定期地(软件模式)查询数据的值,要么使用CPU支持的调试寄存器(硬件模式)。软件监测点是通过单步运行程序并在每一步检查被跟踪的变量来实现的,这种方式使程序比正常运行要慢数百倍。

由于单步运行不能保证在多线程环境下的结果一致性,因此在多线程和多处理器的环境下,这种方法可能无法捕获到数据被访问的瞬间。硬件监测点则没有这个问题,因为被跟踪的变量的计算是由硬件完成的,调试器不用介入,它根本不会减慢程序的执行速度。但是硬件监测点在数量上是非常有限的,大多数CPU只有少数几个可用的调试寄存器。如果监测点表达式很复杂或需要设置很多监测点,那么数据大小可能会超过硬件的总容量。在这种情况下,调试器会隐式地回归到软件监测点,这可能会导致程序运行变得极慢。因此,应该始终注意调试器是否在软件模式下设置了监测点。如果是这样,那么我们可能需要调整调试策略。例如,可以将复杂的数据结构分解为更小的部分,以便尽量使用硬件断点。

监测点可以设置与断点类似的条件。例如,以下的GDB命令在变量sum改变且变量index大于100时停止程序:

     (gdb)watch sum if index > 100

这条命令可以解读为“监视变量sum,如果index大于100,则停止程序”。

虽然硬件断点对性能的影响较小,但计算条件表达式会带来与前文提到的同样的性能损耗。内核必须临时停止程序并与调试器通信,然后由调试器计算条件并确定下一步的操作。

1.4.2 改变执行及其副作用

调试器的主要用途之一是观察被追踪进程的状态。然而,它也能够更改被调试进程的状态,从而改变其本来预设的执行路径。这种能力为调试带来无限创新的可能性。例如,若要验证当内存耗尽时程序会发生什么错误,在调试器上可以简单地设置malloc函数的返回值为NULL。这是一种有效且低成本的方式,用于测试一些难以模拟或模拟成本较高的极端情况。

调试器提供了多种方式来改变程序的执行路径,最直接的方式是设置变量为新的值。调试器首先利用调试符号确定变量的内存地址,然后通过如ptrace方法以及内核的帮助来覆写目标进程的内存。例如,下面的命令将变量gFlags的值设置为5:

     (gdb)set var gFlags=5

改变线程的上下文也会影响程序的执行。例如,程序计数器(下一条要执行的指令)可以设置为另一指令的地址。这个功能通常用于重新执行一段已经运行过的代码,以便更仔细地查看其运行过程。如果要重新执行的代码段中包含了断点,那么这个断点将再次被触发。例如,下面的命令将当前线程的执行点移动到foo.c文件的第123行:

     (gdb)jump foo.c:123

上述命令仅更改线程的程序计数器,线程上下文的其他部分保持不变。当前函数的栈帧仍然位于线程栈的顶部。这意味着该功能有一个限制,即如果使用上述jump命令跳转到了另一个函数的地址,那么根据两个函数的参数和局部变量的布局,结果可能是不可预见的。除非我们对函数调用的所有细节都了如指掌,否则跳转到另一个函数通常是不明智的。

当被调试的进程已经暂停时,我们可以在调试器内调用任何函数。调试器会在当前线程最内层的帧上为被调用的函数创建一个新的栈帧。注意,调用C++类方法有些特殊,因为它“隐秘地”将this指针视为被调用函数的第一个参数,而调用C函数就简单直观一些。在下面的例子中,调试器调用了函数malloc来分配一个8字节的内存块,并打印出返回的内存块地址:

     (gdb) print /x malloc(8)
          $1 = 0x501010

如果被调用的函数有副作用,那么它可能在不易察觉的情况下改变程序的行为。例如,下面的条件断点将启用临时跟踪和日志记录,每次变量sum的值发生改变时,GDB命令都会调用Logme函数:

     (gdb)watch sum if Logme(sum) > 0

1.4.3 符号匹配的自动化

希望前面的讨论已经使读者相信调试符号需要准确匹配才能使调试器真正有效。缺少正确的符号,调试器要么拒绝执行用户的命令,要么更糟糕,它可能给出错误的数据从而误导我们走入歧途。从理论上讲,找到包含匹配符号的文件并不复杂,但如果一个产品包含许多模块,并且有许多要支持的版本、服务包、热修复和补丁,要手动找到正确的调试符号文件可能会变得烦琐且易于出错。在这种情况下,自动化查找正确的调试符号文件就显得更为必要。

Windows符号服务器就是一个可以实现这种自动化功能的工具。这个工具的基本原理很简单。首先,将调试符号文件上传到名称为“符号存储”的服务器。这些文件会按照时间戳、校验和、文件大小等参数进行排序和索引。每个文件都有多个版本,并且有不同的索引,以便进行快速查找。创建符号存储后,用户可以设置调试器的符号搜索路径,使其包含符号存储服务器。调试器将会自动通过符号服务器获取正确版本的符号文件。符号存储可以通过公司内部的LAN或全球互联网进行访问。例如,下面的符号搜索路径指向Windows的所有系统DLL的在线符号服务器:

     SRV*D:\Public\WinSymbol*http://msdl.microsoft.com/download/symbols

· 第一个星号后的路径指向一个已下载文件的本地缓存,这将加速已下载符号文件的搜索速度。

· 第二个星号之后的URL指向微软的公共下载网址。

有了符号服务器的帮助,开发人员就无须手动寻找正确的符号文件了。在Linux或UNIX上,长期以来都没有类似的统一工具,直到最近几年,出现了如elfutils debuginfod这样的工具,才实现了类似的功能。推荐读者阅读官方文档 来更好地设置它。

如果由于系统要求无法使用debuginfod,可以利用其基本原理编写脚本来自动化此过程。例如,当各种版本的二进制文件安装在文件服务器的某个位置时,脚本可以创建一个临时文件夹,找到具有匹配调试符号的正确二进制文件,然后在这些临时文件夹中创建对应的软链接。调试器GDB可以设置将原始二进制文件的搜索路径映射到新的临时文件夹,从而获取匹配的符号。

1.4.4 后期分析

调试器可以跟踪正在运行的进程,也可以对由系统例程在进程崩溃时生成的核心转储文件(Core Dump)进行调试。系统还为应用程序或工具软件提供了API,可以在不终止目标进程的情况下生成进程的核心转储文件。这对于调查不间断运行的服务器程序的性能,或者对难以访问的远程程序的线下分析非常有用。

核心转储文件基本上是进程内存映像在某一时刻的快照。调试器可以将它视为一个正在运行的进程,例如,我们可以检查内存内容、列出线程的调用栈、打印变量等。然而,需要明白的是,核心转储文件只是一个静态磁盘文件,它与活动进程有本质上的区别,因为在宿主机的内核中并没有相应的进程运行的上下文。

这样的进程不能被调度到任何CPU上运行,用户也无法在核心转储文件中执行任何代码。进程的状态只能被查看,无法被改变。这就意味着我们无法调用函数,也无法打印需要调用函数的表达式,例如类的运算符函数。一个常见的让人感到困惑的例子是,调试器拒绝打印像vec[2]这样的简单表达式,其中vec是一个STL向量。这是因为调试器需要调用std::vector的operator[]方法来计算表达式。出于同样的原因,我们也不能在后期分析中设置断点或单步执行代码。

核心转储文件有一个标志位表明它是为什么生成的,这也是我们最先想知道的事情(将在第6章讨论更多核心转储文件的结构体的细节)。一些常见的生成核心转储文件的原因如下:

(1)段错误。内存访问越界或者数据内存保护陷入。它表示程序正在试图访问未分配给进程的地址空间或者被保护免于特定的操作(读、写或者是运行)的内存。正在运行的指令试图从这个地址读取或者向这个地址写入,因而被硬件异常捕获。例如,悬空指针指向已经释放的内存块和野指针指向随机地址,使用其中任何一个访问内存都可能导致段错误。

(2)总线错误。这个错误通常由访问未对齐的数据导致。例如,从奇数地址的内存读取整数。一些体系结构允许这样的行为,但可能带来潜在的性能消耗(x86),而其他体系结构(SPARC)则会以总线错误异常让程序崩溃。

(3)非法地址。当程序下一个运行指令不属于CPU指令集时,会抛出此异常。例如,一个函数指针持有一个不正确的地址,该地址落入堆段而不是文本段。

(4)未处理的异常。当C++程序抛出异常且没有代码来捕获它时,就会发生此错误。C++运行库有一个默认的处理函数来捕获异常,它的作用是生成一个核心转储文件然后终止程序。

(5)浮点数异常。除数为0、太大或者太小的浮点数都可能会导致这个错误。

(6)栈溢出。当程序没有足够的栈空间来存储函数调用和本地变量时,就会发生栈溢出。这种情况通常发生在函数被递归调用次数过多,或者函数使用了过多的本地变量的情况下。

后期分析中的一种常见问题是核心转储文件不完整或被截断,这种情况下我们只能看到调试对象的部分内存图像。这通常会阻止我们得出崩溃原因的结论,因为一些重要的数据对象是不可访问的。例如,调试器可能无法显示堆上的相关数据对象或线程的调用栈,因为与它们相关的内存没有保存在核心转储文件中。

完整的核心转储文件的大小与运行的进程的内存大小大致相同,其中不包含本地磁盘备份的加载文件,比如可执行的二进制文件。核心转储文件被截断的原因有很多,例如,系统默认设置为仅允许部分核心转储;系统管理员可能将最大核心转储文件大小设置为较低的值,以避免磁盘使用过度;核心转储设备上剩下的磁盘空间不足,或用户的磁盘配额超过了限制。如果上面的任何一个条件没有被满足,系统就会选择将一部分内存镜像保存起来而丢弃剩下的部分。

由于核心转储文件不包含任何二进制代码,而只记录每个可执行文件或库的名称、大小、路径、加载地址和其他信息,因此当我们在另外一台机器上用调试器加载和分析核心转储文件时,这些二进制文件可能缺失或者安装在不同的路径。用户需要设置正确的二进制文件路径并告知调试器。如果根据用户提供的搜索路径找到了不匹配的二进制文件,则调试器往往会打印出警告信息但是不会停止,结果可能导致错误。这跟前面讨论的符号匹配是一样的。

1.4.5 内存保护

在一些平台上,如HP-UX,用户可能无法在已加载的共享库中设置断点。这是因为共享库默认被加载在仅可读的公共内存段中,如果它允许被某个进程覆写,必然会影响到与之共享的别的进程。因此调试器无法在代码段插入断点的陷入指令。有多种方法可以改变这个默认行为,使用户可以修改共享库加载的模式。

下面的HP-UX命令在输入的模块中设置标志,让系统运行时将模块加载到私有的可写段中:

     chatr +dbg enable <modules>

系统加载器还会读取以下环境变量,并将所有模块加载到私有的可写段中:

     setenv _HP_DLDOPTS –text_private

也可以将特定模块加载到私有的可写段中:

     setenv _HP_DLDOPTS –text_private=libfoo.sl;libbar.sl

1.4.6 断点不工作

如果程序没有在预设的断点处停下,那么可以根据以下内容逐一排查,确保断点被正确设置。

(1)调试器读到的源代码与调试符号不匹配。常见原因是源代码文件在编译生成二进制以后又有新的修改。调试符号包含的源代码文件路径是在二进制构建时的,它并不包含源代码的实际内容。除非用户指定另外的源代码搜索路径,调试器会从调试符号里面的路径来加载源代码文件。如果源码文件的时间戳比二进制创建时间戳更新,调试器会发出一个警告信息。如果忽略这个警告,那么调试器看到的源代码行将不会与调试符号中的源代码行匹配,这并不罕见,因为警告消息可能被淹没在大量的其他信息中。当调试器被要求在某个特定行设置断点时,它实际上可能会把陷入指令插入别的行。

(2)如果断点要设置在一个共享库中,则在库被映射到目标进程的地址空间之前,调试器不能够插入陷入指令。如果希望调试库的初始化代码,这是很困难的,因为当我们有机会设置断点的时候,通常有点晚了。例如,调用函数dlopen或者LoadLibrary可以动态加载库,但是当函数返回时,库的初始代码已经执行完成了。幸运的是,像GDB这样的调试器可以将断点设置推迟到库加载到进程中时。当库文件被加载到目标进程里但是在执行任何代码之前,内核将向调试器发送一个事件。这使调试器有机会检查其延迟断点并正确设置它们。Windows Visual Studio支持在“项目设置”对话框的“调试”选项卡上添加其他DLL,允许用户在要加载的DLL中设置断点。

(3)如果启用了优化,编译器可能会打乱源代码的执行顺序。因此,调试器可能无法完全按照用户的意愿在源代码的某行设置断点。这种情况下,最好在函数入口或指令级别处设置断点,以便可靠地触发断点(有关更多详细信息,可参见第5章)。 7KZ7fgph9nrQHeJcTbvTR9X9Jk0OxuNFuOkNbGP1qMLfzqOLoDymdZSNYp1PaK0/

点击中间区域
呼出菜单
上一章
目录
下一章
×

打开