一個調試器應該可以跟蹤被調試程式執行到了什麼地方,顯示下一條将要執行的語句,顯示各個變量的值,設定斷點,進行單步執行等等,這些功能都需要一個基礎設施的支援,那就是調試符号。
什麼是調試符号
我們知道,在exe、dll等可執行檔案中儲存的資料大部分都是二進制指令,CPU直接讀取這些指令并執行。那麼調試器是如何知道每條指令對應哪個源檔案的哪一行代碼呢?它又是如何知道每個變量和函數的名稱,并顯示變量的值呢?很顯然,可執行檔案的二進制資料中不可能包含這麼多資訊,這一切都是由調試符号來支援的。
所謂符号,簡單來說就是源代碼中每個對象的名稱。例如變量、函數、類型等,它們都有一個名稱,以及其它的相關資訊:變量有類型、位址等資訊;函數有傳回值類型、參數類型、位址等資訊;類型有長度等資訊。編譯器在編譯每個源檔案的時候都會收集該源檔案中的符号的資訊,在生成目标檔案的時候将這些資訊儲存到符号表中。連結器使用符号表中的資訊将各個目标檔案連結成可執行檔案,同時将多個符号表整合成一個檔案,這個檔案就是用于調試的符号檔案,它既可以嵌入可執行檔案中,也可以獨立存在。
符号檔案中包含的資訊可多可少,這樣可以避免洩露程式的資訊。調試版程式的符号檔案包含了所有的調試資訊,而發行版程式的符号檔案隻包含非常少的調試資訊,甚至沒有符号檔案。
符号檔案有多種不同的格式,不同的編譯器可能使用不同的格式。目前Visual Studio預設使用的是PDB格式,生成項目之後,在Debug或者Release檔案夾下都可以找到與生成的檔案同名的PDB檔案。本文以及接下來的文章中,均使用PDB格式的符号檔案來進行調試。
使用調試符号
Windows提供了兩種方法讓我們可以通路調試符号,分别是DbgHelp(Debug Help Library)和DIA(Debug 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.h和DbgHelp.h頭檔案即可。(Windows.h需要包含在DbgHelp.h的前面)
加載調試符号
一個程序會有多個子產品,每個子產品都有它自己的符号檔案,有關符号檔案的資訊儲存在子產品的可執行檔案中。DbgHelp通過符号處理器(Symbol Handler)來處理子產品的符号檔案。符号處理器位于調試器程序中,每個被調試的程序對應一個符号處理器。通常,調試器在被調試程序啟動的時候建立符号處理器,在被調試程序結束的時候清理相應符号處理器占用的資源。
建立一個符号處理器使用SymInitialize函數,該函數聲明如下:
BOOL WINAPI SymInitialize(
HANDLE hProcess,
PCTSTR UserSearchPath,
fInvadeProcess
);
第一個參數是被調試程序的句柄,它是符号管理器的辨別符,其它的DbgHelp函數都需要這樣一個參數值指明使用哪個符号管理器。實際上這個參數不一定是句柄:當fInvadeProcess參數為TRUE時,它必須是一個有效的程序句柄;當fInvadeProcess為FALSE時,它可以是任意一個唯一的數值。
fInvadeProcess的作用是訓示是否加載程序所有子產品的調試符号,如果該參數為FALSE,那麼SymInitialize隻是建立一個符号處理器,不加載任何子產品的調試符号,此時需要我們自己調用SymLoadModule64函數來加載子產品;如果為TRUE,SymInitialize會周遊程序的所有子產品,并加載其調試符号,是以在這種情況下hProcess必須是一個有效的程序句柄。
當fInvadeProcess為TRUE時,第二個參數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總是傳回FALSE,GetLastError傳回-1。這是因為在處理CREATE_PROCESS_DEBUG_EVENT事件時,被調試程序需要的子產品還未加載完成,處于一個不完整的狀态。是以,應該等到被調試程序初始化之後才使用這種方法。由于每個程序在初始化完畢之後都會引發一個斷點異常,是以加載調試符号的最好的時機就是在處理這個初始斷點的時候。關于初始斷點的内容在講解斷點的時候會提及。
第二種方法是在調用SymInitialize的時候第三個參數傳入FALSE,然後對每個子產品調用SymLoadModule64函數加載調試符号。我們可以在處理CREATE_PROCESS_DEBUG_EVENT和LOAD_DLL_DEBUG_EVENT事件時分别加載exe檔案和dll檔案的調試符号。SymLoadModule64函數的聲明如下:
DWORD64 WINAPI SymLoadModule64(
HANDLE hProcess,
HANDLE hFile,
PCSTR ImageName,
PCSTR ModuleName,
DWORD64 BaseOfDll,
DWORD SizeOfDll
);
第一個參數是符号處理器的辨別符,也就是在調用SymInitialize時第一個參數的值。第二個參數是子產品檔案的句柄,該函數通過這個檔案句柄來擷取有關符号檔案的資訊。你可能記得在CREATE_PROCESS_DEBUG_INFO和LOAD_DLL_DEBUG_INFO結構體中都有一個hFile的字段,這個字段剛好可以用在SymLoadModule64函數上。
第三個參數ImageName用于指定子產品檔案的路徑和名稱,當第二個參數為NULL時,SymLoadModule64會通過這裡指定的路徑和名稱去尋找子產品檔案。一般情況下都不會使用這個參數,因為我們可以使用更可靠的hFile參數。
第四個參數ModuleName為該子產品賦予一個名稱,在使用其它DbgHelp函數的時候可以通過這個名稱來引用子產品。如果該參數為NULL,SymLoadModule64會使用符号檔案的檔案名作為子產品名稱。
第五個參數BaseOfDll是子產品加載到程序位址空間之後的基位址。這個參數很重要,因為符号檔案中每個符号的位址都是相對于子產品基位址的偏移位址,而不是絕對位址,這樣的話,不論子產品被加載到哪個位址,它的符号檔案都是可用的。當然,這一切的前提是你将正确的子產品基位址傳給了SymLoadModule64函數。幸運的是,CREATE_PROCESS_DEBUG_INFO和LOAD_DLL_DEBUG_INFO結構體中已包含了一個lpBaseOfImage字段,我們直接使用即可,不必為了擷取子產品基位址而大動幹戈。
至于最後一個參數SizeOfDll,表示子產品檔案的大小。我還不知道這個參數的作用,也不知道應該傳一個什麼樣的值給它。我一直都給它傳一個0,即使如此SymLoadModule64也能正常工作。是以我們還是暫且将它放在一旁,将注意力轉移到别的地方吧。
添加了加載調試符号的代碼之後,處理CREATE_PROCESS_DEBUG_EVENT事件的代碼大概像下面這樣子:
BOOL OnProcessCreated(const CREATE_PROCESS_DEBUG_INFO* pInfo) {
//初始化符号處理器
//注意,這裡不能使用pInfo->hProcess,因為g_hProcess和pInfo->hProcess
//的值并不相同,而其它DbgHelp函數使用的是g_hProcess。
if (SymInitialize(g_hProcess, NULL, FALSE) == TRUE) {
//加載子產品的調試資訊
DWORD64 moduleAddress = SymLoadModule64(
g_hProcess,
pInfo->hFile,
NULL,
NULL,
(DWORD64)pInfo->lpBaseOfImage,
0);
if (moduleAddress == 0) {
std::wcout << TEXT("SymLoadModule64 failed: ") << GetLastError() << std::endl;
}
}
else {
std::wcout << TEXT("SymInitialize failed: ") << GetLastError() << std::endl;
}
CloseHandle(pInfo->hFile);
CloseHandle(pInfo->hThread);
CloseHandle(pInfo->hProcess);
return TRUE;
}
處理
LOAD_DLL_DEBUG_EVENT
事件的代碼:
BOOL OnDllLoaded(const LOAD_DLL_DEBUG_INFO* pInfo) {
//加載子產品的調試資訊
DWORD64 moduleAddress = SymLoadModule64(
g_hProcess,
pInfo->hFile,
NULL,
NULL,
(DWORD64)pInfo->lpBaseOfDll,
0);
if (moduleAddress == 0) {
std::wcout << TEXT("SymLoadModule64 failed: ") << GetLastError() << std::endl;
}
CloseHandle(pInfo->hFile);
return TRUE;
}
判斷符号檔案的格式
前面說過,SymInitialize在找不到符号檔案的情況下仍然會傳回TRUE,此時它隻加載了子產品的資訊,而沒有加載調試符号。SymLoadModule64函數同樣如此。那麼,如何知道某個子產品是否含有調試資訊呢?或者,如何知道某個子產品的符号檔案使用哪種格式呢?可以通過調用SymGetModuleInfo64函數來擷取這些資訊。該函數的聲明如下:
BOOL WINAPI SymGetModuleInfo64(
HANDLE hProcess,
DWORD64 dwAddr,
PIMAGEHLP_MODULE64 ModuleInfo
);
第一個參數是符号處理器的辨別符,現在你應該對它很熟悉了。第二個參數是子產品的基位址,也就是在調用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函數:
SymSetOptions(SYMOPT_DEFERRED_LOADS);
該函數需要在調用SymInitialize之前調用。
所謂“真正使用的時候”究竟是什麼時候,我也搞不清楚。我在開啟了延遲加載調試符号的情況下調用SymGetLineFromAddr64擷取源檔案路徑和行号資訊時總是失敗,而關閉了這個特性之後卻成功了,這說明并不是所有需要通路調試符号的DbgHelp函數都會使調試符号加載進來。是以,為了確定DbgHelp函數可以正确執行,我建議不要開啟這項特性。
清理調試符号
在被調試程序結束的時候必須删除與之對應的符号處理器,以及清理它占用的資源。隻要在處理EXIT_PROCESS_DEBUG_EVENT事件的時候調用SymCleanup函數就可以完成這個操作,該函數接受一個符号處理器的辨別符。
另外,在dll檔案解除安裝的時候也應該清理與之相關的調試符号,避免占用記憶體。這要在處理UNLOAD_DLL_DEBUG_EVENT事件時調用SymUnloadModule64函數。該函數接受一個符号處理器的辨別符,以及子產品的基位址,我們可以直接使用UNLOAD_DLL_DEBUG_INFO結構體中唯一的字段lpBaseOfDll。
作者:
Zplutor