调试器工作原理

调试器工作原理(3):调试信息

本文是调试器工作原理探究系列的第三篇,在阅读前请先确保已经读过本系列的第一第二

本篇主要内容

在本文中我将向大家解释关于调试器是如何在机器码中寻找C函数以及变量的,以及调试器使用了何种数据能够在C源代码的行号和机器码中来回映射。

调试信息

现代的编译器在转换高级语言程序代码上做得十分出色,能够将源代码中漂亮的缩进、嵌套的控制结构以及任意类型的变量全都转化为一长串的比特流——这就是机器码。这么做的唯一目的就是希望程序能在目标CPU上尽可能快的运行。大多数的C代码都被转化为一些机器码指令。变量散落在各处——在栈空间里、在寄存器里,甚至完全被编译器优化掉。结构体和对象甚至在生成的目标代码中根本不存在——它们只不过是对内存缓冲区中偏移量的抽象化表示。

那么当你在某些函数的入口处设置断点时,调试器如何知道该在哪里停止目标进程的运行呢?当你希望查看一个变量的值时,调试器又是如何找到它并展示给你呢?答案就是——调试信息。

调试信息是在编译器生成机器码的时候一起产生的。它代表着可执行程序和源代码之间的关系。这个信息以预定义的格式进行编码,并同机器码一起存储。许多年以来,针对不同的平台和可执行文件,人们发明了许多这样的编码格式。由于本文的主要目的不是介绍这些格式的历史渊源,而是为您展示它们的工作原理,所以我们只介绍一种最重要的格式,这就是DWARF。作为Linux以及其他类Unix平台上的ELF可执行文件的调试信息格式,如今的DWARF可以说是无处不在。

ELF文件中的DWARF格式

根据维基百科上的词条解释,DWARF是同ELF可执行文件格式一同设计出来的,尽管在理论上DWARF也能够嵌入到其它的对象文件格式中。

DWARF是一种复杂的格式,在多种体系结构和操作系统上经过多年的探索之后,人们才在之前的格式基础上创建了DWARF。它肯定是很复杂的,因为它解决了一个非常棘手的问题——为任意类型的高级语言和调试器之间提供调试信息,支持任意一种平台和应用程序二进制接口(ABI)。要完全解释清楚这个主题,本文就显得太微不足道了。说实话,我也不理解其中的所有角落。本文我将采取更加实践的方法,只介绍足量的DWARF相关知识,能够阐明实际工作中调试信息是如何发挥其作用的就可以了。

ELF文件中的调试段

首先,让我们看看DWARF格式信息处在ELF文件中的什么位置上。ELF可以为每个目标文件定义任意多个段(section)。而Section header表中则定义了实际存在有哪些段,以及它们的名称。不同的工具以各自特殊的方式来处理这些不同的段,比如链接器只寻找它关注的段信息,而调试器则只关注其他的段。

我们通过下面的C代码构建一个名为traceprog2的可执行文件来做下实验。

C

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

#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可执行文件中的段头信息,我们注意到其中有几个段的名字是以.debug_打头的,这些就是DWARF格式的调试段:

C

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

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的基本描述实体被称为调试信息表项(Debugging Information Entry —— DIE),每个DIE有一个标签——包含它的类型,以及一组属性。各个DIE之间通过兄弟和孩子结点互相链接,属性值可以指向其他的DIE。

我们运行

Shell

1

objdump –dwarf=info traceprog2

得到的输出非常长,对于这个例子,我们只用关注这几行就可以了:

C

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

<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做反汇编后这个地址是什么:

C

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

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

没错,从反汇编结果来看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>: 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的类型根据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(location list),这表示该值必须要在location list段去查询。我们看看objdump的输出:

Shell

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

$ 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的开头几条指令:

C

1

2

3

4

5

6

7

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的有效值,就可以很方便的计算出与它之间的偏移量。因为之后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=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——刚过do_stuff的开场白。这个行信息能够方便的在C源码的行号同指令地址间建立双向的映射关系。

1.  当在某一行上设定断点时,调试器将利用行信息找到实际应该陷入的地址(还记得前一篇中的int 3指令吗?)

2.  当某个指令引起段错误时,调试器会利用行信息反过来找出源代码中的行号,并告诉用户。

libdwarf —— 在程序中访问DWARF

通过命令行工具来访问DWARF信息这虽然有用但还不能完全令我们满意。作为程序员,我们希望知道应该如何写出实际的代码来解析DWARF格式并从中读取我们需要的信息。

自然的,一种方法就是拿起DWARF规范开始钻研。还记得每个人都告诉你永远不要自己手动解析HTML,而应该使用函数库来做吗?没错,如果你要手动解析DWARF的话情况会更糟糕,DWARF比HTML要复杂的多。本文展示的只是冰山一角而已。更困难的是,在实际的目标文件中,这些信息大部分都以非常紧凑和压缩的方式进行编码处理。

因此我们要走另一条路,使用一个函数库来同DWARF打交道。我知道的这类函数库主要有两个:

1.    BFD(libbfd),GNU binutils就是使用的它,包括本文中多次使用到的工具objdump,ld(GNU链接器),以及as(GNU汇编器)。

2.    libdwarf —— 同它的老大哥libelf一样,为Solaris以及FreeBSD系统上的工具服务。

我这里选择了libdwarf,因为对我来说它看起来没那么神秘,而且license更加自由(LGPL,BFD是GPL)。

由于libdwarf自身非常复杂,需要很多代码来操作。我这里不打算把所有代码贴出来,但你可以下载,然后自己编译运行。要编译这个文件,你需要安装libelf以及libdwarf,并在编译时为链接器提供-lelf以及-ldwarf标志。

这个演示程序接收一个可执行文件,并打印出程序中的函数名称同函数入口点地址。下面是本文用以演示的C程序产生的输出:

Shell

1

2

3

4

5

6

7

$ 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下创建一个具有基本功能的调试器。

至于下一步,我还不确定。也许我会就此终结这一系列文章,也许我会再写一些高级主题比如backtrace,甚至Windows系统上的调试。读者们也可以为今后这一系列文章提供意见和想法。不要客气,请随意在评论栏或通过Email给我提些建议吧。

时间: 2024-11-05 18:39:33

调试器工作原理的相关文章

Java类加载器工作原理

Java类加载器是用来在运行时加载类(*.class文件).Java类加载器基于三个原则:委托.可见性.唯一性.委托原则把加载类的请求转发给父 类加载器,而且仅加载类当父 类加载器无法找到或者不能加载类时.可见性原则允许子类加载器查看由父类加载器加载的所有的类,但是父类加载器不能查看由子类加载器加载的类.唯一性原则只允许加载一次类文件,这基本上是通过委托原则来实现的并确保子类加载器不重新加载由父类加载器加载过的类.正确的理解类加载器原理必须解决像 NoClassDefFoundError in

浅谈C++编译原理 ------ C++编译器与链接器工作原理

原文:https://blog.csdn.net/zyh821351004/article/details/46425823 第一篇:     首先是预编译,这一步可以粗略的认为只做了一件事情,那就是“宏展开”,也就是对那些#***的命令的一种展开. 例如define MAX 1000就是建立起MAX和1000之间的对等关系,好在编译阶段进行替换. 例如ifdef/ifndef就是从一个文件中有选择性的挑出一些符合条件的代码来交给下一步的编译阶段来处理.这里面最复杂的莫过于include了,其实

Python 装饰器工作原理解析

#!/usr/bin/env python #coding:utf-8 """ 装饰器实例拆解 """ def login00(func):     print('00请通过验证用户!')     return func def tv00(name):     print('00你的用户是:%s' %name) # 装饰器的精简工作原理解释: tv = login00(tv00) # 返回tv函数的对象,赋值给tv tv('yh00') # 调用

C++编译器与链接器工作原理

http://blog.csdn.net/success041000/article/details/6714195 1. 几个概念 1)编译:把源文件中的源代码翻译成机器语言,保存到目标文件中.如果编译通过,就会把CPP转换成OBJ文件. 2)编译单元:根据C++标准,每一个CPP文件就是一个编译单元.每个编译单元之间是相互独立并且互相不可知. 3)目标文件:编译所生成的文件,以机器码的形式包含了编译单元里所有的代码和数据. 还有一些其他信息,如未解决符号表,导出符号表和地址重定向表等.目标文

C++之编译器与链接器工作原理

http://www.cnblogs.com/kunhu/p/3629636.html 原文来自:http://blog.sina.com.cn/s/blog_5f8817250100i3oz.html 这里并没不是讨论大学课程中所学的<编译原理>,只是写一些我自己对C++编译器及链接器的工作原理的理解和看法吧,以我的水平,还达不到讲解编译原理(这个很复杂,大学时几乎没学明白). 要明白的几个概念: 1.编译:编译器对源文件进行编译,就是把源文件中的文本形式存在的源代码翻译成机器语言形式的目标

MySQL查询优化器工作原理解析

手册上查询优化器概述 查询优化器的任务是发现执行SQL查询的最佳方案.大多数查询优化器,包括MySQL的查询优化器,总或多或少地在所有可能的查询评估方案中搜索最佳方案.对于联接查询,MySQL优化器所调查的可能的方案数随查询中所引用的表的数目呈指数增长.对于小数量的表(典型小于7-10),这不是一个问题.然而,当提交的查询更大时,查询优化所花的时间会很容易地成为服务器性能的主要瓶颈. 查询优化的一个更加灵活的方法是允许用户控制优化器详尽地搜索最佳查询评估方案.一般思想是优化器调查的方案越少,它编

嵌入式调试器原理和各类调试器集锦(JLINK、STLINK、CCDEBUG)

工欲善其事,必先善其器.调试器在嵌入式开发调试中的重要性不言而喻,单步.断点和监察的效率远高于串口打印.但是,调试器对于一般开发人员往往是一个黑匣子.今天我们就来谈谈调试器的原理,顺便把自己的几类调试器接线和注意事项记录下来,以便查找.我常常要面对几个方案,而各个方案的调试器都不一样,接线有时连自己都记不住.所以这个帖子应值得嵌入式开发工程师收藏. 一.嵌入式调试多样性 我们先来回想调试的场景,思考一下这几个问题: 1. ARM开发环境有Keil.IAR.ADS等等,我们发现这几个平台都能用同一

嵌入式调试器原理和各类调试器集锦

工欲善其事,必先善其器.调试器在嵌入式开发调试中的重要性不言而喻,单步.断点和监察的效率远高于串口打印.但是,调试器对于一般开发人员往往是一个黑匣子.今天我们就来谈谈调试器的原理,顺便把自己的几类调试器接线和注意事项记录下来,以便查找.我常常要面对几个方案,而各个方案的调试器都不一样,接线有时连自己都记不住.所以这个帖子应值得嵌入式开发工程师收藏. 一.嵌入式调试多样性 我们先来回想调试的场景,思考一下这几个问题: 1. ARM开发环境有Keil.IAR.ADS等等,我们发现这几个平台都能用同一

JavaScript的工作原理:解析、抽象语法树(AST)+ 提升编译速度5个技巧

这是专门探索 JavaScript 及其所构建的组件的系列文章的第 14 篇. 如果你错过了前面的章节,可以在这里找到它们: JavaScript 是如何工作的:引擎,运行时和调用堆栈的概述! JavaScript 是如何工作的:深入V8引擎&编写优化代码的5个技巧! JavaScript 是如何工作的:内存管理+如何处理4个常见的内存泄漏 ! JavaScript 是如何工作的:事件循环和异步编程的崛起+ 5种使用 async/await 更好地编码方式! JavaScript 是如何工作的: