c++ 異常處理(1)

twoon發表於2014-03-19

異常 (exception) 是 c++ 中新增的一個特性,它提供了一種新的方式來結構化地處理錯誤,使得程式可以很方便地把異常處理與出錯的程式分離,而且在使用上,它語法相當地簡潔,以至於會讓人錯覺覺得它底層的實現也應該很簡單,但事實上並不是這樣。恰恰因為它語法上的簡單沒有規定過多細節,從而留給了編譯器足夠的空間來自己發揮,因此在不同作業系統,不同編譯器下,它的實現是有很大不同的。這篇文章介紹了 windows 和 visual c++ 是怎樣基於 SEH 來實現 c++ 上的異常處理的,講得很詳細,雖然已經寫了很久,但原理性的東西到現在也沒過時,有興趣可以去細讀一下。

至於 linux 下 gcc 是怎樣做的,網上充斥著各種文件,很多但也比較雜,我這兒就簡單把我這幾天看到的,想到的,理解了的,不理解的,做個簡單的總結,歡迎指正。

異常丟擲後,發生了什麼事情?

根據 c++ 的標準,異常丟擲後如果在當前函式內沒有被捕捉(catch),它就要沿著函式的呼叫鏈繼續往上拋,直到走完整個呼叫鏈,或者在某個函式中找到相應的 catch。如果走完呼叫鏈都沒有找到相應的 catch,那麼std::terminate() 就會被呼叫,這個函式預設是把程式 abort,而如果最後找到了相應的 catch,就會進入該 catch 程式碼塊,執行相應的操作。

程式中的 catch 那部分程式碼有一個專門的名字叫作:Landing pad(不十分準確),從拋異常開始到執行 landing pad 裡的程式碼這中間的整個過程叫作 stack unwind,這個過程包含了兩個階段:

1)從拋異常的函式開始,對呼叫鏈上的函式逐個往前查詢 landing pad。

2)如果沒有找到 landing pad 則把程式 abort,否則,則記下 landing pad 的位置,再重新回到拋異常的函式那裡開始,一幀一幀地清理呼叫鏈上各個函式內部的區域性變數,直到 landing pad 所在的函式為止。

簡而言之,正常情況下,stack unwind 所要做的事情就是從丟擲異常的函式開始,沿著呼叫鏈向上找到 catch 所在的函式,然後從拋異常的地方開始,清理呼叫鏈上各棧幀內已經建立了的區域性變數。

void func1()
{
  cs a; // stack unwind時被析構。
  throw 3;
}

void func2()
{
  cs b;
  func1();
}

void func3()
{
  cs c;
  try 
  {
    func2();
  }
  catch (int)
  {
    //進入這裡之前, func1, func2已經被unwind.
  }
}

可以看出,unwind 的過程可以簡單看成是函式呼叫的逆過程,這個過程在實現上由一個專門的 stack unwind 庫來進行,在 intel 平臺上,它屬於 Itanium ABI 介面中的一部分,且與具體的語言無關,由系統提供實現,任何上層語言都可以在這個介面的基礎上實現各自的異常處理,GCC 就基於這個介面來實現c++的異常處理。

Itanium C++ ABI

Itanium ABI 定義了一系列函式及相應的資料結構來建立整個異常處理的流程及框架,主要的函式包括以下這些:

_Unwind_RaiseException,
_Unwind_Resume,
_Unwind_DeleteException,
_Unwind_GetGR,
_Unwind_SetGR,
_Unwind_GetIP,
_Unwind_SetIP,
_Unwind_GetRegionStart,
_Unwind_GetLanguageSpecificData,
_Unwind_ForcedUnwind

其中的 _Unwind_RaiseException() 函式用於進行stack unwind,它在使用者執行 throw 時被呼叫,主要功能是從當前函式開始,對呼叫鏈上每個函式幀都呼叫一個叫作 personality routine 的函式(__gxx_personality_v0),該函式由上層的語言定義及提供實現,_Unwind_RaiseException() 會在內部把當前函式棧的呼叫現場重建,然後傳給 personality routine,personality routine則主要負責做兩件事情:

1)檢查當前函式是否含有相應 catch 可以處理上面丟擲的異常。

2)清掉呼叫棧上的區域性變數。

顯然,我們可以發現 personality routine 所做的這兩件事情和前面所說的 stack unwind 所要經歷的兩個階段一一對應起來了,因此也可以說,stack unwind 主要就是由 personality routine 來完成,它相當於一個 callback。

_Unwind_Reason_Code (*__personality_routine)
        (int version,
         _Unwind_Action actions,
         uint64 exceptionClass,
         struct _Unwind_Exception *exceptionObject,
         struct _Unwind_Context *context);

注意上面的第二個引數,它是用來告訴 personality routine,當前是處於 stack unwind 的哪個階段的,其它的引數則主要用來傳遞與異常相關的資訊及當前函式的上下文。前面一直所說的 stack unwind 包含兩個階段,具體到呼叫鏈上的函式來說,就是每個函式在 unwind 的過程中都會被 personality routine 遍歷兩次。

下面的虛擬碼展示了 _Unwind_RaiseException() 內部的大概實現,算是對前面的一個總結:

_Unwind_RaiseException(exception)
{
    bool found = false;
    while (1)
     {
         // 建立上個函式的上下文
         context = build_context();
         if (!context) break;
         found = personality_routine(exception, context, SEARCH);
         if (found or reach the end) break;
     }

    while (found)
    {
        context = build_context();
        if (!context) break;
        personality_routine(exception, context, UNWIND);
        if (reach_catch_function) break;
    }
}

 ABI中的函式使用到了兩個自定義的資料結構,用於傳遞一些內部的資訊。

struct _Unwind_Context;

struct _Unwind_Exception {
  uint64     exception_class;
  _Unwind_Exception_Cleanup_Fn exception_cleanup;
  uint64     private_1;
  uint64     private_2;
};

根據介面的介紹,_Unwind_Context 是一個對呼叫者透明的結構,用於表示程式執行時的上下文,主要就是一些暫存器的值,函式返回地址等,它由介面實現者來定義及建立,但我沒在介面中找到它的定義,只在 gcc 的原始碼裡找到了一份它的定義

struct _Unwind_Context
{
  void *reg[DWARF_FRAME_REGISTERS+1];
  void *cfa;
  void *ra;
  void *lsda;
  struct dwarf_eh_bases bases;
  _Unwind_Word args_size;
};

至於 _Unwind_Exception,顧名思義,它在 unwind 庫內用於表示一個異常。

C++ ABI.

基於前面介紹的 Itanium ABI,編譯器層面也定義了一系列的 ABI 來與之互動。當我們在程式碼中寫下 "throw xxx" 時,編譯器會分配一個資料結構來表示該異常,該異常有一個頭部,定義如下:

struct __cxa_exception 
{ std::type_info
* exceptionType; void (*exceptionDestructor) (void *); unexpected_handler unexpectedHandler; terminate_handler terminateHandler; __cxa_exception * nextException; int handlerCount; int handlerSwitchValue; const char * actionRecord; const char * languageSpecificData; void * catchTemp; void * adjustedPtr; _Unwind_Exception unwindHeader; };

注意其中最後一個變數:_Unwind_Exception unwindHeader,這個變數就是前面 Itanium 介面裡提到的介面內部用的結構體。當使用者 throw 一個異常時,編譯器會幫我們呼叫相應的函式分配出如下一個結構:

 

其中 _cxa_exception 就是頭部,exception_obj 則是 "throw xxx" 中的 xxx,這兩部分在記憶體中是連續的。異常物件由函式 __cxa_allocate_exception() 進行建立,最後由 __cxa_free_exception() 進行銷燬。當我們在程式裡執行了丟擲異常後,編譯器為我們做了如下的事情:

1)呼叫 __cxa_allocate_exception 函式,分配一個異常物件。

2)呼叫 __cxa_throw 函式,這個函式會將異常物件做一些初始化。

3)__cxa_throw() 呼叫 Itanium ABI 裡的 _Unwind_RaiseException() 從而開始 unwind。

4)_Unwind_RaiseException() 對呼叫鏈上的函式進行 unwind 時,呼叫 personality routine。

5)如果該異常如能被處理(有相應的 catch),則 personality routine 會依次對呼叫鏈上的函式進行清理。

6)_Unwind_RaiseException() 將控制權轉到相應的catch程式碼。

7) unwind 完成,使用者程式碼繼續執行。

從 c++ 的角度看,一個完整的異常處理流程就完成了,當然,其中省略了很多的細節,其中最讓人覺得神祕的也許就是 personality routine了,它是怎麼知道當前 Unwind 的函式是否有相應的 catch 語句呢?又是怎麼知道該怎樣去清理這個函式內的區域性變數呢?具體實現這兒先不細說,只需要大概明白,其實它也不知道,只有編譯器知道,因此在編譯階段編譯器會建立建立一些表項來儲存相應的資訊,使得 personality routine 可以在執行時通過這些事先建立起來的資訊進行相應的查詢。

從原始碼看Unwind的過程

 unwind 的過程是從 __cxa_throw() 裡開始的,請看如下原始碼:

extern "C" void
__cxxabiv1::__cxa_throw (void *obj, std::type_info *tinfo,
void (_GLIBCXX_CDTOR_CALLABI *dest) (void *))
{
   PROBE2 (throw, obj, tinfo);

   // Definitely a primary.
   __cxa_refcounted_exception *header = __get_refcounted_exception_header_from_obj (obj);
   header->referenceCount = 1;
   header->exc.exceptionType = tinfo;
   header->exc.exceptionDestructor = dest;
   header->exc.unexpectedHandler = std::get_unexpected ();
   header->exc.terminateHandler = std::get_terminate ();
   __GXX_INIT_PRIMARY_EXCEPTION_CLASS(header->exc.unwindHeader.exception_class);
   header->exc.unwindHeader.exception_cleanup = __gxx_exception_cleanup;

   #ifdef _GLIBCXX_SJLJ_EXCEPTIONS
   _Unwind_SjLj_RaiseException (&header->exc.unwindHeader);
   #else
   _Unwind_RaiseException (&header->exc.unwindHeader);
   #endif

   // Some sort of unwinding error. Note that terminate is a handler.
   __cxa_begin_catch (&header->exc.unwindHeader);
   std::terminate ();
}

我們可以看到 __cxa_throw 最終呼叫了 _Unwind_RaiseException(),stack unwind 就此開始,如前面所說,unwind 分為兩個階段,分別進行搜尋 catch 及清理呼叫棧,其相應的程式碼如下:

/* Raise an exception, passing along the given exception object.  */

_Unwind_Reason_Code
_Unwind_RaiseException(struct _Unwind_Exception *exc)
{
  struct _Unwind_Context this_context, cur_context;
  _Unwind_Reason_Code code;

  uw_init_context (&this_context);
  cur_context = this_context;

  /* Phase 1: Search.  Unwind the stack, calling the personality routine
     with the _UA_SEARCH_PHASE flag set.  Do not modify the stack yet.  */
  while (1)
    {
      _Unwind_FrameState fs;

      code = uw_frame_state_for (&cur_context, &fs);

      if (code == _URC_END_OF_STACK)
    /* Hit end of stack with no handler found.  */
    return _URC_END_OF_STACK;

      if (code != _URC_NO_REASON)
    /* Some error encountered.  Ususally the unwinder doesn't
       diagnose these and merely crashes.  */
    return _URC_FATAL_PHASE1_ERROR;

      /* Unwind successful.  Run the personality routine, if any.  */
      if (fs.personality)
    {
      code = (*fs.personality) (1, _UA_SEARCH_PHASE, exc->exception_class,
                    exc, &cur_context);
      if (code == _URC_HANDLER_FOUND)
        break;
      else if (code != _URC_CONTINUE_UNWIND)
        return _URC_FATAL_PHASE1_ERROR;
    }

      uw_update_context (&cur_context, &fs);
    }

  /* Indicate to _Unwind_Resume and associated subroutines that this
     is not a forced unwind.  Further, note where we found a handler.  */
  exc->private_1 = 0;
  exc->private_2 = uw_identify_context (&cur_context);

  cur_context = this_context;
  code = _Unwind_RaiseException_Phase2 (exc, &cur_context);
  if (code != _URC_INSTALL_CONTEXT)
    return code;

  uw_install_context (&this_context, &cur_context);
}


static _Unwind_Reason_Code
_Unwind_RaiseException_Phase2(struct _Unwind_Exception *exc,
                  struct _Unwind_Context *context)
{
  _Unwind_Reason_Code code;

  while (1)
    {
      _Unwind_FrameState fs;
      int match_handler;

      code = uw_frame_state_for (context, &fs);

      /* Identify when we've reached the designated handler context.  */
      match_handler = (uw_identify_context (context) == exc->private_2
               ? _UA_HANDLER_FRAME : 0);

      if (code != _URC_NO_REASON)
    /* Some error encountered.  Usually the unwinder doesn't
       diagnose these and merely crashes.  */
      return _URC_FATAL_PHASE2_ERROR;

      /* Unwind successful.  Run the personality routine, if any.  */
      if (fs.personality)
      {
        code = (*fs.personality) (1, _UA_CLEANUP_PHASE | match_handler,
                    exc->exception_class, exc, context);
        if (code == _URC_INSTALL_CONTEXT)
          break;
        if (code != _URC_CONTINUE_UNWIND) 
          return _URC_FATAL_PHASE2_ERROR;
      }

      /* Don't let us unwind past the handler context.  */
      if (match_handler)
         abort ();

      uw_update_context (context, &fs);
    }

  return code;
}

如上兩個函式分別對應了 unwind 過程中的這兩個階段,注意其中的:

uw_init_context()
uw_frame_state_for()
uw_update_context()

這幾個函式主要是用來重建函式呼叫現場的,它們的實現涉及到一大堆的細節,這兒賣個關子先不細說,大概原理就是,對於呼叫鏈上的函式來說,它們的很大一部分上下文是可以從堆疊上恢復回來的,如 ebp, esp, 返回地址等。編譯器為了讓 unwinder 可以從棧上獲取這些資訊,它在編譯程式碼的時候,建立了很多表項用於記錄每個可以拋異常的函式的相關資訊,這些資訊在重建上下文時將會指導程式怎麼去搜尋棧上的東西。

做點有意思的事情

說了一大堆,下面寫個測試的程式簡單回顧一下前面所說的關於異常處理的大概流程:

#include <iostream>
using namespace std;

void test_func3()
{
    throw 3;

    cout << "test func3" << endl;
}

void test_func2()
{
    cout << "test func2" << endl;
    try
    {
        test_func3();
    }
    catch (int)
    {
        cout << "catch 2" << endl;
    }
}

void test_func1()
{
    cout << "test func1" << endl;
    try
    {
        test_func2();
    }
    catch (...)
    {
        cout << "catch 1" << endl;
    }
}

int main()
{
    test_func1();
    return 0;
}

上面的程式執行起來後,我們可以在 __gxx_personality_v0 裡下一個斷點。

Breakpoint 2, 0x00dd0a46 in __gxx_personality_v0 () from /usr/lib/libstdc++.so.6
(gdb) bt
#0  0x00dd0a46 in __gxx_personality_v0 () from /usr/lib/libstdc++.so.6
#1  0x00d2af2c in _Unwind_RaiseException () from /lib/libgcc_s.so.1
#2  0x00dd10e2 in __cxa_throw () from /usr/lib/libstdc++.so.6
#3  0x08048979 in test_func3 () at exc.cc:6
#4  0x080489ac in test_func2 () at exc.cc:16
#5  0x08048a52 in test_func1 () at exc.cc:29
#6  0x08048ad1 in main () at exc.cc:39
(gdb)

從這個呼叫棧可以看出,異常丟擲後,我們的程式都做了些什麼。如果你覺得好玩,你甚至可以嘗試去 hook 掉其中某些函式,從而改變異常處理的行為,這種 hack 的技巧在某些時候是很有用的,比如說我現在用到的一個場景:

我們使用了一個第三庫,這個庫裡有一個訊息迴圈,它是放在一個 try/catch 裡面的。

void wxEntry()
{
    try
    {
       call_user_func();
    }
    catch(...)
    {
       unhandled_exception();
    }
}

call_user_func() 會呼叫一系列的函式,其中涉及我們自己寫的程式碼,在某些時候我們的程式碼拋異常了,而且我們沒有捕捉住,因此 wxEntry 最終會 catch 住這些異常,然後呼叫 unhandled_exception(), 這個函式預設會呼叫一些清理函式,然後把程式 abort,而在呼叫清理函式的時候,由於我們的程式碼已經行為不正常了,在種情況下去清理通常又會引出很多其它的奇奇怪怪的錯誤,最後就算得到了coredump 也很難判斷出我們的程式哪裡出了問題。所以我們希望當我們的程式碼丟擲異常且沒有被我們自己處理而最後在 wxEntry() 中被捕捉了的話,我們可以把拋異常的地方的呼叫棧給打出來。一開始我們嘗試把 __cxa_throw 給 hook 了,也就是每當有人一拋異常,我們就把當時的呼叫棧給打出來,這個方案可以解決問題,但是問題很明顯,它影響了所有拋異常的程式碼的執行效率,畢竟收集呼叫棧相對來說是比較費時的。

其實我們並沒必要對每個 throw 都去處理,問題的關鍵就在於我們能不能識別出我們所想要處理的異常。

在這個案例中,我們恰恰可以,因為所有沒被處理的異常,最終都會統一上拋到 wxEntry 中,那麼我們只要 hook 一下 personality routine,看看當前 unwind 的是不是 wxEntry 不就可以了嗎!

#include <execinfo.h>
#include <dlfcn.h>
#include <cxxabi.h>
#include <unwind.h>

#include <iostream>
using namespace std; void test_func1(); static personality_func gs_gcc_pf = NULL; static void hook_personality_func() { gs_gcc_pf = (personality_func)dlsym(RTLD_NEXT, "__gxx_personality_v0"); } static int print_call_stack() { //to do. } extern "C" _Unwind_Reason_Code __gxx_personality_v0 (int version, _Unwind_Action actions, _Unwind_Exception_Class exception_class, struct _Unwind_Exception *ue_header, struct _Unwind_Context *context) { _Unwind_Reason_Code code = gs_gcc_pf(version, actions, exception_class, ue_header, context); if (_URC_HANDLER_FOUND == code) { //找到了catch所有的函式 //當前函式內的指令的地址 void* cur_ip = (void*)(_Unwind_GetIP(context)); Dl_info info; if (dladdr(cur_ip, &info)) { if (info.dli_saddr == &test_func1) { // 當前函式是目標函式 print_call_stack(); } } } return code; } void test_func3() { char* p = new char[2222222222222]; cout << "test func3" << endl; } void test_func2() { cout << "test func2" << endl; try { test_func3(); } catch (int) { cout << "catch 2" << endl; } } void test_func1() { cout << "test func1" << endl; try { test_func2(); } catch (...) { cout << "catch 1" << endl; } } int main() { hook_personality_func(); test_func1(); return 0; }

上面的程式碼中,personality routine 返回_URC_HANDLER_FOUND 則意味著當前函式幀裡找到相應的 landing pad,然後我們就嘗試判斷一下,該函式是否就是我們的目標函式,如果是則馬上進行相應的處理。這個做法顯然比 hook __cxa_throw 要好一些,畢竟只針對一個函式做了處理,當然,與原生的異常處理相比,這裡還是有一定的效率損失的,就看怎麼取捨了,追求方便 debug 是必然要付出些代價的。

 

相關文章