調試器工作原理之一——基礎篇

英文原文:Eli Bendersky  翻譯:伯樂在線— 陳舸

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

關於本文

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

動機

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

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

Linux下的調試——ptrace

Linux下調試器擁有一個瑞士軍刀般的工具,這就是ptrace系統調用。這是一個功能衆多且相當複雜的工具,能允許一個進程控制另一個進程的運行,而且可以監視和滲入到進程內部。ptrace本身需要一本中等篇幅的書才能對其進行完整的解釋,這就是爲什麼我只打算通過例子把重點放在它的實際用途上。讓我們繼續深入探尋。

 

遍歷進程的代碼

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

int main(intargc,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]);
    elseif(child_pid > 0)
        run_debugger(child_pid);
    else{
        perror("fork");
        return-1;
    }
 
    return0;
}

代碼相當簡單,我們通過fork產生一個新的子進程。隨後的if語句塊處理子進程(這裏稱爲“目標進程”),而else if語句塊處理父進程(這裏稱爲“調試器”)。下面是目標進程:

void run_target(constchar* programname)
{
    procmsg("target started. will run '%s'\n", programname);
 
    /* Allow tracing of this process */
    if(ptrace(PTRACE_TRACEME, 0, 0, 0) < 0) {
        perror("ptrace");
        return;
    }
 
    /* Replace this process's image with the given program */
    execl(programname, programname, 0);
}

這部分最有意思的地方在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)
{
    intwait_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”);
    return0;
}

令我驚訝的是,我們的跟蹤程序運行了很長的時間然後報告顯示一共有超過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 forthe linker (ld)
    global _start
 
_start:
 
    ; Prepare arguments forthe sys_write systemcall:
    ;   - eax: systemcall 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 systemcall
    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)
{
    intwait_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++;
        structuser_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中。這兒有個有趣的地方——如果你打開這個頭文件看看,靠近文件頂端的地方有一條這樣的註釋:

1
/* 本文件的唯一目的是爲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函數入口處設置一個斷點,從斷點處開始單步執行。下一篇文章中我將向您展示該如何實現斷點機制。

 

參考文獻

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

Playing with ptrace, Part I

Process tracing using ptrace

How debugger works

 

發佈了41 篇原創文章 · 獲贊 38 · 訪問量 42萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章