2009年10月26日 星期一

淺談C++例外處理 (中篇)

前言: 經過了漫長的混戰當中,終於把GNU G++例外處理的機制實作在某32位元CPU上了,為了避免富姦化,個人考慮多發表一些另類的文章才是阿!!

前文淺談了C++例外處理的機制,根據前篇的內容,我們可以初略的了解到例外處理機制在語言中的定位和C++例外處理機制中容易產生的迷失。然而,有志者總是喜歡更"深入"的去了解更底層的行為,看似平凡的東西有時候會使人驚艷,而像C++ 例外處理機制這個從Spec 看來就如此複雜機制該如何來實作呢?相信大家心中都有一樣的疑惑。編譯器是個複雜的東西,然而在GNU Toolchain還沒問世之前,要講例外處理的實作通常只能從產生出來的代碼來探討,然而一個開放原始碼的編譯器卻能讓程式工作者能以"編譯器"的角度出發,深入研究C++的例外處理機制,這也是開放原始碼偉大的地方。本文就以開放原始碼的編譯器 GNU G++為出發點,來探討C++例外處理的實作。

註: 本文探討的原始碼以GCC 4.4.2 為主

GNU Toolchain支援的C++例外處理模型的實作有兩種,與其說例外處理的實作有兩種,其實正確的說法是Stack Unwinder的實作方式有兩種。第一種叫Set Jump Long Jump Exception Handling (簡稱SJLJ) 另一種叫Dawrf 2 Stack Unwinder Exception Handling。如果要用簡單的幾句話來介紹一下兩者皆的差別,其實就是Set Jump Long Jump Exception Handling 這種方式實作起來比較簡單,可攜性也較高,可是效能和產生的代碼大小都差強人意。 而 Dawrf 2 Stack Unwinder是利用Table Driven的方式來進行例外處理,效能和速度都比前者好,想當然是可攜性比較差,而且需要其他二元工具組一些額外的支援,實作上也複雜許多了。本篇的探討會以Set Jump Long Jump Exception Handling為主,而下一篇再探討Dawrf 2 Stack Unwinder。

Set Jump Long Jump Exception 的原理很簡單,顧名思義就是用到setjmp和longjmp這兩個函式,這兩個函式是C標準函式庫的一員,原本是用來處理non-local goto 的堆疊平衡。所以用他們來處理C++的例外也是蠻單純的。就學理上來說,就是進入到需要被偵測例外的區域就呼叫setjmp來保持執行狀態,在丟出例外時使用longjmp來復原原本的執行狀態。然而事實上這部份的處理確沒那麼簡單,而且比想像中的複雜很多,因為C++程式語言規定了離開例外處理區塊的時候必須要正確的解構auto object,這造成許多麻煩。一個簡單的方法就是把例外處理區塊製造出來需要解構的物件都塞到一個list內,如果例外發生就能依序解構他們。如此一來想當然效能是非常差的,最致命的是,不論例外有沒有發生,這些保存的動作都是必須的。會採取這種實作有許多理由,除了簡單之外,可攜性也是當初在實作的一個重大考量,因為他在那些把C++原始碼轉成C原始碼的早期C++編譯器中也能夠運行的很好,

回到實作的層面,C++程式語言中用來作例外處理主要的關鍵字有三個,分別是try, catch, 和throw。如果大家用C++程式語言的角度來看,他們的作用我就不贅述,相信C++程式語言的書籍中都有介紹。如果想從標準文件的角度對這三個關鍵字的作用和描述更深入的研究,他們在ISO C++文件的第15章。然而,如果大家用的是更低階的角度來看例外處理,就會發現其實組合語言是沒辦法對這個行為進行原生的支援的。也就是說編譯器其實會根據那些關鍵字,在他們的程式區塊(Code Block)中產生特定的代碼和函式呼叫。關於這個假設,我們可以用GNU G++產生組合語言的功能(-S)來驗證。經過觀察我們就可以輕鬆的發現GNU G++會分別在這三種區塊內加入函式呼叫的代碼。稍微對G++有些了解的讀者會知道其實這些函式名稱其實就是GNU G++的"約定",也就是在運行時期函式庫中必須要有這些函式,才有辦法正確的進行例外處理。以下為例外處理模型設定成sjlj-exception 的 G++ 編譯器對特定的例外處理關鍵字產生的函式。

try-block
    _Unwind_SjLj_Register
    _Unwind_SjLj_Unregister
    _Unwind_SjLj_Resume
handler: (catch block)
    __cxa_begin_catch
    __cxa_end_catch
throw-expesssion
    __cxa_allocate_exception
    __cxa_throw

註: 以上的結果必須要將編譯器都設定成 “-wtih-sjljexception=yes “ 才有辦法看到一樣的結果,其中要注意的是大部分主流處理器的編譯器和主流Linux發行版預設的編譯器,預設都是使用Dawrf 2 Exception Handling,所以無法看到相同的結果。

_Unwind_SjLj_Register 和_Unwind_SjLj_Unregister分別用來加入或移除一個到Function_Context到unwinding-chain當中。_Unwind_SjLj_Resume是用來恢復例外的傳遞(也就是說用來做cleanup),前兩個函式的原始碼在<gcc>/gcc/unwind-sjlj.c 中,後面一個在<gcc>/gcc/unwind.inc中。如果大家有去觀看看原始碼會發現其實_Unwind_SjLj_Resume有一個兄弟函式叫_Unwind_SjLj_Resume_or_Rethrow,他是用來處理FORCE_UNWIND例外和實作rethrow的功能__cxa_begin_catch 和__cxa_end_catch就沒那麼複雜了,他們主要用來處理C++例外處理語意中一些housekeeping的動作,他們在libstdc++中libspu++的 eh_catch.cc 內。值得一提的是,C++運行函式庫最容易被問題的問題是有沒有支援thread-safe,然而G++在例外處理的實作是有考慮到thread-safe的。

throw的處理就複雜很多了,因為throw的處理牽扯到了執行狀態的恢復、例外處理函式的尋找和程式碼控制權的轉移。以下為__cxa_throw的原始碼(在libstdc++/libspu++/eh_throw.cc當中)

extern "C" void
__cxxabiv1::__cxa_throw (void *obj, std::type_info *tinfo, void (*dest) (void *))
{
  // Definitely a primary.
  __cxa_refcounted_exception *header
    = __get_refcounted_exception_header_from_obj (obj);
  header->referenceCount = 1;
  header->exc.exceptionType = tinfo;

… 中間省略 … 

  _Unwind_SjLj_RaiseException (&header->
exc.unwindHeader);
 

  __cxa_begin_catch (&header->exc.unwindHeader);
  std::terminate ();
}

看過原始碼後我們會發現,其實__cxa_throw並不複雜,比較值得注意的就是藍色的部份,就是如果unwind失敗的時候呼叫std::terminate離開,這也說明了大部分的工作就落是_Unwind_SjLj_RaiseException當中,而且這個函式如果執行成功的話是不會返回到__cxa_throw的。

_Unwind_SjLj_RaiseException的部份就是分成兩個階段,第一個階段是尋找例外處理函式的部份,這部份的程式會翻閱frame的資訊來尋找是否有例外處理函式被註冊,如果這部份有找到例外處理函式就會進入第二階段,反之就會返回__cxa_throw,這會造成程式被結束。第二階段會進行恢復到有例外處理函式的那個frame的執行狀態,並且取出他的Function_Context並且longjmp回到例外處理函式去。如果某一個函式有多個例外處理函式,那編譯器會在那個函式產生一段充滿跳躍指令的程式碼叫landing pad,而_Unwind_SjLj_RaiseException的會先longjmp回到landing pad後再判斷要跳躍到哪個例外處理函式中。

沒有留言:

張貼留言