調試器工作原理(三):調試信息
這是調試器的工作原理系列文章的第三篇。閱讀這篇文章之前應當先閱讀第一篇與第二篇。
這篇文章的主要內容
本文將解釋調試器是如何在機器碼中查找它將 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 < my_local; ++i)
printf("i = %dn", i);
}
int main()
{
do_stuff(2);
return 0;
}
使用 objdump -h 命令檢查 ELF 可執行文件中的 節頭 ,我們會看到幾個以 .debug_ 開頭的節,這些就是 DWARF 的調試部分。
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
輸出文件相當的長,為了方便舉例我們只關注這些行(從這裡開始,無用的冗長信息我會以 (...)代替,方便排版):
<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 標籤的入口,在 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+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_staff 函數中某個位置上設置斷點停了下來。我們想通過調試器取得 my_local 這個變數的值。調試器怎麼知道在哪裡去找這個值呢?很顯然這要比查找函數更為困難。變數可能存儲在全局存儲區、堆棧、甚至是寄存器中。此外,同名變數在不同的作用域中可能有著不同的值。調試信息必須能夠反映所有的這些變化,當然,DWARF 就能做到。
我不會逐一去將每一種可能的狀況,但我會以調試器在 do_stuff 函數中查找 my_local 變數的過程來舉個例子。下面我們再看一遍 .debug_info 中 do_stuff 的每一個入口,這次連它的子入口也要一起看。
<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 的變數類型指向另一個 DIE <0x4b>。如果使用 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>
我們需要關注的是第一列(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
只有當第二條指令執行後,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 文檔
via: http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information
作者:Eli Bendersky 譯者:YYforymj 校對:wxy
本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

















