偵錯程式工作原理(三):除錯資訊
這是偵錯程式的工作原理系列文章的第三篇。閱讀這篇文章之前應當先閱讀第一篇與第二篇。
這篇文章的主要內容
本文將解釋偵錯程式是如何在機器碼中查詢它將 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 = %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
中每一個描述型別被稱之為除錯資訊入口(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
相關文章
- 偵錯程式工作原理(3):除錯資訊除錯
- 偵錯程式工作原理(1):基礎篇
- 偵錯程式工作原理(2):實現斷點斷點
- Emacs 除錯祕籍之 GUD 偵錯程式Mac除錯
- 反除錯 -- 利用ptrace阻止偵錯程式附加除錯
- 偵錯程式到底怎樣工作
- 反除錯&反反除錯 -- 利用sysctl檢測偵錯程式是否存在除錯
- 使用GDB命令列偵錯程式除錯C/C++程式命令列除錯C++
- 微信偵錯程式
- C 語言偵錯程式是如何工作的?
- [Win32]一個偵錯程式的實現(五)除錯符號Win32除錯符號
- Xcode偵錯程式LLDBXCodeLLDB
- go語言偵錯程式Go
- 用GDB除錯程式(三) (轉)除錯
- Python 偵錯程式入門Python
- PsySH作為偵錯程式
- 偵錯程式--jdb.exe(轉)
- CodeBlocks偵錯程式設定錯誤問題BloC
- firewalld: 列印除錯資訊除錯
- .NET應用程式除錯—原理、工具、方法除錯
- .NET應用程式除錯:原理、工具、方法除錯
- 在偵錯程式下看Panic機制及oops資訊分析OOP
- 另一個Swoole偵錯程式 - Yasd
- 偵錯程式是個大騙子!
- GDB偵錯程式(學習筆記)筆記
- 程式碼除錯-入門、實踐到原理除錯
- QTP第三方偵錯程式PowerDebug試用手記QT
- 除錯的第一個Jdon出錯資訊除錯
- 在Docker內部使用gdb偵錯程式報錯-Operation not permittedDockerMIT
- Python 程式碼除錯—使用 pdb 除錯Python除錯
- Linux gdb偵錯程式用法全面解析Linux
- iOS Debuger(便捷輔助偵錯程式)iOS
- 輕量級偵錯程式mimikatz
- Linux 核心偵錯程式內幕(轉)Linux
- 為什麼在Docker裡使用gdb偵錯程式會報錯Docker
- Objective-C列印除錯資訊Object除錯
- Xcode動態除錯原理XCode除錯
- node 除錯三種方式除錯