自己動(dòng)手寫一個(gè)GDB|設(shè)置斷點(diǎn)(原理篇)
在上一篇文章《自己動(dòng)手寫一個(gè)GDB|基礎(chǔ)功能》中,我們介紹了怎么使用?ptrace()?系統(tǒng)調(diào)用來(lái)實(shí)現(xiàn)一個(gè)簡(jiǎn)單進(jìn)程追蹤程序,本文主要介紹怎么實(shí)現(xiàn)斷點(diǎn)設(shè)置功能。
什么是斷點(diǎn)
當(dāng)使用 GDB 調(diào)試程序時(shí),如果想在程序執(zhí)行到某個(gè)位置(某一行代碼)時(shí)停止運(yùn)行,我們可以通過(guò)在此處位置設(shè)置一個(gè)?斷點(diǎn)?來(lái)實(shí)現(xiàn)。
當(dāng)程序執(zhí)行到斷點(diǎn)的位置時(shí),會(huì)停止運(yùn)行。這時(shí),我們可以對(duì)進(jìn)程進(jìn)行調(diào)試,比如打印當(dāng)前進(jìn)程的堆棧信息或者打印變量的值等。如下圖所示:

斷點(diǎn)原理
要說(shuō)明?斷點(diǎn)?的原理,我們首先需要了解下什么是?中斷。本公眾號(hào)以前也寫過(guò)很多關(guān)于?中斷?的文章,例如:《一文看懂|Linux中斷處理》。
想深入了解中斷原理的,可以看看上文。下面簡(jiǎn)單介紹一下什么是中斷:
中斷?是為了解決外部設(shè)備完成某些工作后通知CPU的一種機(jī)制(譬如硬盤完成讀寫操作后通過(guò)中斷告知CPU已經(jīng)完成)。從物理學(xué)的角度看,中斷是一種電信號(hào),由硬件設(shè)備產(chǎn)生,并直接送入中斷控制器(如 8259A)的輸入引腳上,然后再由中斷控制器向處理器發(fā)送相應(yīng)的信號(hào)。處理器一經(jīng)檢測(cè)到該信號(hào),便中斷自己當(dāng)前正在處理的工作,轉(zhuǎn)而去處理中斷。此后,處理器會(huì)通知 OS 已經(jīng)產(chǎn)生中斷。這樣,OS 就可以對(duì)這個(gè)中斷進(jìn)行適當(dāng)?shù)奶幚怼2煌脑O(shè)備對(duì)應(yīng)的中斷不同,而每個(gè)中斷都通過(guò)一個(gè)唯一的數(shù)字標(biāo)識(shí),這些值通常被稱為中斷請(qǐng)求線。
如果進(jìn)程在運(yùn)行的過(guò)程中,發(fā)生了中斷,CPU 將會(huì)停止運(yùn)行當(dāng)前進(jìn)程,轉(zhuǎn)而執(zhí)行內(nèi)核設(shè)置好的?中斷服務(wù)例程。如下圖所示:

軟中斷
大概了解中斷的原理后,接下來(lái)我們將會(huì)介紹?斷點(diǎn)?會(huì)用到的?軟中斷?功能。軟中斷跟上面介紹的中斷(也稱為?硬中斷)類似,不過(guò)軟中斷并不是由外部設(shè)備產(chǎn)生,而是有特殊的指令觸發(fā),這個(gè)特殊的指令稱為?int3。
int3?是一個(gè)單字節(jié)的操作碼(十六進(jìn)制為?0xcc)。當(dāng) CPU 執(zhí)行到?int3?指令時(shí),將會(huì)停止運(yùn)行當(dāng)前進(jìn)程,轉(zhuǎn)而執(zhí)行內(nèi)核定義好的 int3 中斷處理例程:do_int3()。
do_int3()?例程會(huì)向當(dāng)前進(jìn)程發(fā)送一個(gè)?SIGTRAP?信號(hào),當(dāng)進(jìn)程接收到?SIGTRAP?信號(hào)后,CPU 將會(huì)停止執(zhí)行當(dāng)前進(jìn)程。這時(shí)調(diào)試進(jìn)程(GDB)就可以對(duì)進(jìn)程進(jìn)行調(diào)試,如:打印變量的值、打印堆棧信息等。
設(shè)置斷點(diǎn)
從上面的介紹可知,設(shè)置斷點(diǎn)的目的是讓進(jìn)程停止運(yùn)行,從而調(diào)試進(jìn)程(GDB)就可以對(duì)其進(jìn)行調(diào)試。
接下來(lái),我們將會(huì)介紹如何設(shè)置一個(gè)斷點(diǎn)。
我們知道,當(dāng) CPU 執(zhí)行到?int3?指令(0xcc)時(shí)會(huì)停止運(yùn)行當(dāng)前進(jìn)程。所以,我們只需要在要進(jìn)行設(shè)置斷點(diǎn)的位置改為?int3?指令即可。如下圖所示:

從上圖可以看出,設(shè)置斷點(diǎn)時(shí),只需要在要設(shè)置斷點(diǎn)的位置修改為?int3?指令即可。但我們還需要保存原來(lái)被替換的指令,因?yàn)檎{(diào)試完畢后,我們還需要把?int3?指令修改為原來(lái)的指令,這樣程序才能正常運(yùn)行。
斷點(diǎn)實(shí)現(xiàn)
既然,我們已經(jīng)知道了斷點(diǎn)的原理。那么,現(xiàn)在是時(shí)候介紹怎么實(shí)現(xiàn)斷點(diǎn)功能了。
我們來(lái)說(shuō)說(shuō)設(shè)置斷點(diǎn)的步驟吧:
第一步:找到要設(shè)置斷點(diǎn)的地址。第二步:保存此地址處的數(shù)據(jù)(為了調(diào)試完能夠恢復(fù)原來(lái)的指令)。第三步:我們把此地址處的指令替換成 int3 指令。第四步:讓被調(diào)試的進(jìn)程繼續(xù)運(yùn)行,直到執(zhí)行到 int3 指令(也就是斷點(diǎn))。此時(shí),被調(diào)試進(jìn)程會(huì)停止運(yùn)行,調(diào)試進(jìn)程(GDB)就可以對(duì)進(jìn)程進(jìn)行調(diào)試。第五步:調(diào)試完畢后,恢復(fù)斷點(diǎn)處原來(lái)的指令,并且讓 IP 寄存器回退一個(gè)字節(jié)(因?yàn)閿帱c(diǎn)處原來(lái)的代碼還沒(méi)執(zhí)行)。第六步:把被調(diào)試進(jìn)程設(shè)置為單步調(diào)試模式,這是因?yàn)橐趫?zhí)行完斷點(diǎn)處原來(lái)的指令后,重新設(shè)置斷點(diǎn)(為什么?這是因?yàn)樵谝恍┭h(huán)語(yǔ)句中,可能需要重新執(zhí)行原來(lái)的斷點(diǎn))。
知道斷點(diǎn)實(shí)現(xiàn)的步驟后,我們可以開始編寫代碼了。
我們定義一個(gè)結(jié)構(gòu)體?breakpoint_context?用于保存斷點(diǎn)被設(shè)置前的信息:
struct?breakpoint_context
{
????void?*addr;?//?設(shè)置斷點(diǎn)的地址
????long?data;??//?斷點(diǎn)原來(lái)的數(shù)據(jù)
};
圍繞?breakpoint_context?結(jié)構(gòu),我們定義幾個(gè)輔助函數(shù),分別是:
create_breakpoint():用于創(chuàng)建一個(gè)斷點(diǎn)。enable_breakpoint():用于啟用斷點(diǎn)。disable_breakpoint():用于禁用斷點(diǎn)。free_breakpoint():用于釋放斷點(diǎn)。
現(xiàn)在我們來(lái)實(shí)現(xiàn)這幾個(gè)輔助函數(shù)。
1. 創(chuàng)建斷點(diǎn)
首先,我們來(lái)實(shí)現(xiàn)用于創(chuàng)建一個(gè)斷點(diǎn)的輔助函數(shù)?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()?函數(shù)需要提供一個(gè)類型為?void *?的參數(shù),表示要設(shè)置的斷點(diǎn)地址。
create_breakpoint()?函數(shù)的實(shí)現(xiàn)比較簡(jiǎn)單,首先調(diào)用?malloc()?函數(shù)申請(qǐng)一個(gè)?breakpoint_context?結(jié)構(gòu),然后把?addr?字段設(shè)置為斷點(diǎn)的地址,并且把?data?字段設(shè)置為 NULL。
2. 啟用斷點(diǎn)
啟用斷點(diǎn)的原理是:首先讀取斷點(diǎn)處的數(shù)據(jù),并且保存到?breakpoint_context?結(jié)構(gòu)的?data?字段中。然后將斷點(diǎn)處的指令設(shè)置為?int3?指令。
獲取某個(gè)內(nèi)存地址處的數(shù)據(jù)可以使用?ptrace(PTRACE_PEEKTEXT,...)?函數(shù)來(lái)實(shí)現(xiàn),如下所示:
long?data?=?ptrace(PTRACE_PEEKTEXT,?pid,?address,?0);
在上面代碼中,pid?參數(shù)指定了目標(biāo)進(jìn)程的PID,而?address?參數(shù)指定了要獲取此內(nèi)存地址處的數(shù)據(jù)。
而要將某內(nèi)存地址處設(shè)置為制定的值,可以使用?ptrace(PTRACE_POKETEXT,...)?函數(shù)來(lái)實(shí)現(xiàn),如下所示:
ptrace(PTRACE_POKETEXT,?pid,?address,?data);
在上面代碼中,pid?參數(shù)指定了目標(biāo)進(jìn)程的PID,而?address?參數(shù)指定了要將此內(nèi)存地址處的值設(shè)置為?data。
有了上面的基礎(chǔ),現(xiàn)在我們可以來(lái)編寫?enable_breakpoint()?函數(shù)的代碼了:
void?enable_breakpoint(pid_t?pid,?breakpoint_context?*ctx)
{
????//?1.?獲取斷點(diǎn)處的數(shù)據(jù),?并且保存到?breakpoint_context?結(jié)構(gòu)的?data?字段中
????ctx->data?=?ptrace(PTRACE_PEEKTEXT,?pid,?ctx->addr,?0);
????//?2.?把斷點(diǎn)處的值設(shè)置為?int3?指令(0xCC)
????ptrace(PTRACE_POKETEXT,?pid,?ctx->addr,?(ctx->data?&?0xFFFFFF00)?|?0xCC);
}
enable_breakpoint()?函數(shù)的原理,上面已經(jīng)詳細(xì)介紹過(guò)了。
不過(guò)有一點(diǎn)我們需要注意的,就是使用?ptrace()?函數(shù)一次只能獲取和設(shè)置一個(gè) 4 字節(jié)大小的長(zhǎng)整型數(shù)據(jù)。但是?int3?指令是一個(gè)單子節(jié)指令,所以設(shè)置斷點(diǎn)時(shí),需要對(duì)設(shè)置的數(shù)據(jù)進(jìn)行處理。如下圖所示:

3. 禁用斷點(diǎn)
禁用斷點(diǎn)的原理與啟用斷點(diǎn)剛好相反,就是把斷點(diǎn)處的?int3?指令替換成原來(lái)的指令,原理如下圖所示:

由于?breakpoint_context?結(jié)構(gòu)的?data?字段保存了斷點(diǎn)處原來(lái)的指令,所以我們只需要把斷點(diǎn)處的指令替換成?data?字段的數(shù)據(jù)即可,代碼如下:
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. 釋放斷點(diǎn)
釋放斷點(diǎn)的實(shí)現(xiàn)就非常簡(jiǎn)單了,只需要調(diào)用?free()?函數(shù)把?breakpoint_context?結(jié)構(gòu)占用的內(nèi)存釋放掉即可,代碼如下:
void?free_breakpoint(breakpoint_context?*ctx)
{
????free(ctx);
}
總結(jié)
本來(lái)想一口氣把斷點(diǎn)的原理和實(shí)現(xiàn)都在本文寫完的,但寫著寫著發(fā)現(xiàn)篇幅有點(diǎn)長(zhǎng)。所以,決定把斷點(diǎn)分為原理篇和實(shí)現(xiàn)篇。
本文是斷點(diǎn)設(shè)置的原理篇,下一篇文章中,我們將會(huì)介紹如何使用上面介紹的知識(shí)點(diǎn)和輔助函數(shù)來(lái)實(shí)現(xiàn)我們的斷點(diǎn)設(shè)置功能,敬請(qǐng)期待。
