调试器工作原理:第三部分 调试信息
本文是英文博客 How debuggers work: Part 3 - Debugging information 的中文翻译,这是关于调试器如何工作的系列文章的第三部分。请务必在此之前阅读第一部分和第二部分。
本文将解释调试器如何定位C函数和变量的位置,并确定C源码翻译后的机器码的信息。
调试信息
现代编译器可以很好地将结构清晰紧的高级代码转换成机器码,其唯一目的是提高CPU运行代码的速度。大多数C代码行转换为多个机器代码指令。变量随意乱放 —— 放入堆栈,放入寄存器或完全优化掉。机器码中不存在结构和对象 —— 它们只是一种抽象,在硬编码中转化为内存缓冲区的偏移量。
那么当你要求它在某个函数的入口处中断时,调试器如何知道对应地址?当你向它询问变量的值时,它如何找到具体的内容?答案是 —— 调试信息。
调试信息由编译器与机器码一起生成,它表示可执行程序和源代码之间的关系。这些信息被编码为预定义的格式并与机器码一起存储。多年来,针对不同平台和可执行文件发明了许多这样的格式。由于本文的目的不是调查这些格式的历史,而是为了展示它们的工作原理,我们必须聚焦某些方面——这就是DWARF
。它几乎无处不在,如今作为Linux
和其他类Unix
平台上ELF
可执行文件的调试信息格式。
The DWARF in the ELF
根据其维基百科,DWARF
与ELF
一起设计,虽然它理论上也可以嵌入其他目标文件格式。
基于对其他操作系统文件格式的多年研究经验,人们设计了DWARF
。它必须很复杂,因为它解决了一个很棘手的问题 —— 如何从任意的高级语言中提取调试信息,提供给调试器,并支持任意平台和ABI。要解释清楚这个问题,一篇文章是远远不够的。本文将采用动手的方法,通过展示DWARF
来解释调试信息如何在实践中被使用。
ELF 文件中的 debug section
首先让我们看一下DWARF
信息在ELF
文件中的位置。ELF
定义了目标文件中各种可能的Section
。Section 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
—— 这些是DWARF
的Section
:
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_subprogram
的DIE
,分别是do_stuff
和main
。现在,我们感兴趣的是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_stuff
的DIE
,这次也看一下它的几个子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_stuff
的DW_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
构架,bpreg4
指esp
,bpreg5
指ebp
。
再次查看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_local
的DW_AT_location
是DW_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
更糟糕。 DWARF
比HTML
复杂得多。我在这里展示的只是冰山一角,雪上加霜的是,大部分信息都是在实际的目标文件中以非常紧凑的方式压缩编码的。
因此,我们将使用库来处理DWARF
。我知道有两个主要的库:
BFD(libbfd)
——它被GNU binutils
选中,包括在本文中扮演重要角色的objdump
,ld
(GNU链接器)和as
(GNU汇编器)。libdwarf
—— 和libelf
一起用于开发了Solaris
和FreeBSD
的许多工具。
我选择libdwarf
而不是BFD
,因为它看起来不那么神秘,而且它的授权更为宽松(LGPL vs GPL)。
由于libdwarf
本身非常复杂,因此需要大量代码才能运行。我不会在这里显示所有这些代码,但你可以下载并运行它。要编译此文件,你需要安装libelf
和libdwarf
,并将-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
创建一个具有基本功能的调试器。