https://www.cnblogs.com/alantu2018/p/8997173.html
https://www.cnblogs.com/zplutor/archive/2011/03/04/1971279.html
https://www.cnblogs.com/zplutor/archive/2011/04/02/2004097.html
斷點是最基本和最重要的調試技術之一,本文講解了如何在調試器中實現斷點功能。
什么是斷點
在進行調試的時候,只有被調試進程暫停執行時調試器才可以對它執行操作,例如觀察內存內容等。如果被調試進程不停下來的話,調試器是什么也做不了的。要使被調試進程停下來,除了幾個在特定時刻才發生的調試事件外,唯一的途徑就是引發異常。
斷點正是用來達到上述目的的異常,在第三篇文章的異常代碼表中,有一種EXCEPTION_BREAKPOINT異常,它就是斷點異常。雖然斷點是一種異常,但並不意味着被調試進程發生了問題,它只是用來調試的一種手段,所以調試器應該將它和別的異常明顯區分開來。實際上Windows對斷點異常的處理也有一些微妙的不同,下文將會講到。
斷點有軟件斷點和硬件斷點之分。硬件斷點是通過CPU的寄存器來設置的,它的功能很強大,既可以在代碼中設置斷點,也可以在數據中設置斷點,但是可以設置的數量有限。軟件斷點即通過int 3指令引發的斷點,機器碼是0xCC,它只能設置在代碼中,但沒有數量的限制。本文只關注軟件斷點。
如果你使用過前幾篇文章中的MiniDebugger來調試程序,肯定會注意到在被調試程序剛開始運行的時候總會有一個發生在高地址處的斷點異常(通過異常代碼是0x80000003來判別),這個斷點就是初始斷點。如果Windows檢測到一個程序正在被調試,那么在這個程序初始化完成之后,就會引發一個斷點異常,告訴調試器一切就緒。調試器可以在接收到這個斷點時進行准備工作,例如加載調試符號。初始斷點是不可避免的,只要在Windows下調試程序都會引發這個斷點。
斷點異常的分發
斷點實際上是異常,所以它同樣也會經歷第三篇文章所說的異常分發的過程。那么,它是屬於錯誤異常還是陷阱異常呢?不妨通過實驗來證實。這里使用上一篇文章的MiniDebugger作為調試器,以下面代碼生成的程序作為被調試程序:
2 __asm { int 3 };
3 return 0;
4 }
首先啟動被調試程序,跳過初始斷點,使它執行__asm {int 3};語句,引發斷點異常:
執行l和r命令查看源代碼和寄存器:
可以看到,執行完int 3指令后,EIP指向了下一條指令,如果以g c命令恢復執行,就會執行return語句,被調試進程就會結束。得出結論:斷點異常屬於陷阱異常。
上文說過Windows對於斷點異常的處理有微妙的不同,現在讓我們看一下有什么不同。執行g命令,不處理異常,在第二次接收到異常時執行l和r命令:
這時EIP回退了一個字節,指向了引發斷點異常的那條指令。Windows在分發其它異常時並不會修改EIP的值,這就是它們的區別。
另外,調試器只會接收到一次初始斷點,無論以DBG_CONTINUE還是DBG_EXCEPTION_NOT_HANDLED調用ContinueDebugEvent,都不會再接收到初始斷點。
陷阱標志
除了斷點之外,CPU本身提供了一個單步執行的功能,也可以使程序在某處中斷。在CPU的標志寄存器中,有一個TF(Trap Flag)位,當該位為1時,CPU每執行一條指令就會引發一次中斷,Windows以單步執行異常來通知調試器,異常代碼為EXCEPTION_SINGLE_STEP。每引發一次中斷,CPU都會自動將TF位設為0,所以如果想連續單步執行多條指令,需要在每次處理單步執行異常時都重新設置TF位。
單步執行異常屬於錯誤異常,引發異常的地址與EIP指向的地址相同。
斷點功能原理
上面的例子在被調試程序中插入了一條int 3指令,那是為了實驗的需要,但是在正常的程序中不可能會有這樣的指令。為了可以在任何指令處設置斷點,調試器要將指令的第一個字節替換成0xCC(int 3的機器碼),接收到斷點異常之后,再替換回原來的那個字節,從該指令開始繼續執行。這樣就實現了在任意指令處中斷,並對原程序毫無影響。
例如,下面的賦值語句對應一條匯編指令:
C7 45 F8 02 00 00 00 mov dword ptr [b],2
這條指令有7個字節。假如調試器想要在這條語句設置斷點,它首先將指令的第一個字節0xC7保存起來,然后替換成0xCC:
此時原來的mov指令變成了一條int 3指令和6個字節的垃圾數據。被調試程序不知道這個變化,它逐條指令地執行,到了int 3指令之后引發斷點異常,暫停執行。此時被調試程序不能再往下執行了,因為接下來的6個字節是垃圾數據,嘗試執行的話肯定會失敗。
調試器可以選擇在第一次或第二次接收斷點異常時進行處理。如果在第一次接收時處理,它就要主動將被調試進程的EIP減。如果在第二次接收時處理,就不需要修改被調試進程的EIP了,因為正如上文所說,第二次接收斷點異常時Windows已經將EIP減1了。無論何時處理異常,調試器都要將0xCC替換回原來的0xC7,然后以DBG_CONTINUE繼續被調試進程執行。
我建議在第一次接收斷點異常時進行處理,因為如果第一次接收時不處理,Windows會執行額外的代碼,這會給單步執行功能帶來一些麻煩。
最后還有一個問題需要留意,如果斷點設置在循環的內部,或者設置在一個被多次調用的函數中,那么該斷點只會中斷一次,因為它在第一次中斷之后就被取消了。為了讓它持續有效,我們需要一種機制,讓斷點所在的指令執行完之后重新設置該斷點。這可以借助TF位的幫助:處理斷點異常的時候,在取消斷點之后立即設置TF位,然后繼續執行;在捕捉到單步執行異常時重新設置斷點。
完整的斷點功能流程圖如下:
實現斷點功能
了解了斷點功能的原理,下面就來逐步實現這個功能。這里只描述大概的思路,具體如何實現可以參考示例代碼。
首先是要確定斷點的地址,這可以通過MiniDebugger的l命令來獲取每一行的地址。注意,斷點只能設置在指令的第一個字節,否則會破壞指令的結構,導致被調試進程無法執行。
確定地址之后就要替換指令第一個字節。讀取這個字節可以使用ReadProcessMemory函數,寫入字節可以使用WriteProcessMemory函數。前者已經在第四篇文章中介紹過,而后者的使用方法與之非常相似,這里不再詳述了。恢復指令也是使用WriteProcessMemory函數。
調試器必須保存一份斷點列表,最好用一個結構體來表示斷點,例如:
2 DWORD address; //斷點地址
3 BYTE content; //原指令第一個字節
4 } BREAK_POINT;
接下來是處理斷點異常的方式。應該將斷點分成三種類型:初始斷點,被調試進程中的斷點,以及調試器設置的斷點。對於初始斷點,不需要進行任何處理,因為它是由Windows管理的。如果對初始斷點應用了以上的處理過程,被調試進程會無法運行。被調試進程中的斷點即代碼中顯式加入的斷點,例如上面例子中的__asm{ int3 }語句。對於這類斷點,只要在第一次接收斷點異常時報告給用戶即可,不需要進行其它處理。而調試器設置的斷點就要按照上文所說的方法來處理了。
如果選擇在第一次接收斷點異常時進行處理,那么需要使用SetThreadContext函數設置被調試進程的EIP,該函數的參數與GetThreadContext完全一致。為了避免修改EIP而影響到其它的寄存器,應該先調用GetThreadContext填充CONTEXT結構,再調用SetThreadContext。例如:
2 context.ContextFlags = CONTEXT_CONTROL;
3 GetThreadContext(g_hThread, &context);
4 context.Eip -= 1;
5 SetThreadContext(g_hThread, &context);
設置TF位的方法與設置EIP的方法一致,同樣是先調用GetThreadContext,然后修改Eflags字段的值,再調用SetThreadContext。TF位是EFLAGS寄存器中的第8位(從0開始算),通過下面的語句可以設置TF位:
在處理單步執行異常時,不能簡單認為EIP減1就是原斷點的地址,因為斷點所在指令的長度是不確定的。為了重新設置斷點,需要保存該斷點的地址,或者干脆將所有斷點都重新設置一次。具體使用什么方法則因人而異了。
最后提醒一下,設置斷點之后使用d命令觀察斷點處的內存時會“露餡”,看到替換之后的0xCC。通常應該對用戶隱藏這個事實,所以在處理d命令時應該將斷點處原來的內容顯示出來。
在Main函數設置斷點
如果按照上面的處理方法將初始斷點忽略之后,帶來了一個新的問題:被調試進程此時不會在初始斷點發生時暫停,而是一直運行到結束,我們根本沒機會對它進行任何操作。解決這個問題的方法就是在Main函數的入口處設置斷點。這里所說的Main函數是一個統稱,指代下面四個入口函數:
main
wmain
WinMain
wWinMain
一個C/C++應用程序的入口函數必定是上面四個的其中之一。
為了在Main函數處設置斷點,首先要知道它的地址,這就需要調試符號的幫助了。一個函數是一個符號,可以通過SymFromName函數根據符號名稱獲取符號的信息。該函數的聲明如下:
2 HANDLE hProcess,
3 PCTSTR Name,
4 PSYMBOL_INFO Symbol
5 );
第一個參數是符號處理器的標識符;第二個參數是符號的名稱;第三個參數是指向SYMBOL_INFO結構體的指針,函數調用成功后符號的信息就保存在這個結構體中。該結構體的定義如下:
2 ULONG SizeOfStruct;
3 ULONG TypeIndex;
4 ULONG64 Reserved[2];
5 ULONG Index;
6 ULONG Size;
7 ULONG64 ModBase;
8 ULONG Flags;
9 ULONG64 Value;
10 ULONG64 Address;
11 ULONG Register;
12 ULONG Scope;
13 ULONG Tag;
14 ULONG NameLen;
15 ULONG MaxNameLen;
16 TCHAR Name[1];
17 } SYMBOL_INFO, *PSYMBOL_INFO;
這個結構體有很多字段,但目前我們只關注Address,它就是符號的起始地址。關於SYMBOL_INFO這個結構體,在后面的文章中還會提到。
獲取Main函數地址的函數大概像下面那樣:
2
3 static LPCTSTR entryPointNames[] = {
4 TEXT("main"),
5 TEXT("wmain"),
6 TEXT("WinMain"),
7 TEXT("wWinMain"),
8 };
9
10 SYMBOL_INFO symbolInfo = { 0 };
11 symbolInfo.SizeOfStruct = sizeof(SYMBOL_INFO);
12
13 for (int index = 0; index != sizeof(entryPointNames) / sizeof(LPCTSTR); ++index) {
14
15 if (SymFromName(g_hProcess, entryPointNames[index], &symbolInfo) == TRUE) {
16
17 return (DWORD)symbolInfo.Address;
18 }
19 }
20
21 return 0;
22 }
示例代碼
這次為MiniDebugger添加了b命令,其功能是設置斷點,命令格式如下:
b [address [d]]
address為斷點的地址,以十六進制表示。如果帶d參數,表示刪除斷點,否則設置斷點。如果不帶任何參數,則顯示所有已設置的斷點。
這個版本的MiniDebugger示范了如何在第二次接收斷點異常時進行處理,正如上文所說,這會給單步執行功能帶來麻煩,所以在添加了單步執行功能之后會改回第一次接收時處理,請大家留意。另外,該版本的MiniDebugger沒有對d命令進行額外處理以隱藏斷點的0xCC機器碼。
http://files.cnblogs.com/zplutor/MiniDebugger7.rar
作者:Zplutor
出處:http://www.cnblogs.com/zplutor/
本文版權歸作者和博客園共有,歡迎轉載。但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接,否則保留追究法律責任的權利。