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

1.1 调试符号

调试符号由编译器生成,与相关的机器代码、全局数据对象等一同产生。链接器会收集并组织这些符号,将它们写入可执行文件的调试部分(在大多数UNIX平台上),或存储到一个单独的文件中(如Windows程序的数据库或pdb文件)。源代码级别的调试器需要从存储库中读取这些调试符号,以便理解进程的内存映像,即程序的运行实例。

在其众多特性中,调试符号可以将进程的指令与对应的源代码行数或表达式进行关联,或者从源程序声明的结构化数据对象的角度对一块内存进行描述。通过这些映射,调试器可以在源代码层面上执行用户命令来查询和操作进程。例如,特定源代码行上的断点会被转换为指令地址;一块内存会被标记为源代码语言上下文中的变量,并按照其声明类型进行格式化。简单来说,调试符号在高级源程序和程序运行实例的原始内存内容之间架起了一座桥梁。

1.1.1 调试符号概览

源代码级别调试是对编程至关重要的一个环节,为了实现这个目标,编译器需要生成富含各类信息的调试符号。按照调试符号所描述的主题进行分类,主要有以下几类:

· 全局函数和变量:这类调试符号涵盖了跨越编译单元的可见的全局符号的类型和位置信息。全局变量的地址相对于其所属模块的基地址是固定的。在全局变量所属模块被卸载之前,比如程序结束运行或者通过链接器API显式地卸载,这些全局变量都是有效且可以访问的。全局变量因其可见性、固定位置和长生命周期,在任何时间和地点都可以进行调试。这使得调试器能够在全局变量的整个生命周期内,无论程序执行的分支为何,都可以观察数据、修改数据和设置断点。

· 源文件和行信息:调试器的一项主要功能是支持源代码级别的调试,这样程序员就可以在程序的源语言上下文中跟踪(Trace)和观察被调试的程序。这一功能依赖于一种将指令序列映射到源文件和行数的调试符号。因为函数是占据进程连续内存空间的最小可执行代码单元,所以源文件和行号的调试符号会记录每个函数的开始和结束地址。然而,为了优化程序性能或减少生成的机器码大小,编译器可能会对源代码进行移位,情况可能变得很复杂。由于宏和内联函数的存在,源代码的行信息可能与实际执行的指令地址并不连续或者交织在其他源代码行中。

· 类型信息:类型的调试符号描述了数据类型的组合关系和属性,包括基本类型的数据,或其他数据的聚合。对于复合类型,调试符号包含每个子字段的名字、大小和相对于整个结构开头的偏移量。调试器需要这些类型信息,以便以程序源语言的形式显示它;否则,数据只会是内存内容的原始形态,即比特位和字节。类型调试符号对于C++这样的复杂语言特别重要,因为编译器会将隐藏的数据成员添加到数据对象中以实现语言的语义,而这些隐藏的数据成员依赖于编译器的实现。此外类型信息还包括了函数签名和它们的链接属性。

· 静态函数和局部变量:与全局符号不同,静态函数和局部变量只在特定的作用域内可见,例如一个文件、一个函数,或者一个特定的块作用域。局部变量在其作用域内存在和有效,因此是临时的。当程序的执行流程离开其作用域时,作用域内的局部变量将被销毁并在语义上变得无效。因为局部变量通常在栈上分配或与容易失效的寄存器关联,所以在程序运行到其作用域之前,它的存储位置是不确定的。因此,调试器只能在特定的作用域内对局部变量进行观察、修改和设置断点。

· 架构和编译器依赖信息:某些调试功能与特定的架构和编译器有关,例如英特尔芯片的FPO(Frame Pointer Omission,帧指针省略),以及微软Visual Studio的修改和运行功能等。

调试符号的生成是一项复杂的任务,它需要编译器传递大量的调试信息给调试器。因此,即使是相对较小的程序,编译器也会生成大量的调试符号,甚至大大超过了生成的机器代码或者源代码的大小。为了节约空间,人们通常会对调试符号进行编码。

遗憾的是没有一个标准可以规定如何实现调试符号。不同的编译器厂商历来在不同的平台上采用不同的调试符号格式,如Linux、Solaris和HP-UX现在使用的是DWARF(Debugging With Attributed Record Formats),AIX和老版本的Solaris使用的是stabs(Symbol Table String),而Windows有多种格式,其中最常用的是程序数据库,即pdb。这些调试符号格式的文档往往难以找到或者即使有也不完整。另一方面,随着编译器新版本的不断发布,与其紧密相关的调试符号格式也必须持续演进。

由于以上原因,调试符号格式多少变成了编译器和调试器之间的“秘密”协议,通常与特定平台紧密相关。在开源社区的努力下,DWARF在公开透明方面做得较好。因此,在下一节中,将以DWARF作为调试符号实现的示例进行深入讨论。

1.1.2 DWARF格式

DWARF以树形结构(即树结构)组织调试符号,这种方式类似于我们在大部分编程语言中看到的结构体和词法作用域。每一个树节点都是一个调试信息记录(Debug Information Entry,DIE),用于表达具体的调试符号,例如对象、函数、源文件等。一个节点可能有任意数量的子节点或同级节点。例如,一个代表函数的DIE可能有许多子DIE,这些子DIE代表函数中的局部变量。

本书不会详尽地阐述每一条DWARF格式的规定。如需了解更多,可以在DWARF的官网上找到关于DWARF的各类论文、教程和正式文档。另一种有效的学习方法是深入研究采用DWARF格式的开源编译器(如GCC)和调试器(如GDB)。从调试的角度来看,我们需要知道有哪些调试符号,它们是如何组织的,以及在需要或者感兴趣的时候如何查看它们。最好的理解方法可能就是通过实例,下面让我们来看一个简单的程序:

可以使用下面的命令选项来编译这个文件:

     $ g++ -g –S foo.cpp

其中-g选项告诉g++编译器生成调试符号,-S选项用于生成供分析的汇编文件。编译器会生成汇编文件作为中间文件,并直接通过管道将其发送到汇编器。因此,如果需要审查汇编,就需要显式地让编译器在磁盘上生成汇编文件。

我们可以使用上面的命令自行生成汇编文件,这个过程很简单。虽然这个文件可能有些长,但笔者还是鼓励读者从头到尾地浏览一遍,这样将对调试符号的各个部分有一个全面的了解。下面是汇编文件的一个片段。由于这个文件是作为汇编器的输入而并非供人阅读的,因此初看可能会觉得有些困惑。但在介绍调试符号的每一个组件的过程中,笔者会对它们的含义进行详细解释。

可以看出,汇编文件中的大部分内容是为了生成调试符号,只有一小部分是可执行指令。对于简短的程序来说,这是非常典型的情况。出于对文件大小的考虑,调试符号通常会被编码到二进制文件中以减小文件的大小。我们可以使用以下工具来解码它,查看调试符号:

     $readelf –-debug-dump foo.o

这个命令会输出目标文件foo.o中的所有调试符号。这些调试符号被划分为多个节(英文对应的单词是section,中文翻译有时是“段”,请读者注意区分section与segment,本书后面会讲解)。每个节代表一种类型的调试符号,并储存在ELF目标文件的特定区域内(在第6章会更深入地讨论二进制文件和ELF的详细内容)。下面让我们逐一查看这些节。

(1)首先要关注的是储存在.debug_abbrev节的缩略表。这个表描述了一种用于减小DWARF文件大小的编码算法。缩略表中的DIE并非真正的DIE。相反,它们作为在其他节中具有相同类型和属性的实际DIE的模板。一个真实的DIE项只包含一个到缩略表模板DIE的引用和用于实例化这个模板DIE的数据。在上述示例中,缩略表包含7项,包括编译单元、全局变量、数据类型、输入参数、局部变量等的模板。表中的第3项(显示为加粗字体)声明了一种具有5个部分调试信息的DIE:名称、文件、行号、数据类型和位置。真实的DIE引用这个模板的,示例代码如下:

(2)下一节(即.debug_info节)包含了调试符号的核心内容,包括数据类型的信息、变量、函数等。需要注意的是,调试信息项(DIEs)是如何编码并通过索引来引用缩略表里的特定项的。

以下是一个例子,首先用粗体字标示的DIE来描述一个名为GlobalFunc的函数的唯一传入参数,这个DIE在缩略表中的索引是3。接着,使用实际的信息来填充该DIE的5个字段:

· 参数的名称是“i”。

· 它出现在第1个文件中。

· 它位于该文件的第5行。

· 参数的类型由另一个DIE(引用为ba)来描述。

· 参数在内存中的位置有一个偏移量,为2。

利用这种编码方式,我们成功地用目标文件中的13字节来表示了参数“i”的调试符号。接下来,可以使用objdump命令来查看.debug_info节中的原始数据:

现在,如果回到汇编文件foo.s,就可以在其中找到传入参数i的调试符号。这些调试符号的相关行会在以下部分高亮显示。在之前列出的汇编文件中,可以轻易地找到它们。

上述DIE项在结构上类似于C语言的结构体。它们编码后的字节含义如图1-1所示。

图1-1 DIE的编码

· 首先,从缩略表的索引(3)开始,这指示了DIE的剩余数据应该如何格式化。我们可以回顾前面列出的缩略表,参考第3个DIE模板来理解。

· 接下来的2字节代表了一个以null结尾的字符串,即我们的参数名称“i”。

· 之后,是文件编号(1)和行号(5)。参数的类型由另外一个DIE(索引为ba)来定义。

· 接着的数据是参数的大小,具体来说,就是2字节。

· 参数的存储位置由接下来的2字节指示,这对应于相对于寄存器fbreg的偏移量-20。

· 最后,这个DIE以一个0字节结束,标志着这一段信息的结束。

每个DIE都会指定其父节点、子节点和兄弟节点(如果存在的话)。图1-2展示了.debug_info节中列出的DIEs之间的父子关系和兄弟关系。注意,参数i的DIE是函数GlobalFunc DIE的子节点,这与源程序中的作用域设置完全一致。

图1-2 树结构的DIEs的关系

(3)源代码的行号调试符号被放置在.debug_line节中,它由一系列操作码构成。调试器可以执行这些操作码以构建一张状态表。这张表的关键在于将指令地址映射到源代码行号。每个状态都包括一个指令地址(以函数开头的偏移量表示)、相应的源代码行号和文件名。

你可能会问,如何通过操作码创建状态表呢?这始于设置初始值的操作码,如初始指令地址。每当源代码行号发生变化时,操作码将操作地址移动一个变化量。调试器会执行这些操作码,并在每次状态发生变化时向状态表添加一行。

以下是readelf输出,显示了样例程序的行号调试符号。注意,高亮的行(在下面的程序中以粗体字标示出来)以可读的方式描述了操作码的操作。指令地址从0x0开始,结束于0x12,对应的源代码行号从4逐渐增加到7。

(4)Call Frame Information(CFI)存储在.debug_frame节中,描述了函数的栈帧和寄存器的分配方式。调试器使用此信息来回滚(unwind)栈。例如,如果一个函数的局部变量被分配在一个寄存器中,该寄存器稍后被一个被调用的函数占用,其原始值会保存在被调用函数的栈帧中。调试器需要依赖CFI来确定保存寄存器的栈地址,从而观察或改变相应的局部变量。

类似于源代码行号,CFI也被编码为一系列的操作码。调试器按照给定的顺序执行这些操作码,以创建一张跟随指令地址前进的寄存器状态表。根据这张状态表,调试器可以确定栈帧的地址位置(通常由栈帧寄存器指向),以及当前函数的返回值和函数参数的位置。下面示例是CFI调试符号,它展示了简单函数glbalFunc的r6寄存器的信息。

(5)除了上述提到的部分,还有一些其他的节包含各种类型的调试信息:

· .debug_loc节包含宏表达式的调试符号,但是本例中并没有宏。

· .debug_pubnames节是全局变量和函数的查找表,用于更快地访问这些调试项。在本例子中有两个项:全局变量gInt和全局函数GlobalFunc。

· .debug_aranges节包含一系列的地址长度对,用于说明每个编译单元的地址范围。

所有这些节为调试器提供了实现各种调试功能所需的足够信息,例如将当前的程序指令地址映射到对应的源代码行,或者计算局部变量的地址并根据其类型打印结构数值。

调试符号首先在每个编译单元中生成,就如我们在目标文件示例中看到的那样。在链接时,多个编译单元的调试符号被收集、组合,并链接到可执行文件或库文件中。

在继续探讨调试器的实现之前,笔者想先分享一个通过类型的调试符号发现bug的故事。这个故事演示了看似不一致的调试符号,尤其是那些分布在不同模块中的调试符号,如何为我们揭示代码或构建过程中的问题。 p05HZiAGTX6Ly43AirKnN3RRrnfRRyVxqKu4kj0OfHbsMtyqDelatVxbVFAA+Rp2

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