VC++ 崩潰處理以及打印調用堆棧
title: VC++ 崩潰處理以及打印調用堆棧
tags: [VC++, 結構化異常處理, 崩潰日誌記錄]
date: 2018-08-28 20:59:54
categories: windows 高級編程
keywords: VC++, 結構化異常處理SEH, 崩潰日誌記錄
---
我們在程序發布後總會面臨崩潰的情況,這個時候一般很難重現或者很難定位到程序崩潰的位置,之前有方法在程序崩潰的時候記錄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),它的處理過程一般如下:
- 如果程序是被調試運行的(比如我們在VS編譯器中調試運行程序),當異常發生時,系統首先將異常信息交給調試程序,如果調試程序處理了那麽程序繼續運行,否則系統便在發生異常的線程棧中查找可能的處理代碼。若找到則處理異常,並繼續運行程序
- 如果在線程棧中沒有找到,則再次通知調試程序,如果這個時候仍然不能處理這個異常,那麽操作系統會對異常進程默認處理,這個時候一般都是直接彈出一個錯誤的對話框然後終止程序。
系統在每個線程的堆棧環境中都維護了一個SEH表,表中是用戶註冊的異常類型以及它對應的處理函數,每當用戶在函數中註冊新的異常處理函數,那麽這個信息會被保存在鏈表的頭部,也就是說它是采用頭插法來插入新的處理函數,從這個角度上來說,我們可以很容易理解為什麽在一般的高級語言中一般會先找與try塊最近的catch塊,然後在找它的上層catch,由裏到外依次查找。與try塊最近的catch是最後註冊的,由於采用的是頭插法,自然它會被首先處理。
在Windows中針對異常處理,擴展了__try
和 __except
兩個操作符,這兩個操作符與c++中的try和catch非常相似,作用也基本類似,它的一般的語法結構如下:
__try
{
//do something
}
__except(filter)
{
//handle
}
使用 __try
和 __except
的時候它主要分為3個部分,分別為:保護代碼體、過濾表達式、異常處理塊
- 保護代碼體一般是try中的語句,它值被保護的代碼,也就是說我們希望處理那個代碼塊產生的異常
- 過濾表達式是 except後面擴號中的值,它只能是3個值中的一個,EXCEPTION_CONTINUE_SEARCH繼續向下查找異常處理,也就是說這裏的異常處理塊不處理這種異常,EXCEPTION_CONTINUE_EXECUTION表示異常已被處理,這個時候可以繼續執行直線產生異常的代碼,EXCEPTION_EXECUTE_HANDLER表示異常已被處理,此時直接跳轉到except裏面的代碼塊中,這種方式下它的執行流程與一般的異常處理的流程類似.
- 異常處理塊,指的是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個
- ShowExceptionResoult: 這個函數主要是根據異常碼來獲取到異常的具體字符串信息,比如非法內存訪問、除0異常等等
- 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這個文件,相信有這篇博文以及源碼各位應該很容易就能夠使用它。
據說這些函數不是多線程安全的,我自己沒有在多線程環境下進行測試,所以具體它在多線程環境下表現如何還是個未知數,如果後續我有興趣繼續完善它的話,可能會加入多線程的支持。
VC++ 崩潰處理以及打印調用堆棧