Linux中國

調試器工作原理(二):斷點

這是調試器工作原理系列文章的第二部分,閱讀本文前,請確保你已經讀過第一部分

關於本文

我將會演示如何在調試器中實現斷點。斷點是調試的兩大利器之一,另一個是可以在被調試進程的內存中檢查變數值。我們在系列的第一部分已經了解過值檢查,但是斷點對我們來說依然神秘。不過本文過後,它們就不再如此了。

軟體中斷

為了在 x86 架構機器上實現斷點,軟體中斷(也被稱作「陷阱」)被會派上用場。在我們深入細節之前,我想先大致解釋一下中斷和陷阱的概念。

CPU 有一條單獨的執行流,一條指令接一條的執行(在更高的層面看是這樣的,但是在底層的細節上來說,現在的許多 CPU 都會並行執行多個指令,這其中的一些指令就不是按照原本的順序執行的)。為了能夠處理非同步的事件,如 IO 和 硬體定時器,CPU 使用了中斷。硬體中斷通常是一個特定的電子信號,並附加了一個特別的」響應電路」。該電路通知中斷激活,並讓 CPU 停止當前執行,保存狀態,然後跳轉到一個預定義的地址,也就是中斷處理程序的位置。當處理程序完成其工作後,CPU 又從之前停止的地方重新恢復運行。

軟體中斷在規則上與硬體相似,但實際操作中有些不同。CPU 支持一些特殊的指令,來允許軟體模擬出一個中斷。當這樣的一個指令被執行時,CPU 像對待一個硬體中斷那樣 —— 停止正常的執行流,保存狀態,然後跳轉到一個處理程序。這種「中斷」使得許多現代 OS 的驚嘆設計得以高效地實現(如任務調度,虛擬內存,內存保護,調試)。

許多編程錯誤(如被 0 除)也被 CPU 當做中斷對待,常常也叫做「異常」, 這時候硬體和軟體中斷之間的界限就模糊了,很難說這種異常到底是硬體中斷還是軟體中斷。但我已經偏離今天主題太遠了,所以現在讓我們回到斷點上來。

int 3 理論

前面說了很多,現在簡單來說斷點就是一個部署在 CPU 上的特殊中斷,叫 int 3int 是一個 「中斷指令」的 x86 術語,該指令是對一個預定義中斷處理的調用。x86 支持 8 位的 int 指令操作數,這決定了中斷的數量,所以理論上可以支持 256 個中斷。前 32 個中斷為 CPU 自己保留,而 int 3 就是本文關注的 —— 它被叫做 「調試器專用中斷」。

避免更深的解釋,我將引用「聖經」里一段話(這裡說的「聖經」,當然指的是英特爾的體系結構軟體開發者手冊, 卷 2A)。

INT 3 指令生成一個以位元組操作碼(CC),用於調用該調試異常處理程序。(這個一位元組格式是非常有用的,因為它可以用於使用斷點來替換任意指令的第一個位元組 ,包括哪些一位元組指令,而不會覆寫其它代碼)

上述引用非常重要,但是目前去解釋它還是為時過早。本文後面我們會回過頭再看。

int 3 實踐

沒錯,知道事物背後的理論非常不錯,不過,這些理論到底意思是啥?我們怎樣使用 int 3 部署斷點?或者怎麼翻譯成通用的編程術語 —— 請給我看代碼!

實際上,實現非常簡單。一旦你的程序執行了 int 3 指令, OS 就會停止程序( OS 是怎麼做到像這樣停止進程的? OS 註冊其 int 3 的控制程序到 CPU 即可,就這麼簡單)。在 Linux(這也是本文比較關心的地方) 上, OS 會發送給進程一個信號 —— 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。(等等,再次 int?是的,Linux 使用 int 0x80 來實現用戶進程到系統內核的系統調用。用戶將系統調用的號碼及其參數放到寄存器,並執行 int 0x80。然後 CPU 會跳到相應的中斷處理程序,其中, OS 註冊了一個過程,該過程查看寄存器並決定要執行的系統調用。)首先,我們需要知道該指令所映射的地址。運行 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 部分開端偏移的一些位元組。如果你看上面導出來的列表,你會看到 text 部分從地址 0x08048080 開始。這告訴 OS 在分配給進程的虛擬地址空間里,將該地址映射到 text 部分開始的地方。在 Linux 上面,這些地址可以是絕對地址(例如,當可執行程序載入到內存中時它不做重定位),因為通過虛擬地址系統,每個進程獲得自己的一塊內存,並且將整個 32 位地址空間看做自己的(稱為 「線性」 地址)。

如果我們使用 readelf 命令檢查 ELF 文件頭部(ELF,可執行和可鏈接格式,是 Linux 上用於對象文件、共享庫和可執行程序的文件格式),我們會看到:

$ 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&apos;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

注意頭部里的 Entry point address,它同樣指向 0x8048080。所以我們在系統層面解釋該 elf 文件的編碼信息,它意思是:

  1. 映射 text 部分(包含所給的內容)到地址 0x8048080
  2. 從入口 —— 地址 0x8048080 處開始執行

但是,為什麼是 0x8048080 呢?事實證明是一些歷史原因。一些 Google 的結果把我引向源頭,宣傳每個進程的地址空間的前 128M 是保留在棧里的。128M 對應為 0x8000000,該地址是可執行程序其他部分可以開始的地方。而 0x8048080,比較特別,是 Linux ld 鏈接器使用的默認入口地址。該入口可以通過給 ld 傳遞 -Ttext 參數改變。

總結一下,這地址沒啥特別的,我們可以隨意修改它。只要 ELF 可執行文件被合理的組織,並且頭部里的入口地址與真正的程序代碼(text 部分)開始的地址匹配,一切都沒問題。

用 int 3 在調試器中設置斷點

為了在被追蹤進程的某些目標地址設置一個斷點,調試器會做如下工作:

  1. 記住存儲在目標地址的數據
  2. 用 int 指令替換掉目標地址的第一個位元組

然後,當調試器要求 OS 運行該進程的時候(通過上一篇文章中提過的 PTRACE_CONT),進程就會運行起來直到遇到 int 3,此處進程會停止運行,並且 OS 會發送一個信號給調試器。調試器會收到一個信號表明其子進程(或者說被追蹤進程)停止了。調試器可以做以下工作:

  1. 在目標地址,用原來的正常執行指令替換掉 int 3 指令
  2. 將被追蹤進程的指令指針回退一步。這是因為現在指令指針位於剛剛執行過的 int 3 之後。
  3. 允許用戶以某些方式與進程交互,因為該進程仍然停止在特定的目標地址。這裡你的調試器可以讓你取得變數值,調用棧等等。
  4. 當用戶想繼續運行,調試器會小心地把斷點放回目標地址去(因為它在第 1 步時被移走了),除非用戶要求取消該斷點。

讓我們來看看,這些步驟是如何翻譯成具體代碼的。我們會用到第一篇里的調試器 「模板」(fork 一個子進程並追蹤它)。無論如何,文末會有一個完整樣例源代碼的鏈接

/* Obtain and show child&apos;s instruction pointer */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child started. EIP = 0x%08xn", regs.eip);

/* Look at the word at the address we&apos;re interested in */
unsigned addr = 0x8048096;
unsigned data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("Original data at 0x%08x: 0x%08xn", addr, data);

這裡調試器從被追蹤的進程中取回了指令指針,也檢查了在 0x8048096 的字。當開始追蹤運行文章開頭的彙編代碼,將會列印出:

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

目前為止都看起來不錯。接下來:

/* Write the trap instruction &apos;int 3&apos; into the address */
unsigned data_with_trap = (data & 0xFFFFFF00) | 0xCC;
ptrace(PTRACE_POKETEXT, child_pid, (void*)addr, (void*)data_with_trap);

/* See what&apos;s there again... */
unsigned readback_data = ptrace(PTRACE_PEEKTEXT, child_pid, (void*)addr, 0);
procmsg("After trap, data at 0x%08x: 0x%08xn", addr, readback_data);

注意到 int 3 是如何被插入到目標地址的。此處列印:

[13028] After trap, data at 0x08048096: 0x000007cc

正如預料的那樣 —— 0xba0xcc 替換掉了。現在調試器運行子進程並等待它在斷點處停止:

/* 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: %sn", strsignal(WSTOPSIG(wait_status)));
}
else {
    perror("wait");
    return;
}

/* See where the child is now */
ptrace(PTRACE_GETREGS, child_pid, 0, &regs);
procmsg("Child stopped at EIP = 0x%08xn", 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, &regs);

/* The child can continue running now */
ptrace(PTRACE_CONT, child_pid, 0, 0);

這會使子進程繼續列印出 「world!」,然後退出。

注意,我們在這裡沒有恢復斷點。通過在單步調試模式下,運行原來的指令,然後將中斷放回去,並且只在運行 PTRACE_CONT 時做到恢復斷點。文章稍後會展示 debuglib 如何做到這點。

更多關於 int 3

現在可以回過頭去看看 int 3 和因特爾手冊里那個神秘的說明,原文如下:

這個一位元組格式是非常有用的,因為它可以用於使用斷點來替換任意指令的第一個位元組 ,包括哪些一位元組指令,而不會覆寫其它代碼

int 指令在 x86 機器上佔兩個位元組 —— 0xcd 緊跟著中斷數(細心的讀者可以在上面列出的轉儲中發現 int 0x80 翻譯成了 cd 80)。int 3 被編碼為 cd 03,但是為其還保留了一個單位元組指令 —— 0xcc

為什麼這樣呢?因為這可以允許我們插入一個斷點,而不需要重寫多餘的指令。這非常重要,考慮下面的代碼:

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

假設你想在 dec eax 這裡放置一個斷點。這對應一個單位元組指令(操作碼為 0x48)。由於替換斷點的指令長於一個位元組,我們不得不強制覆蓋掉下個指令(call)的一部分,這就會篡改 call 指令,並很可能導致一些完全不合理的事情發生。這樣一來跳轉到 foo 分支的 jz foo 指令會導致什麼?就會不在 dec eax 這裡停止,CPU 徑直去執行後面一些無效的指令了。

而有了單位元組的 int 3 指令,這個問題就解決了。 1 位元組是在 x86 上面所能找到的最短指令,這樣我們可以保證僅改變我們想中斷的指令。

封裝一些晦澀的細節

很多上述章節樣例代碼的底層細節,都可以很容易封裝在方便使用的 API 里。我已經做了很多封裝的工作,將它們都放在一個叫做 debuglib 的通用庫里 —— 文末可以去下載。這裡我僅僅是想展示它的用法示例,但是繞了一圈。下面我們將追蹤一個用 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 反彙編一下可執行文件,但是列印出的東西太多。尤其看到很多無用,也不感興趣的 C 程序運行時的初始化代碼。所以我們僅看一下 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 startedn");

    /* Wait for child to stop on its first instruction */
    wait(0);
    procmsg("child now at EIP = 0x%08xn", get_child_eip(child_pid));

    /* Create breakpoint and run to it*/
    debug_breakpoint* bp = create_breakpoint(child_pid, (void*)0x080483e4);
    procmsg("breakpoint createdn");
    ptrace(PTRACE_CONT, child_pid, 0, 0);
    wait(0);

    /* Loop as long as the child didn&apos;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%08Xn", get_child_eip(child_pid));
        procmsg("resumingn");
        int rc = resume_from_breakpoint(child_pid, bp);

        if (rc == 0) {
            procmsg("child exitedn");
            break;
        }
        else if (rc == 1) {
            continue;
        }
        else {
            procmsg("unexpected: %dn", rc);
            break;
        }
    }

    cleanup_breakpoint(bp);
}

為了避免修改 EIP 標誌位和目的進程的內存空間的麻煩,我們僅需要調用 create_breakpointresume_from_breakpointcleanup_breakpoint。讓我們來看看追蹤上面的 C 代碼樣例會輸出什麼:

$ bp_use_lib traced_c_loop
[13363] debugger started
[13364] target started. will run &apos;traced_c_loop&apos;
[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 庫。
  • bpuselib.c - 大部分代碼使用了 debuglib 庫,用於在第二個代碼範例中演示在 C 程序的循環中追蹤。

引文

在準備本文的時候,我搜集了如下的資源和文章:

via: http://eli.thegreenplace.net/2011/01/27/how-debuggers-work-part-2-breakpoints

作者:Eli Bendersky 譯者:wi-cuckoo 校對:wxy

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


本文轉載來自 Linux 中國: https://github.com/Linux-CN/archive

對這篇文章感覺如何?

太棒了
0
不錯
0
愛死了
0
不太好
0
感覺很糟
0
雨落清風。心向陽

    You may also like

    Leave a reply

    您的電子郵箱地址不會被公開。 必填項已用 * 標註

    此站點使用Akismet來減少垃圾評論。了解我們如何處理您的評論數據

    More in:Linux中國