标签:
调试器如何工作:第三部分——调试信息
原作者:Eli Bendersky
http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information
这是关于调试器如何工作系列文章的第三部分。在这之前确保你读过第一、第二部分。
在这部分
我将解释调试器如何明白,在它跋涉机器代码里,在哪里找到C函数与变量,以及它用来在C源代码与机器语言内存字间进行映射的数据。
调试信息
现代编译器在翻译高级语言代码方面做得相当好。其良好缩进及嵌套的控制结构以及任意类型的变量被翻译为一大堆称为机器码的比特,主要目的是在目标CPU上运行得尽可能地快。大多数C代码行被翻译为多条机器代码指令。变量则被到处乱塞——进入栈,进入寄存器,或完全被优化掉。结构体与对象在结果代码里甚至不存在——它们只是一个抽象,被翻译为到内存缓存的写死的偏移。
因此在你要求调试器在某些函数的入口暂停时,它如何知道哪里暂停?在你向它要求一个变量值时,它如何设法确定给你显示什么?答案是——调试信息。
调试信息连同机器代码一起由编译器产生。它代表了可执行程序与源代码的联系。这个信息以一个预定义的格式编码并连同机器码一起保存。多年来为不同的平台及可执行文件发展了许多这样的格式。因为本文的目标不是调查这些格式的历史,而是展示它们如何工作,我们必须解决一些问题。这个问题将是DWARF,在今天它几乎一统在Linux及其他类Unix平台上ELF可执行文件的调试信息。
ELF中的DWARF
根据其维基网页,DWARF连同ELF一起设计,虽然在理论上它也可以嵌入到其他目标文件格式[1]。DWARF是一个复杂的格式,基于之前用于各种架构及操作系统格式的多年经验。它不得不复杂,因为它解决了一个非常棘手的问题——向调试器展示来自高级语言的调试信息,提供对任意平台及ABI的支持。这不是这篇粗陋的文章能完全解释的,并且说实话我对它角角落落的理解不足以支撑这样的尝试[2]。在本文里我将采取更多动手实践的做法,只显示足够的DWARF以解释实践中调试信息如何工作。
ELF文件里的调试节
首先看一下ELF文件中DWARF信息放在哪里。在每个目标文件里ELF可以任意定义节。节头表定义了存在哪些节以及它们的名字。不同的工具以特殊的方式处理各种节——例如链接器查找某些节,调试器查找另一些。
我们将使用从这个C源代码构建的可执行程序作为本文中的例子,它被编译为tracedprog2:
#include <stdio.h>
voiddo_stuff(int my_arg)
{
int my_local = my_arg + 2;
int i;
for (i = 0; i < my_local;++i)
printf("i = %d\n",i);
}
intmain()
{
do_stuff(2);
return0;
}
使用objdump–h转储ELF可执行文件的节头,我们会注意到有几个节名字以.debug_开始——它们是DWARF调试节:
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
这里每个节的第一个数字是其大小,最后一个是它在该ELF文件的起始偏移。调试器使用这个信息来读入可执行文件的节。
现在让我们看一下在DWARF中找出有用的调试信息的几个实际例子。
查找函数
我们想做的一个最基本的事情是,当在某个函数上放置断点调试时,期望调试器在其入口暂停执行。为了能够完成这个伟业,调试器必须在高级代码的函数名与该函数开始指令的机器码地址间持有某种映射。
这个信息可以从DWARF通过查看.debug_info节得到。在我们深入之前,需要一点背景知识。DWARF中基本的描述项称为调试信息项(DIE)。每个DIE有一个标签——它的类型,及一组属性。DIE通过兄弟、父子关系联系起来,并且属性的值可以指向其他DIE。
运行:
objdump --dwarf=info tracedprog2
输出相当长,对这个例子我们只关注这些行[3]:
<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),它是函数的DWARF术语。注意分别有一个项用于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: 8945 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
确实,0x8048604是do_stuff的开头,因此调试器是有函数与它们在可执行文件位置间的映射的。
查找变量
假定我们真的停在了do_stuff里的一个断点上。我们希望调试器给我们显式变量my_local的值。调试器怎么知道在哪里找到它?最终证明这比查找函数要棘手得多。变量可以位于全局储存区,在栈上,甚至在寄存器里。另外,同名的变量在不同的上下文里具有不同的值。调试信息必须能够反映这些变化,而DWARF确实做到了。
我不准备讨论所有的可能性,但通过一个例子我将展示调试器如何能在do_stuff里找到my_local。让我们从.debug_info开始并且再看一下do_stuff的项,这次还看一下它的几个子项:
<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)
注意每个项中尖括号内的第一个数字。这是嵌套级别——在这个例子里带有<2>的项是带有<1>的项的孩子。因此我们知道变量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),这表示应该在位置列表节查找这个值。让我们看一下:
$ objdump --dwarf=loc tracedprog2
tracedprog2: fileformat elf32-i386
Contents of the .debug_loc section:
Offset Begin End Expression
00000000 0804860408048605 (DW_OP_breg4: 4 )
00000000 0804860508048607 (DW_OP_breg4: 8 )
00000000 080486070804863e (DW_OP_breg5: 8 )
00000000 <End oflist>
0000002c 0804863e0804863f (DW_OP_breg4: 4 )
0000002c 0804863f08048641 (DW_OP_breg4: 8 )
0000002c 080486410804865a (DW_OP_breg5: 8 )
0000002c <End oflist>
我们感兴趣的位置信息是第一个[4]。对于调试器可能到达的每个地址,它指定了当前的栈框基址,它到变量的偏移被计算为从一个寄存器出发的偏移。对于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。让我们算一下:从栈框指针ebp+ 8减去20。得到ebp-12。再看一下反汇编,注意该数据确实从eax迁移过来,而ebp- 12就是my_local保存的地方。
查找行号
在我们谈论在调试信息里查找函数时,我撒了点小谎。在我们调试C源代码并在一个函数里放置断点时,我们通常对第一条机器代码指令不感兴趣[5]。我们真正感兴趣的是函数的第一行C代码。
这是为什么DWARF在可执行文件里加入了C源代码行与机器代码地址的完整映射。这个信息包含在.debug_line节,并可以如下的一个可读形式提取出来:
$ objdump --dwarf=decodedline tracedprog2
tracedprog2: fileformat elf32-i386
Decoded dump of debug contents of section .debug_line:
CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c:
File name Linenumber 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,刚刚超过函数的prologue。这个行信息很容易支持行与地址间的双向映射:
· 当要求在某行上放置一个断点时,调试器使用它找出应该在哪个地址上放置它的“陷阱”(记得前一篇文章里我们的朋友int3吗?)
· 当一条指令导致段错误时,调试器使用它找出所对应的源代码行。
Libdwarf——在编程中使用DWARF
应用命令行工具访问DWARF信息,虽然有用,但不完全令人满意。作为程序员,我们更喜欢知道如何编写可以读这个格式并从中提取我们所需的实际代码。
自然地,一个做法是抓住DWARF规范并开始淘宝。现在,记得人们总是不停地说你永远不应该手动地解析HTML,而是应该使用库吗?好吧,对DWARF会甚至更糟。DWARF比HTML复杂得多。我在这里展示的只是冰山一角,让事情变得更困难的是,大多数信息以非常紧凑且高度压缩的方式编码在实际的目标文件里[6]。因此我们将采取另一个方式,使用库来处理DWARF。我知道的有两个主要的库(加上几个不那么完整的库):
1. GNUbinutils使用的BFD(libbfd),包括在本文里的明星objdump,ld(GNU链接器)以及as(GNU汇编器)。
2. Libdwarf——与它的哥哥libelf一起由Solaris及FreeBSD操作系统的工具使用。
我挑选libdwarf,因为对我而言它看起来没那么晦涩,而且它的许可也更宽松(LGPL,对比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节提取本文展示的其他信息,应该不会有问题。
结论与下一步
理论上调试信息是一个简单的概念。实现细节可能是错综复杂的,但一天下来重要的是我们现在知道调试器如何从它所追踪程序中找出所需要的源代码信息。有了这些信息,调试器在以代码行与数据结构思考的用户方,与只是一堆机器代码以及寄存器与内存中数据的可执行文件间构建了一座桥梁。
本文,以及前两篇文章,构成了解释调试器内部工作的一个介绍性系列。使用这里展示的信息以及一些编程工作,应该可以创建一个基本但可用的Linux上的调试器。
作为下一步,我还不确定。也许我将在这里结束整个系列,也许我将展示更高级的议题,比如回溯,以及也许Windows上的调试。读者也可以在这个系列或相关的文章里给出未来文章的建议。通过评论或给我发邮件都可以。
参考
[2] 在本文的结尾我收集了一些对辅助你更熟悉DWARF有用的资源,如果你有兴趣。特别地,以DWARF教程开始。
[3] 这里及后续的例子中,为了更方便地格式化,我将使用(…)来代替不感兴趣的信息。
[4] 因为do_stuff的DW_AT_frame_base属性包含位置列表的偏移0x0。注意main的相同属性包含偏移0x2c,这是第二组位置表达式的偏移。
[5] 在那里通常执行函数的prologue,局部变量还没生效。
[6] 这个信息的某些部分(比如位置即行号数据)被编码为一个特殊虚拟机器的指令。是的,真的是这样。
标签:
原文地址:http://blog.csdn.net/wuhui_gdnt/article/details/51577820