[Win32]一個調試器的實現(五)調試符號


一個調試器應該可以跟蹤被調試程序執行到了什么地方,顯示下一條將要執行的語句,顯示各個變量的值,設置斷點,進行單步執行等等,這些功能都需要一個基礎設施的支持,那就是調試符號。

 

什么是調試符號

我們知道,在exedll等可執行文件中保存的數據大部分都是二進制指令,CPU直接讀取這些指令並執行。那么調試器是如何知道每條指令對應哪個源文件的哪一行代碼呢?它又是如何知道每個變量和函數的名稱,並顯示變量的值呢?很顯然,可執行文件的二進制數據中不可能包含這么多信息,這一切都是由調試符號來支持的。

 

所謂符號,簡單來說就是源代碼中每個對象的名稱。例如變量、函數、類型等,它們都有一個名稱,以及其它的相關信息:變量有類型、地址等信息;函數有返回值類型、參數類型、地址等信息;類型有長度等信息。編譯器在編譯每個源文件的時候都會收集該源文件中的符號的信息,在生成目標文件的時候將這些信息保存到符號表中。鏈接器使用符號表中的信息將各個目標文件鏈接成可執行文件,同時將多個符號表整合成一個文件,這個文件就是用於調試的符號文件,它既可以嵌入可執行文件中,也可以獨立存在。

 

符號文件中包含的信息可多可少,這樣可以避免泄露程序的信息。調試版程序的符號文件包含了所有的調試信息,而發行版程序的符號文件只包含非常少的調試信息,甚至沒有符號文件。

 

符號文件有多種不同的格式,不同的編譯器可能使用不同的格式。目前Visual Studio默認使用的是PDB格式,生成項目之后,在Debug或者Release文件夾下都可以找到與生成的文件同名的PDB文件。本文以及接下來的文章中,均使用PDB格式的符號文件來進行調試。

 

使用調試符號

Windows提供了兩種方法讓我們可以訪問調試符號,分別是DbgHelpDebug Help Library)和DIADebug Interface Access)。DIA是基於COM的,對於不熟悉COM的人使用起來會比較麻煩;而使用DbgHelp就像使用普通的Windows API那樣,比較容易。本文以及接下來的文章中,使用的都是DbgHelp

 

使用DbgHelp的程序需要加載DbgHelp.dll這個動態鏈接庫,Windows自帶這個文件,位於C:\Windows\System32。但是Windows自帶的通常是較低版本的文件,所以最好是獲取一個最新版本的,將其與程序的可執行文件放在同一個目錄中,這樣既可以使用最新的DbgHelp,又不需要改動系統文件。

 

獲取最新DbgHelp.dll的一個方法是下載Windows Debugging Tools,地址為http://msdn.microsoft.com/en-us/windows/hardware/gg463009.aspx。不過這個工具包很大,為了這一個小小的文件可能要下載很長時間。其實在Visual Studio 2010中已包含了最新版本的DbgHelp(至少在寫作本文的時候是如此),路徑是C:\Program Files\Microsoft Visual Studio 10.0\Common7\IDE\dbghelp.dll。(假設Visual Studio 2010安裝在C:\Program Files

 

為了在程序中使用DbgHelp,你需要先完成以下的事情:

打開項目屬性對話框,定位到“配置屬性”-“鏈接器”-“輸入”,在右邊的“附加依賴項”中添加dbghelp.lib

有一點需要注意,DbgHelp使用DBGHELP_TRANSLATE_TCHAR這個預定義標記來決定是否使用Unicode字符串,而不是UNICODE標記。所以,如果你的程序使用Unicode字符串,那就定位到“配置屬性”-C/C++-“預處理器”,在右邊的“預處理器定義”中添加DBGHELP_TRANSLATE_TCHAR

最后,在需要使用DbgHelp的源文件中,包含Windows.hDbgHelp.h頭文件即可。(Windows.h需要包含在DbgHelp.h的前面)

 

加載調試符號

一個進程會有多個模塊,每個模塊都有它自己的符號文件,有關符號文件的信息保存在模塊的可執行文件中。DbgHelp通過符號處理器(Symbol Handler)來處理模塊的符號文件。符號處理器位於調試器進程中,每個被調試的進程對應一個符號處理器。通常,調試器在被調試進程啟動的時候創建符號處理器,在被調試進程結束的時候清理相應符號處理器占用的資源。

 

創建一個符號處理器使用SymInitialize函數,該函數聲明如下:

1  BOOL WINAPI SymInitialize(
2      HANDLE hProcess,
3      PCTSTR UserSearchPath,
4      fInvadeProcess
5  );

 第一個參數是被調試進程的句柄,它是符號管理器的標識符,其它的DbgHelp函數都需要這樣一個參數值指明使用哪個符號管理器。實際上這個參數不一定是句柄:當fInvadeProcess參數為TRUE時,它必須是一個有效的進程句柄;當fInvadeProcessFALSE時,它可以是任意一個唯一的數值。

 

fInvadeProcess的作用是指示是否加載進程所有模塊的調試符號,如果該參數為FALSE,那么SymInitialize只是創建一個符號處理器,不加載任何模塊的調試符號,此時需要我們自己調用SymLoadModule64函數來加載模塊;如果為TRUESymInitialize會遍歷進程的所有模塊,並加載其調試符號,所以在這種情況下hProcess必須是一個有效的進程句柄。

 

fInvadeProcessTRUE時,第二個參數UserSearchPath指示SymInitialize函數去哪里尋找符號文件。使用PDB符號文件的可執行文件中已包含有符號文件的絕對路徑,如果符號文件不存在,SymInitialize就會使用UserSearchPath指定的路徑去尋找符號文件。該參數可指定多個路徑,以分號(;)分割。如果該參數為NULL,那么SymInitialize會按照以下的順序尋找符號文件:

調試器進程的工作目錄;

_NT_SYMBOL_PATH環境變量指定的路徑;

_NT_ALTERNATE_SYMBOL_PATH環境變量指定的路徑。

 

如果在以上路徑中仍然找不到符號文件,SymInitialize並不會返回FALSE,而是返回TRUE。也就是說,它成功創建了符號處理器,並且加載了模塊的信息,但是沒有加載調試符號(關於如何判斷某個模塊是否加載了調試符號,下文會有講解)。實際上,SymInitialize幾乎不會返回FALSE,然而在某種情況下它會這么做,下面會有關於這方面的說明。

 

根據對SymInitialize的描述,有兩種方法可以加載調試符號。第一種方法是在調用SymInitialize的時候第三個參數傳入TRUE,由它負責加載每個模塊的調試符號。這種方法的好處是方便,但是有一個前提:被調試進程必須初始化完畢。我曾經嘗試在處理CREATE_PROCESS_DEBUG_EVENT事件的時候使用這種方法加載調試符號,但SymInitialize總是返回FALSEGetLastError返回-1。這是因為在處理CREATE_PROCESS_DEBUG_EVENT事件時,被調試進程需要的模塊還未加載完成,處於一個不完整的狀態。所以,應該等到被調試進程初始化之后才使用這種方法。由於每個進程在初始化完畢之后都會引發一個斷點異常,所以加載調試符號的最好的時機就是在處理這個初始斷點的時候。關於初始斷點的內容在講解斷點的時候會提及。

 

第二種方法是在調用SymInitialize的時候第三個參數傳入FALSE,然后對每個模塊調用SymLoadModule64函數加載調試符號。我們可以在處理CREATE_PROCESS_DEBUG_EVENTLOAD_DLL_DEBUG_EVENT事件時分別加載exe文件和dll文件的調試符號。SymLoadModule64函數的聲明如下:

1  DWORD64 WINAPI SymLoadModule64(
2      HANDLE hProcess,
3      HANDLE hFile,
4      PCSTR ImageName,
5      PCSTR ModuleName,
6      DWORD64 BaseOfDll,
7      DWORD SizeOfDll
8  );

第一個參數是符號處理器的標識符,也就是在調用SymInitialize時第一個參數的值。第二個參數是模塊文件的句柄,該函數通過這個文件句柄來獲取有關符號文件的信息。你可能記得在CREATE_PROCESS_DEBUG_INFOLOAD_DLL_DEBUG_INFO結構體中都有一個hFile的字段,這個字段剛好可以用在SymLoadModule64函數上。

 

第三個參數ImageName用於指定模塊文件的路徑和名稱,當第二個參數為NULL時,SymLoadModule64會通過這里指定的路徑和名稱去尋找模塊文件。一般情況下都不會使用這個參數,因為我們可以使用更可靠的hFile參數。

 

第四個參數ModuleName為該模塊賦予一個名稱,在使用其它DbgHelp函數的時候可以通過這個名稱來引用模塊。如果該參數為NULLSymLoadModule64會使用符號文件的文件名作為模塊名稱。

 

第五個參數BaseOfDll是模塊加載到進程地址空間之后的基地址。這個參數很重要,因為符號文件中每個符號的地址都是相對於模塊基地址的偏移地址,而不是絕對地址,這樣的話,不論模塊被加載到哪個地址,它的符號文件都是可用的。當然,這一切的前提是你將正確的模塊基地址傳給了SymLoadModule64函數。幸運的是,CREATE_PROCESS_DEBUG_INFOLOAD_DLL_DEBUG_INFO結構體中已包含了一個lpBaseOfImage字段,我們直接使用即可,不必為了獲取模塊基地址而大動干戈。

 

至於最后一個參數SizeOfDll,表示模塊文件的大小。我還不知道這個參數的作用,也不知道應該傳一個什么樣的值給它。我一直都給它傳一個0,即使如此SymLoadModule64也能正常工作。所以我們還是暫且將它放在一旁,將注意力轉移到別的地方吧。

 

添加了加載調試符號的代碼之后,處理CREATE_PROCESS_DEBUG_EVENT事件的代碼大概像下面這樣子:

 1  BOOL OnProcessCreated( const  CREATE_PROCESS_DEBUG_INFO *  pInfo) {
 2 
 3       // 初始化符號處理器
 4       // 注意,這里不能使用pInfo->hProcess,因為g_hProcess和pInfo->hProcess
 5       // 的值並不相同,而其它DbgHelp函數使用的是g_hProcess。
 6       if  (SymInitialize(g_hProcess, NULL, FALSE)  ==  TRUE) {
 7      
 8           // 加載模塊的調試信息
 9          DWORD64 moduleAddress  =  SymLoadModule64(
10              g_hProcess,
11              pInfo -> hFile, 
12              NULL,
13              NULL,
14              (DWORD64)pInfo -> lpBaseOfImage,
15               0 );
16 
17           if  (moduleAddress  ==   0 ) {
18 
19              std::wcout  <<  TEXT( " SymLoadModule64 failed:  " <<  GetLastError()  <<  std::endl;
20          }
21      }
22       else  {
23 
24          std::wcout  <<  TEXT( " SymInitialize failed:  " <<  GetLastError()  <<  std::endl;
25      }
26 
27      CloseHandle(pInfo -> hFile);
28      CloseHandle(pInfo -> hThread);
29      CloseHandle(pInfo -> hProcess);
30 
31       return  TRUE;
32  }

 

處理LOAD_DLL_DEBUG_EVENT事件的代碼:

 1  BOOL OnDllLoaded( const  LOAD_DLL_DEBUG_INFO *  pInfo) {
 2 
 3       // 加載模塊的調試信息
 4      DWORD64 moduleAddress  =  SymLoadModule64(
 5          g_hProcess,
 6          pInfo -> hFile, 
 7          NULL,
 8          NULL,
 9          (DWORD64)pInfo -> lpBaseOfDll,
10           0 );
11 
12       if  (moduleAddress  ==   0 ) {
13 
14          std::wcout  <<  TEXT( " SymLoadModule64 failed:  " <<  GetLastError()  <<  std::endl;
15      }
16 
17      CloseHandle(pInfo -> hFile);
18 
19       return  TRUE;
20  }

 

判斷符號文件的格式

前面說過,SymInitialize在找不到符號文件的情況下仍然會返回TRUE,此時它只加載了模塊的信息,而沒有加載調試符號。SymLoadModule64函數同樣如此。那么,如何知道某個模塊是否含有調試信息呢?或者,如何知道某個模塊的符號文件使用哪種格式呢?可以通過調用SymGetModuleInfo64函數來獲取這些信息。該函數的聲明如下:

1  BOOL WINAPI SymGetModuleInfo64(
2      HANDLE hProcess,
3      DWORD64 dwAddr,
4      PIMAGEHLP_MODULE64 ModuleInfo
5  );

第一個參數是符號處理器的標識符,現在你應該對它很熟悉了。第二個參數是模塊的基地址,也就是在調用SymLoadModule64時傳給BaseOfDll參數的值。第三個參數是指向IMAGEHLP_MODULE64結構體的指針,調用函數完成之后模塊的信息將會保存到這個結構體中。

 

IMAGEHLP_MODULE64結構體含有非常多的字段,不過我們一般只關心其中的一個:SymType。這個字段指示模塊使用的是哪種格式的符號文件,其可能的取值如下:

SymCoff

COFF格式。

SymCv

CodeView 格式。

SymDeferred

調試符號是延遲加載的。下文會提及。

SymDia

DIA 格式。

SymExport

符號是從DLL文件的導出表中生成的。

SymNone

沒有調試符號。

SymPdb

PDB格式。

SymSym

使用.sym類型的符號文件。

SymVirtual

SymLoadModuleEx函數的最后一個參數有關,還未知道什么意思。

 在調用SymGetModuleInfo64之前需要將IMAGEHLP_MODULE64結構體的SizeOfStruct字段設置為sizeof(IMAGEHLP_MODULE64)

 

延遲加載調試符號

在上面SymType的取值列表中有一個SymDeferred的值,它表示什么意思呢?DbgHelp支持延遲加載調試符號,意思是說在調用SymLoadModule64時,只加載模塊信息,不加載調試符號,等到真正使用的時候才加載。這樣做的好處是可以節省內存,避免加載了符號而不使用的情況。

 

如果要開啟這個特性,可以使用SymSetOptions函數:

1  SymSetOptions(SYMOPT_DEFERRED_LOADS);

該函數需要在調用SymInitialize之前調用。

 

所謂“真正使用的時候”究竟是什么時候,我也搞不清楚。我在開啟了延遲加載調試符號的情況下調用SymGetLineFromAddr64獲取源文件路徑和行號信息時總是失敗,而關閉了這個特性之后卻成功了,這說明並不是所有需要訪問調試符號的DbgHelp函數都會使調試符號加載進來。所以,為了確保DbgHelp函數可以正確執行,我建議不要開啟這項特性。

 

清理調試符號

在被調試進程結束的時候必須刪除與之對應的符號處理器,以及清理它占用的資源。只要在處理EXIT_PROCESS_DEBUG_EVENT事件的時候調用SymCleanup函數就可以完成這個操作,該函數接受一個符號處理器的標識符。

 

另外,在dll文件卸載的時候也應該清理與之相關的調試符號,避免占用內存。這要在處理UNLOAD_DLL_DEBUG_EVENT事件時調用SymUnloadModule64函數。該函數接受一個符號處理器的標識符,以及模塊的基地址,我們可以直接使用UNLOAD_DLL_DEBUG_INFO結構體中唯一的字段lpBaseOfDll

 

示例代碼

示例代碼按照本文的描述添加了對調試符號的加載和清理代碼,改動不是很大。

http://files.cnblogs.com/zplutor/MiniDebugger5.rar


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2021 ITdaan.com