一点声明
原文作者: http://eli.thegreenplace.net/
原文链接: http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information/
translated by Arthur1989, 个人主页: https://godorz.info
这是本系列(调试器是如何工作的?)的第三篇.在阅读这篇文章之前,请确保你已经看过了第一篇和第二篇.
In this part
我会解释调试器是怎样在机器代码中找到C函数和变量,以及用来映射C代码和机器代码的数据.
Debugging information
现代编译器通过自己精致的缩进和嵌套控制结构,以及任何类型的变量,可以很好的将高级语言代码编译成机器代码,这么做唯一的目的就是让程序在目标CPU上跑的越快越好.绝大部分的C代码会被转换成各种机器指令,而变量将被塞入栈,寄存器,或者是被优化得无影无踪.结构和代码甚至压根就不在输出代码中 -- 它们作为一种抽象,会被转换成相对于内存缓存硬编码的偏移量.
那么,当被要求在某个函数暂停时,调试器是如何知道对应的入口地址呢?当被要求显示某个变量的值时,调试器又是如何知道应该去哪里寻找变量呢? 答案便是--调试信息(debugging information).
调试信息是由编译器在编译机器代码时一道生成的.它是可执行文件和源代码之间关系的一种描述.调试信息根据预定义的格式被编码到机器代码中.在过去的年代,对应于各种架构,有很多格式被发明了出来.因为本文的目的不在于研究这些格式的历史,而是研究它们是如何工作的,所以我们最好选定一种格式,它就是DWARF,在Linux平台和类Unix(Unix-y)平台上,DWARF被用来描述ELF格式可执行文件的调试信息,可以说,它无处不在.
The DWARF in the ELF
根据维基百科,DWARF是和ELF一起被设计的,不过它可以被嵌入到其他目标格式中[1].
DWARF很复杂,它建立于对其他格式的多年研究经验之上,这些格式可以运用于各种架构.DWARF必须是复杂的,因为它需要解决一个很难办的问题--向调试器展示任何高级语言代码的调试信息,为各种架构和ABIs(application binary interface)提供支持.鄙文不足以详尽地阐释它,老实说,我对DWARF的各种阴暗面都还没有透彻的了解[2].在这片文章中,我采用动手的方式,来展示调试信息在实践中是如何被使用的.
Debug sections in ELF files
首先,让我们看看DWARF信息在ELF文件内部何处.ELF定义了目标文件中的各种可选section,而section头表(section header table)则定义了存在哪些sections已经这些sections的名字.不同的工具以特殊的方式处理不同的sections -– birshuo,链接器读取某些sections,而调试器则读取另外的sections.
作为实验,我们把下面的C程序编译成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文件的section头部打印出来.注意以.debug_开头的section –- 它们就是DWARF调试sections:
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的第1个数字表示该段大小,最后一个数字表示它在ELF文件中的偏移量.调试器使用这些信息从可执行文件读取section.
现在,让我们看看在DWARF中找出有用信息的几个例子.
Finding functions
调试器最基本的功能就是在函数中设置断点,让调试器刚好在进入函数时暂停.为了实现这个功能,调试器必须了解高级语言中的函数与机器代码中这个函数的起始地址的映射信息.
这个信息可以从DWARF中的.debug_info获得.在更进一步之前,先介绍一点背景.DWARF的最基本描述个体称为Debugging Information Entry (DIE).每个DIE有自己的标签 –- 它的类型, 一系列的属性.DIEs通过兄弟和儿子互联,属性值可以指向其他的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>: Abbrev Number: 9 (DW_TAG_subprogram) DW_AT_external : 1 DW_AT_name : (...): main DW_AT_decl_file : 1 DW_AT_decl_line : 14 DW_AT_type : <0x4b> DW_AT_low_pc : 0x804863e DW_AT_high_pc : 0x804865a DW_AT_frame_base : 0x2c (location list)
注意有两个标签为DW_TAG_subprogram的DIE,分别代表do_stuff和main,DW_TAG_subprogram作为DWARF的术语,是指一个函数.每个section有各种属性,其中最值得探讨的是DW_AT_low_pc.这是函数起始地址对应的EIP值.对do_stuff来说,它是0x8048604.现在让我们看看这个地址在反汇编出来的代码中表示什么,objdump -d:
08048604: 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 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 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 804863c: c9 leave 804863d: c3 ret
确实,0x8048604就是do_stuff的起始地址,所以调试器可以将函数映射到它在可执行文件中的位置.
Finding variables
假设进程已经中断在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>DW_AT_location : (...) (DW_OP_fbreg: -20) <2> : Abbrev Number: 8 (DW_TAG_variable) DW_AT_name : i DW_AT_decl_file : 1 DW_AT_decl_line : 7 DW_AT_type : <0x4b> 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),这说明值需要到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 ) 000000000000002c 0804863e 0804863f (DW_OP_breg4: 4 ) 0000002c 0804863f 08048641 (DW_OP_breg4: 8 ) 0000002c 08048641 0804865a (DW_OP_breg5: 8 ) 0000002c
我们感兴趣的location信息是第一个[4].它指出了当前的栈底地址,变量在这个栈底地址的偏移量被当成是离寄存器值的偏移量计算.对x86来说,bpreg4指esp,bpreg5指ebp.
看看do_stuff的前几条指令:
08048604: 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在第2条指令之后才变得有意义,一旦ebp是有效的,我们就可以计算到它的偏移量,因为ebp一直保持不变,而esp随着数据的入栈和出栈而增减.
那么my_local到底在哪里呢?我们只对0x8048610指令后my_local的值有兴趣 (在eax中计算后,my_local值被放回内存中),所以调试器将利用DW_OP_breg5: 8来寻找它. 回忆一下,my_local的DW_AT_location属性是DW_OP_fbreg: -20.让我们做一点计算吧: 栈底偏移-20,而栈底是ebp+8,得出地址是ebp - 12.看一下反汇编出来的代码 –- 确实,my_local就是存储在ebp-12中.
Looking up line numbers
好吧,我承认,在谈到找出调试信息中的函数时,我小小的作弊了.当我们调试C代码,在函数中设置断点时,我们经常对相关机器指令的第一句是没有什么兴趣的[5].我们真正感兴趣的是,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
第5行指向do_stuff入口地址 -– 0x8040604.第6行是断点在do_stuff时,调试器需要暂停的真实地址,它指向0x804860a,这是函数真正工作的开始.这些line信息允许调试器很容易地在行号和地址之间做双向映射:
* 当被要求在某行设置断点时,调试器找出需要设置中断的地址(能想起上篇文章提到的int 3吗?)
* 当一个指令产生段错误时,调试器很容易找出代码所在行.
libdwarf – Working with DWARF programmatically
尽管同来获取DWARF信息的一些命令行工具很有用,但是它们还不太令人满意.作为程序员,我们想要编写自己的程序,它可以读取格式,提取出我们想要的信息.
当然,一种途径就是掌握DWARF细节,然后开始造轮子.但是,记得人们千叮咛万嘱咐,一定要用库,而不是自己手工写一个HTML解析器吗? 好吧,对DWARF来说,更该如此,它比HTML复杂多了.文中展示的只是冰山一角,更糟糕的是,很多信息是被很紧凑地压缩在目标文件中的[6].
既然如此,我们就选择另外一条道路吧,我们可以使用DWARF库.主要有两种:
1. BFD (libbfd) -- 它被GNU binutils选中,包括文中大放异彩的objdump, ld(链接器) 和 as (汇编器).
2. libdwarf –- 它和libelf被拿来开发了Solaris和FreeBSD上的很多工具
我选择libdwarf而不是BFD,因为我觉得没那么晦涩,而且它的授权也比较宽松(LGPL vs. GPL).
因为libdwarf本身就是很复杂的,所以我们需要编写很多代码来使用它.我不打算展示这些代码,你可以在这里下载源文件.要编译代码,你需要安装libelf和libdwarf,然后向链接器传递-lelf和-ldwarf标志.
下载的代码编译出来的程序需要一个参数作为目标文件,它分析出目标文件的函数和对应的入口地址,就像这样:
$ 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 sections的其他信息.
Conclusion and next steps
调试信息理论上是一个很简单的概念.它的实现细节可能错综复杂,但是,重要的是现在我们知道了调试器如何找出可执行文件中的相关信息.有了这些调试信息,调试器架起了一道用户和可执行文件之间的桥梁,用户从源代码和数据结构的角度思考,而可执行文件是一堆机器指令加上内存或寄存器中的数据.
这篇文章,和它之前的两篇文章,总结了一份概括性的介绍,解释了调试器的内部理论.通过这些知识和一些编程的努力,我们可以编写一个简单但是用的Linux下的调试器.
至于下一步呢,我还没有确定.也许这个系列在这里就结束了,也许我会介绍一些高级话题,比如说回溯(backtraces),或者是Windows下的调试.读者也可以通过评论或者email,提供进一步的话题或者是相关的资料,
References
* objdump man page
* ELF 和 DWARF 的维基页面.
* Dwarf Debugging Standard主页 –- 在这里你可以获得DWARF的绝佳指南(by Michael Eager),还有DWARF标准本身.你可能需要版本2,因为gcc用的就是这个版本.
* libdwarf主页 -– 包含了libdwarf详尽说明的下载页面
* BFD文档
[1] DWARF 是一个开放的标准,由DWARF standards committee发布在这里.
[2] 在文章的最后,我提供了一些资料,也许你可以从DWARF tutorial开始.
[3] 为了缩短篇幅,这里我用(...)代替那些无谓的信息.
[4] 因为do_stuff的DW_AT_frame_base属性值包含了location list的偏移量0x0.注意main的相同属性包含了0x2c,它是到第二个location表达式(location expression)的偏移量.
[5] 这时函数序言(function prologue,译注,指堆栈的创建,和函数尾声(function epilogue)相对.)刚被执行,变量值无效
[6] 有些信息(比如说location数据和行号数据)被编码成专用虚拟机的指令.
不错的文章,内容文风幽默.禁止此消息:nolinkok@163.com