本文是英文博客 How debuggers work: Part 3 - Debugging information 的中文翻译,这是关于调试器如何工作的系列文章的第三部分。请务必在此之前阅读第一部分和第二部分。

本文将解释调试器如何定位C函数和变量的位置,并确定C源码翻译后的机器码的信息。

调试信息

现代编译器可以很好地将结构清晰紧的高级代码转换成机器码,其唯一目的是提高CPU运行代码的速度。大多数C代码行转换为多个机器代码指令。变量随意乱放 —— 放入堆栈,放入寄存器或完全优化掉。机器码中不存在结构和对象 —— 它们只是一种抽象,在硬编码中转化为内存缓冲区的偏移量。

那么当你要求它在某个函数的入口处中断时,调试器如何知道对应地址?当你向它询问变量的值时,它如何找到具体的内容?答案是 —— 调试信息。

调试信息由编译器与机器码一起生成,它表示可执行程序和源代码之间的关系。这些信息被编码为预定义的格式并与机器码一起存储。多年来,针对不同平台和可执行文件发明了许多这样的格式。由于本文的目的不是调查这些格式的历史,而是为了展示它们的工作原理,我们必须聚焦某些方面——这就是DWARF。它几乎无处不在,如今作为Linux和其他类Unix平台上ELF可执行文件的调试信息格式。

The DWARF in the ELF

根据其维基百科,DWARFELF一起设计,虽然它理论上也可以嵌入其他目标文件格式。

基于对其他操作系统文件格式的多年研究经验,人们设计了DWARF。它必须很复杂,因为它解决了一个很棘手的问题 —— 如何从任意的高级语言中提取调试信息,提供给调试器,并支持任意平台和ABI。要解释清楚这个问题,一篇文章是远远不够的。本文将采用动手的方法,通过展示DWARF来解释调试信息如何在实践中被使用。

ELF 文件中的 debug section

首先让我们看一下DWARF信息在ELF文件中的位置。ELF定义了目标文件中各种可能的SectionSection Header Table保存了所有Section的描述信息,通过Section Header Table可以找到每个Section在文件中的位置。不同的工具以特殊方式处理不同section

作为实验,我们把以下代码编译为tracedprog2

#include <stdio.h>


void do_stuff(int my_arg)
{
    int my_local = my_arg + 2;
    int i;

    for (i = 0; i < my_local; ++i)
        printf("i = %d\n", i);
}


int main()
{
    do_stuff(2);
    return 0;
}

使用objdump -h打印ELF文件的header,注意以.debug_开头的 Section —— 这些是DWARFSection

26 .debug_aranges 00000020  00000000  00000000  00001037
                 CONTENTS, READONLY, DEBUGGING
27 .debug_pubnames 00000028  00000000  00000000  00001057
                 CONTENTS, READONLY, DEBUGGING
28 .debug_info   000000cc  00000000  00000000  0000107f
                 CONTENTS, READONLY, DEBUGGING
29 .debug_abbrev 0000008a  00000000  00000000  0000114b
                 CONTENTS, READONLY, DEBUGGING
30 .debug_line   0000006b  00000000  00000000  000011d5
                 CONTENTS, READONLY, DEBUGGING
31 .debug_frame  00000044  00000000  00000000  00001240
                 CONTENTS, READONLY, DEBUGGING
32 .debug_str    000000ae  00000000  00000000  00001284
                 CONTENTS, READONLY, DEBUGGING
33 .debug_loc    00000058  00000000  00000000  00001332
                 CONTENTS, READONLY, DEBUGGING

这里每个Section的第一个数字是它的大小,最后一个数字是它在ELF文件中开始的偏移量。调试器使用此信息从可执行文件中读取section

现在,让我们看看从DWARF查找有用调试信息的例子。

定位函数

我们在调试时最常见的行为是在一些函数上放置断点,让调试器在入口处暂停。为了能够执行此类任务,调试器必须在了解高级代码中的函数名称和机器码中调用函数的指令的地址之间的映射关系。

这些信息通过DWARF 中的 .debug_info section获取。在此之前,我们需要了解一些背景知识。 DWARF中的基本描述性实体称为 Debugging Information Entry (DIE)。每个DIE都有自己的标签 —— 它的类型和一组属性。 DIE通过兄弟链接和子链接相互链接,属性值可以指向其他DIE

让我们运行命令:

objdump --dwarf=info tracedprog2

输出结果很长,在这个例子中我们只关注以下行:

<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
    <72>   DW_AT_external    : 1
    <73>   DW_AT_name        : (...): do_stuff
    <77>   DW_AT_decl_file   : 1
    <78>   DW_AT_decl_line   : 4
    <79>   DW_AT_prototyped  : 1
    <7a>   DW_AT_low_pc      : 0x8048604
    <7e>   DW_AT_high_pc     : 0x804863e
    <82>   DW_AT_frame_base  : 0x0      (location list)
    <86>   DW_AT_sibling     : <0xb3>

<1><b3>: Abbrev Number: 9 (DW_TAG_subprogram)
    <b4>   DW_AT_external    : 1
    <b5>   DW_AT_name        : (...): main
    <b9>   DW_AT_decl_file   : 1
    <ba>   DW_AT_decl_line   : 14
    <bb>   DW_AT_type        : <0x4b>
    <bf>   DW_AT_low_pc      : 0x804863e
    <c3>   DW_AT_high_pc     : 0x804865a
    <c7>   DW_AT_frame_base  : 0x2c     (location list)

注意到有两条标记为DW_TAG_subprogramDIE,分别是do_stuffmain。现在,我们感兴趣的是DW_AT_low_pc。这是函数开头的程序计数器(x86中的EIP)值。请注意,do_stuff为0x8048604。现在让我们通过运行objdump -d,来看看这个地址在可执行文件的反汇编中是什么:

08048604 <do_stuff>:
 8048604:       55           push   ebp
 8048605:       89 e5        mov    ebp,esp
 8048607:       83 ec 28     sub    esp,0x28
 804860a:       8b 45 08     mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02     add    eax,0x2
 8048610:       89 45 f4     mov    DWORD PTR [ebp-0xc],eax
 8048613:       c7 45 (...)  mov    DWORD PTR [ebp-0x10],0x0
 804861a:       eb 18        jmp    8048634 <do_stuff+0x30>
 804861c:       b8 20 (...)  mov    eax,0x8048720
 8048621:       8b 55 f0     mov    edx,DWORD PTR [ebp-0x10]
 8048624:       89 54 24 04  mov    DWORD PTR [esp+0x4],edx
 8048628:       89 04 24     mov    DWORD PTR [esp],eax
 804862b:       e8 04 (...)  call   8048534 <printf@plt>
 8048630:       83 45 f0 01  add    DWORD PTR [ebp-0x10],0x1
 8048634:       8b 45 f0     mov    eax,DWORD PTR [ebp-0x10]
 8048637:       3b 45 f4     cmp    eax,DWORD PTR [ebp-0xc]
 804863a:       7c e0        jl     804861c <do_stuff+0x18>
 804863c:       c9           leave
 804863d:       c3           ret

实际上,do_stuff的起始地址就是0x8048604,调试器通过这一信息可以实现函数和可执行文件中地址的映射。

定位变量

假设我们确实在do_stuff内的断点处停了下来。我们想让调试器向我们展示变量my_local的值。调试器如何定位变量的位置?事实证明这比查找函数要复杂得多。变量可以位于全局存储,堆栈中,甚至寄存器中。此外,具有相同名称的变量在不同的词法范围中可以具有不同的值。调试信息必须能够反映所有这些变化,而DWARF确实做到了这一点。

我不会讲解所有可能性,但作为一个例子,我将演示调试器如何在do_stuff中找到my_local。让我们从.debug_info开始,再次查看do_stuffDIE,这次也看一下它的几个子DIE

<1><71>: Abbrev Number: 5 (DW_TAG_subprogram)
    <72>   DW_AT_external    : 1
    <73>   DW_AT_name        : (...): do_stuff
    <77>   DW_AT_decl_file   : 1
    <78>   DW_AT_decl_line   : 4
    <79>   DW_AT_prototyped  : 1
    <7a>   DW_AT_low_pc      : 0x8048604
    <7e>   DW_AT_high_pc     : 0x804863e
    <82>   DW_AT_frame_base  : 0x0      (location list)
    <86>   DW_AT_sibling     : <0xb3>
 <2><8a>: Abbrev Number: 6 (DW_TAG_formal_parameter)
    <8b>   DW_AT_name        : (...): my_arg
    <8f>   DW_AT_decl_file   : 1
    <90>   DW_AT_decl_line   : 4
    <91>   DW_AT_type        : <0x4b>
    <95>   DW_AT_location    : (...)       (DW_OP_fbreg: 0)
 <2><98>: Abbrev Number: 7 (DW_TAG_variable)
    <99>   DW_AT_name        : (...): my_local
    <9d>   DW_AT_decl_file   : 1
    <9e>   DW_AT_decl_line   : 6
    <9f>   DW_AT_type        : <0x4b>
    <a3>   DW_AT_location    : (...)      (DW_OP_fbreg: -20)
<2><a6>: Abbrev Number: 8 (DW_TAG_variable)
    <a7>   DW_AT_name        : i
    <a9>   DW_AT_decl_file   : 1
    <aa>   DW_AT_decl_line   : 7
    <ab>   DW_AT_type        : <0x4b>
    <af>   DW_AT_location    : (...)      (DW_OP_fbreg: -24)

请注意每个DIE中尖括号内的第一个数字,这是嵌套层级。 在此示例中,带有<2>DIE是具有<1>DIE的子级。所以我们知道变量my_local(标签为DW_TAG_variable)是do_stuff函数的子级。调试器对变量类型也感兴趣,以便能够正确显示它。my_local的类型指向另一个DIE —— <0x4b>。如果我们查看objdump的输出,我们将看到它是一个带符号的4字节整数。

为了在内存中定位变量的位置,调试器将查看DW_AT_location属性。对于my_local而言,DW_OP_fbreg:-20。这意味着变量的基址为DW_AT_frame_base属性,偏移量为-20。

do_stuffDW_AT_frame_base属性具有值0x0(位置列表),这意味着实际上必须在location list section中查找该值。具体如下:

$ objdump --dwarf=loc tracedprog2

tracedprog2:     file format elf32-i386

Contents of the .debug_loc section:

    Offset   Begin    End      Expression
    00000000 08048604 08048605 (DW_OP_breg4: 4 )
    00000000 08048605 08048607 (DW_OP_breg4: 8 )
    00000000 08048607 0804863e (DW_OP_breg5: 8 )
    00000000 <End of list>
    0000002c 0804863e 0804863f (DW_OP_breg4: 4 )
    0000002c 0804863f 08048641 (DW_OP_breg4: 8 )
    0000002c 08048641 0804865a (DW_OP_breg5: 8 )
    0000002c <End of list>

我们对第一个位置信息感兴趣。它指定了当前栈底地址,变量相对于栈底地址的偏移量被当做相对寄存器的偏移量。对于x86构架,bpreg4espbpreg5ebp

再次查看do_stuff的前几条指令:

08048604 <do_stuff>:
 8048604:       55          push   ebp
 8048605:       89 e5       mov    ebp,esp
 8048607:       83 ec 28    sub    esp,0x28
 804860a:       8b 45 08    mov    eax,DWORD PTR [ebp+0x8]
 804860d:       83 c0 02    add    eax,0x2
 8048610:       89 45 f4    mov    DWORD PTR [ebp-0xc],eax

注意,在第二条指令被执行后,ebp才有意义,实际上对于前两个地址,基地址是根据位置信息中的esp计算的。一旦ebp有效,就可以方便地计算相对于它的偏移量,因为它保持不变,而esp随着数据的入栈和出栈不断变化。

那么哪里能找到my_local?只有在0x8048610(数值在寄存器eax中计算后被放入内存)之后,我们才对它的值感兴趣,因此调试器使用DW_OP_breg5:8栈底地址来查找它。现在回忆一下my_localDW_AT_locationDW_OP_fbreg:-20。接下来是数学运算:相对栈底偏移-20,而栈底地址是ebp+8我们得到地址ebp-12。现在再看反汇编代码并注意数据从哪里移动到eax—— 实际上,ebp-12正是my_local的存储地址。

查找行号

当我们谈到在调试信息中定位函数时,我偷懒了。当我们调试C源代码并在函数中放置断点时,我们通常对第一个机器码指令不感兴趣。我们真正感兴趣的是该函数的第一行C代码。

这就是为什么DWARF保存C源代码中的行与可执行文件中的机器代码地址之间的完整映射。此信息包含在.debug_line部分中,可以以可读形式提取,如下所示:

$ objdump --dwarf=decodedline tracedprog2

tracedprog2:     file format elf32-i386

Decoded dump of debug contents of section .debug_line:

CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name           Line number    Starting address
tracedprog2.c                5           0x8048604
tracedprog2.c                6           0x804860a
tracedprog2.c                9           0x8048613
tracedprog2.c               10           0x804861c
tracedprog2.c                9           0x8048630
tracedprog2.c               11           0x804863c
tracedprog2.c               15           0x804863e
tracedprog2.c               16           0x8048647
tracedprog2.c               17           0x8048653
tracedprog2.c               18           0x8048658

不难根据这些信息,看出C源代码和反汇编转储之间的对应关系。第5行指向do_stuff的入口—— 0x8040604。第6行断点do_stuff时,调试器应该停止的地址,它指向0x804860a,刚好超过了函数的起始地址。这些 line 信息可以轻松实现行和地址之间的双向映射:

  • 当被要求在某行放置断点时,调试器将使用它来查找它应该放置陷阱(int 3)的地址

  • 当指令导致分段错误时,调试器将使用它来查找发生错误的源代码行。

libdwarf

尽管使用命令行工具来访问DWARF信息很有用,但并不令人完全满意。作为程序员,我们想要编写程序,读取格式,并从中提取所需内容。

当然,一种方法是彻底掌握DWARF的内容,然后开始造轮子。但是,记得人们的忠告,永远不要手动解析HTML而是使用库吗?好吧,DWARF更糟糕。 DWARFHTML复杂得多。我在这里展示的只是冰山一角,雪上加霜的是,大部分信息都是在实际的目标文件中以非常紧凑的方式压缩编码的。

因此,我们将使用库来处理DWARF。我知道有两个主要的库:

  • BFD(libbfd)——它被GNU binutils选中,包括在本文中扮演重要角色的objdumpld(GNU链接器)和 as(GNU汇编器)。

  • libdwarf—— 和libelf一起用于开发了SolarisFreeBSD的许多工具。

我选择libdwarf而不是BFD,因为它看起来不那么神秘,而且它的授权更为宽松(LGPL vs GPL)。

由于libdwarf本身非常复杂,因此需要大量代码才能运行。我不会在这里显示所有这些代码,但你可以下载并运行它。要编译此文件,你需要安装libelflibdwarf,并将-lelf-ldwarf标志传递给链接器。

演示的程序需要可执行文件并打印其中的函数名称及其入口点。以下是我们在本文中使用的C程序所产生的内容:

$ dwarf_get_func_addr tracedprog2
DW_TAG_subprogram: 'do_stuff'
low pc  : 0x08048604
high pc : 0x0804863e
DW_TAG_subprogram: 'main'
low pc  : 0x0804863e
high pc : 0x0804865a

libdwarf文档很不错,您可以毫不费力地从DWARF section提取任何其他信息。

结论

调试信息原则上是一个简单的概念。实现细节可能是错综复杂的,但最重要的是,我们现在知道调试器如何找到可执行代码的相关信息。有了这些信息,调试器就可以在用户和可执行文件之间架起桥梁,用户可以从源代码和数据结构的角度进行思考,而可执行文件只是一堆机器码指令加上寄存器和内存中的数据 。

本文及其前两篇文章,介绍了调试器的内部工作原理。使用此处提供的信息和一些源代码,可以为Linux创建一个具有基本功能的调试器。