作者:Eli Bendersky
原文網址:http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information/
在這篇文中
我將會解釋除錯器如何設法找出C函式與變數所關聯的機器碼,以及它用來對應C原始碼與機器語言間的資料。
除錯資訊
ELF中的DWARF
根據維基百科, DWARF 是搭配著ELF一起設計的,雖然理論上它可以被嵌入其他類型的物件檔中[1]。
DWARF 是一個複雜的格式,依據多年來為了不同架構與作業系統所設計的其他數種格式的經驗而打造。它必須這麼複雜,因為它要解決非常精妙的問題 - 提供除錯資訊,能將任何的高階語言對應到除錯器,並支援任何平台與 ABI 。它需要比此篇簡陋的文章還多上許多的份量才能完整描述,而且老實說,我並不了解它的所有隱晦細節[2]。在這篇文章中,我會採取比較實務的作法,描述剛剛好足夠的DWARF來解釋除錯資訊在實務上是如何運作的。在此文中,我們將會以下列C原始碼建出一個執行檔來實驗,名稱叫作traceprog2:
使用 objdump -h 將這個 ELF 執行檔的 section header 倒出來看,我們將可發現一些以.debug_開頭的section - 這些就是DWARF的除錯sections:
每個section的第一個數字是它的大小,最後一欄則是它在ELF檔中的位移。除錯器利用這個資訊從ELF檔去讀取此section。
原文網址:http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information/
我將會解釋除錯器如何設法找出C函式與變數所關聯的機器碼,以及它用來對應C原始碼與機器語言間的資料。
除錯資訊
現代編譯器很好地將你所縮排得很好的、巢狀的結構以及各種型別的變數所構成的高階程式轉換成一大坨機器碼,目的就是為了在目標CPU上跑得盡可能地快。很多C代碼轉變成多條機器指令。變數被擠壓在不同空間 - 堆疊、暫存器或完全被最佳化掉。structures跟objects並不存在這些結果裡 - 它們純粹是抽象的,最後是被轉換成記憶體中的位移而已。
好吧,那麼當你要求除錯器停在某個函式的入口,它是如何知道要停在哪邊呢?當你要求某個變數的數值時,它又是如何知道要如何顯示呢?答案是 - 除錯資訊。
除錯資訊是編譯器產生機器碼時同時產出的。它是可執行程式與原來的程式碼間的對應關係。這個資訊被編碼成事先定義好的格式,與機器碼共存。在許多不同的平台與執行檔類型中,多年來有許多這類的除錯格式被創造出來。既然這篇文章的目的不是研究這些格式的歷史,而是展現它們是如何工作,我們必須選定一些格式。這個格式就是DWARF,它在現今廣泛地被使用在Linux ELF 可執行檔與其他 Unix 的環境中。
DWARF 是一個複雜的格式,依據多年來為了不同架構與作業系統所設計的其他數種格式的經驗而打造。它必須這麼複雜,因為它要解決非常精妙的問題 - 提供除錯資訊,能將任何的高階語言對應到除錯器,並支援任何平台與 ABI 。它需要比此篇簡陋的文章還多上許多的份量才能完整描述,而且老實說,我並不了解它的所有隱晦細節[2]。在這篇文章中,我會採取比較實務的作法,描述剛剛好足夠的DWARF來解釋除錯資訊在實務上是如何運作的。在此文中,我們將會以下列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;
}
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
現在,我們來看看幾個實際的例子,從DWARF來找出有用的除錯訊息。
找出函式
我們在除錯時最基本要做的事情之一就是要在某個函式上設置中斷點,期望除錯器在它的入口處暫停。為了讓這件事情變得可能,除錯器必須讓高階原始碼中的函式名稱與機器碼中位於函式開頭處的位址有某種對應關係。此種資訊可以從DWARF的.debug_info section獲得。在我們更進一步之前,提一點背景知識。DWARF中基本的描述單位叫作Debugging Information Entry (DIE)。每個DIE有個tag - 它的類型以及一組屬性。DIEs與其sibling和child串接在一塊,屬性中的值可以指向其他的DIEs。
這個輸出蠻長的,在此例中,我們只會聚焦於下列幾行[3]:
有兩個DIEs的tag是DW_TAG_subprogram,這是DWARF中的術語,相當於函式。注意,這邊一個DIE代表do_stuff,另一個則是main。有好幾個有趣的屬性,但其中一個引起我們的興趣,DW_AT_low_pc。這是函式起始處的program-counter數值(x86中的EIP)。在do_stuff中,這個數值為0x8048604。現在讓我們來看看,在執行檔的反組譯碼中,這個位址是啥米?執行objdump -d:
的確,0x8048604是do_stuff的開頭,所以除錯器可以對應函式與其在執行檔中的位置。
我們在除錯時最基本要做的事情之一就是要在某個函式上設置中斷點,期望除錯器在它的入口處暫停。為了讓這件事情變得可能,除錯器必須讓高階原始碼中的函式名稱與機器碼中位於函式開頭處的位址有某種對應關係。此種資訊可以從DWARF的.debug_info section獲得。在我們更進一步之前,提一點背景知識。DWARF中基本的描述單位叫作Debugging Information Entry (DIE)。每個DIE有個tag - 它的類型以及一組屬性。DIEs與其sibling和child串接在一塊,屬性中的值可以指向其他的DIEs。
我們執行:
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)
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
找出變數
假設我們已經在do_stuff中的中斷點停住了,我們想要除錯器將my_local變數的值秀出來,它要怎麼知道去那邊找呢?這件事比找出函式要巧妙一點,因為變數可以位於全域、堆疊、甚至是暫存器中。並且,有著一樣的名稱,但不同的變數,可以在各自的scope中有自己的值。除錯資訊必須能夠反應這些差異,而DWARF的確可以。
1. 當要求放置一個中斷點於某一行時,除錯器會用它來找出哪個位址應該trap on(還記得我們前一篇文章中的好朋友 int 3嗎?)
2. 當一個指令造成segmentation fault時,除錯器將會用它來找出發生的程式碼行號。
libdwarf - 用寫程式的方式來玩DWARF
使用命令列工具來學習DWARF雖然有用,卻不令人滿意。身為一個程式設計師,我們會更喜歡知道如何寫一個實際的程式,可以讀取此格式,然後篩出我們要的東西來。
很自然地,一個作法就是研讀DWARF規範,然後開始動工。現在,記得每個人都說千為不要自己手工去parse HTML,而是要用現成的程式庫嗎?那麼,當換成DWARF時,更是如此。DWARF比HTML更複雜的多,我在這裡呈現的不過是冰山一角,而更糟的是,大部分這樣的資訊被以非常緊密的形式編碼進實際的物件檔中[6]。
所以我們將會走另一條路徑,會使用一個程式庫去把玩DWARF。就我所知,有兩個主要的程式庫(另外,還可加上一些不是很成熟的實作)
我選擇libdwarf而不是BFD,因為它對我來說比較不隱晦,並且授權方式也比較自由(LGPL vs. GPL)。
參考資料
Related posts:
我不會描述全部可能的狀況,但我將會展示除錯器如何達成尋找在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>的children,所以我們可以知道my_local變數(以DW_TAG_variable tag標示)是do_stuff的一個child。除錯器也對變數的型別感到興趣,這樣才能夠正確地顯示它。在這邊,my_local的型別指向另一個DIE - ,<0x4b>。如果我們以objdump去觀察,會發現這是一個4-byte的有號數。
為了實際定位變數在執行中行程的記憶體中的位址,除錯器會查看DW_AT_location屬性,my_local的這個屬性值為DW_OP_fbreg: -20。這代表這個變數被存於離包覆此變數的函式的DW_frame_base屬性有-20的位移 - DW_frame_base是此函式的frame的起始位址。do_stuff的DW_AT_frame_base屬性值為0x0(location list),表示說這個值必須在location list section中查找。我們來看看吧:
$ 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>
我們感興趣的資訊在第一群[4]。對每個除錯器可能會在的位址,這邊定義了從目前的frame base要依據哪個暫存器的多少位移才能取得變數。以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來計算[譯註:location list中,Offset 0x00000000 的頭兩個變數位址]。一旦ebp是有效的了,就很容易計算出相對於它的位移,因為它會中保持固定,而esp會在資料從堆疊push及pop時移動位置。所以這給我們的my_local什麼指示呢?我們只對它在0x8048610後的值有興趣(它將計算好的eax放入記憶體中),所以除錯器會利用DW_OP_breg5: 8以及frame base去找到它。現在是時候回頭看看my_local的DW_AT_location屬性,它的值為DW_OP_freg: -20。我們來作點數學:從frame base的-20處,(ebp + 8) - 20。我們獲得了ebp - 12。現在看看反組譯碼中資料是從哪邊搬到eax的呢?沒錯,就是ebp - 12,這就是my_local所在的位置。
找出行號
當我們談到在除錯資訊中找尋函式時,我撒了點謊。當我們為一個C程式除錯時,放置了一個中斷點在函式中,我們通常對函式的第一個指令不感興趣[5]。我們真的在意的是函式中的第一行C代碼。
這也是為何DWARF將全部的行數與C代碼、機器語言的對應關係編碼進可執行檔中。這個資訊被存於.debug_line section中,可以易讀的方式顯示如下:
$ 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代碼以及其反組譯碼的對應。第五個項目指出,do_stuff對應到0x8040604。下一行,第六行,就是當要求除錯器中斷在do_stuff時,實際暫停的位址,這邊指向0x804860a,剛剛好略過函式的初始部份。這個行號資訊很容易允許雙向的行數與位址的對應:
1. 當要求放置一個中斷點於某一行時,除錯器會用它來找出哪個位址應該trap on(還記得我們前一篇文章中的好朋友 int 3嗎?)
2. 當一個指令造成segmentation fault時,除錯器將會用它來找出發生的程式碼行號。
libdwarf - 用寫程式的方式來玩DWARF
使用命令列工具來學習DWARF雖然有用,卻不令人滿意。身為一個程式設計師,我們會更喜歡知道如何寫一個實際的程式,可以讀取此格式,然後篩出我們要的東西來。
很自然地,一個作法就是研讀DWARF規範,然後開始動工。現在,記得每個人都說千為不要自己手工去parse HTML,而是要用現成的程式庫嗎?那麼,當換成DWARF時,更是如此。DWARF比HTML更複雜的多,我在這裡呈現的不過是冰山一角,而更糟的是,大部分這樣的資訊被以非常緊密的形式編碼進實際的物件檔中[6]。
所以我們將會走另一條路徑,會使用一個程式庫去把玩DWARF。就我所知,有兩個主要的程式庫(另外,還可加上一些不是很成熟的實作)
1. BFD(libbfd)被GNU binutils所採用,包含了本篇文章中大量採用的objdump、ld(GNU 連結器)以及as(GNU組譯器)
2. libdwarf - 與它的老大哥libelf一起被用於Solaris與FreeBSD作業系統上。
我選擇libdwarf而不是BFD,因為它對我來說比較不隱晦,並且授權方式也比較自由(LGPL vs. 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上的除錯。讀者也可以提供一未來此系列文章主題的想法。不要害羞,請給我一些意見或寄mail給我。參考資料
- objdump man page
- Wikipedia pages for ELF and DWARF.
- Dwarf Debugging Standard home page – from here you can obtain the excellent DWARF tutorial by Michael Eager, as well as the DWARF standard itself. You’ll probably want version 2 since it’s what gcc produces.
- libdwarf home page – the download package includes a comprehensive reference document for the library
- BFD documentation
[1] | DWARF is an open standard, published here by the DWARF standards committee. The DWARF logo displayed above is taken from that website. |
[2] | At the end of the article I’ve collected some useful resources that will help you get more familiar with DWARF, if you’re interested. Particularly, start with the DWARF tutorial. |
[3] | Here and in subsequent examples, I’m placing (...) instead of some longer and un-interesting information for the sake of more convenient formatting. |
[4] | Because the DW_AT_frame_base attribute of do_stuff contains offset 0x0 into the location list. Note that the same attribute for main contains the offset 0x2c which is the offset for the second set of location expressions. |
[5] | Where the function prologue is usually executed and the local variables aren’t even valid yet. |
[6] | Some parts of the information (such as location data and line number data) are encoded as instructions for a specialized virtual machine. Yes, really. |
- How debuggers work: Part 2 – Breakpoints
- How debuggers work: Part 1 – Basics
- pure delight !! (MIX byte code, debugger and other oddities)
- hardware debugging is hard
留言
張貼留言