跳到主要內容

中文試譯:How debuggers work: Part 3 – Debugging information

作者:Eli Bendersky
原文網址:http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information/


這篇文章是"除錯器如何工作"系列文章的第三篇。在閱讀前,確定你有看過第一篇第二篇

在這篇文中
我將會解釋除錯器如何設法找出C函式與變數所關聯的機器碼,以及它用來對應C原始碼與機器語言間的資料。

除錯資訊
現代編譯器很好地將你所縮排得很好的、巢狀的結構以及各種型別的變數所構成的高階程式轉換成一大坨機器碼,目的就是為了在目標CPU上跑得盡可能地快。很多C代碼轉變成多條機器指令。變數被擠壓在不同空間 - 堆疊、暫存器或完全被最佳化掉。structures跟objects並不存在這些結果裡 - 它們純粹是抽象的,最後是被轉換成記憶體中的位移而已。

好吧,那麼當你要求除錯器停在某個函式的入口,它是如何知道要停在哪邊呢?當你要求某個變數的數值時,它又是如何知道要如何顯示呢?答案是 - 除錯資訊。

除錯資訊是編譯器產生機器碼時同時產出的。它是可執行程式與原來的程式碼間的對應關係。這個資訊被編碼成事先定義好的格式,與機器碼共存。在許多不同的平台與執行檔類型中,多年來有許多這類的除錯格式被創造出來。既然這篇文章的目的不是研究這些格式的歷史,而是展現它們是如何工作,我們必須選定一些格式。這個格式就是DWARF,它在現今廣泛地被使用在Linux ELF 可執行檔與其他 Unix 的環境中。  


ELF中的DWARF

http://eli.thegreenplace.net/wp-content/uploads/2011/02/dwarf_logo.gif
根據維基百科, DWARF 是搭配著ELF一起設計的,雖然理論上它可以被嵌入其他類型的物件檔中[1]。
 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;
}
使用 objdump -h 將這個 ELF 執行檔的 section header 倒出來看,我們將可發現一些以.debug_開頭的section - 這些就是DWARF的除錯sections:

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
每個section的第一個數字是它的大小,最後一欄則是它在ELF檔中的位移。除錯器利用這個資訊從ELF檔去讀取此section。
現在,我們來看看幾個實際的例子,從DWARF來找出有用的除錯訊息。

找出函式
我們在除錯時最基本要做的事情之一就是要在某個函式上設置中斷點,期望除錯器在它的入口處暫停。為了讓這件事情變得可能,除錯器必須讓高階原始碼中的函式名稱與機器碼中位於函式開頭處的位址有某種對應關係。此種資訊可以從DWARF的.debug_info section獲得。在我們更進一步之前,提一點背景知識。DWARF中基本的描述單位叫作Debugging Information Entry (DIE)。每個DIE有個tag - 它的類型以及一組屬性。DIEs與其sibling和child串接在一塊,屬性中的值可以指向其他的DIEs。
我們執行:

objdump --dwarf=info tracedprog2
這個輸出蠻長的,在此例中,我們只會聚焦於下列幾行[3]:
<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)
有兩個DIEs的tag是DW_TAG_subprogram,這是DWARF中的術語,相當於函式。注意,這邊一個DIE代表do_stuff,另一個則是main。有好幾個有趣的屬性,但其中一個引起我們的興趣,DW_AT_low_pc。這是函式起始處的program-counter數值(x86中的EIP)。在do_stuff中,這個數值為0x8048604。現在讓我們來看看,在執行檔的反組譯碼中,這個位址是啥米?執行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_stuff中的中斷點停住了,我們想要除錯器將my_local變數的值秀出來,它要怎麼知道去那邊找呢?這件事比找出函式要巧妙一點,因為變數可以位於全域、堆疊、甚至是暫存器中。並且,有著一樣的名稱,但不同的變數,可以在各自的scope中有自己的值。除錯資訊必須能夠反應這些差異,而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>的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
http://eli.thegreenplace.net/wp-content/uploads/hline.jpg
[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.
Related posts:
  1. How debuggers work: Part 2 – Breakpoints
  2. How debuggers work: Part 1 – Basics
  3. pure delight !! (MIX byte code, debugger and other oddities)
  4. hardware debugging is hard






留言

這個網誌中的熱門文章

淺讀Linux root file system初始化流程

在Unix的世界中,file system佔據一個極重要的抽象化地位。其中,/ 所代表的rootfs更是所有後續新增file system所必須依賴前提條件。以Linux為例,黑客 Jserv 就曾經詳細說明過 initramfs的背後設計考量 。本篇文章不再重複背景知識,主要將追蹤rootfs初始化的流程作點整理,免得自己日後忘記。 :-) file system與特定CPU架構無關,所以我觀察的起點從init/main.c的start_kernel()開始,這是Linux作完基本CPU初始化後首先跳進的C function(我閱讀的版本為 3.12 )。跟root file system有關的流程羅列如下: start_kernel()         -> vfs_caches_init_early()         -> vfs_caches_init()                 -> mnt_init()                         -> init_rootfs()                         -> init_mount_tree()         -> rest_init()                 -> kernel_thread(kernel_init,...) 其中比較重要的是mnt_int()中的init_rootfs()與init_mout_tree()。init_rootfs()實作如下: int __init init_rootfs(void) {         int err = register_filesystem(&rootfs_fs_type);         if (err)                 return err;         if (IS_ENABLED(CONFIG_TMPFS) && !saved_root_name[0] &&                 (!root_fs_names || strstr(root_fs_names, "tmpfs"))) {          

誰在呼叫我?不同的backtrace實作說明好文章

今天下班前一個同事問到:如何在Linux kernel的function中主動印出backtrace以方便除錯? 寫過kernel module的人都知道,基本上就是用dump_stack()之類的function就可以作到了。但是dump_stack()的功能是如何作到的呢?概念上其實並不難,慣用手法就是先觀察stack在function call時的變化(一般OS或計組教科書都有很好的說明,如果不想翻書,可以參考 這篇 ),然後將對應的return address一層一層找出來後,再將對應的function名稱印出即可(透過執行檔中的section去讀取函式名稱即可,所以要將KALLSYM選項打開)。在userspace的實作可參考Jserv介紹過的 whocallme 或對岸好手實作過的 backtrace() ,都是針對x86架構的很好說明文章。 不過從前面兩篇文章可以知道,只要知道編譯器的calling convention,就可以實作出backtrace,所以是否GCC有提供現成的機制呢?Yes, that is what __builtin_return_address() for!! 可以參考這篇 文章 。該篇文章還提到了其他可以拿來實作功能更齊全的backtrace的 程式庫 ,在了解了運作原理後,用那些東西還蠻方便的。 OK,那Linux kernel是怎麼做的呢?就是用頭兩篇文章的方式啦~ 每個不同的CPU架構各自手工實作一份dump_stack()。 為啥不用GCC的機制?畢竟...嗯,我猜想,除了backtrace以外,開發者還會想看其他register的值,還有一些有的沒的,所以光是GCC提供的介面是很難印出全部所要的資訊,與其用半套GCC的機制,不如全都自己來~ arm的實作 大致上長這樣,可以看到基本上就只是透過迭代fp, lr, pc來完成: 352 void unwind_backtrace (struct pt_regs * regs , struct task_struct *tsk) 353 { 354 struct stackframe frame ; 355 register unsigned long current_sp asm ( "

kernel panic之後怎麼辦?

今天同事在處理一個陌生的模組時遇到kernel panic,Linux印出了backtrace,同事大致上可以知道是在哪個function中,但該function的長度頗長,短時間無法定位在哪個位置,在這種情況下,要如何收斂除錯範圍呢?更糟的是,由於加入printk會改變模組行為,所以printk基本上無法拿來檢查參數的值是否正常。 一般這樣的問題會backtrace的資訊來著手。從這個資訊我們可以知道在function中的多少offset發生錯誤,以x86為例(從 LDD3 借來的例子): Unable to handle kernel NULL pointer dereference at virtual address 00000000 printing eip: d083a064 Oops: 0002 [#1] SMP CPU:    0 EIP:    0060:[<d083a064>]    Not tainted EFLAGS: 00010246   (2.6.6) EIP is at faulty_write+0x4/0x10 [faulty] eax: 00000000   ebx: 00000000   ecx: 00000000   edx: 00000000 esi: cf8b2460   edi: cf8b2480   ebp: 00000005   esp: c31c5f74 ds: 007b   es: 007b   ss: 0068 Process bash (pid: 2086, threadinfo=c31c4000 task=cfa0a6c0) Stack: c0150558 cf8b2460 080e9408 00000005 cf8b2480 00000000 cf8b2460 cf8b2460        fffffff7 080e9408 c31c4000 c0150682 cf8b2460 080e9408 00000005 cf8b2480        00000000 00000001 00000005 c0103f8f 00000001 080e9408 00000005 00000005 Call Trace:  [<c0150558>] vfs