VC++ 崩潰處理以及列印呼叫堆疊

weixin_33850890發表於2018-08-28

我們在程式釋出後總會面臨崩潰的情況,這個時候一般很難重現或者很難定位到程式崩潰的位置,之前有方法在程式崩潰的時候記錄dump檔案然後通過windbg來分析。那種方法對開發人員的要求較高,它需要程式設計師理解記憶體、暫存器等等一系列概念還需要手動載入對應的符號表。Java、Python等等語言在崩潰的時候都會列印一條異常的堆疊資訊並告訴使用者那塊出錯了,根據這個資訊程式設計師可以很容易找到對應的程式碼位置並進行處理,而C/C++則會彈出一個框告訴使用者程式崩潰了,二者對比來看,C++似乎對使用者太不友好了,而且根據它的彈框很難找到對應的問題,那麼有沒有可能使c++像Java那樣列印異常的堆疊呢?這個自然是可能的,本文就是要討論如何在Windows上實現類似的功能

異常處理

一般當程式發生異常時,使用者程式碼停止執行,並將CPU的控制權轉交給作業系統,作業系統接到控制權後,將當前執行緒的環境儲存到結構體CONTEXT中,然後查詢針對此異常的處理函式。系統利用結構EXCEPTION_RECORD儲存了異常描述資訊,它與CONTEXT一同構成了結構體EXCEPTION_POINTERS,一般在異常處理中經常使用這個結構體。
異常資訊EXCEPTION_RECORD的定義如下:

typedef struct _EXCEPTION_RECORD
{
   DWORD ExceptionCode;  //異常碼
   DWORD ExceptionFlags;  //標誌異常是否繼續,標誌異常處理完成後是否接著之前有問題的程式碼
   struct _EXCEPTION_RECORD* ExceptionRecord; //指向下一個異常節點的指標,這是一個連結串列結構
   PVOID ExceptionAddress; //異常發生的地址
   DWORD NumberParameters; //異常附加資訊
   ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; //異常的字串
} EXCEPTION_RECORD,  *PEXCEPTION_RECORD;

Windows平臺提供的這一套異常處理的機制,我們叫它結構化異常處理(SEH),它的處理過程一般如下:

  1. 如果程式是被除錯執行的(比如我們在VS編譯器中除錯執行程式),當異常發生時,系統首先將異常資訊交給除錯程式,如果除錯程式處理了那麼程式繼續執行,否則系統便在發生異常的執行緒棧中查詢可能的處理程式碼。若找到則處理異常,並繼續執行程式
  2. 如果線上程棧中沒有找到,則再次通知除錯程式,如果這個時候仍然不能處理這個異常,那麼作業系統會對異常程式預設處理,這個時候一般都是直接彈出一個錯誤的對話方塊然後終止程式。

系統在每個執行緒的堆疊環境中都維護了一個SEH表,表中是使用者註冊的異常型別以及它對應的處理函式,每當使用者在函式中註冊新的異常處理函式,那麼這個資訊會被儲存在連結串列的頭部,也就是說它是採用頭插法來插入新的處理函式,從這個角度上來說,我們可以很容易理解為什麼在一般的高階語言中一般會先找與try塊最近的catch塊,然後在找它的上層catch,由裡到外依次查詢。與try塊最近的catch是最後註冊的,由於採用的是頭插法,自然它會被首先處理。

在Windows中針對異常處理,擴充套件了__try__except 兩個操作符,這兩個操作符與c++中的try和catch非常相似,作用也基本類似,它的一般的語法結構如下:

__try
{
  //do something
}
__except(filter)
{
  //handle
}

使用 __try__except 的時候它主要分為3個部分,分別為:保護程式碼體、過濾表示式、異常處理塊

  1. 保護程式碼體一般是try中的語句,它值被保護的程式碼,也就是說我們希望處理那個程式碼塊產生的異常
  2. 過濾表示式是 except後面擴號中的值,它只能是3個值中的一個,EXCEPTION_CONTINUE_SEARCH繼續向下查詢異常處理,也就是說這裡的異常處理塊不處理這種異常,EXCEPTION_CONTINUE_EXECUTION表示異常已被處理,這個時候可以繼續執行直線產生異常的程式碼,EXCEPTION_EXECUTE_HANDLER表示異常已被處理,此時直接跳轉到except裡面的程式碼塊中,這種方式下它的執行流程與一般的異常處理的流程類似.
  3. 異常處理塊,指的是except下面的擴號中的程式碼塊.

注意:我們說過濾表示式只能是這三個值中的一個,但是沒有說這裡一定得填這三個值,它還支援函式或者其他的表示式型別,只要函式或者表示式的返回值是這三個值中的一個即可。

上述的方式也有他的侷限性,也就是說它只能保護我們指定的程式碼,如果是在 __try 塊之外的程式碼發生了崩潰,可能還是會造成程式被kill掉,而且每個位置都需要寫上這麼些程式碼實在是太麻煩了。其實處理異常還有一種方式,那就是採用 SetUnhandledExceptionFilter來註冊一個全域性的異常處理函式來處理所有未被處理的異常,其實它的主要工作原理就是往異常處理的連結串列頭上新增一個處理函式,函式的原型如下:

LPTOP_LEVEL_EXCEPTION_FILTER WINAPI SetUnhandledExceptionFilter(__in  LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter);

它需要傳入一個函式,以便發生異常的時候呼叫這個函式,這個回撥函式的原型如下:

LONG WINAPI UnhandledExceptionFilter(
  __in  struct _EXCEPTION_POINTERS* ExceptionInfo
);

回撥函式會傳入一個表示當前堆疊和異常資訊的結構體的指標,結構的具體資訊請參考MSDN, 函式會返回一個long型的數值,這個數值為上述3個值中的一個,表示當系統呼叫了這個異常處理函式處理異常之後該如何繼續執行使用者程式碼。

SetUnhandledExceptionFilter 函式返回一個函式指標,這個指標指向連結串列的頭部,如果插入處理函式失敗那麼它將指向原來的連結串列頭,否則指向新的連結串列頭(也就是註冊的這個回撥函式的地址)

而這次要實現這麼一個能列印異常資訊和呼叫堆疊的功能就是要使用這個方法。

列印函式呼叫堆疊

關於列印堆疊的內容,這裡不再多說了,請參考本人之前寫的部落格
windows平臺呼叫函式堆疊的追蹤方法
這裡的主要思路是使用StackWalker來根據當前的堆疊環境來獲取對應的函式資訊,這個資訊需要根據符號表來生成,因此我們需要首先載入符號表,而獲取當前執行緒的環境,我們可以像我部落格中寫的那樣使用GetThreadContext來獲取,但是在異常中就簡單的多了,還記得異常處理函式的原型嗎?異常處理函式本身會帶入一個EXCEPTION_POINTERS結構的指標,而這個結構中就包含了異常堆疊的資訊。

還有一些需要注意的問題,我把它放到實現那塊了,請小心的往下看_

實現

實現部分的原始碼我放到了github上,地址

這個專案中主要分為兩個類CBaseException,主要是對異常的一個簡單的封裝,提供了我們需要的一些功能,比如獲取載入的模組的資訊,獲取呼叫的堆疊,以及解析發生異常時的相關資訊。而這些的基礎都在CStackWalker中。
使用上,我把CBaseException中的大部分函式都定義成了virtual 允許進行重寫。因為具體我還沒想好這塊後續會需要進行哪些擴充套件。但是裡面最主要的功能是OutputString函式,這個函式是用來進行資訊輸出的,預設CBaseException是將資訊輸出到控制檯上,後續可以過載這個函式把資料輸出到日誌中。

CBaseException 類

CBaseException 主要是用來處理異常,在程式碼裡面我提供了兩種方式來進行異常處理,第一種是通過 SetUnhandledExceptionFilter 來註冊一個全域性的處理函式,這個函式是類中的靜態函式UnhandledExceptionFilter,在這個函式中我主要根據異常的堆疊環境來初始化了一個CBaseException類,然後簡單的呼叫類的方法顯示異常與堆疊的相關資訊。第二種是通過 _set_se_translator 來註冊一個將SEH轉化為C++異常的方法,在對應的回撥中我簡單的丟擲了一個CBaseException的異常,在具體的程式碼中只要簡單的用c++的異常處理捕獲這麼一個異常即可

CBaseException 類中主要用來解析異常的資訊,裡面提供這樣功能的函式主要有3個

  1. ShowExceptionResoult: 這個函式主要是根據異常碼來獲取到異常的具體字串資訊,比如非法記憶體訪問、除0異常等等
  2. GetLogicalAddress:根據發生異常的程式碼的地址來獲取對應的模組資訊,比如它在PE檔案中屬於第幾個節,節的地址範圍等等,它在實現上首先使用 VirtualQuery來獲取對應的虛擬記憶體資訊,主要是這個模組的首地址資訊,然後解析PE檔案獲取節表的資訊,我們迴圈節表中的每一項,根據節表中的地址範圍來判斷它屬於第幾個節,注意這裡我們根據它在記憶體中的偏移計算了它在PE檔案中的偏移,具體的計算方式請參考PE檔案的相關內容.
    3.ShowRegistorInformation:獲取各個暫存器的值,這個值儲存在CONTEXT結構中,我們只需要簡單列印它就好

CStackWalker類

這個類主要實現一些基礎的功能,它主要提供了初始化符號表環境、獲取對應的呼叫堆疊資訊、獲取載入的模組資訊
在初始化符號表的時候儘可以多的遍歷了常見的幾種符號表的位置並將這些位置中的符號表載入進來,以便能更好的獲取到堆疊呼叫的情況。在獲取到對應的符號表位置後有這樣的程式碼

if (NULL != m_lpszSymbolPath)
{
        m_bSymbolLoaded = SymInitialize(m_hProcess, T2A(m_lpszSymbolPath), TRUE); //這裡設定為TRUE,讓它在初始化符號表的同時載入符號表
}

DWORD symOptions = SymGetOptions();
symOptions |= SYMOPT_LOAD_LINES;
symOptions |= SYMOPT_FAIL_CRITICAL_ERRORS;
symOptions |= SYMOPT_DEBUG;
SymSetOptions(symOptions);

return m_bSymbolLoaded;

這裡將 SymInitialize的最後一個函式置為TRUE,這個引數的意思是是否列舉載入的模組並載入對應的符號表,直接在開始的時候載入上可能會比較浪費記憶體,這個時候我們可以採用動態載入的方式,在初始化的時候先填入FALSE,然後在需要的時候自己列舉所有的模組,然後手動載入所有模組的符號表,手動載入需要呼叫SymLoadModuleEx。這裡需要提醒各位的是,這裡如果填的是FALSE的話,後續一定得自己載入模組的符號表,否則在後續呼叫SymGetSymFromAddr64的時候會得到一堆的487錯誤(也就是地址無效)
我之前就是這個問題困擾了我很久的時間。

在獲取模組的資訊時主要提供了兩種方式,一種是使用CreateToolhelp32Snapshot 函式來獲取程式中模組資訊的快照然後呼叫Module32Next 和 Module32First來列舉模組資訊,還有一種是使用EnumProcessModules來獲取所有模組的控制程式碼,然後根據控制程式碼來獲取模組的資訊,當然還有另外的方式,其他的方式可以參考我的這篇部落格 列舉程式中的模組

在列舉載入的模組的同時還針對每個模組呼叫了 GetModuleInformation 函式,這個函式主要有兩個功能,獲取模組檔案的版本號和獲取載入的符號表資訊。

接下來就是重頭戲了——獲取呼叫堆疊。獲取呼叫堆疊首先得獲取當前的環境,在程式碼中進行了相應的判斷,如果當前傳入的CONTEXT為NULL,則函式自己獲取當前的堆疊資訊。在獲取堆疊資訊的時候首先判斷是否為當前執行緒,如果不是那麼為了結果準確,需要先停止目標執行緒,然後獲取,否則直接使用巨集來獲取,對應的巨集定義如下:

#define GET_CURRENT_THREAD_CONTEXT(c, contextFlags) \
    do\
    {\
        memset(&c, 0, sizeof(CONTEXT));\
        c.ContextFlags = contextFlags;\
        __asm    call $+5\
        __asm    pop eax\
        __asm    mov c.Eip, eax\
        __asm    mov c.Ebp, ebp\
        __asm    mov c.Esp, esp\
} while (0)

在呼叫StackWalker時只需要關注esp ebp eip的資訊,所以這裡我們也只簡單的獲取這些暫存器的環境,而其他的就不管了。這樣有一個問題,就是我們是在CStackWalker類中的函式中獲取的這個執行緒環境,那麼這個環境裡面會包含CStackWalker::StackWalker,結果自然與我們想要的不太一樣(我們想要的是隱藏這個庫中的相關資訊,而只保留呼叫者的相關堆疊資訊)。這個問題我還沒有什麼好的解決方案。

在獲取到執行緒環境後就是簡單的呼叫StackWalker以及那堆Sym開頭的函式來獲取各種資訊了,這裡就不再詳細說明了。

至此這個功能已經實現的差不多了。庫的具體使用請參考main.cpp這個檔案,相信有這篇博文以及原始碼各位應該很容易就能夠使用它。

據說這些函式不是多執行緒安全的,我自己沒有在多執行緒環境下進行測試,所以具體它在多執行緒環境下表現如何還是個未知數,如果後續我有興趣繼續完善它的話,可能會加入多執行緒的支援。
<hr />

相關文章