调试器工作原理探究系列第三篇.docx
- 文档编号:13914128
- 上传时间:2023-06-19
- 格式:DOCX
- 页数:14
- 大小:34.96KB
调试器工作原理探究系列第三篇.docx
《调试器工作原理探究系列第三篇.docx》由会员分享,可在线阅读,更多相关《调试器工作原理探究系列第三篇.docx(14页珍藏版)》请在冰点文库上搜索。
调试器工作原理探究系列第三篇
本文是调试器工作原理探究系列的第三篇,在阅读前请先确保已经读过本系列的第一和第二篇。
本篇主要内容
在本文中我将向大家解释关于调试器是如何在机器码中寻找C函数以及变量的,以及调试器使用了何种数据能够在C源代码的行号和机器码中来回映射。
调试信息
现代的编译器在转换高级语言程序代码上做得十分出色,能够将源代码中漂亮的缩进、嵌套的控制结构以及任意类型的变量全都转化为一长串的比特流——这就是机器码。
这么做的唯一目的就是希望程序能在目标CPU上尽可能快的运行。
大多数的C代码都被转化为一些机器码指令。
变量散落在各处——在栈空间里、在寄存器里,甚至完全被编译器优化掉。
结构体和对象甚至在生成的目标代码中根本不存在——它们只不过是对内存缓冲区中偏移量的抽象化表示。
那么当你在某些函数的入口处设置断点时,调试器如何知道该在哪里停止目标进程的运行呢?
当你希望查看一个变量的值时,调试器又是如何找到它并展示给你呢?
答案就是——调试信息。
调试信息是在编译器生成机器码的时候一起产生的。
它代表着可执行程序和源代码之间的关系。
这个信息以预定义的格式进行编码,并同机器码一起存储。
许多年以来,针对不同的平台和可执行文件,人们发明了许多这样的编码格式。
由于本文的主要目的不是介绍这些格式的历史渊源,而是为您展示它们的工作原理,所以我们只介绍一种最重要的格式,这就是DWARF。
作为Linux以及其他类Unix平台上的ELF可执行文件的调试信息格式,如今的DWARF可以说是无处不在。
ELF文件中的DWARF格式
根据维基百科上的词条解释,DWARF是同ELF可执行文件格式一同设计出来的,尽管在理论上DWARF也能够嵌入到其它的对象文件格式中。
DWARF是一种复杂的格式,在多种体系结构和操作系统上经过多年的探索之后,人们才在之前的格式基础上创建了DWARF。
它肯定是很复杂的,因为它解决了一个非常棘手的问题——为任意类型的高级语言和调试器之间提供调试信息,支持任意一种平台和应用程序二进制接口(ABI)。
要完全解释清楚这个主题,本文就显得太微不足道了。
说实话,我也不理解其中的所有角落。
本文我将采取更加实践的方法,只介绍足量的DWARF相关知识,能够阐明实际工作中调试信息是如何发挥其作用的就可以了。
ELF文件中的调试段
首先,让我们看看DWARF格式信息处在ELF文件中的什么位置上。
ELF可以为每个目标文件定义任意多个段(section)。
而Sectionheader表中则定义了实际存在有哪些段,以及它们的名称。
不同的工具以各自特殊的方式来处理这些不同的段,比如链接器只寻找它关注的段信息,而调试器则只关注其他的段。
我们通过下面的C代码构建一个名为traceprog2的可执行文件来做下实验。
C
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include
voiddo_stuff(intmy_arg)
{
intmy_local=my_arg+2;
inti;
for(i=0;i printf("i=%d\n",i); } intmain() { do_stuff (2); return0; } 通过objdump–h导出ELF可执行文件中的段头信息,我们注意到其中有几个段的名字是以.debug_打头的,这些就是DWARF格式的调试段: C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 26.debug_aranges00000020 00000000 00000000 00001037 CONTENTS,READONLY,DEBUGGING 27.debug_pubnames00000028 00000000 00000000 00001057 CONTENTS,READONLY,DEBUGGING 28.debug_info 000000cc 00000000 00000000 0000107f CONTENTS,READONLY,DEBUGGING 29.debug_abbrev0000008a 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的基本描述实体被称为调试信息表项(DebuggingInformationEntry——DIE),每个DIE有一个标签——包含它的类型,以及一组属性。 各个DIE之间通过兄弟和孩子结点互相链接,属性值可以指向其他的DIE。 我们运行 Shell 1 objdump–dwarf=infotraceprog2 得到的输出非常长,对于这个例子,我们只用关注这几行就可以了: C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 <1><71>: AbbrevNumber: 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 (locationlist) <86> DW_AT_sibling : <0xb3> <1> AbbrevNumber: 9(DW_TAG_subprogram) 1 (...): main 1 14 <0x4b> 0x804863e 0x804865a 0x2c (locationlist) 这里有两个被标记为DW_TAG_subprogram的DIE,从DWARF的角度看这就是函数。 注意,这里do_stuff和main都各有一个表项。 这里有许多有趣的属性,但我们感兴趣的是DW_AT_low_pc。 这就是函数起始处的程序计数器的值(x86下的EIP)。 注意,对于do_stuff来说,这个值是0x8048604。 现在让我们看看,通过objdump–d做反汇编后这个地址是什么: C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 08048604 8048604: 55 push ebp 8048605: 89e5 mov ebp,esp 8048607: 83ec28 sub esp,0x28 804860a: 8b4508 mov eax,DWORDPTR[ebp+0x8] 804860d: 83c002 add eax,0x2 8048610: 8945f4 mov DWORDPTR[ebp-0xc],eax 8048613: c745(...) mov DWORDPTR[ebp-0x10],0x0 804861a: eb18 jmp 8048634 804861c: b820(...) mov eax,0x8048720 8048621: 8b55f0 mov edx,DWORDPTR[ebp-0x10] 8048624: 89542404 mov DWORDPTR[esp+0x4],edx 8048628: 890424 mov DWORDPTR[esp],eax 804862b: e804(...) call 8048534 8048630: 8345f001 add DWORDPTR[ebp-0x10],0x1 8048634: 8b45f0 mov eax,DWORDPTR[ebp-0x10] 8048637: 3b45f4 cmp eax,DWORDPTR[ebp-0xc] 804863a: 7ce0 jl 804861c 804863c: c9 leave 804863d: c3 ret 没错,从反汇编结果来看0x8048604确实就是函数do_stuff的起始地址。 因此,这里调试器就同函数和它们在可执行文件中的位置确立了映射关系。 定位变量 假设我们确实在do_stuff中的断点处停了下来。 我们希望调试器能够告诉我们my_local变量的值,调试器怎么知道去哪里找到相关的信息呢? 这可比定位函数要难多了,因为变量可以在全局数据区,可以在栈上,甚至是在寄存器中。 另外,具有相同名称的变量在不同的词法作用域中可能有不同的值。 调试信息必须能够反映出所有这些变化,而DWARF确实能做到这些。 我不会涵盖所有的可能情况,作为例子,我将只展示调试器如何在do_stuff函数中定位到变量my_local。 我们从.debug_info段开始,再次看看do_stuff这一项,这一次我们也看看其他的子项: C 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 <1><71>: AbbrevNumber: 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 (locationlist) <86> DW_AT_sibling : <0xb3> <2><8a>: AbbrevNumber: 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>: AbbrevNumber: 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_OP_fbreg: -20) <2> AbbrevNumber: 8(DW_TAG_variable) i 1 7 <0x4b> (...) (DW_OP_fbreg: -24) 注意每一个表项中第一个尖括号里的数字,这表示嵌套层次——在这个例子中带有<2>的表项都是表项<1>的子项。 因此我们知道变量my_local(以DW_TAG_variable作为标签)是函数do_stuff的一个子项。 调试器同样还对变量的类型感兴趣,这样才能正确的显示变量的值。 这里my_local的类型根据DW_AT_type标签可知为<0x4b>。 如果查看objdump的输出,我们会发现这是一个有符号4字节整数。 要在执行进程的内存映像中实际定位到变量,调试器需要检查DW_AT_location属性。 对于my_local来说,这个属性为DW_OP_fberg: -20。 这表示变量存储在从所包含它的函数的DW_AT_frame_base属性开始偏移-20处,而DW_AT_frame_base正代表了该函数的栈帧起始点。 函数do_stuff的DW_AT_frame_base属性的值是0x0(locationlist),这表示该值必须要在locationlist段去查询。 我们看看objdump的输出: Shell 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $objdump--dwarf=loctracedprog2 tracedprog2: fileformatelf32-i386 Contentsofthe.debug_locsection: Offset Begin End Expression 000000000804860408048605(DW_OP_breg4: 4) 000000000804860508048607(DW_OP_breg4: 8) 00000000080486070804863e(DW_OP_breg5: 8) 00000000 0000002c0804863e0804863f(DW_OP_breg4: 4) 0000002c0804863f08048641(DW_OP_breg4: 8) 0000002c080486410804865a(DW_OP_breg5: 8) 0000002c 关于位置信息,我们这里感兴趣的就是第一个。 对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。 对于x86体系结构,bpreg4代表esp寄存器,而bpreg5代表ebp寄存器。 让我们再看看do_stuff的开头几条指令: C 1 2 3 4 5 6 7 08048604 8048604: 55 push ebp 8048605: 89e5 mov ebp,esp 8048607: 83ec28 sub esp,0x28 804860a: 8b4508 mov eax,DWORDPTR[ebp+0x8] 804860d: 83c002 add eax,0x2 8048610: 8945f4 mov DWORDPTR[ebp-0xc],eax 注意,ebp只有在第二条指令执行后才与我们建立起关联,对于前两个地址,基地址由前面列出的位置信息中的esp计算得出。 一旦得到了ebp的有效值,就可以很方便的计算出与它之间的偏移量。 因为之后ebp保持不变,而esp会随着数据压栈和出栈不断移动。 那么这到底为我们定位变量my_local留下了什么线索? 我们感兴趣的只是在地址0x8048610上的指令执行过后my_local的值(这里my_local的值会通过eax寄存器计算,而后放入内存)。 因此调试器需要用到DW_OP_breg5: 8基址来定位。 现在回顾一下my_local的DW_AT_location属性: DW_OP_fbreg: -20。 做下算数: 从基址开始偏移-20,那就是ebp–20,再偏移+8,我们得到ebp–12。 现在再看看反汇编输出,注意到数据确实是从eax寄存器中得到的,而ebp–12就是my_local存储的位置。 定位到行号 当我说到在调试信息中寻找函数时,我撒了个小小的谎。 当我们调试C源代码并在函数中放置了一个断点时,我们通常并不会对第一条机器码指令感兴趣。 我们真正感兴趣的是函数中的第一行C代码。 这就是为什么DWARF在可执行文件中对C源码到机器码地址做了全部映射。 这部分信息包含在.debug_line段中,可以按照可读的形式进行解读: Shell 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $objdump--dwarf=decodedlinetracedprog2 tracedprog2: fileformatelf32-i386 Decodeddumpofdebugcontentsofsection.debug_line: CU: /home/eliben/eli/eliben-code/debugger/tracedprog2.c: Filename Linenumber Startingaddress 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——刚过do_stuff的开场白。 这个行信息能够方便的在C源码的行号同指令地址间建立双向的映射关系。 1. 当在某一行上设定断点时,调试器将利用行信息找到实际应该陷入的地址(还记得前一篇中的int3指令吗? ) 2. 当某个指令引起段错误时,调试器会利用行信息反过来找出源代码中的行号,并告诉用户。 libdwarf——
- 配套讲稿:
如PPT文件的首页显示word图标,表示该PPT已包含配套word讲稿。双击word图标可打开word文档。
- 特殊限制:
部分文档作品中含有的国旗、国徽等图片,仅作为作品整体效果示例展示,禁止商用。设计者仅对作品中独创性部分享有著作权。
- 关 键 词:
- 调试 工作 原理 探究 系列 第三