###導入###
| この記事では、デバッガーが C 言語ソース コードを機械語コードに変換するために必要な C 言語関数、変数、データを機械語コード内でどのように調べるかについて説明します。
|
これは、デバッガーの動作に関する一連の記事の 3 番目の記事です。この記事を読む前に、パート 1 とパート 2 を読んでください。
デバッグ情報
最新のコンパイラは、さまざまなインデントや入れ子になったプログラム フロー、およびさまざまなデータ型の変数を持つ高級言語コードを、マシン コードと呼ばれる多数の 0/1 データに変換できます。これを行う唯一の方法は、ターゲット CPU 上でできるだけ早くプログラムを実行します。一般に、1 行の C 言語コードは、数行のマシンコードに変換できます。変数はマシン コードのさまざまな部分に散在しており、一部はスタック上、一部はレジスタ内、または直接最適化されます。データ構造とオブジェクトはマシン コード内に「存在」さえせず、特定の構造エンコーディングに従ってデータをキャッシュに保存するためにのみ使用されます。
では、特定の関数の入り口で一時停止する必要がある場合、デバッガはプログラムをどこで停止すべきかをどのようにして知るのでしょうか?変数の値を調べたときに、その値をどのようにして見つけることができるのでしょうか?答えは、デバッグ情報です。
コンパイラーは、マシンコードを生成するときに、対応するデバッグ情報を生成します。デバッグ情報とは、実行可能プログラムとソースコードとの関係を表す情報であり、あらかじめ定められた形式でマシンコードとともに格納されます。長年にわたって、この情報を保存するための多くの形式が、さまざまなプラットフォームや実行可能ファイル用に発明されてきました。ただし、この記事ではこれらの形式の歴史について説明するのではなく、このデバッグ情報がどのように機能するかについて説明するので、DWARF などに焦点を当てます。 DWARF は現在、Linux および Unix 系プラットフォーム上の実行可能ファイルのデバッグ形式として広く使用されています。
エルフのドワーフ
Wikipedia の説明によると、DWARF は ELF と一緒に設計されました (DWARF は DWARF 標準委員会によって立ち上げられたオープン標準です。上に表示されているアイコンはこの Web サイトからのものです)。しかし、DWARF は理論的には他の製品にも埋め込むことができます。実行可能ファイル形式。
DWARF は、さまざまなアーキテクチャやオペレーティング システム形式に関する長年の経験を活用した複雑な形式です。あらゆるプラットフォームおよび ABI (アプリケーション バイナリ インターフェイス) 上の高級言語のデバッグ情報を生成するという困難な問題を解決するため、複雑になる必要があります。 DWARF について徹底的に説明したい場合は、この薄い記事を読むだけでは十分ではありません。正直に言うと、私は DWARF を細部まで完全に理解しているわけではないので、あまり詳しく説明することはできません (気が向いたら興味がある場合は、記事の最後に役立つリソースがいくつかあります。DWARF チュートリアルから始めることをお勧めします)。この記事では、DWARF をわかりやすく紹介し、デバッグ情報が実際にどのように機能するかを説明します。
ELF ファイルのデバッグ セクション
まず、ELF ファイル内の DWARF がどこにあるかを見てみましょう。 ELF
この記事の実験では、この C 言語ソース ファイルからビルドされ、tracedprog2 にコンパイルされた実行可能ファイルを使用します。
リーリー
objdump -h コマンドを使用して、ELF 実行可能ファイルの セクション ヘッダーセクション ヘッダー を確認します。.debug_ で始まるいくつかのセクションが表示されます。これらは DWARF のデバッグです。一部。
リーリー
各セクションの最初の数字はセクションのサイズを表し、最後の数字はセクションの先頭から ELF までのオフセットを表します。デバッガーはこの情報を使用して、実行可能ファイルからセクションを読み取ります。
次に、DWARF で有用なデバッグ情報を見つけるための実践的な例をいくつか見てみましょう。
検索機能
デバッガーの最も基本的なタスクの 1 つは、特定の関数にブレークポイントを設定するときに、デバッガーがそのエントリで一時停止できる必要があることです。これを行うには、高レベル コード内の関数名と、マシン コード内で関数の命令が開始されるアドレスとの間に何らかのマッピングを確立する必要があります。
このマッピング関係を取得するには、DWARF の .debug_info セクションを探します。本題に入る前に、少しの基本的な知識が必要です。 DWARF の各記述タイプは、デバッグ情報エントリ (DIE) と呼ばれます。各 DIE には、そのタイプ、プロパティなどに関するタグがあります。 DIE は兄弟ノードまたは子ノードを介して相互に接続されており、属性の値が他の DIE を指すこともできます。
次のコマンドを実行します:
リーリー
出力ファイルは非常に長いので、便宜上、これらの行のみに焦点を当てます(これ以降、植字を容易にするために、無駄な長い情報を (...) に置き換えます)。
: Abbrev Number: 5 (DW_TAG_subprogram)
DW_AT_external : 1
DW_AT_name : (...): do_stuff
DW_AT_decl_file : 1
DW_AT_decl_line : 4
DW_AT_prototyped : 1
DW_AT_low_pc : 0x8048604
DW_AT_high_pc : 0x804863e
DW_AT_frame_base : 0x0 (location list)
DW_AT_sibling :
<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 :
<bf> DW_AT_low_pc : 0x804863e
<c3> DW_AT_high_pc : 0x804865a
<c7> DW_AT_frame_base : 0x2c (location list)
</c7></c3></bf></bb></ba></b9></b5></b4></b3>
上面的代码中有两个带有 DW_TAG_subprogram 标签的入口,在 DWARF 中这是对函数的指代。注意,这是两个节的入口,其中一个是 do_stuff 函数的入口,另一个是主(main)函数的入口。这些信息中有很多值得关注的属性,但其中最值得注意的是 DW_AT_low_pc。它代表了函数开始处程序指针的值(在 x86 平台上是 EIP)。此处 0x8048604 代表了 do_stuff 函数开始处的程序指针。下面我们将利用 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>
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>
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>
804863c: c9 leave
804863d: c3 ret
</do_stuff></printf></do_stuff></do_stuff>
显然,0x8048604 是 do_stuff 的开始地址,这样一来,调试器就可以建立函数与其在可执行文件中的位置间的映射关系。
查找变量
假设我们当前在 do_staff 函数中某个位置上设置断点停了下来。我们想通过调试器取得 my_local 这个变量的值。调试器怎么知道在哪里去找这个值呢?很显然这要比查找函数更为困难。变量可能存储在全局存储区、堆栈、甚至是寄存器中。此外,同名变量在不同的作用域中可能有着不同的值。调试信息必须能够反映所有的这些变化,当然,DWARF 就能做到。
我不会逐一去将每一种可能的状况,但我会以调试器在 do_stuff 函数中查找 my_local 变量的过程来举个例子。下面我们再看一遍 .debug_info 中 do_stuff 的每一个入口,这次连它的子入口也要一起看。
: Abbrev Number: 5 (DW_TAG_subprogram)
DW_AT_external : 1
DW_AT_name : (...): do_stuff
DW_AT_decl_file : 1
DW_AT_decl_line : 4
DW_AT_prototyped : 1
DW_AT_low_pc : 0x8048604
DW_AT_high_pc : 0x804863e
DW_AT_frame_base : 0x0 (location list)
DW_AT_sibling :
: Abbrev Number: 6 (DW_TAG_formal_parameter)
DW_AT_name : (...): my_arg
DW_AT_decl_file : 1
DW_AT_decl_line : 4
DW_AT_type :
DW_AT_location : (...) (DW_OP_fbreg: 0)
: Abbrev Number: 7 (DW_TAG_variable)
DW_AT_name : (...): my_local
DW_AT_decl_file : 1
DW_AT_decl_line : 6
DW_AT_type :
<a3> DW_AT_location : (...) (DW_OP_fbreg: -20)
<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 :
<af> DW_AT_location : (...) (DW_OP_fbreg: -24)
</af></ab></aa></a9></a7></a6></a3>
看到每个入口处第一对尖括号中的数字了吗?这些是嵌套的等级,在上面的例子中,以 开头的入口是以 开头的子入口。因此我们得知 my_local 变量(以 DW_TAG_variable 标签标记)是 do_stuff 函数的局部变量。除此之外,调试器也需要知道变量的数据类型,这样才能正确的使用与显示变量。上面的例子中 my_local 的变量类型指向另一个 DIE 。如果使用 objdump 命令查看这个 DIE 的话,我们会发现它是一个有符号 4 字节整型数据。
而为了在实际运行的程序内存中查找变量的值,调试器需要使用到 DW_AT_location 属性。对于 my_local 而言,是 DW_OP_fbreg: -20。这个代码段的意思是说 my_local 存储在距离它所在函数起始地址偏移量为 -20 的地方。
do_stuff 函数的 DW_AT_frame_base 属性值为 0x0 (location list)。这意味着这个属性的值需要在 location list 中查找。下面我们来一起看看。
$ 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>
</end></end>
我们需要关注的是第一列(do_stuff 函数的 DW_AT_frame_base 属性包含 location list 中 0x0 的偏移量。而 main 函数的相同属性包含 0x2c 的偏移量,这个偏移量是第二套地址列表的偏移量)。对于调试器可能定位到的每一个地址,它都会指定当前栈帧到变量间的偏移量,而这个偏移就是通过寄存器来计算的。对于 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
</do_stuff>
只有当第二条指令执行后,ebp 寄存器才真正存储了有用的值。当然,前两条指令的基址是由上面所列出来的地址信息表计算出来的。一但 ebp 确定了,计算偏移量就十分方便了,因为尽管 esp 在操作堆栈的时候需要移动,但 ebp 作为栈底并不需要移动。
究竟我们应该去哪里找 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。现在再次查看反汇编代码,来看看数据从 eax 中被移动到哪里了。当然,这里 my_local 应当被存储在了 ebp - 12 的地址中。
查看行号
当我们谈到在调试信息寻找函数的时候,我们利用了些技巧。当调试 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 行,指向 0x804860a ,正是调试器在调试 do_stuff 函数时需要停下来的地方。这里已经完成了函数调用的准备工作。上面的这些信息形成了行号与地址间的双向映射关系。
- 当在某一行设置断点的时候,调试器会利用这些信息去查找相应的地址来做断点工作(还记得上篇文章中的 int 3 指令吗?)
- 当指令造成段错误时,调试器会利用这些信息来查看源代码中发生问题的行。
libdwarf - 用 DWARF 编程
尽管使用命令行工具来获得 DWARF 很有用,但这仍然不够易用。作为程序员,我们希望知道当我们需要这些调试信息时应当怎么编程来获取这些信息。
自然我们想到的第一种方法就是阅读 DWARF 规范并按规范操作阅读使用。有句话说的好,分析 HTML 应当使用库函数,永远不要手工分析。对于 DWARF 来说正是如此。DWARF 比 HTML 要复杂得多。上面所展示出来的只是冰山一角。更糟糕的是,在实际的目标文件中,大部分信息是以非常紧凑的压缩格式存储的,分析起来更加复杂(信息中的某些部分,例如位置信息与行号信息,在某些虚拟机下是以指令的方式编码的)。
所以我们要使用库来处理 DWARF。下面是两种我熟悉的主要的库(还有些不完整的库这里没有写)
- BFD (libbfd),包含了 objdump (对,就是这篇文章中我们一直在用的这货),ld(GNU 连接器)与 as(GNU 编译器)。BFD 主要用于 GNU binutils。
- libdwarf ,同它的哥哥 libelf 一同用于 Solaris 与 FreeBSD 中的调试信息分析。
相比较而言我更倾向于使用 libdwarf,因为我对它了解的更多,并且 libdwarf 的开源协议更开放(LGPL 对比 GPL)。
因为 libdwarf 本身相当复杂,操作起来需要相当多的代码,所以我在这不会展示所有代码。你可以在 这里 下载代码并运行试试。运行这些代码需要提前安装 libelfand 与 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 的文档很棒,如果你花些功夫,利用 libdwarf 获得这篇文章中所涉及到的 DWARF 信息应该并不困难。
结论与计划
原理上讲,调试信息是个很简单的概念。尽管实现细节可能比较复杂,但经过了上面的学习我想你应该了解了调试器是如何从可执行文件中获取它需要的源代码信息的了。对于程序员而言,程序只是代码段与数据结构;对可执行文件而言,程序只是一系列存储在内存或寄存器中的指令或数据。但利用调试信息,调试器就可以将这两者连接起来,从而完成调试工作。
此文与这系列的前两篇,一同介绍了调试器的内部工作过程。利用这里所讲到的知识,再敲些代码,应该可以完成一个 Linux 中最简单、基础但也有一定功能的调试器。
下一步我并不确定要做什么,这个系列文章可能就此结束,也有可能我要讲些堆栈调用的事情,又或者讲 Windows 下的调试。你们有什么好的点子或者相关材料,可以直接评论或者发邮件给我。
参考
- objdump 参考手册
- ELF 与 DWARF 的维基百科
- Dwarf Debugging Standard 主页,这里有很棒的 DWARF 教程与 DWARF 标准,作者是 Michael Eager。第二版基于 GCC 也许更能吸引你。
- libdwarf 主页,这里可以下载到 libwarf 的完整库与参考手册
- BFD 文档