導讀 | 本文將解釋偵錯器是如何在機器碼中尋找它將 C 語言原始碼轉換成機器語言程式碼時所需要的 C 語言函數、變數、與資料。 |
這是調試器的工作原理系列文章的第三篇。閱讀這篇文章之前應先閱讀第一篇與第二篇。
調試資訊現代編譯器能夠將有著各種縮排或嵌套的程式流程、各種資料類型的變數的高階語言程式碼轉換為一大堆稱之為機器碼的0/1 數據,這麼做的唯一目的是盡可能快的在目標CPU 上執行程式。通常來說一行 C 語言程式碼能夠轉換為若干條機器碼。變數分散在機器碼中的各個部分,有的在堆疊中,有的在暫存器中,或是直接被優化掉了。資料結構與物件在機器碼中甚至不“存在”,它們只是用於將資料以一定的結構編碼儲存進快取。
那麼調試器怎麼知道,當你需要在某個函數入口處暫停時,程式要停下來呢?它怎麼知道當你查看某個變數值時,它怎麼找到這個值?答案是,調試資訊。
編譯器在產生機器碼時同時會產生對應的偵錯資訊。偵錯資訊代表了可執行程式與原始程式碼之間的關係,並以一種提前定義好的格式,同機器碼存放在一起。在過去的數年裡,人們針對不同的平台與可執行檔發明了許多種用於儲存這些資訊的格式。不過我們這篇文章不會講這些格式的歷史,而是將闡述這些調試資訊是如何運作的,所以我們將專注於一些事情,例如 DWARF。 DWARF 如今十分廣泛的用作 Linux 和類 Unix 平台上的可執行檔的調試格式。
ELF 中的 DWARF#根據它的維基百科所描述,雖然DWARF 是同ELF 一同設計的(DWARF 是由DWARF 標準委員會推出的開放標準。上文中所示的圖標就來自這個網站。),但DWARF在理論上也可以嵌入到其他的可執行檔格式。
DWARF 是一種複雜的格式,它吸收了過去許多年各種不同的架構與作業系統的格式的經驗。正是因為它解決了一個在任何平台與 ABI (應用二進位介面)上為任意高階語言產生除錯資訊這樣棘手的難題,它也必須很複雜。想要透徹的講解DWARF 僅僅是透過這單薄的一篇文章是遠遠不夠的,說實話我也並沒有充分地了解DWARF 到每一個微小的細節,所以我也不能十分透徹的講解(如果你感興趣的話,文末有一些能夠幫助你的資源。建議從DWARF 教學開始上手)。這篇文章中我將以淺顯易懂的方式展示 DWARF,以說明調試資訊是如何實際運作的。
ELF 檔案中的偵錯部分首先讓我們看看DWARF 處在 ELF 檔案中的什麼位置。 ELF
我們本文的實驗會使用從這個 C 語言原始檔建構的可執行文件,編譯成 tracedprog2:
#include <stdio.h> void do_stuff(int my_arg)、 { int my_local = my_arg + 2; int i; for (i = 0; i <p>使用objdump -h 指令檢查ELF 執行檔中的<ruby>節頭<rt>section header</rt></ruby>,我們會看到幾個以.debug_ 開頭的節,這些就是DWARF 的調試部分。 </p> <pre class="brush:php;toolbar:false">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
輸出檔案相當的長,為了方便舉例我們只關注這些行(從這裡開始,無用的冗長信息我會以 (...)代替,方便排版):
: 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 函数时需要停下来的地方。这里已经完成了函数调用的准备工作。上面的这些信息形成了行号与地址间的双向映射关系。
尽管使用命令行工具来获得 DWARF 很有用,但这仍然不够易用。作为程序员,我们希望知道当我们需要这些调试信息时应当怎么编程来获取这些信息。
自然我们想到的第一种方法就是阅读 DWARF 规范并按规范操作阅读使用。有句话说的好,分析 HTML 应当使用库函数,永远不要手工分析。对于 DWARF 来说正是如此。DWARF 比 HTML 要复杂得多。上面所展示出来的只是冰山一角。更糟糕的是,在实际的目标文件中,大部分信息是以非常紧凑的压缩格式存储的,分析起来更加复杂(信息中的某些部分,例如位置信息与行号信息,在某些虚拟机下是以指令的方式编码的)。
所以我们要使用库来处理 DWARF。下面是两种我熟悉的主要的库(还有些不完整的库这里没有写)
相比较而言我更倾向于使用 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 下的调试。你们有什么好的点子或者相关材料,可以直接评论或者发邮件给我。
参考以上是解析調試器工作原理之(三):深入研究調試信息的詳細內容。更多資訊請關注PHP中文網其他相關文章!