自己動手寫一個 GDB|設置斷點(原理篇)

在上一篇文章《自己動手寫一個 GDB|基礎功能》中,我們介紹了怎麼使用 ptrace() 系統調用來實現一個簡單進程追蹤程序,本文主要介紹怎麼實現斷點設置功能。

什麼是斷點

當使用 GDB 調試程序時,如果想在程序執行到某個位置(某一行代碼)時停止運行,我們可以通過在此處位置設置一個 斷點 來實現。

當程序執行到斷點的位置時,會停止運行。這時,我們可以對進程進行調試,比如打印當前進程的堆棧信息或者打印變量的值等。如下圖所示:

斷點原理


要說明 斷點 的原理,我們首先需要了解下什麼是 中斷。本公衆號以前也寫過很多關於 中斷 的文章,例如:《一文看懂|Linux 中斷處理》。

想深入瞭解中斷原理的,可以看看上文。下面簡單介紹一下什麼是中斷:

中斷 是爲了解決外部設備完成某些工作後通知 CPU 的一種機制(譬如硬盤完成讀寫操作後通過中斷告知 CPU 已經完成)。

從物理學的角度看,中斷是一種電信號,由硬件設備產生,並直接送入中斷控制器(如 8259A)的輸入引腳上,然後再由中斷控制器向處理器發送相應的信號。處理器一經檢測到該信號,便中斷自己當前正在處理的工作,轉而去處理中斷。此後,處理器會通知 OS 已經產生中斷。這樣,OS 就可以對這個中斷進行適當的處理。不同的設備對應的中斷不同,而每個中斷都通過一個唯一的數字標識,這些值通常被稱爲中斷請求線。

如果進程在運行的過程中,發生了中斷,CPU 將會停止運行當前進程,轉而執行內核設置好的 中斷服務例程。如下圖所示:

軟中斷

大概瞭解中斷的原理後,接下來我們將會介紹 斷點 會用到的 軟中斷 功能。軟中斷跟上面介紹的中斷(也稱爲 硬中斷)類似,不過軟中斷並不是由外部設備產生,而是有特殊的指令觸發,這個特殊的指令稱爲 int3

int3 是一個單字節的操作碼(十六進制爲 0xcc)。當 CPU 執行到 int3 指令時,將會停止運行當前進程,轉而執行內核定義好的 int3 中斷處理例程:do_int3()

do_int3() 例程會向當前進程發送一個 SIGTRAP 信號,當進程接收到 SIGTRAP 信號後,CPU 將會停止執行當前進程。這時調試進程(GDB)就可以對進程進行調試,如:打印變量的值、打印堆棧信息等。

設置斷點

從上面的介紹可知,設置斷點的目的是讓進程停止運行,從而調試進程(GDB)就可以對其進行調試。

接下來,我們將會介紹如何設置一個斷點。

我們知道,當 CPU 執行到 int3 指令(0xcc)時會停止運行當前進程。所以,我們只需要在要進行設置斷點的位置改爲 int3 指令即可。如下圖所示:

從上圖可以看出,設置斷點時,只需要在要設置斷點的位置修改爲 int3 指令即可。但我們還需要保存原來被替換的指令,因爲調試完畢後,我們還需要把 int3 指令修改爲原來的指令,這樣程序才能正常運行。

斷點實現

既然,我們已經知道了斷點的原理。那麼,現在是時候介紹怎麼實現斷點功能了。

我們來說說設置斷點的步驟吧:

知道斷點實現的步驟後,我們可以開始編寫代碼了。

我們定義一個結構體 breakpoint_context 用於保存斷點被設置前的信息:

struct breakpoint_context
{
    void *addr; // 設置斷點的地址
    long data;  // 斷點原來的數據
};

圍繞 breakpoint_context 結構,我們定義幾個輔助函數,分別是:

現在我們來實現這幾個輔助函數。

1. 創建斷點

首先,我們來實現用於創建一個斷點的輔助函數 create_breakpoint()

breakpoint_context *create_breakpoint(void *addr)
{
    breakpoint_context *ctx = malloc(sizeof(*ctx));
    if (ctx) {
        ctx->addr = addr;
        ctx->data = NULL;
    }

    return ctx;
}

create_breakpoint() 函數需要提供一個類型爲 void * 的參數,表示要設置的斷點地址。

create_breakpoint() 函數的實現比較簡單,首先調用 malloc() 函數申請一個 breakpoint_context 結構,然後把 addr 字段設置爲斷點的地址,並且把 data 字段設置爲 NULL。

2. 啓用斷點

啓用斷點的原理是:首先讀取斷點處的數據,並且保存到 breakpoint_context 結構的 data 字段中。然後將斷點處的指令設置爲 int3 指令。

獲取某個內存地址處的數據可以使用 ptrace(PTRACE_PEEKTEXT,...) 函數來實現,如下所示:

long data = ptrace(PTRACE_PEEKTEXT, pid, address, 0);

在上面代碼中,pid 參數指定了目標進程的 PID,而 address 參數指定了要獲取此內存地址處的數據。

而要將某內存地址處設置爲制定的值,可以使用 ptrace(PTRACE_POKETEXT,...) 函數來實現,如下所示:

ptrace(PTRACE_POKETEXT, pid, address, data);

在上面代碼中,pid 參數指定了目標進程的 PID,而 address 參數指定了要將此內存地址處的值設置爲 data

有了上面的基礎,現在我們可以來編寫 enable_breakpoint() 函數的代碼了:

void enable_breakpoint(pid_t pid, breakpoint_context *ctx)
{
    // 1. 獲取斷點處的數據, 並且保存到 breakpoint_context 結構的 data 字段中
    ctx->data = ptrace(PTRACE_PEEKTEXT, pid, ctx->addr, 0);

    // 2. 把斷點處的值設置爲 int3 指令(0xCC)
    ptrace(PTRACE_POKETEXT, pid, ctx->addr, (ctx->data & 0xFFFFFF00) | 0xCC);
}

enable_breakpoint() 函數的原理,上面已經詳細介紹過了。

不過有一點我們需要注意的,就是使用 ptrace() 函數一次只能獲取和設置一個 4 字節大小的長整型數據。但是 int3 指令是一個單子節指令,所以設置斷點時,需要對設置的數據進行處理。如下圖所示:

3. 禁用斷點

禁用斷點的原理與啓用斷點剛好相反,就是把斷點處的 int3 指令替換成原來的指令,原理如下圖所示:

由於 breakpoint_context 結構的 data 字段保存了斷點處原來的指令,所以我們只需要把斷點處的指令替換成 data 字段的數據即可,代碼如下:

void disable_breakpoint(pid_t pid, breakpoint_context *ctx)
{
    long data = ptrace(PTRACE_PEEKTEXT, pid, ctx->addr, 0);
    ptrace(PTRACE_POKETEXT, pid, ctx->addr, (data & 0xFFFFFF00) | (ctx->data & 0xFF));
}

4. 釋放斷點

釋放斷點的實現就非常簡單了,只需要調用 free() 函數把 breakpoint_context 結構佔用的內存釋放掉即可,代碼如下:

void free_breakpoint(breakpoint_context *ctx)
{
    free(ctx);
}

總結

本來想一口氣把斷點的原理和實現都在本文寫完的,但寫着寫着發現篇幅有點長。所以,決定把斷點分爲原理篇和實現篇。

本文是斷點設置的原理篇,下一篇文章中,我們將會介紹如何使用上面介紹的知識點和輔助函數來實現我們的斷點設置功能,敬請期待。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/ZjPD4tWszkUyS9C0CCF05w