偵錯程式工作原理(三):除錯資訊

Eli Bendersky發表於2017-06-06

這是偵錯程式的工作原理系列文章的第三篇。閱讀這篇文章之前應當先閱讀第一篇第二篇

這篇文章的主要內容

本文將解釋偵錯程式是如何在機器碼中查詢它將 C 語言原始碼轉換成機器語言程式碼時所需要的 C 語言函式、變數、與資料。

除錯資訊

現代編譯器能夠將有著各種縮排或巢狀的程式流程、各種資料型別的變數的高階語言程式碼轉換為一大堆稱之為機器碼的 0/1 資料,這麼做的唯一目的是儘可能快的在目標 CPU 上執行程式。通常來說一行 C 語言程式碼能夠轉換為若干條機器碼。變數被分散在機器碼中的各個部分,有的在堆疊中,有的在暫存器中,或者直接被優化掉了。資料結構與物件在機器碼中甚至不“存在”,它們只是用於將資料按一定的結構編碼儲存進快取。

那麼偵錯程式怎麼知道,當你需要在某個函式入口處暫停時,程式要在哪停下來呢?它怎麼知道當你檢視某個變數值時,它怎麼找到這個值?答案是,除錯資訊。

編譯器在生成機器碼時同時會生成相應的除錯資訊。除錯資訊代表了可執行程式與原始碼之間的關係,並以一種提前定義好的格式,同機器碼存放在一起。過去的數年裡,人們針對不同的平臺與可執行檔案發明了很多種用於儲存這些資訊的格式。不過我們這篇文章不會講這些格式的歷史,而是將闡述這些除錯資訊是如何工作的,所以我們將專注於一些事情,比如 DWARFDWARF 如今十分廣泛的用作 Linux 和類 Unix 平臺上的可執行檔案的除錯格式。

ELF 中的 DWARF

偵錯程式工作原理(三):除錯資訊

根據它的維基百科 所描述,雖然 DWARF 是同 ELF 一同設計的(DWARF 是由 DWARF 標準委員會推出的開放標準。上文中展示的圖示就來自這個網站。),但 DWARF 在理論上來說也可以嵌入到其他的可執行檔案格式中。

DWARF 是一種複雜的格式,它吸收了過去許多年各種不同的架構與作業系統的格式的經驗。正是因為它解決了一個在任何平臺與 ABI (應用二進位制介面)上為任意高階語言產生除錯資訊這樣棘手的難題,它也必須很複雜。想要透徹的講解 DWARF 僅僅是通過這單薄的一篇文章是遠遠不夠的,說實話我也並沒有充分地瞭解 DWARF 到每一個微小的細節,所以我也不能十分透徹的講解 (如果你感興趣的話,文末有一些能夠幫助你的資源。建議從 DWARF 教程開始上手)。這篇文章中我將以淺顯易懂的方式展示 DWARF,以說明除錯資訊是如何實際工作的。

ELF 檔案中的除錯部分

首先讓我們看看 DWARF 處在 ELF 檔案中的什麼位置。ELF 定義了每一個生成的目標檔案中的每一節。 節頭表section header table 宣告並定義了每一節及其名字。不同的工具以不同的方式處理不同的節,例如聯結器會尋找聯結器需要的部分,偵錯程式會查詢偵錯程式需要的部分。

我們本文的實驗會使用從這個 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 可執行檔案中的節頭section header,我們會看到幾個以 .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

顯然,0x8048604do_stuff 的開始地址,這樣一來,偵錯程式就可以建立函式與其在可執行檔案中的位置間的對映關係。

查詢變數

假設我們當前在 do_staff 函式中某個位置上設定斷點停了下來。我們想通過偵錯程式取得 my_local 這個變數的值。偵錯程式怎麼知道在哪裡去找這個值呢?很顯然這要比查詢函式更為困難。變數可能儲存在全域性儲存區、堆疊、甚至是暫存器中。此外,同名變數在不同的作用域中可能有著不同的值。除錯資訊必須能夠反映所有的這些變化,當然,DWARF 就能做到。

我不會逐一去將每一種可能的狀況,但我會以偵錯程式在 do_stuff 函式中查詢 my_local 變數的過程來舉個例子。下面我們再看一遍 .debug_infodo_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 list0x0 的偏移量。而 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_localDW_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。下面是兩種我熟悉的主要的庫(還有些不完整的庫這裡沒有寫)

  1. BFD (libbfd),包含了 objdump (對,就是這篇文章中我們一直在用的這貨),ldGNU 聯結器)與 asGNU 編譯器)。BFD 主要用於 GNU binutils
  2. libdwarf ,同它的哥哥 libelf 一同用於 SolarisFreeBSD 中的除錯資訊分析。

相比較而言我更傾向於使用 libdwarf,因為我對它瞭解的更多,並且 libdwarf 的開源協議更開放(LGPL 對比 GPL)。

因為 libdwarf 本身相當複雜,操作起來需要相當多的程式碼,所以我在這不會展示所有程式碼。你可以在 這裡 下載程式碼並執行試試。執行這些程式碼需要提前安裝 libelfandlibdwarf ,同時在使用聯結器的時候要使用引數 -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 下的除錯。你們有什麼好的點子或者相關材料,可以直接評論或者發郵件給我。

參考



via: http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information

作者:Eli Bendersky 譯者:YYforymj 校對:wxy

本文由 LCTT 原創編譯,Linux中國 榮譽推出

相關文章