調試器工做原理

調試器工做原理(1):基礎篇

本文是一系列探究調試器工做原理的文章的第一篇。我還不肯定這個系列須要包括多少篇文章以及它們所涵蓋的主題,但我打算從基礎知識開始提及。html

關於本文linux

我打算在這篇文章中介紹關於Linux下的調試器實現的主要組成部分——ptrace系統調用。本文中出現的代碼都在32位的Ubuntu系統上開發。請注意,這裏出現的代碼是同平臺緊密相關的,但移植到別的平臺上應該不會太難。ios

動機c++

要想理解咱們究竟要作什麼,試着想象一下調試器是如何工做的。調試器能夠啓動某些進程,而後對其進行調試,或者將本身自己關聯到一個已存在的進程之上。它能夠單步運行代碼,設置斷點而後運行程序,檢查變量的值以及跟蹤調用棧。許多調試器已經擁有了一些高級特性,好比執行表達式並在被調試進程的地址空間中調用函數,甚至能夠直接修改進程的代碼並觀察修改後的程序行爲。git

儘管現代的調試器都是複雜的大型程序,但使人驚訝的是構建調試器的基礎確是如此的簡單。調試器只用到了幾個由操做系統以及編譯器/連接器提供的基礎服務,剩下的僅僅就是簡單的編程問題了。(可查閱維基百科中關於這個詞條的解釋,做者是在反諷)程序員

Linux下的調試——ptracegithub

Linux下調試器擁有一個瑞士軍刀般的工具,這就是ptrace系統調用。這是一個功能衆多且至關複雜的工具,能容許一個進程控制另外一個進程的運行,並且能夠監視和滲入到進程內部。ptrace自己須要一本中等篇幅的書才能對其進行完整的解釋,這就是爲何我只打算經過例子把重點放在它的實際用途上。讓咱們繼續深刻探尋。web

 

遍歷進程的代碼redis

我如今要寫一個在「跟蹤」模式下運行的進程的例子,這裏咱們要單步遍歷這個進程的代碼——由CPU所執行的機器碼(彙編指令)。我會在這裏給出例子代碼,解釋每一個部分,本文結尾處你能夠經過連接下載一份完整的C程序文件,能夠自行編譯執行並研究。從高層設計來講,咱們要寫一個程序,它產生一個子進程用來執行一個用戶指定的命令,而父進程跟蹤這個子進程。首先,main函數是這樣的:shell

int main(int argc, char** argv)
{
    pid_t child_pid;
 
    if (argc < 2) {
        fprintf(stderr, "Expected a program name as argument\n");
        return -1;
    }
 
    child_pid = fork();
    if (child_pid == 0)
        run_target(argv[1]);
    else if (child_pid > 0)
        run_debugger(child_pid);
    else {
        perror("fork");
        return -1;
    }
 
    return 0;
}

 

代碼至關簡單,咱們經過fork產生一個新的子進程。隨後的if語句塊處理子進程(這裏稱爲「目標進程」),而else if語句塊處理父進程(這裏稱爲「調試器」)。下面是目標進程:

這部分最有意思的地方在ptrace調用。ptrace的原型是(在sys/ptrace.h):

long ptrace(enum __ptrace_request request,  pid_t pid, void *addr,  void *data);
 

第一個參數是request,能夠是預約義的以PTRACE_打頭的常量值。第二個參數指定了進程id,第三以及第四個參數是地址和指向數據的指針,用來對內存作操做。上面代碼段中的ptrace調用使用了PTRACE_TRACEME請求,這表示這個子進程要求操做系統內核容許它的父進程對其跟蹤。這個請求在man手冊中解釋的很是清楚:

「代表這個進程由它的父進程來跟蹤。任何發給這個進程的信號(除了SIGKILL)將致使該進程中止運行,而它的父進程會經過wait()得到通知。另外,該進程以後全部對exec()的調用都將使操做系統產生一個SIGTRAP信號發送給它,這讓父進程有機會在新程序開始執行以前得到對子進程的控制權。若是不但願由父進程來跟蹤的話,那就不該該使用這個請求。(pid、addr、data被忽略)」

我已經把這個例子中咱們感興趣的地方高亮顯示了。注意,run_target在ptrace調用以後緊接着作的是經過execl來調用咱們指定的程序。這裏就會像咱們高亮顯示的部分所解釋的那樣,操做系統內核會在子進程開始執行execl中指定的程序以前中止該進程,併發送一個信號給父進程。

所以,是時候看看父進程須要作些什麼了:

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");
 
    /* Wait for child to stop on its first instruction */
    wait(&wait_status);
 
    while (WIFSTOPPED(wait_status)) {
        icounter++;
        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }
 
        /* Wait for child to stop on its next instruction */
        wait(&wait_status);
    }
 
    procmsg("the child executed %u instructions\n", icounter);
}

經過上面的代碼咱們能夠回顧一下,一旦子進程開始執行exec調用,它就會中止而後接收到一個SIGTRAP信號。父進程經過第一個wait調用正在等待這個事件發生。一旦子進程中止(若是子進程因爲發送的信號而中止運行,WIFSTOPPED就返回true),父進程就去檢查這個事件。

父進程接下來要作的是本文中最有意思的地方。父進程經過PTRACE_SINGLESTEP以及子進程的id號來調用ptrace。這麼作是告訴操做系統——請從新啓動子進程,但當子進程執行了下一條指令後再將其中止。而後父進程再次等待子進程的中止,整個循環繼續得以執行。當從wait中獲得的不是關於子進程中止的信號時,循環結束。在正常運行這個跟蹤程序時,會獲得子進程正常退出(WIFEXITED會返回true)的信號。

icounter會統計子進程執行的指令數量。所以咱們這個簡單的例子實際上仍是作了點有用的事情——經過在命令行上指定一個程序名,咱們的例子會執行這個指定的程序,而後統計出從開始到結束該程序執行過的CPU指令總數。讓咱們看看實際運行的狀況。

 

實際測試

我編譯了下面這個簡單的程序,而後在咱們的跟蹤程序下執行:

#include <stdio.h>
int main()
{
    printf(「Hello, world!\n」);
    return 0;
}

令我驚訝的是,咱們的跟蹤程序運行了很長的時間而後報告顯示一共有超過100000條指令獲得了執行。僅僅只是一個簡單的printf調用,爲何會這樣?答案很是有意思。默認狀況下,Linux中的gcc編譯器會動態連接到C運行時庫。這意味着任何程序在運行時首先要作的事情是加載動態庫。這須要不少代碼實現——記住,咱們這個簡單的跟蹤程序會針對每一條被執行的指令計數,不只僅是main函數,而是整個進程。

所以,當我採用-static標誌靜態連接這個測試程序時(注意到可執行文件所以增長了500KB的大小,由於它靜態連接了C運行時庫),咱們的跟蹤程序報告顯示只有7000條左右的指令被執行了。這仍是很是多,但若是你瞭解到libc的初始化工做仍然先於main的執行,而清理工做會在main以後執行,那麼這就徹底說得通了。並且,printf也是一個複雜的函數。

咱們仍是不知足於此,我但願能看到一些可檢測的東西,例如我能夠從總體上看到每一條須要被執行的指令是什麼。這一點咱們能夠經過彙編代碼來獲得。所以我把這個「Hello,world」程序彙編(gcc -S)爲以下的彙編碼:

section    .text
    ; The _start symbol must be declared for the linker (ld)
    global _start
 
_start:
 
    ; Prepare arguments for the sys_write system call:
    ;   - eax: system call number (sys_write)
    ;   - ebx: file descriptor (stdout)
    ;   - ecx: pointer to string
    ;   - edx: string length
    mov    edx, len
    mov    ecx, msg
    mov    ebx, 1
    mov    eax, 4
 
    ; Execute the sys_write system call
    int    0x80
 
    ; Execute sys_exit
    mov    eax, 1
    int    0x80
 
section   .data
msg db    'Hello, world!', 0xa
len equ    $ - msg

這就足夠了。如今跟蹤程序會報告有7條指令獲得了執行,我能夠很容易地從彙編代碼來驗證這一點。

 

深刻指令流

彙編碼程序得以讓我爲你們介紹ptrace的另外一個強大的功能——詳細檢查被跟蹤進程的狀態。下面是run_debugger函數的另外一個版本:

void run_debugger(pid_t child_pid)
{
    int wait_status;
    unsigned icounter = 0;
    procmsg("debugger started\n");
 
    /* Wait for child to stop on its first instruction */
    wait(&wait_status);
 
    while (WIFSTOPPED(wait_status)) {
        icounter++;
        struct user_regs_struct regs;
        ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
        unsigned instr = ptrace(PTRACE_PEEKTEXT, child_pid, regs.eip, 0);
 
        procmsg("icounter = %u.  EIP = 0x%08x.  instr = 0x%08x\n",
                    icounter, regs.eip, instr);
 
        /* Make the child execute another instruction */
        if (ptrace(PTRACE_SINGLESTEP, child_pid, 0, 0) < 0) {
            perror("ptrace");
            return;
        }
 
        /* Wait for child to stop on its next instruction */
        wait(&wait_status);
    }
 
    procmsg("the child executed %u instructions\n", icounter);
}

同前個版本相比,惟一的不一樣之處在於while循環的開始幾行。這裏有兩個新的ptrace調用。第一個讀取進程的寄存器值到一個結構體中。結構體user_regs_struct定義在sys/user.h中。這兒有個有趣的地方——若是你打開這個頭文件看看,靠近文件頂端的地方有一條這樣的註釋:

/* 本文件的惟一目的是爲GDB,且只爲GDB所用。對於這個文件,不要看的太多。除了GDB之外不要用於任何其餘目的,除非你知道你正在作什麼。*/

如今,我不知道你是怎麼想的,但我感受咱們正處於正確的跑道上。不管如何,回到咱們的例子上來。一旦咱們將全部的寄存器值獲取到regs中,咱們就能夠經過PTRACE_PEEKTEXT標誌以及將regs.eip(x86架構上的擴展指令指針)作參數傳入ptrace來調用。咱們所獲得的就是指令。讓咱們在彙編代碼上運行這個新版的跟蹤程序。

$ simple_tracer traced_helloworld
[5700] debugger started
[5701] target started. will run 'traced_helloworld'
[5700] icounter = 1.  EIP = 0x08048080.  instr = 0x00000eba
[5700] icounter = 2.  EIP = 0x08048085.  instr = 0x0490a0b9
[5700] icounter = 3.  EIP = 0x0804808a.  instr = 0x000001bb
[5700] icounter = 4.  EIP = 0x0804808f.  instr = 0x000004b8
[5700] icounter = 5.  EIP = 0x08048094.  instr = 0x01b880cd
Hello, world!
[5700] icounter = 6.  EIP = 0x08048096.  instr = 0x000001b8
[5700] icounter = 7.  EIP = 0x0804809b.  instr = 0x000080cd
[5700] the child executed 7 instructions
 

OK,因此如今除了icounter之外,咱們還能看到指令指針以及每一步的指令。如何驗證這是否正確呢?能夠經過在可執行文件上執行objdump –d來實現:

$ objdump -d traced_helloworld
 
traced_helloworld:     file format elf32-i386
 
Disassembly of section .text:
 
08048080 <.text>:
8048080:     ba 0e 00 00 00          mov    $0xe,%edx
8048085:     b9 a0 90 04 08          mov    $0x80490a0,%ecx
804808a:     bb 01 00 00 00          mov    $0x1,%ebx
804808f:     b8 04 00 00 00          mov    $0x4,%eax
8048094:     cd 80                   int    $0x80
8048096:     b8 01 00 00 00          mov    $0x1,%eax
804809b:     cd 80                   int    $0x80
 

用這份輸出對比咱們的跟蹤程序輸出,應該很容易觀察到相同的地方。

 

關聯到運行中的進程上

你已經知道了調試器也能夠關聯到已經處於運行狀態的進程上。看到這裏,你應該不會感到驚訝,這也是經過ptrace來實現的。這須要經過PTRACE_ATTACH請求。這裏我不會給出一段樣例代碼,由於經過咱們已經看到的代碼,這應該很容易實現。基於教學的目的,這裏採用的方法更爲便捷(由於咱們能夠在子進程剛啓動時馬上將它中止)。

 

代碼

本文給出的這個簡單的跟蹤程序的完整代碼(更高級一點,能夠將具體指令打印出來)能夠在這裏找到。程序經過-Wall –pedantic –std=c99編譯選項在4.4版的gcc上編譯。

 

結論及下一步要作的

誠然,本文並無涵蓋太多的內容——咱們離一個真正可用的調試器還差的很遠。可是,我但願這篇文章至少已經揭開了調試過程的神祕面紗。ptrace是一個擁有許多功能的系統調用,目前咱們只展現了其中少數幾種功能。

可以單步執行代碼是頗有用處的,但做用有限。以「Hello, world」爲例,要到達main函數,須要先遍歷好幾千條初始化C運行時庫的指令。這就不太方便了。咱們所但願的理想方案是能夠在main函數入口處設置一個斷點,從斷點處開始單步執行。下一篇文章中我將向您展現該如何實現斷點機制。

 

參考文獻

寫做本文時我發現下面這些文章頗有幫助:

 

 

 

 

調試器工做原理(2):實現斷點

 

本文是關於調試器工做原理探究系列的第二篇。在開始閱讀本文前,請先確保你已經讀過本系列的第一篇(基礎篇)

本文的主要內容

這裏我將說明調試器中的斷點機制是如何實現的。斷點機制是調試器的兩大主要支柱之一 ——另外一個是在被調試進程的內存空間中查看變量的值。咱們已經在第一篇文章中稍微涉及到了一些監視被調試進程的知識,但斷點機制仍然仍是個迷。閱讀完本文以後,這將再也不是什麼祕密了。

軟中斷

要在x86體系結構上實現斷點咱們要用到軟中斷(也稱爲「陷阱」trap)。在咱們深刻細節以前,我想先大體解釋一下中斷和陷阱的概念。

CPU有一個單獨的執行序列,會一條指令一條指令的順序執行。要處理相似IO或者硬件時鐘這樣的異步事件時CPU就要用到中斷。硬件中斷一般是一個專門的電信號,鏈接到一個特殊的「響應電路」上。這個電路會感知中斷的到來,而後會使CPU中止當前的執行流,保存當前的狀態,而後跳轉到一個預約義的地址處去執行,這個地址上會有一箇中斷處理例程。當中斷處理例程完成它的工做後,CPU就從以前中止的地方恢復執行。

軟中斷的原理相似,但實際上有一點不一樣。CPU支持特殊的指令容許經過軟件來模擬一箇中斷。當執行到這個指令時,CPU將其當作一箇中斷——中止當前正常的執行流,保存狀態而後跳轉到一個處理例程中執行。這種「陷阱」讓許多現代的操做系統得以有效完成不少複雜任務(任務調度、虛擬內存、內存保護、調試等)。

一些編程錯誤(好比除0操做)也被CPU當作一個「陷阱」,一般被認爲是「異常」。這裏軟中斷同硬件中斷之間的界限就變得模糊了,由於這裏很難說這種異常究竟是硬件中斷仍是軟中斷引發的。我有些偏離主題了,讓咱們回到關於斷點的討論上來。

關於int 3指令

看過前一節後,如今我能夠簡單地說斷點就是經過CPU的特殊指令——int 3來實現的。int就是x86體系結構中的「陷阱指令」——對預約義的中斷處理例程的調用。x86支持int指令帶有一個8位的操做數,用來指定所發生的中斷號。所以,理論上能夠支持256種「陷阱」。前32個由CPU本身保留,這裏第3號就是咱們感興趣的——稱爲「trap to debugger」。

很少說了,我這裏就引用「聖經」中的原話吧(這裏的聖經就是Intel’s Architecture software developer’s manual, volume2A):

「INT 3指令產生一個特殊的單字節操做碼(CC),這是用來調用調試異常處理例程的。(這個單字節形式很是有價值,由於這樣能夠經過一個斷點來替換掉任何指令的第一個字節,包括其它的單字節指令也是同樣,而不會覆蓋到其它的操做碼)。」

上面這段話很是重要,但如今解釋它仍是太早,咱們稍後再來看。

使用int 3指令

是的,懂得事物背後的原理是很棒的,可是這到底意味着什麼?咱們該如何使用int 3來實現斷點機制?套用常見的編程問答中出現的對話——請用代碼說話!

實際上這真的很是簡單。一旦你的進程執行到int 3指令時,操做系統就將它暫停。在Linux上(本文關注的是Linux平臺),這會給該進程發送一個SIGTRAP信號。

這就是所有——真的!如今回顧一下本系列文章的第一篇,跟蹤(調試器)進程能夠得到全部其子進程(或者被關聯到的進程)所獲得信號的通知,如今你知道咱們該作什麼了吧?

就是這樣,再沒有什麼計算機體系結構方面的東東了,該寫代碼了。

手動設定斷點

如今我要展現如何在程序中設定斷點。用於這個示例的目標程序以下:

section    .text
    ; The _start symbol must be declared for the linker (ld)
    global _start
 
_start:
 
    ; Prepare arguments for the sys_write system call:
    ;   - eax: system call number (sys_write)
    ;   - ebx: file descriptor (stdout)
    ;   - ecx: pointer to string
    ;   - edx: string length
    mov     edx, len1
    mov     ecx, msg1
    mov     ebx, 1
    mov     eax, 4
 
    ; Execute the sys_write system call
    int     0x80
 
    ; Now print the other message
    mov     edx, len2
    mov     ecx, msg2
    mov     ebx, 1
    mov     eax, 4
    int     0x80
 
    ; Execute sys_exit
    mov     eax, 1
    int     0x80
 
section    .data
 
msg1    db      'Hello,', 0xa
len1    equ     $ - msg1
msg2    db      'world!', 0xa
len2    equ     $ - msg2

 

我如今使用的是彙編語言,這是爲了不當使用C語言時涉及到的編譯和符號的問題。上面列出的程序功能就是在一行中打印「Hello,」,而後在下一行中打印「world!」。這個例子與上一篇文章中用到的例子很類似。

我但願設定的斷點位置應該在第一條打印以後,但剛好在第二條打印以前。咱們就讓斷點打在第一個int 0x80指令以後吧,也就是mov edx, len2。首先,我須要知道這條指令對應的地址是什麼。運行objdump –d:

 

traced_printer2:     file format elf32-i386
 
Sections:
Idx Name          Size      VMA       LMA       File off  Algn
  0 .text         00000033  08048080  08048080  00000080  2**4
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
  1 .data         0000000e  080490b4  080490b4  000000b4  2**2
                  CONTENTS, ALLOC, LOAD, DATA
 
Disassembly of section .text:
 
08048080 <.text>:
8048080:     ba 07 00 00 00          mov    $0x7,%edx
8048085:     b9 b4 90 04 08          mov    $0x80490b4,%ecx
804808a:     bb 01 00 00 00          mov    $0x1,%ebx
804808f:     b8 04 00 00 00          mov    $0x4,%eax
8048094:     cd 80                   int    $0x80
8048096:     ba 07 00 00 00          mov    $0x7,%edx
804809b:     b9 bb 90 04 08          mov    $0x80490bb,%ecx
80480a0:     bb 01 00 00 00          mov    $0x1,%ebx
80480a5:     b8 04 00 00 00          mov    $0x4,%eax
80480aa:     cd 80                   int    $0x80
80480ac:     b8 01 00 00 00          mov    $0x1,%eax
80480b1:     cd 80                   int    $0x80

 

經過上面的輸出,咱們知道要設定的斷點地址是0x8048096。等等,真正的調試器不是像這樣工做的,對吧?真正的調試器能夠根據代碼行數或者函數名稱來設定斷點,而不是基於什麼內存地址吧?很是正確。可是咱們離那個標準還差的遠——若是要像真正的調試器那樣設定斷點,咱們還須要涵蓋符號表以及調試信息方面的知識,這須要用另外一篇文章來講明。至於如今,咱們還必須得經過內存地址來設定斷點。

看到這裏我真的很想再扯一點題外話,因此你有兩個選擇。若是你真的對於爲何地址是0x8048096,以及這表明什麼意思很是感興趣的話,接着看下一節。若是你對此毫無興趣,只是想看看怎麼設定斷點,能夠略過這一部分。

題外話——進程地址空間以及入口點

坦白的說,0x8048096自己並無太大意義,這只不過是相對可執行鏡像的代碼段(text section)開始處的一個偏移量。若是你仔細看看前面objdump出來的結果,你會發現代碼段的起始位置是0x08048080。這告訴了操做系統要將代碼段映射到進程虛擬地址空間的這個位置上。在Linux上,這些地址能夠是絕對地址(好比,有的可執行鏡像加載到內存中時是不可重定位的),由於在虛擬內存系統中,每一個進程都有本身獨立的內存空間,並把整個32位的地址空間都看作是屬於本身的(稱爲線性地址)。

若是咱們經過readelf工具來檢查可執行文件的ELF頭,咱們將獲得以下輸出:

$ readelf -h traced_printer2
ELF Header:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00
  Class:                                 ELF32
  Data:                                  2's complement, little endian
  Version:                               1 (current)
  OS/ABI:                                UNIX - System V
  ABI Version:                           0
  Type:                                  EXEC (Executable file)
  Machine:                               Intel 80386
  Version:                               0x1
  Entry point address:                   0x8048080
  Start of program headers:              52 (bytes into file)
  Start of section headers:              220 (bytes into file)
  Flags:                                 0x0
  Size of this header:                   52 (bytes)
  Size of program headers:               32 (bytes)
  Number of program headers:             2
  Size of section headers:               40 (bytes)
  Number of section headers:             4
  Section header string table index:     3

 

注意,ELF頭的「entry point address」一樣指向的是0x8048080。所以,若是咱們把ELF文件中的這個部分解釋給操做系統的話,就表示:

1.  將代碼段映射到地址0x8048080處

2.  從入口點處開始執行——地址0x8048080

可是,爲何是0x8048080呢?它的出現是因爲歷史緣由引發的。每一個進程的地址空間的前128MB被保留給棧空間了(注:這一部分緣由可參考Linkers and Loaders)。128MB恰好是0x80000000,可執行鏡像中的其餘段能夠從這裏開始。0x8048080是Linux下的連接器ld所使用的默認入口點。這個入口點能夠經過傳遞參數-Ttext給ld來進行修改。

所以,獲得的結論是這個地址並無什麼特別的,咱們能夠自由地修改它。只要ELF可執行文件的結構正確且在ELF頭中的入口點地址同程序代碼段(text section)的實際起始地址相吻合就OK了。

經過int 3指令在調試器中設定斷點

要在被調試進程中的某個目標地址上設定一個斷點,調試器須要作下面兩件事情:

1.  保存目標地址上的數據

2.  將目標地址上的第一個字節替換爲int 3指令

而後,當調試器向操做系統請求開始運行進程時(經過前一篇文章中提到的PTRACE_CONT),進程最終必定會碰到int 3指令。此時進程中止,操做系統將發送一個信號。這時就是調試器再次出馬的時候了,接收到一個其子進程(或被跟蹤進程)中止的信號,而後調試器要作下面幾件事:

1.  在目標地址上用原來的指令替換掉int 3

2.  將被跟蹤進程中的指令指針向後遞減1。這麼作是必須的,由於如今指令指針指向的是已經執行過的int 3以後的下一條指令。

3.  因爲進程此時仍然是中止的,用戶能夠同被調試進程進行某種形式的交互。這裏調試器可讓你查看變量的值,檢查調用棧等等。

4.  當用戶但願進程繼續運行時,調試器負責將斷點再次加到目標地址上(因爲在第一步中斷點已經被移除了),除非用戶但願取消斷點。

讓咱們看看這些步驟如何轉化爲實際的代碼。咱們將沿用第一篇文章中展現過的調試器「模版」(fork一個子進程,而後對其跟蹤)。不管如何,本文結尾處會給出完整源碼的連接。

/* Obtain and show child's instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child started. EIP = 0x%08x\n", regs.eip);
 
/* Look at the word at the address we're interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08x\n", addr, data);

 

這裏調試器從被跟蹤進程中獲取到指令指針,而後檢查當前位於地址0x8048096處的字長內容。運行本文前面列出的彙編碼程序,將打印出:

[13028] Child started. EIP = 0x08048080
[13028] Original data at 0x08048096: 0x000007ba

 

目前爲止一切順利,下一步:

/* Write the trap instruction 'int 3' into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);
 
/* See what's there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08x\n", addr, readback_data);

 

注意看咱們是如何將int 3指令插入到目標地址上的。這部分代碼將打印出:

[13028] After trap, data at 0x08048096: 0x000007cc
 
再一次如同預計的那樣——0xba被0xcc取代了。調試器如今運行子進程而後等待子進程在斷點處中止住。
/* Let the child run to the breakpoint and wait for it to
** reach it
*/
ptrace(PTRACE_CONT, child_pid, 0, 0);
 
wait(&wait_status);
if (WIFSTOPPED(wait_status)) {
    procmsg("Child got a signal: %s\n", strsignal(WSTOPSIG(wait_status)));
}
else {
    perror("wait");
    return;
}
 
/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, ®s);
procmsg("Child stopped at EIP = 0x%08x\n", regs.eip);
 
 
這段代碼打印出:
Hello,
[13028] Child got a signal: Trace/breakpoint trap
[13028] Child stopped at EIP = 0x08048097
 
 

注意,「Hello,」在斷點以前打印出來了——同咱們計劃的同樣。同時咱們發現子進程已經中止運行了——就在這個單字節的陷阱指令執行以後。

/* Remove the breakpoint by restoring the previous data
** at the target address, and unwind the EIP back by 1 to
** let the CPU execute the original instruction that was
** there.
*/
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data);
regs.eip -= 1;
ptrace(PTRACE_SETREGS, child_pid, 0, ®s);
 
/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);
 
 

這會使子進程打印出「world!」而後退出,同以前計劃的同樣。

注意,咱們這裏並無從新加載斷點。這能夠在單步模式下執行,而後將陷阱指令加回去,再作PTRACE_CONT就能夠了。本文稍後介紹的debug庫實現了這個功能。

更多關於int 3指令

如今是回過頭來講說int 3指令的好機會,以及解釋一下Intel手冊中對這條指令的奇怪說明。

「這個單字節形式很是有價值,由於這樣能夠經過一個斷點來替換掉任何指令的第一個字節,包括其它的單字節指令也是同樣,而不會覆蓋到其它的操做碼。」

x86架構上的int指令佔用2個字節——0xcd加上中斷號。int 3的二進制形式能夠被編碼爲cd 03,但這裏有一個特殊的單字節指令0xcc以一樣的做用而被保留。爲何要這樣作呢?由於這容許咱們在插入一個斷點時覆蓋到的指令不會多於一條。這很重要,考慮下面的示例代碼:

.. some code ..
    jz    foo
    dec   eax
foo:
    call  bar
    .. some code ..
 
 

假設咱們要在dec eax上設定斷點。這剛好是條單字節指令(操做碼是0x48)。若是替換爲斷點的指令長度超過1字節,咱們就被迫改寫了接下來的下一條指令(call),這可能會產生一些徹底非法的行爲。考慮一下條件分支jz foo,這時進程可能不會在dec eax處中止下來(咱們在此設定的斷點,改寫了原來的指令),而是直接執行了後面的非法指令。

經過對int 3指令採用一個特殊的單字節編碼就能解決這個問題。由於x86架構上指令最短的長度就是1字節,這樣咱們能夠保證只有咱們但願中止的那條指令被修改。

封裝細節

前面幾節中的示例代碼展現了許多底層的細節,這些能夠很容易地經過API進行封裝。我已經作了一些封裝,使其成爲一個小型的調試庫——debuglib。代碼在本文末尾處能夠下載。這裏我只想介紹下它的用法,咱們要開始調試C程序了。

跟蹤C程序

目前爲止爲了簡單起見我把重點放在對彙編程序的跟蹤上了。如今升一級來看看咱們該如何跟蹤一個C程序。

其實事情並無很大的不一樣——只是如今有點難以找到放置斷點的位置。考慮以下這個簡單的C程序:

#include <stdio.h>
 
void do_stuff()
{
    printf("Hello, ");
}
 
int main()
{
    for (int i = 0; i < 4; ++i)
        do_stuff();
    printf("world!\n");
    return 0;
}
 
 
假設我想在do_stuff的入口處設置一個斷點。我將請出咱們的老朋友objdump來反彙編可執行文件,但獲得的輸出太多。其實,查看text段不太管用,由於這裏麪包含了大量的初始化C運行時庫的代碼,我目前對此並不感興趣。因此,咱們只須要在dump出來的結果裏看do_stuff部分就行了。
080483e4 <do_stuff>:
80483e4:     55                      push   %ebp
80483e5:     89 e5                   mov    %esp,%ebp
80483e7:     83 ec 18                sub    $0x18,%esp
80483ea:     c7 04 24 f0 84 04 08    movl   $0x80484f0,(%esp)
80483f1:     e8 22 ff ff ff          call   8048318 <puts@plt>
80483f6:     c9                      leave
80483f7:     c3                      ret
 
 
好的,因此咱們應該把斷點設定在0x080483e4上,這是do_stuff的第一條指令。另外,因爲這個函數是在循環體中調用的,咱們但願在循環所有結束前保留斷點,讓程序能夠在每一輪循環中都在斷點處停下。我將使用debuglib來簡化代碼編寫。這裏是完整的調試器函數:
void run_debugger(pid_t child_pid)
{
    procmsg("debugger started\n");
 
    /* Wait for child to stop on its first instruction */
    wait(0);
    procmsg("child now at EIP = 0x%08x\n", get_child_eip(child_pid));
 
    /* Create breakpoint and run to it*/
    debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
    procmsg("breakpoint created\n");
    ptrace(PTRACE_CONT, child_pid, 0, 0);
    wait(0);
 
    /* Loop as long as the child didn't exit */
    while (1) {
        /* The child is stopped at a breakpoint here. Resume its
        ** execution until it either exits or hits the
        ** breakpoint again.
        */
        procmsg("child stopped at breakpoint. EIP = 0x%08X\n", get_child_eip(child_pid));
        procmsg("resuming\n");
        int rc = resume_from_breakpoint(child_pid, bp);
 
        if (rc == 0) {
            procmsg("child exited\n");
            break;
        }
        else if (rc == 1) {
            continue;
        }
        else {
            procmsg("unexpected: %d\n", rc);
            break;
        }
    }
 
    cleanup_breakpoint(bp);
}
 
 
咱們不用手動修改EIP指針以及目標進程的內存空間,咱們只須要經過create_breakpoint, resume_from_breakpoint以及cleanup_breakpoint來操做就能夠了。咱們來看看當跟蹤這個簡單的C程序後的打印輸出:
 
$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run 'traced_c_loop'
[13363] child now at EIP = 0x00a37850
[13363] breakpoint created
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
[13363] child stopped at breakpoint. EIP = 0x080483E5
[13363] resuming
Hello,
world!
[13363] child exited
 

跟預計的狀況如出一轍!

代碼

這裏是完整的源碼。在文件夾中你會發現:

debuglib.h以及debuglib.c——封裝了調試器的一些內部工做。

bp_manual.c —— 本文一開始介紹的「手動」式設定斷點。用到了debuglib庫中的一些樣板代碼。

bp_use_lib.c—— 大部分代碼用到了debuglib,這就是本文中用於說明跟蹤一個C程序中的循環的示例代碼。

結論及下一步要作的

咱們已經涵蓋了如何在調試器中實現斷點機制。儘管實現細節根據操做系統的不一樣而有所區別,但只要你使用的是x86架構的處理器,那麼一切變化都基於相同的主題——在咱們但願中止的指令上將其替換爲int 3。

我敢確定,有些讀者就像我同樣,對於經過指定原始地址來設定斷點的作法不會感到很激動。咱們更但願說「在do_stuff上停住」,甚至是「在do_stuff的這一行上停住」,而後調試器就能照辦。在下一篇文章中,我將向您展現這是如何作到的。

 

 

 

 

 

調試器工做原理(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。

咱們運行

objdumpdwarf=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來講,這個值是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變量的值,調試器怎麼知道去哪裏找到相關的信息呢?這可比定位函數要難多了,由於變量能夠在全局數據區,能夠在棧上,甚至是在寄存器中。另外,具備相同名稱的變量在不一樣的詞法做用域中可能有不一樣的值。調試信息必須可以反映出全部這些變化,而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屬性的值是0x0(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留下了什麼線索?咱們感興趣的只是在地址0x8048610上的指令執行事後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的入口點——地址0x8040604。接下第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給我提些建議吧。

 

 

 

幾個主要軟件調試方法及調試原則

調試(Debug)

 

軟件調試是在進行了成功的測試以後纔開始的工做,它與軟件測試不一樣,調試的任務是進一步診斷和改正程序中潛在的錯誤。

 

調試活動由兩部分組成:

u  肯定程序中可疑錯誤的確切性質和位置

u  對程序(設計,編碼)進行修改,排除這個錯誤

 

調試工做是一個具備很強技巧性的工做

 

軟件運行失效或出現問題,每每只是潛在錯誤的外部表現,而外部表現與內在緣由之間經常沒有明顯的聯繫,若是要找出真正的緣由,排除潛在的錯誤,不是一件易事。

 

能夠說,調試是經過現象,找出緣由的一個思惟分析的過程。

 

調試步驟:

(1)      從錯誤的外部表現形式入手,肯定程序中出錯位置

(2)      研究有關部分的程序,找出錯誤的內在緣由

(3)      修改設計代碼,以排除這個錯誤

(4)      重複進行暴露了這個錯誤的原始測試或某些有關測試。

 

 

從技術角度來看查找錯誤的難度在於:

 

u  現象與緣由所處的位置可能相距甚遠

u  當其餘錯誤獲得糾正時,這一錯誤所表現出的現象可能會暫時消失,但併爲實際排除

u  現象其實是由一些非錯誤緣由(例如,舍入不精確)引發的

u  現象多是因爲一些不容易發現的人爲錯誤引發的

u  錯誤是因爲時序問題引發的,與處理過程無關

u  現象是因爲難於精確再現的輸入狀態(例如,實時應用中輸入順序不肯定)引發

u  現象多是週期出現的,在軟,硬件結合的嵌入式系統中經常遇到

 

 

幾種主要的調試方法

 

調試的關鍵在於推斷程序內部的錯誤位置及緣由,能夠採用如下方法:

 

強行排錯

 

這種調試方法目前使用較多,效率較低,它不須要過多的思考,比較省腦筋。例如:

經過內存所有打印來調試,在這大量的數據中尋找出錯的位置。

u  在程序特定位置設置打印語句,把打印語句插在出錯的源程序的各個關鍵變量改變部位,重要分支部位,子程序調用部位,跟蹤程序的執行,監視重要變量的變化

自動調用工具,利用某些程序語言的調試功能或專門的交互式調試工具,分析程序的動態過程,而沒必要修改程序。

 

應用以上任一種方法以前,都應當對錯誤的徵兆進行全面完全的分析,得出對出錯位置及錯誤性質的推測,再使用一種適當的調試方法來檢驗推測的正確性。

 

回溯法調試

 

這是在小程序中經常使用的一種有效的調試方法,一旦發現了錯誤,人們先分析錯誤的徵兆,肯定最早發現「症狀「的位置

而後,人工沿程序的控制流程,向回追蹤源程序代碼,直到找到錯誤根源或肯定錯誤產生的範圍,

例如,程序中發現錯誤處是某個打印語句,經過輸出值可推斷程序在這一點上變量的值,再從這一點出發,回溯程序的執行過程,反覆思考:「若是程序在這一點上的狀態(變量的值)是這樣,那麼程序在上一點的狀態必定是這樣···「直到找到錯誤所在。

 

 

 

概括法調試

 

概括法是一種從特殊推斷通常的系統化思考方法,概括法調試的基本思想是:從一些線索(錯誤徵兆)着手,經過分析它們之間的關係來找出錯誤

 

u  收集有關的數據,列出全部已知的測試用例和程序執行結果,看哪些輸入數據的運行結果是正確的,哪些輸入數據的運行通過是有錯誤的

u  組織數據

因爲概括法是從特殊到通常的推斷過程,因此須要組織整理數據,以發現規律

 

常以3W1H形式組織可用的數據

「What「列出通常現象

「Where「說明發現現象的地點

「When「列出現象發生時全部已知狀況

「How「說明現象的範圍和量級


「Yes「描述出現錯誤的3W1H;

「No「做爲比較,描述了沒有錯誤的3W1H,經過分析找出矛盾來

 

提出假設

分析線索之間的關係,利用在線索結構中觀察到的矛盾現象,設計一個或多個關於出錯緣由的假設,若是一個假設也提不出來,概括過程就須要收集更多的數據,此時,應當再設計與執行一些測試用例,以得到更多的數據。

 

證實假設

把假設與原始線索或數據進行比較,若它能徹底解釋一切現象,則假設獲得證實,不然,認爲假設不合理,或不徹底,或是存在多個錯誤,以至只能消除部分錯誤

 

 

演繹法調試

 

演繹法是一種從通常原理或前提出發,通過排除和精華的過程來推導出結論的思考方法,演繹法排錯是測試人員首先根據已有的測試用例,設想及枚舉出全部可能出錯的緣由做爲假設,而後再用原始測試數據或新的測試,從中逐個排除不可能正確的假設,最後,再用測試數據驗證餘下的假設確是出錯的緣由。

 

列舉全部可能出錯緣由的假設,把全部可能的錯誤緣由列成表,經過它們,能夠組織,分析現有數據

利用已有的測試數據,排除不正確的假設

仔細分析已有的數據,尋找矛盾,力求排除前一步列出全部緣由,若是全部緣由都被排除了,則須要補充一些數據(測試用例),以創建新的假設。

 

改進餘下的假設

利用已知的線索,進一步改進餘下的假設,使之更具體化,以即可以精確地肯定出錯位置

 

證實餘下的假設

 

 

調試原則

n  在調試方面,許多原則本質上是心理學方面的問題,調試由兩部分組成,調試原則也分紅兩組。

肯定錯誤的性質和位置的原則

u  用頭腦去分析思考與錯誤徵兆有關的信息

u  避開死衚衕

u  只把調試工具當作輔助手段來使用,利用調試工具,能夠幫助思考,但不能代替思考

u  避免用試探法,最多隻能把它當作最後手段

 

修改錯誤的原則

 

u  在出現錯誤的地方,頗有可能還有別的錯誤

u  修改錯誤的一個常見失誤是只修改了這個錯誤的徵兆或這個錯誤的表現,而沒有修改錯誤的自己。

u  小心修正一個錯誤的同時有可能會引入新的錯誤

u  修改錯誤的過程將迫令人們暫時回到程序設計階段

u  修改源代碼程序,不要改變目標代碼

 

 

 

調試手段及原理

本文將從應用程序、編譯器和調試器三個層次來說解,在不一樣的層次,有不一樣的方法,這些方法有各本身的長處和侷限。瞭解這些知識,一方面知足一下新手的好奇心,另外一方面也可能有用得着的時候。
  從應用程序的角度
  最好的狀況是從設計到編碼都紮紮實實的,避免把錯誤引入到程序中來,這纔是解決問題的根本之道。問題在於,理想狀況並不存在,現實中存在着大量有內存錯誤的程序,若是內存錯誤很容易避免,JAVA/C#的優點將不會那麼突出了。
  對於內存錯誤,應用程序本身能作的很是有限。但因爲這類內存錯誤很是典型,所佔比例很是大,所付出的努力與所得的回報相比是很是划算的,仍然值得研究。
  前面咱們講了,堆裏面的內存是由內存管理器管理的。從應用程序的角度來看,咱們能作到的就是打內存管理器的主意。其實原理很簡單:
  對付內存泄露。重載內存管理函數,在分配時,把這塊內存的記錄到一個鏈表中,在釋放時,從鏈表中刪除吧,在程序退出時,檢查鏈表是否爲空,若是不爲空,則說明有內存泄露,不然說明沒有泄露。固然,爲了查出是哪裏的泄露,在鏈表還要記錄是誰分配的,一般記錄文件名和行號就好了。
  對付內存越界/野指針。對這二者,咱們只能檢查一些典型的狀況,對其它一些狀況無能爲力,但效果仍然不錯。其方法以下(源於《Comparing and contrasting the runtime error detection technologies》):
  l 首尾在加保護邊界值
   Header
   Leading guard(0xFC)
 
   User data(0xEB)
 
   Tailing guard(0xFC)
  在內存分配時,內存管理器按如上結構填充分配出來的內存。其中Header是管理器本身用的,先後各有幾個字節的guard數據,它們的值是固定的。當內存釋放時,內存管理器檢查這些guard數據是否被修改,若是被修改,說明有寫越界。
  它的工做機制註定了有它的侷限性: 只能檢查寫越界,不能檢查讀越界,並且只能檢查連續性的寫越界,對於跳躍性的寫越界無能爲力。
  l 填充空閒內存
 
   空閒內存(0xDD) 
  內存被釋放以後,它的內容填充成固定的值。這樣,從指針指向的內存的數據,能夠大體判斷這個指針是不是野指針。
  它一樣有它的侷限:程序要主動判斷才行。若是野指針指向的內存當即被從新分配了,它又被填充成前面那個結構,這時也沒法檢查出來。
  從編譯器的角度
  boundschecker和purify的實現均可以歸於編譯器一級。前者採用一種稱爲CTI(compile-time instrumentation)的技術。VC的編譯不是要分幾個階段嗎?boundschecker在預處理和編譯兩個階段之間,對源文件進行修改。它對全部內存分配釋放、內存讀寫、指針賦值和指針計算等全部內存相關的操做進行分析,並插入本身的代碼。好比:
   Before
   if (m_hsession) gblHandles->ReleaseUserHandle( m_hsession );
   if (m_dberr) delete m_dberr;
  
   After
   if (m_hsession) {
   _Insight_stack_call(0);
   gblHandles->ReleaseUserHandle(m_hsession);
   _Insight_after_call();
   }
  
   _Insight_ptra_check(1994, (void **) &m_dberr, (void *) m_dberr);
   if (m_dberr) {
   _Insight_deletea(1994, (void **) &m_dberr, (void *) m_dberr, 0);
   delete m_dberr;
   }
   Purify則採用一種稱爲OCI(object code insertion)的技術。不一樣的是,它對可執行文件的每條指令進行分析,找出全部內存分配釋放、內存讀寫、指針賦值和指針計算等全部內存相關的操做,用本身的指令代替原始的指令。
  boundschecker和purify是商業軟件,它們的實現是保密的,甚至擁有專利的,沒法對其研究,只能找一些皮毛性的介紹。不管是CTI仍是OCI這樣的名稱,多少有些神祕感。其實它們的實現原理並不複雜,經過對valgrind和gcc的bounds checker擴展進行一些粗淺的研究,咱們能夠知道它們的大體原理。
  gcc的bounds checker基本上能夠與boundschecker對應起來,都是對源代碼進行修改,以達到控制內存操做功能,如malloc/free等內存管理函數、memcpy/strcpy/memset等內存讀取函數和指針運算等。Valgrind則與Purify相似,都是經過對目標代碼進行修改,來達到一樣的目的。
  Valgrind對可執行文件進行修改,因此不須要從新編譯程序。但它並非在執行前對可執行文件和全部相關的共享庫進行一次性修改,而是和應用程序在同一個進程中運行,動態的修改即將執行的下一段代碼。
  Valgrind是插件式設計的。Core部分負責對應用程序的總體控制,並把即將修改的代碼,轉換成一種中間格式,這種格式相似於RISC指令,而後把中間代碼傳給插件。插件根據要求對中間代碼修改,而後把修改後的結果交給core。core接下來把修改後的中間代碼轉換成原始的x86指令,並執行它。
  因而可知,不管是boundschecker、purify、gcc的bounds checker,仍是Valgrind,修改源代碼也罷,修改二進制也罷,都是代碼進行修改。究竟要修改什麼,修改爲什麼樣子呢?別急,下面咱們就要來介紹:
  管理全部內存塊。不管是堆、棧仍是全局變量,只要有指針引用它,它就被記錄到一個全局表中。記錄的信息包括內存塊的起始地址和大小等。要作到這一點並不難:對於在堆裏分配的動態內存,能夠經過重載內存管理函數來實現。對於全局變量等靜態內存,能夠從符號表中獲得這些信息。
  攔截全部的指針計算。對於指針進行乘除等運算一般意義不大,最多見運算是對指針加減一個偏移量,如++p、p=p+n、p=a[n]等。全部這些有意義的指針操做,都要受到檢查。再也不是由一條簡單的彙編指令來完成,而是由一個函數來完成。
  有了以上兩點保證,要檢查內存錯誤就很是容易了:好比要檢查++p是否有效,首先在全局表中查找p指向的內存塊,若是沒有找到,說明p是野指針。若是找到了,再檢查p+1是否在這塊內存範圍內,若是不是,那就是越界訪問,不然是正常的了。怎麼樣,簡單吧,不管是全局內存、堆仍是棧,不管是讀仍是寫,無一可以逃過出工具的法眼。
  代碼賞析(源於tcc):
  對指針運算進行檢查:
   void *__bound_ptr_add(void *p, int offset)
   {
   unsigned long addr = (unsigned long)p;
   BoundEntry *e;
   #if defined(BOUND_DEBUG)
   printf("add: 0x%x %d\n", (int)p, offset);
   #endif
  
   e = __bound_t1[addr >> (BOUND_T2_BITS + BOUND_T3_BITS)];
   e = (BoundEntry *)((char *)e +
   ((addr >> (BOUND_T3_BITS - BOUND_E_BITS)) &
   ((BOUND_T2_SIZE - 1) << BOUND_E_BITS)));
   addr -= e->start;
   if (addr > e->size) {
   e = __bound_find_region(e, p);
   addr = (unsigned long)p - e->start;
   }
   addr += offset;
   if (addr > e->size)
   return INVALID_POINTER;
   return p + offset;
   }
   static void __bound_check(const void *p, size_t size)
   {
   if (size == 0)
   return;
   p = __bound_ptr_add((void *)p, size);
   if (p == INVALID_POINTER)
   bound_error("invalid pointer");
   }
 
  重載內存管理函數:
 
   void *__bound_malloc(size_t size, const void *caller)
   {
   void *ptr;
  
  
   ptr = libc_malloc(size + 1);
  
   if (!ptr)
   return NULL;
   __bound_new_region(ptr, size);
   return ptr;
   }
   void __bound_free(void *ptr, const void *caller)
   {
   if (ptr == NULL)
   return;
   if (__bound_delete_region(ptr) != 0)
   bound_error("freeing invalid region");
  
   libc_free(ptr);
   }
  
  重載內存操做函數:
   void *__bound_memcpy(void *dst, const void *src, size_t size)
   {
   __bound_check(dst, size);
   __bound_check(src, size);
  
   if (src >= dst && src < dst + size)
   bound_error("overlapping regions in memcpy()");
   return memcpy(dst, src, size);
   }
  從調試器的角度
  如今有OS的支持,實現一個調試器變得很是簡單,至少原理再也不神祕。這裏咱們簡要介紹一下win32和linux中的調試器實現原理。
  在Win32下,實現調試器主要經過兩個函數:WaitForDebugEvent和ContinueDebugEvent。下面是一個調試器的基本模型(源於: 《Debugging Applications for Microsoft .NET and Microsoft Windows》)
   void main ( void )
   {
   CreateProcess ( ..., DEBUG_ONLY_THIS_PROCESS ,... ) ;
  
   while ( 1 == WaitForDebugEvent ( ... ) )
   {
   if ( EXIT_PROCESS )
   {
  break ;
   }
   ContinueDebugEvent ( ... ) ;
   }
   }
  由調試器起動被調試的進程,並指定DEBUG_ONLY_THIS_PROCESS標誌。按Win32下事件驅動的一向原則,由被調試的進程主動上報調試事件,調試器而後作相應的處理。
  在linux下,實現調試器只要一個函數就好了:ptrace。下面是個簡單示例:(源於《Playing with ptrace》)。
   #include <sys/ptrace.h>
   #include <sys/types.h>
   #include <sys/wait.h>
   #include <unistd.h>
   #include <linux/user.h>
   int main(int argc, char *argv[])
   { pid_t traced_process;
   struct user_regs_struct regs;
   long ins;
   if(argc != 2) {
   printf("Usage: %s <pid to be traced>\n",
   argv[0], argv[1]);
   exit(1);
   }
   traced_process = atoi(argv[1]);
   ptrace(PTRACE_ATTACH, traced_process,
   NULL, NULL);
   wait(NULL);
   ptrace(PTRACE_GETREGS, traced_process,
   NULL, &regs);
   ins = ptrace(PTRACE_PEEKTEXT, traced_process,
   regs.eip, NULL);
   printf("EIP: %lx Instruction executed: %lx\n",
   regs.eip, ins);
   ptrace(PTRACE_DETACH, traced_process,
   NULL, NULL);
   return 0;
   }
  因爲篇幅有限,這裏對於調試器的實現不做深刻討論,主要是給新手指一個方向。之後如有時間,再寫個專題來介紹linux下的調試器和ptrace自己的實現方法。

 

 

通常調試器工做原理

調試器簡介

嚴格的講,調試器是幫助程序員跟蹤,分隔和從軟件中移除bug的工具。它幫助程序員更進一步理解程序。一開始,主要是開發人員使用它,後來測試人員,維護人員也開始使用它。

調試器的發展歷程:

  • 1. 靜態存儲
  • 2. 交互式存儲分析器
  • 3. 二進制調試器
  • 4. 基本的符號調試器(源碼調試器)
  • 5. 命令行符號調試器
  • 6. 全屏文本模式調試器
  • 7. 圖形用戶接口調試器
  • 8. 集成開發環境調試器

調試器的設計和開發要遵循四個關鍵的原則:

  • 1. 在開發過程當中,不能改變被調試程序的行爲;
  • 2. 提供真實可靠的調試信息;
  • 3. 提供詳細的信息,是調試人員知道他們調試到代碼的哪一行而且知道他們是怎麼到達的;
  • 4. 很是不幸的是,咱們使用的調試老是不能知足咱們的需求。

按照劃分的標準不一樣,調試器主要分爲一下幾類:

  • 1. 源碼調試器與機器碼調試器
  • 2. 單獨的調試器與集成開發環境的調試器
  • 3. 第四代語言調試器與第三代語言調試器
  • 4. 操做系統內核調試器與應用程序調試器
  • 5. 利用處理器提供的功能的調試器與利用自行仿真處理器進行調試的調試器

 

調試器的架構

調試器之間的區別更多的是體如今他們展示給用戶的窗口。至於底層結構都是很相近的。下圖展現了調試器的整體架構:

調試器內核

調試器服務於全部的調試器視圖。包括進程控制,執行引擎,表達式計算,符號表管理四部分。

操做系統接口

調試器內核爲了訪問被調試程序,必須使用操做系統提供的一系列例程。 

 

 

硬件調試功能

調試器控制被調試程序的能力主要是依靠硬件支持和操做系統的調試機制。調試器須要最少三種的硬件功能的支持:

1.    提供設置斷點的方法;

2.    通知操做系統發生中斷或者陷阱的功能;

3.    當中斷或者陷阱發生時,直接讀寫寄存器,包括程序計數器。

通用的硬件調試機制

1.    斷點支持

斷點功能是經過特定的指令來實現的。對於變長指令的處理器,斷點指令一般是最短的指令,下圖給出了四個處理器的斷點指令:

 

2.    單步調試支持

單步調試是指執行一條指令就產生一次中斷,是用戶能夠查找每條指令的執行狀態。通常的處理器都提供一個模式位來實現單步調試功能。

3.    錯誤檢測支持

錯誤檢測功能是指當操做系統檢測到錯誤發生時,他通知調試器被它調試的程序發生了錯誤。

4.    檢測點支持

用來查看被調試程序的地址空間(數據空間)。

5.    多線程支持

6.    多處理器支持

 

 

 

調試器的操做系統支持功能

爲了控制一個被調試程序的過程,調試器須要一種機制去通知操做系統該可執行文件但願被控制。即一旦被調試程序因爲某些緣由中止的時候,調試器須要獲取詳細的信息使得他知道被調試程序是什麼緣由形成他中止的。

調試器是用戶級的程序,並非操做系統的一部分,並不能運行特權級指令,所以,它只能經過調用操做系統的系統調用來實現對特權級指令的訪問。

調試器運行被調試程序,並將控制權轉交給被調試程序,須要進行上下文切換。在一個簡單的斷點功能實現,有6個主要的轉換:

1.    當調試器運行到斷點指令的時候,產生陷阱跳轉到操做系統;

2.    經過操做系統,跳轉到調試器,調試器開始運行;

3.    調試器請求被調試程序的狀態信息,該請求送到操做系統進行處理;

4.    轉換到被調試程序文本以獲取信息,被調試程序激活;

5.    返回信息給操做系統;

6.    轉換到調試器以處理信息。

一旦使用圖形界面調試器,過程會更加的複雜。

對於多線程調試的支持;

l      一旦進程建立和刪除,操做系統必須通知調試器;

l      可以詢問和設置特定進程的進程狀態;

l      可以檢測到應用程序中止,或者線程中止。

例子:UNIX ptrace()

UNIX ptrace 是操做系統支持調試器的一個真實的API。

 

   控制執行

調試器的核心是它的進程控制和運行控制。爲了可以調試程序,調試器必須可以對被調試程序進行狀態設置,斷點設置,運行進程,終止進程。

控制執行主要包含一下幾個功能:

1.    建立被調試程序

調試器作的第一件工做,就是建立被調試程序。通常經過兩種手段:一種是爲調試程序建立被調試進程,另外一種是將調試器附到被調試進程上。

 

2.    附到被調試進程

當一個進程發生錯誤異常,而且在被刷出(內存刷新)內存的時候,容許調試器掛到出錯進程以此來檢查內存鏡像。這個時候,用戶不能再繼續執行進程。

3.    設置斷點

設置斷點的功能是在可執行文本中插入特殊的指令來實現的。當程序執行到該特殊指令的時候,就產生陷阱,陷到操做系統。

4.    使被調試程序運行

當調試中斷產生的時候,調試器屬於激活進程,而被調試程序屬於未激活進程。調試器產生一個系統中斷請求恢復被調用函數的執行,操做系統對被調試程序進行上下文切換,恢復被調用程序的現場狀態,而後執行被調用程序。

執行區間的調試事件生成類型:

l      斷點,單步調試事件

l      線程建立/刪除事件

l      進程建立/刪除事件

l      檢測點事件

l      模塊加載/卸載事件

l      異常事件

l      其餘事件

 

斷點和單步調試

斷點一般須要兩層的表示:

l  邏輯表示:指在源代碼中設置的斷點,用來告訴用戶的;

l  物理表示:指真實的在機器碼中寫入,是用來告訴物理機器的。斷點必須存儲寫入位置的機器指令,以便可以在移除斷點的時候恢復原來的指令。

斷點存在條件斷點。

斷點存在多對一的關係,即多個用戶在同一個地方設置斷點(多個邏輯斷點對應一個物理斷點),固然也有多對多的關係。下圖展現了這樣的一個關係:

 

臨時斷點

臨時斷點是指只運行一次的斷點。

內部斷點

內部斷點對用戶是不可見的。他們是被調試器設置的。

通常主要用於:

l  單步調試:內部斷點和運行到內部斷點;

l  跳出函數:在函數返回地址設置內部斷點;

l  進入函數

 

查看程序的上下文信息

通常要查找程序的上下文信息主要有如下幾種方法:

  • 源代碼窗口

經過源代碼查看程序執行到代碼的那一部分

  • 程序堆棧

程序堆棧是由硬件,操做系統和編譯器共同支持的:

硬件:    提供堆棧指針;

操做系統:爲每一個進程創建堆棧空間,並管理堆棧。一旦堆棧溢出,而產生一個錯誤;

  • 彙編級調試:反彙編,查看寄存器,查看內存

 

 

 

 

結合程序崩潰後的core文件分析bug

引言

    在《I/O的效率比較》中,咱們在修改圖1程序的BUF_SIZE爲8388608時,運行程序出現崩潰,以下圖1:
    
    圖1. 段錯誤
    通常而言,致使程序段錯誤的緣由以下:
  • 內存訪問出錯,這類問題的典型表明就是數組越界。
  • 非法內存訪問,出現這類問題主要是程序試圖訪問內核段內存而產生的錯誤。
  • 棧溢出, Linux默認給一個進程分配的棧空間大小爲8M,所以你的數組開得過大的話會出現這種問題。
    首先咱們先看一下系統默認分配的資源:
$ ulimit -a
core file size          (blocks, -c) unlimited
data seg size           (kbytes, -d) unlimited
scheduling priority             (-e) 0
file size               (blocks, -f) unlimited
pending signals                 (-i) 7884
max locked memory       (kbytes, -l) 64
max memory size         (kbytes, -m) unlimited
open files                      (-n) 1024
pipe size            (512 bytes, -p) 8
POSIX message queues     (bytes, -q) 819200
real-time priority              (-r) 0
stack size              (kbytes, -s) 8192
cpu time               (seconds, -t) unlimited
max user processes              (-u) 7884
virtual memory          (kbytes, -v) unlimited
file locks                      (-x) unlimited
    能夠看到默認分配的棧大小爲8M。而恰好咱們的代碼裏的棧大小調到了8M,所以出現了段錯誤。
    那麼有沒有一種更直接明瞭的方法來識別和分析應用程序崩潰產生的bug呢? 有,那就是經過程序崩潰後產生的core文件。

core文件

何爲core文件.

    core dump又叫內核轉儲, 在Unix系統中,核心映像(core image)就是「進程」執行當時的內存內容,當進程發生錯誤或收到「信號」而終止執行時,系統會將核心映像寫入一個文件,以做爲調試之用,這就是所謂的核心轉儲(core dump)。而core文件通常產生在進程的當前工做目錄下。
    因此core文件中只是程序的內存映像, 若是在編譯時加入調試信息的話,那麼還會有調試信息。

如何產生core文件

    咱們運行了a.out程序出現了「段錯誤」,但沒有產生core文件。這是由於系統默認core文件的大小爲0,因此沒有建立。能夠用ulimit命令查看和修改core文件的大小。 
$ ulimit -c 0     <--------- c選項指定修改core文件的大小
$ ulimit -c 1000   <--------指定了core文件大小爲1000KB, 若是設置的大小小於core文件,則對core文件截取
$ ulimit -c unlimited   <---------------對core文件的大小不作限制
    若是想讓修改永久生效,則須要修改配置文件,如.bash_profile、/etc/profile或/etc/security/limits.conf
    咱們回到上面的代碼演示,把core文件的大小調成不限制,再執行a.out,就能夠在當前目錄看到core文件了。
    
    另外補充一些資料,說明一些狀況也不會產生core文件。
  1. 進程是設置-用戶-ID,並且當前用戶並不是程序文件的全部者; 
  2. 進程是設置-組-ID,並且當前用戶並不是該程序文件的組全部者; 
  3. 用戶沒有寫當前工做目錄的許可權; 
  4. 文件太大。core文件的許可權(假定該文件在此以前並不存在)一般是用戶讀/寫,組讀和其餘讀。 

爲何須要core文件

    關於core產生的緣由不少,好比過去一些Unix的版本不支持現代Linux上這種gdb直接附着到進程上進行調試的機制,須要先向進程發送終止信號,而後用工具閱讀core文件。在Linux上,咱們就能夠使用kill向一個指定的進程發送信號或者使用gcore命令來使其主動出core並退出。
    若是從淺層次的緣由上來說,出core意味着當前進程存在BUG,須要程序員修復。
    從深層次的緣由上講,是當前進程觸犯了某些OS層級的保護機制,逼迫OS向當前進程發送諸如SIGSEGV(即signal 11)之類的信號, 例如訪問空指針或數組越界出core,其實是觸犯了OS的內存管理,訪問了非當前進程的內存空間,OS須要經過出core來進行警示,這就好像一我的身體內存在病毒,免疫系統就會經過發熱來警示,並致使人體發燒是一個道理(有意思的是,並非每次數組越界都會出Core,這和OS的內存管理中虛擬頁面分配大小和邊界有關,即便不出core,也頗有可能讀到髒數據,引發後續程序行爲紊亂,這是一種很難追查的BUG)。

core文件的名稱和生成路徑 

    默認狀況下core的文件名叫"core"
    /proc/sys/kernel/core_uses_pid能夠控制core文件的文件名中是否添加pid做爲擴展
  • 文件內容爲1,表示添加pid做爲擴展名,生成的core文件格式爲core.PID
  • 爲0則表示生成的core文件統一命名爲core.
    如何修改這個文件的內容?
$ echo "0" > /proc/sys/kernel/core_uses_pid
    /proc/sys/kernel/core_pattern文件用於定製core的文件名,通常使用%配合不一樣的字符:
  • %p  出core進程的PID
  • %u  出core進程的UID
  • %s  形成core的signal號
  • %t  出core的時間,從1970-01-0100:00:00開始的秒數
  • %e  出core進程對應的可執行文件名

如何閱讀core文件

    產生了core文件以後,就是如何查看core文件,並肯定問題所在,進行修復。爲此,咱們不妨先來看看core文件的格式,多瞭解一些core文件。
$ file core.4244 
core.4244: ELF 64-bit LSB  core file x86-64, version 1 (SYSV), SVR4-style, from '/home/fireway/study/temp/a.out'
    首先能夠明確一點,core文件的格式ELF格式,經過使用readelf -h命令來查看更詳細內容
$ readelf -h core.4244
ELF 頭:
  Magic:   7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 
  Class:                             ELF64
  Data:                              2's complement, little endian
  Version:                           1 (current)
  OS/ABI:                            UNIX - System V
  ABI Version:                       0
  Type:                              CORE (Core 文件)
  Machine:                           Advanced Micro Devices X86-64
  Version:                           0x1
  入口點地址:               0x0
  程序頭起點:          64 (bytes into file)
  Start of section headers:          0 (bytes into file)
  標誌:             0x0
  本頭的大小:       64 (字節)
  程序頭大小:       56 (字節)
  Number of program headers:         19
  節頭大小:         0 (字節)
  節頭數量:         0
  字符串表索引節頭: 0
    瞭解了這些以後,咱們來看看如何閱讀core文件,並從中追查BUG。在Linux下,通常讀取core的命令爲:
$ gdb exec_file core_file
    使用gdb,先從可執行文件中讀取符號表信息,而後讀取core文件。若是不與可執行文件攪合在一塊兒能夠嗎?答案是不行,由於core文件中沒有符號表信息,沒法進行調試,能夠使用以下命令來驗證:
$ objdump -x core.4244 | tail
 26 load16        00001000  00007ffff7ffe000  0000000000000000  0003f000  2**12
                  CONTENTS, ALLOC, LOAD
 27 load17        00801000  00007fffff7fe000  0000000000000000  00040000  2**12
                  CONTENTS, ALLOC, LOAD
 28 load18        00001000  ffffffffff600000  0000000000000000  00841000  2**12
                  CONTENTS, ALLOC, LOAD, READONLY, CODE
SYMBOL TABLE:
no symbols    <----------------- 代表當前的ELF格式文件中沒有符號表信息
    結合上面知識點,咱們分別編譯帶-g的目標可執行mycat_debug和不帶-g的目標可執行mycat,會發現mycat_debug的文件大小稍微大一些。使用readelf命令得出的結果比較報告,詳細見附件-readelf報告.html
    各自執行產生的core文件,再使用objdump命令得出的結果比較報告,詳細見附件-objdump報告.html
    最後咱們各自使用gdb讀取core文件,得出的結果比較報告,詳細見附件-gdb_core報告.html
    若是咱們強制使用gdb mycat, 接着是帶有調試信息的core文件,gdb會有什麼提示呢?
Reading symbols from mycat...(no debugging symbols found)...done.
warning: core file may not match specified executable file.
[New LWP 2037]
Core was generated by `./mycat_debug'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000000000400957 in main ()
    接下來重點來看,爲啥產生段錯誤?
    使用gdb mycat_debug core.2037可見:
GNU gdb (Ubuntu 7.7.1-0ubuntu5~14.04.2) 7.7.1
Copyright (C) 2014 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "x86_64-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
<http://www.gnu.org/software/gdb/documentation/>.
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from mycat_debug...done.
[New LWP 2037]
Core was generated by `./mycat_debug'.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  main () at io1.c:16
16    int n = 0;
可知程序段錯誤,代碼是int n = 0;這一句,咱們來看當前棧信息:
(gdb) info f
Stack level 0, frame at 0x7ffc4b59d670:
  rip = 0x400957 in main (io1.c:16); saved rip = 0x7fc5c0d5aec5
 source language c.
 Arglist at 0x7ffc4b59d660, args: 
 Locals at 0x7ffc4b59d660, Previous frame's sp is 0x7ffc4b59d670
 Saved registers:
  rbp at 0x7ffc4b59d660, rip at 0x7ffc4b59d668
    其中可見指令指針rip指向地址爲0x400957, 咱們用x命令來查看內存地址中的值。具體幫助查看 gdb調試 - 查看內存一節
    (gdb) x/5i 0x400957 或者x/5i $rip
=> 0x400957 <main+26>:movl   $0x0,-0x800014(%rbp)
   0x400961 <main+36>:lea    -0x800010(%rbp),%rax
   0x400968 <main+43>:mov    $0x800000,%edx
   0x40096d <main+48>:mov    $0x0,%esi
   0x400972 <main+53>:mov    %rax,%rdi
    這條movl指令要把當即數0送到-0x800014(%rbp)這個地址去,其中rbp存儲的是幀指針,其地址是 0x7ffc4b59d660,而-0x800014顯然是個負數,十進制是 8388628,且棧空間是由高地址向低地址延伸,見圖2,那麼n的棧地址就是-0x800014(%rbp),也就是$rbp-8388628。當咱們嘗試訪問此地址時


圖2. 典型的存儲空間安排
 
(gdb) x /b 0x7ffc4ad9d64c
0x7ffc4ad9d64c: Cannot access memory at address 0x7ffc4ad9d64c
能夠看到沒法訪問此內存地址,這是由於它已經超過了OS容許的範圍。

ulimit命令參數及用法

功能說明:控制shell程序的資源。
補充說明:ulimit爲shell內建指令,可用來控制shell執行程序的資源。
參  數: 
  • -a   顯示目前資源限制的設定。 
  • -c   設定core文件的最大值,單位爲KB。 
  • -d    <數據節區大小> 程序數據節區的最大值,單位爲KB。 
  • -f     <文件大小> shell所能創建的最大文件,單位爲區塊。 
  • -H  設定資源的硬性限制,也就是管理員所設下的限制。 
  • -m    <內存大小> 指定可以使用內存的上限,單位爲KB。
  • -n     <文件數目> 指定同一時間最多可開啓的文件數。
  • -p     <緩衝區大小> 指定管道緩衝區的大小,單位512字節。
  • -s     <堆疊大小> 指定堆疊的上限,單位爲KB。
  • -S  設定資源的彈性限制。 
  • -t   指定CPU使用時間的上限,單位爲秒。 
  • -u    <程序數目> 用戶最多可開啓的程序數目。 
  • -v    <虛擬內存大小>  指定可以使用的虛擬內存上限,單位爲KB。

參考

 

 

詳解coredump

一,什麼是coredump

        咱們常常聽到你們說到程序core掉了,須要定位解決,這裏說的大部分是指對應程序因爲各類異常或者bug致使在運行過程當中異常退出或者停止,而且在知足必定條件下(這裏爲何說須要知足必定的條件呢?下面會分析)會產生一個叫作core的文件。

        一般狀況下,core文件會包含了程序運行時的內存,寄存器狀態,堆棧指針,內存管理信息還有各類函數調用堆棧信息等,咱們能夠理解爲是程序工做當前狀態存儲生成第一個文件,許多的程序出錯的時候都會產生一個core文件,經過工具分析這個文件,咱們能夠定位到程序異常退出的時候對應的堆棧調用等信息,找出問題所在並進行及時解決。

 

二,coredump文件的存儲位置

   core文件默認的存儲位置與對應的可執行程序在同一目錄下,文件名是core,你們能夠經過下面的命令看到core文件的存在位置:

   cat  /proc/sys/kernel/core_pattern

   缺省值是core

 

注意:這裏是指在進程當前工做目錄的下建立。一般與程序在相同的路徑下。但若是程序中調用了chdir函數,則有可能改變了當前工做目錄。這時core文件建立在chdir指定的路徑下。有好多程序崩潰了,咱們卻找不到core文件放在什麼位置。和chdir函數就有關係。固然程序崩潰了不必定都產生 core文件。

以下程序代碼:則會把生成的core文件存儲在/data/coredump/wd,而不是你們認爲的跟可執行文件在同一目錄。

 

經過下面的命令能夠更改coredump文件的存儲位置,若你但願把core文件生成到/data/coredump/core目錄下:

   echo 「/data/coredump/core」> /proc/sys/kernel/core_pattern

 

注意,這裏當前用戶必須具備對/proc/sys/kernel/core_pattern的寫權限。

 

缺省狀況下,內核在coredump時所產生的core文件放在與該程序相同的目錄中,而且文件名固定爲core。很顯然,若是有多個程序產生core文件,或者同一個程序屢次崩潰,就會重複覆蓋同一個core文件,所以咱們有必要對不一樣程序生成的core文件進行分別命名。

 

咱們經過修改kernel的參數,能夠指定內核所生成的coredump文件的文件名。例如,使用下面的命令使kernel生成名字爲core.filename.pid格式的core dump文件:

echo 「/data/coredump/core.%e.%p」 >/proc/sys/kernel/core_pattern

這樣配置後,產生的core文件中將帶有崩潰的程序名、以及它的進程ID。上面的%e和%p會被替換成程序文件名以及進程ID。

若是在上述文件名中包含目錄分隔符「/」,那麼所生成的core文件將會被放到指定的目錄中。 須要說明的是,在內核中還有一個與coredump相關的設置,就是/proc/sys/kernel/core_uses_pid。若是這個文件的內容被配置成1,那麼即便core_pattern中沒有設置%p,最後生成的core dump文件名仍會加上進程ID。

三,如何判斷一個文件是coredump文件?

在類unix系統下,coredump文件自己主要的格式也是ELF格式,所以,咱們能夠經過readelf命令進行判斷。

  

     能夠看到ELF文件頭的Type字段的類型是:CORE (Core file)

     能夠經過簡單的file命令進行快速判斷:    

四,產生coredum的一些條件總結

1,  產生coredump的條件,首先須要確認當前會話的ulimit –c,若爲0,則不會產生對應的coredump,須要進行修改和設置。

ulimit  -c unlimited  (能夠產生coredump且不受大小限制)

 

若想甚至對應的字符大小,則能夠指定:

ulimit –c [size]

               

       能夠看出,這裏的size的單位是blocks,通常1block=512bytes

        如:

        ulimit –c 4  (注意,這裏的size若是過小,則可能不會產生對應的core文件,筆者設置過ulimit –c 1的時候,系統並不生成core文件,並嘗試了1,2,3均沒法產生core,至少須要4才生成core文件)

       

但當前設置的ulimit只對當前會話有效,若想系統均有效,則須要進行以下設置:

Ø  在/etc/profile中加入如下一行,這將容許生成coredump文件

ulimit-c unlimited

Ø  在rc.local中加入如下一行,這將使程序崩潰時生成的coredump文件位於/data/coredump/目錄下:

echo /data/coredump/core.%e.%p> /proc/sys/kernel/core_pattern 

注意rc.local在不一樣的環境,存儲的目錄可能不一樣,susu下可能在/etc/rc.d/rc.local

      更多ulimit的命令使用,能夠參考:http://baike.baidu.com/view/4832100.htm

      這些須要有root權限, 在ubuntu下每次從新打開中斷都須要從新輸入上面的ulimit命令, 來設置core大小爲無限.

2, 當前用戶,即執行對應程序的用戶具備對寫入core目錄的寫權限以及有足夠的空間。

3, 幾種不會產生core文件的狀況說明:

The core file will not be generated if

(a)    the process was set-user-ID and the current user is not the owner of the program file, or

(b)     the process was set-group-ID and the current user is not the group owner of the file,

(c)     the user does not have permission to write in the current working directory, 

(d)     the file already exists and the user does not have permission to write to it, or 

(e)     the file is too big (recall the RLIMIT_CORE limit in Section 7.11). The permissions of the core file (assuming that the file doesn't already exist) are usually user-read and user-write, although Mac OS X sets only user-read.

 

五,coredump產生的幾種可能狀況

形成程序coredump的緣由有不少,這裏總結一些比較經常使用的經驗吧:

 1,內存訪問越界

  a) 因爲使用錯誤的下標,致使數組訪問越界。

  b) 搜索字符串時,依靠字符串結束符來判斷字符串是否結束,可是字符串沒有正常的使用結束符。

  c) 使用strcpy, strcat, sprintf, strcmp,strcasecmp等字符串操做函數,將目標字符串讀/寫爆。應該使用strncpy, strlcpy, strncat, strlcat, snprintf, strncmp, strncasecmp等函數防止讀寫越界。

 2,多線程程序使用了線程不安全的函數。

應該使用下面這些可重入的函數,它們很容易被用錯:

asctime_r(3c) gethostbyname_r(3n) getservbyname_r(3n)ctermid_r(3s) gethostent_r(3n) getservbyport_r(3n) ctime_r(3c) getlogin_r(3c)getservent_r(3n) fgetgrent_r(3c) getnetbyaddr_r(3n) getspent_r(3c)fgetpwent_r(3c) getnetbyname_r(3n) getspnam_r(3c) fgetspent_r(3c)getnetent_r(3n) gmtime_r(3c) gamma_r(3m) getnetgrent_r(3n) lgamma_r(3m) getauclassent_r(3)getprotobyname_r(3n) localtime_r(3c) getauclassnam_r(3) etprotobynumber_r(3n)nis_sperror_r(3n) getauevent_r(3) getprotoent_r(3n) rand_r(3c) getauevnam_r(3)getpwent_r(3c) readdir_r(3c) getauevnum_r(3) getpwnam_r(3c) strtok_r(3c) getgrent_r(3c)getpwuid_r(3c) tmpnam_r(3s) getgrgid_r(3c) getrpcbyname_r(3n) ttyname_r(3c)getgrnam_r(3c) getrpcbynumber_r(3n) gethostbyaddr_r(3n) getrpcent_r(3n)

 3,多線程讀寫的數據未加鎖保護。

對於會被多個線程同時訪問的全局數據,應該注意加鎖保護,不然很容易形成coredump

 4,非法指針

  a) 使用空指針

  b) 隨意使用指針轉換。一個指向一段內存的指針,除非肯定這段內存原先就分配爲某種結構或類型,或者這種結構或類型的數組,不然不要將它轉換爲這種結構或類型的指針,而應該將這段內存拷貝到一個這種結構或類型中,再訪問這個結構或類型。這是由於若是這段內存的開始地址不是按照這種結構或類型對齊的,那麼訪問它時就很容易由於bus error而core dump。

 5,堆棧溢出

不要使用大的局部變量(由於局部變量都分配在棧上),這樣容易形成堆棧溢出,破壞系統的棧和堆結構,致使出現莫名其妙的錯誤。  

六,利用gdb進行coredump的定位

  其實分析coredump的工具備不少,如今大部分類unix系統都提供了分析coredump文件的工具,不過,咱們常常用到的工具是gdb。

  這裏咱們以程序爲例子來講明如何進行定位。

1,  段錯誤 – segmentfault

Ø  咱們寫一段代碼往受到系統保護的地址寫內容。

 

Ø  按以下方式進行編譯和執行,注意這裏須要-g選項編譯。

能夠看到,當輸入12的時候,系統提示段錯誤而且core dumped

 

Ø  咱們進入對應的core文件生成目錄,優先確認是否core文件格式並啓用gdb進行調試。

從紅色方框截圖能夠看到,程序停止是由於信號11,且從bt(backtrace)命令(或者where)能夠看到函數的調用棧,即程序執行到coremain.cpp的第5行,且裏面調用scanf 函數,而該函數其實內部會調用_IO_vfscanf_internal()函數。

接下來咱們繼續用gdb,進行調試對應的程序。

記住幾個經常使用的gdb命令:

l(list) ,顯示源代碼,而且能夠看到對應的行號;

b(break)x, x是行號,表示在對應的行號位置設置斷點;

p(print)x, x是變量名,表示打印變量x的值

r(run), 表示繼續執行到斷點的位置

n(next),表示執行下一步

c(continue),表示繼續執行

q(quit),表示退出gdb

 

啓動gdb,注意該程序編譯須要-g選項進行。

 

注:  SIGSEGV     11       Core    Invalid memoryreference

 

七,附註:

1,  gdb的查看源碼

顯示源代碼

GDB 能夠打印出所調試程序的源代碼,固然,在程序編譯時必定要加上-g的參數,把源程序信息編譯到執行文件中。否則就看不到源程序了。當程序停下來之後,GDB會報告程序停在了那個文件的第幾行上。你能夠用list命令來打印程序的源代碼。仍是來看一看查看源代碼的GDB命令吧。

list<linenum>

顯示程序第linenum行的周圍的源程序。

list<function>

顯示函數名爲function的函數的源程序。

list

顯示當前行後面的源程序。

list -

顯示當前行前面的源程序。

通常是打印當前行的上5行和下5行,若是顯示函數是是上2行下8行,默認是10行,固然,你也能夠定製顯示的範圍,使用下面命令能夠設置一次顯示源程序的行數。

setlistsize <count>

設置一次顯示源代碼的行數。

showlistsize

查看當前listsize的設置。

list命令還有下面的用法:

list<first>, <last>

顯示從first行到last行之間的源代碼。

list ,<last>

顯示從當前行到last行之間的源代碼。

list +

日後顯示源代碼。

通常來講在list後面能夠跟如下這些參數:

 

<linenum>   行號。

<+offset>   當前行號的正偏移量。

<-offset>   當前行號的負偏移量。

<filename:linenum>  哪一個文件的哪一行。

<function>  函數名。

<filename:function>哪一個文件中的哪一個函數。

<*address>  程序運行時的語句在內存中的地址。

 

2,  一些經常使用signal的含義

SIGABRT:調用abort函數時產生此信號。進程異常終止。

SIGBUS:指示一個實現定義的硬件故障。

SIGEMT:指示一個實現定義的硬件故障。EMT這一名字來自PDP-11的emulator trap 指令。

SIGFPE:此信號表示一個算術運算異常,例如除以0,浮點溢出等。

SIGILL:此信號指示進程已執行一條非法硬件指令。4.3BSD由abort函數產生此信號。SIGABRT如今被用於此。

SIGIOT:這指示一個實現定義的硬件故障。IOT這個名字來自於PDP-11對於輸入/輸出TRAP(input/outputTRAP)指令的縮寫。系統V的早期版本,由abort函數產生此信號。SIGABRT如今被用於此。

SIGQUIT:當用戶在終端上按退出鍵(通常採用Ctrl-/)時,產生此信號,並送至前臺進

程組中的全部進程。此信號不只終止前臺進程組(如SIGINT所作的那樣),同時產生一個core文件。

SIGSEGV:指示進程進行了一次無效的存儲訪問。名字SEGV表示「段違例(segmentationviolation)」。

SIGSYS:指示一個無效的系統調用。因爲某種未知緣由,進程執行了一條系統調用指令,但其指示系統調用類型的參數倒是無效的。

SIGTRAP:指示一個實現定義的硬件故障。此信號名來自於PDP-11的TRAP指令。

SIGXCPUSVR4和4.3+BSD支持資源限制的概念。若是進程超過了其軟C P U時間限制,則產生此信號。

SIGXFSZ:若是進程超過了其軟文件長度限制,則SVR4和4.3+BSD產生此信號。

 

3,  Core_pattern的格式

能夠在core_pattern模板中使用變量還不少,見下面的列表:

%% 單個%字符

%p 所dump進程的進程ID

%u 所dump進程的實際用戶ID

%g 所dump進程的實際組ID

%s 致使本次core dump的信號

%t core dump的時間 (由1970年1月1日計起的秒數)

%h 主機名

%e 程序文件名

 

 

 

利用Core Dump調試程序

[描述]
這裏介紹Linux環境下使用gdb結合core dump文件進行程序的調試和定位。

[簡介]
當用戶程序運行,可能會因爲某些緣由發生崩潰(crash),這個時候能夠產生一個Core Dump文件,記錄程序發生崩潰時候內存的運行情況。這個Core Dump文件,通常名稱爲core或者core.pid(pid就是應用程序運行時候的pid號),它能夠幫助咱們找出程序崩潰的緣由。
對於一個運行出錯的程序,咱們能夠有多種方法調試它,以便發生錯誤的緣由:a)經過閱讀代碼;b)經過在代碼中設置一些打印語句(插旗子);c)經過使用gdb設置斷點來跟蹤程序的運行。可是這些方法對於調試程序運行崩潰這樣相似的錯誤,定位都不夠迅速,若是程序代碼不少的話,顯然前面的方法有不少缺陷。在後面,咱們來看看另一種能夠定位錯誤的方法:d)使用gdb結合Core Dump文件來迅速定位到這個錯誤。這個方法,若是程序運行崩潰,那麼能夠迅速找到致使程序崩潰的緣由。
固然,調試程序,沒有哪一個方法是最好的,這裏只對最後一種方法重點講解,實際過程當中,每每根據須要和其餘方法結合使用。

[舉例]
下面,給出一個實際的操做過程,描述咱們使用gdb調試工具,結合Core Dump文件,定位程序崩潰的位置。
1、程序源代碼
下面是咱們的程序源代碼:
  1 #include <iostream>
  2 using std::cerr;
  3 using std::endl;
  4
  5 void my_print(int d1, int d2);
  6 int main(int argc, char *argv[])
  7 {
  8     int a = 1;
  9     int b = 2;
10     my_print(a,b);
11     return 0;
12 }
13
14 void my_print(int d1, int d2)
15 {
16     int *p1=&d1;
17     int *p2 = NULL;
18     cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
19 }
這裏,程序代碼不多,咱們能夠直接經過代碼看到這個程序第17行的p2是NULL,而18行卻用*p2來進行引用,明顯這樣訪問一個空的地址是一個錯誤(也許咱們的初衷是使用p2來指向d2)。
咱們能夠有多種方法調試這個程序,以便發生上面的錯誤:a)經過閱讀代碼;b)經過在代碼中設置一些打印語句(插旗子);c)經過使用gdb設置斷點來跟蹤程序的運行。可是這些方法對於這個程序中相似的錯誤,定位都不夠迅速,若是程序代碼不少的話,顯然前面的方法有不少缺陷。在後面,咱們來看看另一種能夠定位錯誤的方法:d)使用gdb結合Core Dump文件來迅速定位到這個錯誤。

2、編譯程序:
編譯過程以下:
[root@lv-k test]# ls
main.cpp
[root@lv-k test]# g++ -g main.cpp
[root@lv-k test]# ls
a.out  main.cpp
這樣,編譯main.cpp生成了可執行文件a.out,必定注意,由於咱們要使用gdb進行調試,因此咱們使用'g++'的'-g'選項。

3、運行程序
運行過程以下:
[root@lv-k test]# ./a.out
段錯誤
[root@lv-k test]# ls
a.out  main.cpp
這裏,如咱們所指望的,會打印段錯誤的信息,可是並無生成Core Dump文件。

配置生成Core Dump文件的選項,並生成Core Dump:
[root@lv-k test]# ulimit -c unlimited
[root@lv-k test]# ./a.out
段錯誤 (core dumped)
[root@lv-k test]# ls
a.out  core.30557  main.cpp
這裏,咱們看到,使用'ulimit'配置以後,程序崩潰的時候就會生成Core Dump文件了,這裏的文件是core.30557,文件名稱不一樣的系統生成的名稱有一點不一樣,這裏linux生成的名稱是:"core"+".pid"。

4、調試程序
使用Core Dump文件,結合gdb工具進行調試,過程以下:
1)初步定位:
[root@lv-k test]# gdb a.out core.30557
GNU gdb (GDB) Red Hat Enterprise Linux (7.0.1-23.el5_5.2)
Copyright (C) 2009 Free Software Foundation, Inc.
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.  Type "show copying"
and "show warranty" for details.
This GDB was configured as "i386-redhat-linux-gnu".
For bug reporting instructions, please see:
<http://www.gnu.org/software/gdb/bugs/>...
Reading symbols from /root/test/a.out...done.
Reading symbols from /usr/lib/libstdc++.so.6...(no debugging symbols found)...done.
Loaded symbols for /usr/lib/libstdc++.so.6
Reading symbols from /lib/libm.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib/libm.so.6
Reading symbols from /lib/libgcc_s.so.1...(no debugging symbols found)...done.
Loaded symbols for /lib/libgcc_s.so.1
Reading symbols from /lib/libc.so.6...(no debugging symbols found)...done.
Loaded symbols for /lib/libc.so.6
Reading symbols from /lib/ld-linux.so.2...(no debugging symbols found)...done.
Loaded symbols for /lib/ld-linux.so.2
Core was generated by `./a.out'.
Program terminated with signal 11, Segmentation fault.
#0  0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
18              cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
這裏,咱們就進入了gdb的調試交互界面,看到gdb直接定位到致使程序出錯的位置了。咱們還能夠使用以下命令:"#gdb a.out --core=core.30557"。
經過錯誤,咱們知道程序因爲"signal 11"致使終止,若是想要大體瞭解"signal 11",那麼咱們可查看signal的man手冊:
#man 7 signal
這樣,在輸出的信息中咱們能夠看見「SIGSEGV      11       Core    Invalid memory reference」這樣的字樣,意思是說,signal(信號)11表示非法內存引用。注意這裏使用"man 7 signal"而不是"man signal",由於咱們要查看的不是signal函數或者signal命令,而是signal的其餘信息,其餘的信息在man手冊的第7節,具體須要瞭解一些使用man的命令。

2)查看具體調用關係
(gdb) bt
#0  0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
#1  0x08048799 in main (argc=<value optimized out>, argv=<value optimized out>) at main.cpp:10
這裏,咱們經過backtrace(簡寫爲bt)命令能夠看到,致使崩潰那條語句是經過什麼調用途徑被調用到的。

3)設置斷點,並進行調試等:
(gdb) b main.cpp:10
Breakpoint 1 at 0x8048787: file main.cpp, line 10.
(gdb) r
Starting program: /root/test/a.out

Breakpoint 1, main (argc=<value optimized out>, argv=<value optimized out>) at main.cpp:10
10              my_print(a,b);
(gdb) s
my_print (d1=1, d2=2) at main.cpp:16
16              int *p1=&d1;
(gdb) n
17              int *p2 = NULL;
(gdb) n
18              cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
(gdb) p d1
$1 = 1
(gdb) p d2
$2 = 2
(gdb) p *p1
$1 = 1
(gdb) p *p2
Cannot access memory at address 0x0
(gdb) n

Program received signal SIGSEGV, Segmentation fault.
0x0804870e in my_print (d1=1, d2=2) at main.cpp:18
18              cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
這裏,咱們在開始初步的定位的基礎上,經過設置斷點(break),運行(run),gdb的單步跟進(step),單步跳過(next),變量的打印(print)等各類gdb命令,來了解產生崩潰時候的具體狀況,肯定產生崩潰的緣由。

4)退出gdb:
(gdb) q
A debugging session is active.

        Inferior 3 [process 30584] will be killed.
        Inferior 1 [process 1] will be killed.

Quit anyway? (y or n) y
Quitting: Couldn't get registers: 沒有那個進程.
[root@lv-k test]#
[root@lv-k test]# ls
a.out core.30557 core.30609 main.cpp
這裏,咱們看到又產生了一個core文件。由於剛纔調試,致使又產生了一個core文件。實際,若是咱們只使用"gdb a.out core.30557"初步定位以後,不進行調試就退出gdb的話,就不會再生成core文件。

5、修正錯誤
1)經過上面的過程咱們最終修正錯誤,獲得正確的源代碼以下:
  1 #include <iostream>
  2 using std::cerr;
  3 using std::endl;
  4
  5 void my_print(int d1, int d2);
  6 int main(int argc, char *argv[])
  7 {
  8     int a = 1;
  9     int b = 2;
10     my_print(a,b);
11     return 0;
12 }
13
14 void my_print(int d1, int d2)
15 {
16     int *p1=&d1;
17     //int *p2 = NULL;//lvkai-
18     int *p2 = &d2;//lvkai+
19     cerr<<"first is:"<<*p1<<",second is:"<<*p2<<endl;
20 }

2)編譯並運行這個程序,最終產生結果以下:
[root@lv-k test]# g++ main.cpp
[root@lv-k test]# ls
a.out  main.cpp
[root@lv-k test]# ./a.out
first is:1,second is:2
這裏,獲得了咱們預期的結果。

另外,有個小技巧,若是對Makefile有些瞭解的話能夠充分利用make的隱含規則來編譯單個源文件的程序,
過程以下:
[root@lv-k test]# ls
main.cpp
[root@lv-k test]# make main
g++     main.cpp   -o main
[root@lv-k test]# ls
main  main.cpp
[root@lv-k test]# ./main
first is:1,second is:2
這裏注意,make的目標參數必須是源文件"main.cpp"去掉後綴以後的"main",等價於"g++ main.cpp -o main",這樣編譯的命令比較簡單。

[其它]
其它內容有待添加。
認真地工做而且思考,是最好的老師。在工做的過程當中思考本身所缺少的技術,以及學習他人的經驗,才能在工做中有所收穫。這篇文章原來是工做中個人一個同事加朋友的經驗,我站在這樣的經驗的基礎上,進行了這樣總結。

 

一,什麼是coredump

        咱們常常聽到你們說到程序core掉了,須要定位解決,這裏說的大部分是指對應程序因爲各類異常或者bug致使在運行過程當中異常退出或者停止,而且在知足必定條件下(這裏爲何說須要知足必定的條件呢?下面會分析)會產生一個叫作core的文件。

        一般狀況下,core文件會包含了程序運行時的內存,寄存器狀態,堆棧指針,內存管理信息還有各類函數調用堆棧信息等,咱們能夠理解爲是程序工做當前狀態存儲生成第一個文件,許多的程序出錯的時候都會產生一個core文件,經過工具分析這個文件,咱們能夠定位到程序異常退出的時候對應的堆棧調用等信息,找出問題所在並進行及時解決。

 

二,coredump文件的存儲位置

   core文件默認的存儲位置與對應的可執行程序在同一目錄下,文件名是core,你們能夠經過下面的命令看到core文件的存在位置:

   cat  /proc/sys/kernel/core_pattern

   缺省值是core

 

注意:這裏是指在進程當前工做目錄的下建立。一般與程序在相同的路徑下。但若是程序中調用了chdir函數,則有可能改變了當前工做目錄。這時core文件建立在chdir指定的路徑下。有好多程序崩潰了,咱們卻找不到core文件放在什麼位置。和chdir函數就有關係。固然程序崩潰了不必定都產生 core文件。

以下程序代碼:則會把生成的core文件存儲在/data/coredump/wd,而不是你們認爲的跟可執行文件在同一目錄。

 

經過下面的命令能夠更改coredump文件的存儲位置,若你但願把core文件生成到/data/coredump/core目錄下:

   echo 「/data/coredump/core」> /proc/sys/kernel/core_pattern

 

注意,這裏當前用戶必須具備對/proc/sys/kernel/core_pattern的寫權限。

 

缺省狀況下,內核在coredump時所產生的core文件放在與該程序相同的目錄中,而且文件名固定爲core。很顯然,若是有多個程序產生core文件,或者同一個程序屢次崩潰,就會重複覆蓋同一個core文件,所以咱們有必要對不一樣程序生成的core文件進行分別命名。

 

咱們經過修改kernel的參數,能夠指定內核所生成的coredump文件的文件名。例如,使用下面的命令使kernel生成名字爲core.filename.pid格式的core dump文件:

echo 「/data/coredump/core.%e.%p」 >/proc/sys/kernel/core_pattern

這樣配置後,產生的core文件中將帶有崩潰的程序名、以及它的進程ID。上面的%e和%p會被替換成程序文件名以及進程ID。

若是在上述文件名中包含目錄分隔符「/」,那麼所生成的core文件將會被放到指定的目錄中。 須要說明的是,在內核中還有一個與coredump相關的設置,就是/proc/sys/kernel/core_uses_pid。若是這個文件的內容被配置成1,那麼即便core_pattern中沒有設置%p,最後生成的core dump文件名仍會加上進程ID。

三,如何判斷一個文件是coredump文件?

在類unix系統下,coredump文件自己主要的格式也是ELF格式,所以,咱們能夠經過readelf命令進行判斷。

  

     能夠看到ELF文件頭的Type字段的類型是:CORE (Core file)

     能夠經過簡單的file命令進行快速判斷:    

四,產生coredum的一些條件總結

1,  產生coredump的條件,首先須要確認當前會話的ulimit –c,若爲0,則不會產生對應的coredump,須要進行修改和設置。

ulimit  -c unlimited  (能夠產生coredump且不受大小限制)

 

若想甚至對應的字符大小,則能夠指定:

ulimit –c [size]

               

       能夠看出,這裏的size的單位是blocks,通常1block=512bytes

        如:

        ulimit –c 4  (注意,這裏的size若是過小,則可能不會產生對應的core文件,筆者設置過ulimit –c 1的時候,系統並不生成core文件,並嘗試了1,2,3均沒法產生core,至少須要4才生成core文件)

       

但當前設置的ulimit只對當前會話有效,若想系統均有效,則須要進行以下設置:

Ø  在/etc/profile中加入如下一行,這將容許生成coredump文件

ulimit-c unlimited

Ø  在rc.local中加入如下一行,這將使程序崩潰時生成的coredump文件位於/data/coredump/目錄下:

echo /data/coredump/core.%e.%p> /proc/sys/kernel/core_pattern 

注意rc.local在不一樣的環境,存儲的目錄可能不一樣,susu下可能在/etc/rc.d/rc.local

      更多ulimit的命令使用,能夠參考:http://baike.baidu.com/view/4832100.htm

      這些須要有root權限, 在ubuntu下每次從新打開中斷都須要從新輸入上面的ulimit命令, 來設置core大小爲無限.

2, 當前用戶,即執行對應程序的用戶具備對寫入core目錄的寫權限以及有足夠的空間。

3, 幾種不會產生core文件的狀況說明:

The core file will not be generated if

(a)    the process was set-user-ID and the current user is not the owner of the program file, or

(b)     the process was set-group-ID and the current user is not the group owner of the file,

(c)     the user does not have permission to write in the current working directory, 

(d)     the file already exists and the user does not have permission to write to it, or 

(e)     the file is too big (recall the RLIMIT_CORE limit in Section 7.11). The permissions of the core file (assuming that the file doesn't already exist) are usually user-read and user-write, although Mac OS X sets only user-read.

 

五,coredump產生的幾種可能狀況

形成程序coredump的緣由有不少,這裏總結一些比較經常使用的經驗吧:

 1,內存訪問越界

  a) 因爲使用錯誤的下標,致使數組訪問越界。

  b) 搜索字符串時,依靠字符串結束符來判斷字符串是否結束,可是字符串沒有正常的使用結束符。

  c) 使用strcpy, strcat, sprintf, strcmp,strcasecmp等字符串操做函數,將目標字符串讀/寫爆。應該使用strncpy, strlcpy, strncat, strlcat, snprintf, strncmp, strncasecmp等函數防止讀寫越界。

 2,多線程程序使用了線程不安全的函數。

應該使用下面這些可重入的函數,它們很容易被用錯:

asctime_r(3c) gethostbyname_r(3n) getservbyname_r(3n)ctermid_r(3s) gethostent_r(3n) getservbyport_r(3n) ctime_r(3c) getlogin_r(3c)getservent_r(3n) fgetgrent_r(3c) getnetbyaddr_r(3n) getspent_r(3c)fgetpwent_r(3c) getnetbyname_r(3n) getspnam_r(3c) fgetspent_r(3c)getnetent_r(3n) gmtime_r(3c) gamma_r(3m) getnetgrent_r(3n) lgamma_r(3m) getauclassent_r(3)getprotobyname_r(3n) localtime_r(3c) getauclassnam_r(3) etprotobynumber_r(3n)nis_sperror_r(3n) getauevent_r(3) getprotoent_r(3n) rand_r(3c) getauevnam_r(3)getpwent_r(3c) readdir_r(3c) getauevnum_r(3) getpwnam_r(3c) strtok_r(3c) getgrent_r(3c)getpwuid_r(3c) tmpnam_r(3s) getgrgid_r(3c) getrpcbyname_r(3n) ttyname_r(3c)getgrnam_r(3c) getrpcbynumber_r(3n) gethostbyaddr_r(3n) getrpcent_r(3n)

 3,多線程讀寫的數據未加鎖保護。

對於會被多個線程同時訪問的全局數據,應該注意加鎖保護,不然很容易形成coredump

 4,非法指針

  a) 使用空指針

  b) 隨意使用指針轉換。一個指向一段內存的指針,除非肯定這段內存原先就分配爲某種結構或類型,或者這種結構或類型的數組,不然不要將它轉換爲這種結構或類型的指針,而應該將這段內存拷貝到一個這種結構或類型中,再訪問這個結構或類型。這是由於若是這段內存的開始地址不是按照這種結構或類型對齊的,那麼訪問它時就很容易由於bus error而core dump。

 5,堆棧溢出

不要使用大的局部變量(由於局部變量都分配在棧上),這樣容易形成堆棧溢出,破壞系統的棧和堆結構,致使出現莫名其妙的錯誤。  

六,利用gdb進行coredump的定位

  其實分析coredump的工具備不少,如今大部分類unix系統都提供了分析coredump文件的工具,不過,咱們常常用到的工具是gdb。

  這裏咱們以程序爲例子來講明如何進行定位。

1,  段錯誤 – segmentfault

Ø  咱們寫一段代碼往受到系統保護的地址寫內容。

 

Ø  按以下方式進行編譯和執行,注意這裏須要-g選項編譯。

能夠看到,當輸入12的時候,系統提示段錯誤而且core dumped

 

Ø  咱們進入對應的core文件生成目錄,優先確認是否core文件格式並啓用gdb進行調試。

從紅色方框截圖能夠看到,程序停止是由於信號11,且從bt(backtrace)命令(或者where)能夠看到函數的調用棧,即程序執行到coremain.cpp的第5行,且裏面調用scanf 函數,而該函數其實內部會調用_IO_vfscanf_internal()函數。

接下來咱們繼續用gdb,進行調試對應的程序。

記住幾個經常使用的gdb命令:

l(list) ,顯示源代碼,而且能夠看到對應的行號;

b(break)x, x是行號,表示在對應的行號位置設置斷點;

p(print)x, x是變量名,表示打印變量x的值

r(run), 表示繼續執行到斷點的位置

n(next),表示執行下一步

c(continue),表示繼續執行

q(quit),表示退出gdb

 

啓動gdb,注意該程序編譯須要-g選項進行。

 

注:  SIGSEGV     11       Core    Invalid memoryreference

 

七,附註:

1,  gdb的查看源碼

顯示源代碼

GDB 能夠打印出所調試程序的源代碼,固然,在程序編譯時必定要加上-g的參數,把源程序信息編譯到執行文件中。否則就看不到源程序了。當程序停下來之後,GDB會報告程序停在了那個文件的第幾行上。你能夠用list命令來打印程序的源代碼。仍是來看一看查看源代碼的GDB命令吧。

list<linenum>

顯示程序第linenum行的周圍的源程序。

list<function>

顯示函數名爲function的函數的源程序。

list

顯示當前行後面的源程序。

list -

顯示當前行前面的源程序。

通常是打印當前行的上5行和下5行,若是顯示函數是是上2行下8行,默認是10行,固然,你也能夠定製顯示的範圍,使用下面命令能夠設置一次顯示源程序的行數。

setlistsize <count>

設置一次顯示源代碼的行數。

showlistsize

查看當前listsize的設置。

list命令還有下面的用法:

list<first>, <last>

顯示從first行到last行之間的源代碼。

list ,<last>

顯示從當前行到last行之間的源代碼。

list +

日後顯示源代碼。

通常來講在list後面能夠跟如下這些參數:

 

<linenum>   行號。

<+offset>   當前行號的正偏移量。

<-offset>   當前行號的負偏移量。

<filename:linenum>  哪一個文件的哪一行。

<function>  函數名。

<filename:function>哪一個文件中的哪一個函數。

<*address>  程序運行時的語句在內存中的地址。

 

2,  一些經常使用signal的含義

SIGABRT:調用abort函數時產生此信號。進程異常終止。

SIGBUS:指示一個實現定義的硬件故障。

SIGEMT:指示一個實現定義的硬件故障。EMT這一名字來自PDP-11的emulator trap 指令。

SIGFPE:此信號表示一個算術運算異常,例如除以0,浮點溢出等。

SIGILL:此信號指示進程已執行一條非法硬件指令。4.3BSD由abort函數產生此信號。SIGABRT如今被用於此。

SIGIOT:這指示一個實現定義的硬件故障。IOT這個名字來自於PDP-11對於輸入/輸出TRAP(input/outputTRAP)指令的縮寫。系統V的早期版本,由abort函數產生此信號。SIGABRT如今被用於此。

SIGQUIT:當用戶在終端上按退出鍵(通常採用Ctrl-/)時,產生此信號,並送至前臺進

程組中的全部進程。此信號不只終止前臺進程組(如SIGINT所作的那樣),同時產生一個core文件。

SIGSEGV:指示進程進行了一次無效的存儲訪問。名字SEGV表示「段違例(segmentationviolation)」。

SIGSYS:指示一個無效的系統調用。因爲某種未知緣由,進程執行了一條系統調用指令,但其指示系統調用類型的參數倒是無效的。

SIGTRAP:指示一個實現定義的硬件故障。此信號名來自於PDP-11的TRAP指令。

SIGXCPUSVR4和4.3+BSD支持資源限制的概念。若是進程超過了其軟C P U時間限制,則產生此信號。

SIGXFSZ:若是進程超過了其軟文件長度限制,則SVR4和4.3+BSD產生此信號。

 

3,  Core_pattern的格式

能夠在core_pattern模板中使用變量還不少,見下面的列表:

%% 單個%字符

%p 所dump進程的進程ID

%u 所dump進程的實際用戶ID

%g 所dump進程的實際組ID

%s 致使本次core dump的信號

%t core dump的時間 (由1970年1月1日計起的秒數)

%h 主機名

%e 程序文件名

相關文章
相關標籤/搜索