[Win32]一個偵錯程式的實現(五)除錯符號

Yuri800發表於2016-09-25

一個偵錯程式應該可以跟蹤被除錯程式執行到了什麼地方,顯示下一條將要執行的語句,顯示各個變數的值,設定斷點,進行單步執行等等,這些功能都需要一個基礎設施的支援,那就是除錯符號。

 

什麼是除錯符號

我們知道,在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時,它必須是一個有效的程式控制程式碼;當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函式的宣告如下:

DWORD64 WINAPI SymLoadModule64(
    HANDLE hProcess,
    HANDLE hFile,
    PCSTR ImageName,
    PCSTR ModuleName,
    DWORD64 BaseOfDll,
    DWORD SizeOfDll
);

第一個引數是符號處理器的識別符號,也就是在呼叫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事件的程式碼大概像下面這樣子:

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 
出處:http://www.cnblogs.com/zplutor/ 
本文版權歸作者和部落格園共有,歡迎轉載。但未經作者同意必須保留此段宣告,且在文章頁面明顯位置給出原文連線,否則保留追究法律責任的權利。



相關文章