本篇主要内容
在本文中我将向大家解釋關于調試器是如何在機器碼中尋找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的可執行檔案來做下實驗。
#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格式的調試段:
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。
我們運作
objdump –dwarf=info traceprog2
得到的輸出非常長,對于這個例子,我們隻用關注這幾行就可以了:
<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來說,這個值是0×8048604。現在讓我們看看,通過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 <[email protected]>
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
沒錯,從反彙編結果來看0×8048604确實就是函數do_stuff的起始位址。是以,這裡調試器就同函數和它們在可執行檔案中的位置确立了映射關系。
定位變量
假設我們确實在do_stuff中的斷點處停了下來。我們希望調試器能夠告訴我們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的類型根據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屬性的值是0×0(location list),這表示該值必須要在location list段去查詢。我們看看objdump的輸出:
$ 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的開頭幾條指令:
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留下了什麼線索?我們感興趣的隻是在位址0×8048610上的指令執行過後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段中,可以按照可讀的形式進行解讀:
$ 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的入口點——位址0×8040604。接下第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程式産生的輸出:
$ 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給我提些建議吧。