(轉)調試器工作原理(3):調試信息

本文是調試器工作原理探究系列的第三篇,在閱讀前請先確保已經讀過本系列的第一第二

本篇主要內容

在本文中我將向大家解釋關於調試器是如何在機器碼中尋找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 <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

沒錯,從反彙編結果來看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給我提些建議吧。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章