本文是偵錯程式工作原理探究系列的第三篇,在閱讀前請先確保已經讀過本系列的第一和第二篇。
本篇主要內容
在本文中我將向大家解釋關於偵錯程式是如何在機器碼中尋找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的可執行檔案來做下實驗。
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格式的除錯段:
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。
我們執行
1 |
objdump –dwarf=info traceprog2 |
得到的輸出非常長,對於這個例子,我們只用關注這幾行就可以了:
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做反彙編後這個地址是什麼:
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這一項,這一次我們也看看其他的子項:
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的輸出:
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的開頭幾條指令:
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段中,可以按照可讀的形式進行解讀:
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程式產生的輸出:
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給我提些建議吧。