C++運行時錯誤處理

閒來無事作點翻譯工作,今天要介紹的是關於錯誤處理的.以下內容大部分不是我的原創,我只是把他們收集到一起來了而已.

  錯誤處理在一個系統裏面算是一個比較底層的東西了.擁有一個穩定的錯誤處理系統,是一個良好的系統的基礎.從發展的角度看,錯誤處理大體有下面幾種方式.

  比較基礎的,使用返回值表示錯誤還是正確,比如使用int作爲返回值,0表示正常1表示錯誤,這種算是c語言裏面的辦法了,比如windows的api都是使用這種方式進行錯誤信息的傳遞.很明顯有的時候光是一個32位的值實在表示不了太複雜的東西,這個時候各個有各個的實現方法,比如windows使用的GetLastError函數.com也使用這種方式,大部分的函數都返回一個HRESULT,他目前是一個typedef,其實是一個32位的long,這個long被分成了好幾個部分,每個部分表示不同的意義,這個可以在msdn或者是windows的頭文件裏面找到解釋.這種方式是比較明顯的錯誤傳遞方式.但是缺點也是很顯然的.因爲返回值用來傳遞錯誤信息,所以函數本身的信息返回就要使用其他的方式,c語言裏面只能使用傳地址的方式了,這個在windows的api裏面也經常看到,另外,錯誤信息是考返回值傳遞的,所以錯誤的檢查必須要調用者來完成,就得寫下比if-else這樣的測試語句,而且如果露掉了這樣的語句就很可能發生想不到的事情,而且必須是層層返回錯誤信息,這樣的方式不僅僅在程序實現本身上面,而且在整個代碼的可讀性上面都有很大損失.但是在c語言裏面,這也是沒有辦法的辦法.windows對這個問題也提供了一種解決方案,seh--結構話異常處理,說他能完全的處理錯誤也不盡然,他面向的不是程序語義方面的錯誤,而是程序的bug,比如說,我的一段程序要打開一個文件,但是這個文件由於某些原因損壞了,這個屬於程序本身應該發現糾正的錯誤,而不是windows來完成的任務.windows只是捕獲那些諸如內存訪問非法,除0一類的錯誤,(當然你可以自己調用RaiseException來達到同樣的目的).seh看起來像下面的樣子:

  __try

  {

    int *p = 0;

    *p =0;

  }

  __except(EXCEPTION_EXECUTE_HANDLER)

  {

  }

  不要和c++的異常混淆了,windows提供的seh是一種操作系統層面的機制,而c++的異常卻是語言層面的機制,雖然馬上就能看到c++的異常和windows的seh有某些關係(指msvc實現的c++異常).

  windows的seh實現上面的細節可以參考msj的under the hood的一篇叫A Crash Course on the Depths of Win32 Structured Exception Handling.的文章,上面對windows的seh有比較詳細的講解,簡單的說就是windows在每個線程的tib(thread information block)裏面保存了一個鏈表,這個鏈表裏面放了些發生異常的時候windows要調用的應用程序註冊的回調函數,當異常發生的時候windows從鏈表的開頭調用那些回調函數,回調函數返回適當的值表示自己是否處理了這個異常,如果沒有,則windows移動的下一個鏈表節點,如果到了鏈表的結尾都沒有人能處理這個異常,windows會轉到一個默認的函數,這個函數就會在屏幕上面顯示一個大家都應該見過的筐---應用程序發生了一個錯誤,將要關閉,同時有一個詳細信息的按鈕.

  在c++裏面可能就不太會使用到這種返回值的方式了,我個人認爲c++的程序員優先考慮的應該是異常,雖然很多人很排斥這個新的東西,c++的異常機制把程序員從小心檢查返回值的地方解救出來,你再不用去檢查函數的返回值(在以前是必須的,不管你關心不關係你調用的函數的返回值,你都必須要去檢查,因爲你有責任把這個返回值返回給調用你的人),在c++的異常機制的幫助下,你可以隨意的寫代碼,而不用去管函數的反覆值,所以的錯誤都應該被最能處理的人處理,那些不想處理錯誤也不能處理的函數就能當錯誤不存在一樣.必須下面的代碼段

  void AFunctionMayMeetSomeError()

  {

    //....

    // 錯誤發生了

    throw exception("meet an error");

  }

  void AFunctionDoNotCare()

  {

    //....

    AFunctionMayMeetSomeError();

    //....

  }

  void AFunctionWouldDealWithTheError()

  {

    //....

    try

    {

      //...

      AFunctionDoNotCare();

    }

    catch(exception& e)

    {

      // deal with the error

    }

  }

  第一個函數可能會產生一個錯誤,而第二函數會調用第一個函數,但是他去不想去處理這個錯誤(也許是程序本身的意圖,也許是他不知道怎麼去處理這個錯誤),而第3個函數纔是真正的錯誤處理函數,他建立一個try-catch的結構來捕獲這個錯誤.這樣能省下很多的代碼,而且代碼在可讀性上面還比較不錯.

  利用try-catch結構能比較大的簡化錯誤的處理的方式,我個人任務應該是很有用的東西,不過使用try-catch會帶來額外的開銷,這個開銷主要是體現在代碼的長度加大,運行的速度都沒有什麼太大的影響(這個可以從編譯器的實現代碼上面看出來,但是很多反對異常的人都任務他會降低運行速度.呵呵)

  作爲c++的程序員,現在我們有了一個比較有力的錯誤處理工具,現在問題又來了,對於程序預料中的異常,是比較能處理的,對於那些程序中預料不到的異常,我們希望獲得更加詳細的信息,比如函數的調用堆棧,位於源代碼的文件行數等等,更甚至,我們想知道當異常發生的時候,我們的程序的具體信息,局部變量,全局變量的值,然後我們可能對此產生一個crash.log文件,要用戶返回這個log文件我們加以分析查找bug等等.這個時候c++的異常能作的事情就非常的少了.像源代碼文件名行數這些信息我們還可以利用__FILE__,__LINE__,__FUNCTION__這樣的編譯的宏來獲取到,但是其他的就不太可能依賴c++語言本身的東西了,這個時候你也許就要求助於seh,因爲windows在異常發生的時候會準備足夠的信息,然後調用我們註冊的異常處理函數,在這些信息裏面,你就能找到你想要的東西.

  這裏纔是我要介紹的內容的關鍵,上面的...嘿嘿...

  首先看看我們怎麼不依賴其他的特性實現獲取源代碼的文件行函數的功能

  我們定義如下的異常類

  class CException

  {

    std::string m_strFile;

    std::string m_strFunction;

    std::string m_strDes;

    int m_nLine;

  public:

    CException(std::string const & strFile,std::string const &strFunc,int nLine,std::string strDes) : m_strFile(strFile),m_strFunction(strFunction),m_nLine(nLine),m_strDes(strDes){}

    LPCTSTR what() const

    {

      //返回你需要的錯誤信息

    }

  };

  然後我們定義下面的宏

  #define ThrowException(x) throw CException(__FILE__,__FUNCTION__,__LINE__,x)

  當然還要對__FUNCTION__宏作點修飾,因爲這個宏只是在函數裏面才起作用

  #ifndef __FUNCTION__

    #define __FUNCTION__ "Global"

  #endif

  這樣我們的異常類裏面就包含我們要的文件名,函數名,源代碼行的信息了,使用vc的時候你還能使用一點小技巧,如果你把這個異常信息輸出到vc的debug的output窗口的時候,能雙擊定位到發生異常的地方,就像你在編譯的時候出的錯誤一樣,雙擊就能定位到錯誤位置,方法很簡單,你使用 "文件名字(行號)"的格式輸出就ok了.

  但是我們並不滿足這麼一點小小的提示信息,我們需要更多的信息.這個時候,我們得藉助windows的seh了.在異常發生的時候windows會調用到你設置的回調函數(這個函數並不是你自己設置的,而是編譯器完成的,vc編譯c++的try結構的時候設置的函數名字叫__CxxFrameHandler,編譯__try結構的時候設置的是_exception_handle3),而c++的異常已經江朗才盡了,我們看看__try結構的時候,編譯器都幹了什麼,編譯器會執行我們寫在__except後面括號裏面的內容,在這個括號裏面我們可以調用2個函數GetExceptionInformation()和GetExceptionCode(),這個兩個函數能返回我們要的信息,注意,這兩個函數只能在__except後面的括號裏面調用.在這個括號裏面還可以調用我們自己的函數,上面兩個函數的返回值是能當作參數傳遞的,很明顯,我們利用這個性質就能作很多的事情了.必須注意我們的函數必須要返回幾個固定的值,來告訴windows這個異常我們處理還是不處理,我們建立下面的結構

  __try

  {

    //....

  }

  __except(CrashFilter(GetExceptionInformation(),GetExceptionCode()))

  {

  }

  真正作事情的是CrashFilter函數,這個函數裏面我們就能爲所欲爲了.

  首先GetExceptionInformation()返回一個結構EXCEPTION_POINTERS的指針,他又包含兩個成員,一個是PEXCEPTION_RECORD他是一個指針,記錄作異常的基本情況,PCONTEXT也是一個指針,記錄了異常發生的時候當時線程的所以寄存器的值(我們要dump全部寄存器的任務就落到他頭上了),有了這兩個東西,我們就能完成很多的事情了

  從CONTEXT裏面獲取到eip,從而定位到發生異常的模塊(使用VirtualQuery先獲取到這個內存地址(eip)所位於的內存塊,然後利用這個內存塊的起始地址調用GetModuleFileName就能獲取到模塊的名字,這個方面可以參考其他很多的例子,到google上面搜索怎樣獲取內存裏的模塊列表就能找到詳細的方法).有了eip,我們還能讀取到異常指令的內容,直接使用eip的值讀就ok(因爲windows使用的是flat地址模式),然後利用異常代碼(可以從EXCEPTION_POINTERS裏面獲取也可以利用GetExceptionCode()來得到)來獲取異常的信息,這個信息大部分能從msdn裏面查找到,其他的可以在ntdll.dll裏面去獲取調(調用FormatMessage函數,指定ntdll.dll的模塊句柄),然後也許你要收集目標計算機的cpu類型,內存狀態,操作系統信息等等(這些能通過GetSystemInfo,GlobalMemoryStatus,GetVersionEx函數來獲取,這些都能在google上面搜索到詳細的方法).然後你可能會dump堆棧,這個時候有個小技巧了,win32下面線程的TIB總是放到fs指定的段裏面而fs:[4]這個地方放的就是棧的top地址,而當前棧的地址在CONTEXT裏面有記錄,你要作的就是把context的esp指針到fs:[4]之間的內存全部dump出來就ok.然後也許你要列出當前進程裏面的全部dll名字,和dll的信息,這個也落在VirtualQuery函數上面,基本的方法就是遍歷4G的虛擬地址空間,反覆的調用VirtualQuery函數,一旦發現是合法的內存地址空間就調用GetModuleFileName函數如果成功了就表示是一個dll,這個時候你就能獲取到dll的dos文件頭,進一步獲取到nt文件頭,接着獲取到dll的全部...你要知道就是一個dll和exe的module handle其實是dll和exe文件在內存裏面的開始的地址,而從這個地址開始的就dll和exe文件的dos文件頭.

  有了這些東西其實也很無趣,你dump出來的東西要麼用處不大,要麼就是實在沒有辦法讀取的信息,那些16進制的stack內容實在用處不大.接下來的東西就有點激動人心了,我們要dump出異常發生的時候函數的調用堆棧,dump出函數的局部變量,全局變量的值.

  這個艱鉅的任務就落到了windows的debug api上面了,windows在nt以後的版本發行了一個叫dbghelp.dll的文件,這個文件就能完成我們要求的內容.具體的信息可以查看msdn,我下面要說的內容也能在msj裏面的Under the Hood: Improved Error Reporting with DBGHELP 5.1一文中找到.注意要完成下面的內容你必須要有exe文件或者dll文件的pdb文件.vc會幫您產生這個文件的,他就是調試用的符號文件.

  關鍵部分在於5.1裏面的幾個新的函數,我們就能獲取到這些想要的東西.這個文章不是講解怎麼使用dbghelp的文章,所以我跳過了他的使用方法.具體的可以到google上面搜索,或者查看msdn.

  想要dump出call stack,必然要查看stack,這個任務交給dbghelp lib的StackWalk函數完成,你要準備一個STACKFRAME結構,傳遞給StackWalk函數,其他的幾個參數都是很容易獲取到的,機器類型,進程句柄,線程句柄,context(剛剛的那個context就能拿來使用),兩個回調函數(不用自己實現,使用dbghelp lib自己實現好的函數),然後StackWalk就填充好你傳遞的STACKFRAME結構,接下來你就利用這個結構調用SymFromAddr這個函數就能獲取到當前棧位置的函數名字,同時還有當前pc位置相對於函數開始代碼pc的偏移量.調用SymGetLineFromAddr函數獲取源代碼文件名和行的信息.這樣就能完成call stack的處理過程,

  STACKFRAME sf;

  memset(&sf,0,sizeof(sf));

  // 初始化stackframe結構

  sf.AddrPC.Offset = pContext->Eip;

  sf.AddrPC.Mode = AddrModeFlat;

  sf.AddrStack.Offset = pContext->Esp;

  sf.AddrStack.Mode = AddrModeFlat;

  sf.AddrFrame.Offset = pContext->Ebp;

  sf.AddrFrame.Mode = AddrModeFlat;

  dwMachineType = IMAGE_FILE_MACHINE_I386;

  while(1)

  {

    // 獲取下一個棧幀

    if(!StackWalk(dwMachineType,hProcess,GetCurrentThread(),&sf,pContext,0,SymFunctionTableAccess,SymGetModuleBase,0))

      break;

    // 檢查幀的正確性

    if(0 == sf.AddrFrame.Offset)

      break;

    // 正在調用的函數名字

    BYTE symbolBuffer[sizeof(SYMBOL_INFO) + 1024];

    PSYMBOL_INFO pSymbol = (PSYMBOL_INFO)symbolBuffer;

    pSymbol->SizeOfStruct = sizeof(symbolBuffer);

    pSymbol->MaxNameLen = 1024;

    // 偏移量

    DWORD64 symDisplacement = 0;

    // 獲取符號

    if(SymFromAddr(hProcess,sf.AddrPC.Offset,&symDisplacement,pSymbol))

    {

      WriteLog(hFile,TEXT("%4d Function : %hs"),i,pSymbol->Name);

    }

 }

  如此就能完成call stack的dump工作.

  接下來的事情就是顯示變量的問題了

  這個可以在dump call stack的時候同步完成

  首先使用SymSetContext函數設置你的dump環境,這個很重要,因爲局部變量都是有自己的生存環境的,他們都有自己的context,你在dump他們的時候必須要先設置這個context.這個也是很容易完成的.

  IMAGEHLP_STACK_FRAME imagehlpStackFrame;

  imagehlpStackFrame.InstructionOffset = sf.AddrPC.Offset;

  SymSetContext(hProcess,&imagehlpStackFrame, 0 );

  唯一你要設置的就是那個地址,簡單的傳遞剛剛的stack frame的pc的offset就ok.切記這個值的不同,你獲取的信息就可能不同.

  接下來調用SymEnumSymbols函數枚舉全部的變量.他需要你提供一個回調函數,很顯然,全部的工作都在一個函數裏面完成.在枚舉全局變量的使用也調用這個函數,唯一不同的時候全局函數不需要指定context.

  當dbghelp枚舉到一個變量的時候,他就會準備好這個變量的基本信息,然後調用你的回調函數你的函數看起來像這個樣子

  BOOL CALLBACK EnumerateSymbolsCallback(PSYMBOL_INFO pSymInfo,ULONG SymbolSize,PVOID UserContext)

  第一個就是符號的信息,你利用這個信息來獲取你要要的結構,第二個是大小,基本可以忽略,最後一個是符號的context,緊記局部變量都是context向關的,都是使用[ebp-??]這樣的來訪問的.

  我們要作的事情就是利用info和context產生合適的輸出

  首先判斷這個符號的類型(info->Flags),我們只是跳過函數符號,而留下變量符號,接着判斷符號的尋址方式(相對ebp尋址?絕對地址尋址?還是放到cpu的寄存器裏面的?這個也是在那個Flags裏面獲取的).接下來我們就要判斷這個符號的具體信息了,使用TI_GET_SYMNAME標誌調用SymGetTypeInfo函數能獲取到這個符號的名字(也就是變量的名字),他要求的參數都能在info裏面找到.然後使用TI_GET_CHILDRENCOUNT再調用SymGetTypeInfo函數,獲取符號的child的個數(複雜的c的結構有很多的子成員),如果他的child數目是0,就表示這個變量是一個基本變量(int形的?float形的?char形的?都屬於這種基本變量),這個時候我們就能使用TI_GET_BASETYPE再調用SymGetTypeInfo函數就能獲取到這個的基本類型了,然後你就能獲得到這個變量的類型,配合上面的尋址方式,你就能在內存裏面讀取出他的值來.如果他的child數目不是0,這個時候你對每一個child重複遞歸的調用上面的步驟,最終會得到一個個的基本類型,然後輸出落...

  到這裏你已經獲得了足夠的信息了....整個事情就都完成了.

  嗯,上面的步驟我自己都感覺是自己寫個看得懂的人看的-_-@.....

  寫得太簡陋了,看不懂的人還是一頭霧水,看得懂的人就會說---這個找知道了...

  呵呵.要看懂上面的內容呢,你要有基本的彙編知識,要知道c語言編譯器是大致上怎麼工作的,有了這個基礎,再瞭解一點windows系統的稍微底層一點知識再在msdn的幫助下面就能實現自己的crash dump函數了.

  推薦幾個文章,高手的文章一定比我寫的好,我的這種東西不登大雅之堂的,讓大家見笑了.

  第一個是來自codeproject上面的一個叫How a C++ compiler implements exception handling的文章

  然後是來自msj的兩個under the hood(專欄作者超牛...)

  A Crash Course on the Depths of Win32 Structured Exception Handling

  Improved Error Reporting with DBGHELP 5.1 APIs

  我已經把這些種技術包含到了自己的新工程裏面了。


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章