2010年1月9日 星期六

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

這一篇是這個系列的完結篇了,重點放在 GCC C++ Compiler 的例外處理機制 - Dwarf2 Exception Handling。看到 "Dwarf" 這個英文單字,如果是執行檔格式有研究的人,大概會想到的東西除了龍與地下城和魔獸爭霸的矮人以外就是debug相關的東西了。沒錯,這邊Dwarf2的意思就跟大家想的那件是一樣,他是一種 "ELF" 格式中的 debugging data format,全名是 "Debug With Attributed Record Format"。會取這個名字的梗是由於"ELF"是精靈,而"Dwarf"雖然我們叫他為矮人,而他們在北歐神話中其實是一種精靈(黑暗精靈)。

例外處理的概念並不困難,只是實作卻是比想像中複雜很多,撇開C++標準規定的"拋出異常時必需要解構所有的自動變數"這個複雜的描述不談,其實對程式語言來說,想要正確的回復到那個時候的狀態,只要能取回例外處理區塊 (在 C++中為catch block) 範圍看的到的區域變數值就差不多了。然而,取回區域變數值說起來很容易,事實上卻不容易,以 C++ 這種語言來說,函式的呼叫或是程式區塊是用堆疊的觀念去處理的,更何況編譯器為了最佳化,是有可能把變數放到暫存器內的。所以可想而知編譯器會在函式的開頭和結尾都插入一些代碼來負責這些平衡堆疊這件事。用簡單的話來說,就是編譯器函式的開頭插入把堆疊指標($esp)調整好,並把有用到且該保存的暫存器壓入堆疊內,並且在離開的時候插入了恢復堆疊指標和暫存器的代碼。

我們看以下的程式碼,如果執行時期 a 呼叫了 b ,且 b 呼叫了 c 的話。

   a() call b() call c()

如果 c 函式是經由例外處理這條路徑回到了 a ( 也就是 c 拋出例外,而且 b 沒有例外處理區域,而 a 是有例外處理區域的話 ),可以而知的是 c  跟 b 結尾的程式(用編譯器的行話來說叫epilogue) 並沒有機會被呼叫到,也就是說,如果發生這樣的狀況,我們如果想要進行堆疊的回復動作 (Stack Unwinding) 就必須要經過特殊處理了。在上一篇中,我們已經談過了 GCC 的 C++ 編譯器如何使用 C 函式庫中,專門用來處理非本地跳躍的函式 Setjmp/Longjmp 來處理這個問題,而這一篇,我們將會討論GCC 的 C++ 編譯器使用的第二種處理機制,也就是利用 Dawrf2 中的 debug informantion 來處理這個問題。

那如何利用 debug informantion 來進行 Stack Unwinding 呢?簡單來說,Dawrf2 的除錯資訊當中裡面有一個欄位是專門用來提供frame相關的資訊稱為CFI (Call Frame Infomation),編譯器當然也準備好每個函式Frame相關的操作如 push register 和 stack / frame pointer 的調整並編碼成debug informantion 存到執行檔當中。 所以當例外發生的時候,C++ Runtime Library 就能解析這個除錯資訊來了解究竟在函式開頭的時候編譯器做了些什麼事,並且把必要回復的資訊恢復回來。由於GCC 的 C++ 編譯器是使用 langauge-specific data area (LSDA) 這個欄位來儲存這部份的資料並且使用了LEB128編碼來進行壓縮。附帶一提的是,由於這種編碼方式的資料存取的時候是 Unaligned,縱使是硬體有支援 Unaligned access 還是會降低存取的效率。而且由於大部分的函式都是aligned,可是這種狀況會讓函式變成Unaligned ,如果dynmaic loader在設計的時候沒有考慮到這一點,在沒有硬體沒有支援Unaligned access 的處理器上,可能就會引發異常了。 

接下來切入實作部份,上一篇中已經將 GCC C++ Compiler 例外處理的應用程式介面(API) 和 C++程式碼中的 try, throw, catch 區塊和表示式對應的程式碼說明了一次。由於對 GCC C++ Compiler 例外處理的機制來說,使用Dwarf2 Exception Handling 來進行例外處理是不會影響大流程的,只有 Unwinding 的部分是會受到使用Setjmp/Longjmp 或Dwarf2 Exception Handling 影響。基本上來說,兩種例外處理機制並不是相容的,為了區別使用兩種例外處理機制的二進位並且讓他們可以在共用函式庫內共存,兩種機制使用的例外處理API是稍有不同的 ─ 在_Unwind開頭的API中,他們使用了名字相似但不同的函式,這個處理方式也能讓autotool之流的工具可以方便的辨認他們的編譯器是使用哪種例外處理的機制。以下為兩種機制使用的例外處理API的名稱。

Dwarf2 (Call Frame)

Setjmp/Longjmp

_Unwind_RaiseException

_Unwind_SjLj_RaiseException

_Unwind_ForcedUnwind

_Unwind_SjLj_ForcedUnwind

_Unwind_Resume  

_Unwind_SjLj_Resume

_Unwind_Resume_or_Rethrow

_Unwind_SjLj_Resume_or_Rethrow

例外處理的三個關鍵字中,以 throw 的處理最複雜,也就是在throw 轉換控制權到catch區塊的時候需要進行Stack Unwind並且進行非本地的跳轉,上一篇中也提到了主要處理throw這個關鍵字語意的例外處理函式就是 _Unwind_RaiseException。其實_Unwind_RaiseException的處理方式也跟SjLj的版本差不多,有比較大差異的地方大概就是尋找handler 的方式 和 跳轉至例外處理函式的方式。基本上尋找handler是利用PC值去Unwind Table查表來尋找例外處理函式,而比較複雜的是跳轉的部分,他用了一個GCC builtin函式 __builtin_eh_return

do                                                                     
  {                                                                    
    long offset = uw_install_context_1 ((CURRENT), (TARGET));          
    void *handler = __builtin_frob_return_addr ((TARGET)->ra); 
    __builtin_eh_return (offset, handler); 
  }                                                                   
while (0)

以上的程式是_Unwind_RaiseException用來返回例外處理函式的程式碼,uw_install_context_1 是用來恢復handler的暫存器資訊並且計算 Stack Pointer 需要調整的值,第二個函式是用來取得 handler 的位址。第三部份是 __builtin_eh_return ,由於它是一個GCC平台相關的內建函式,所以每個平台的實作稍有不同。大部分平台採取的實作方式是利用一個被大部分的RTOS用來進行 Context Switch 時候技法 ─ 在Stack中更新 pushed return address ,也就是說函式返回的時候就可以自動進行跳轉,然後返回前必須修正Stack Pointer的值,這樣控制權轉移到正確的地方的時候 (某個catch block),這時堆疊中的值也是會正確的了。