本文深入討論了VC++編譯器異常處理的實現機制。附件原始碼包含了一個VC++的異常處理庫。
介紹
相對於傳統語言,C++ 的革命性特徵之一,就是它對異常處理的支援。傳統異常處理技術有缺陷並且易於出錯,而 C++ 提供了一個非常優秀的替代方案。它將正常流程程式碼與錯誤處理程式碼清晰的隔離出來,使得程式更加健壯,易於維護。這篇文章將討論編譯器是如何實現異常處理的。假定讀者已經對異常處理機制及其語法已經有了大致的瞭解。我用 VC++ 實現了本文中介紹的異常處理庫。要將異常處理器替換成我的 VC++ 實現方式,呼叫下面的函式:
1 |
install_my_handler(); |
在此之後,程式中發生的任何異常——從丟擲一個異常到棧展開、呼叫catch塊、然後恢復執行——都被我的異常處理庫處理。
和C++的其他特性一樣,C++標準沒有關於應該如何實現異常處理的任何說明。這意味著每個編譯器廠商都可以自由選擇它認為合適的任何實現方式。我將介紹VC++是如何實現這一特性的。對使用其他編譯器或者作業系統的開發者[1],它應該是一個很好的學習資料。VC++基於Windows作業系統的結構化異常處理(SEH),構建了它的異常處理支援[2]。
結構化異常處理——概述
在此討論中,我將考慮那些顯式丟擲或者被 0 除、訪問空指標等操作導致的異常。當異常發生時,將產生中斷,然後控制權轉移給作業系統。作業系統呼叫異常處理器,它將從產生異常的函式開始檢查函式呼叫序列,然後執行棧展開,並轉移控制權。我們可以寫自己的異常處理器,然後註冊到作業系統;這樣當異常事件發生時,作業系統會呼叫它。
為了註冊,Windows定義了一個特殊的結構體,叫做EXCEPTION_REGISTRATION:
1 2 3 4 5 |
struct EXCEPTION_REGISTRATION { EXCEPTION_REGISTRATION *prev; DWORD handler; }; |
要註冊你自己的異常處理器,就需要建立這個結構體,然後將它的地址儲存到暫存器 FS 所指位置的0偏移處,如下偽彙編程式碼所示:
1 |
mov FS:[0], exc_regp |
欄位 prev 表明 EXCEPTION_REGISTRATION 結構體是一個連結串列結構。當我們註冊 EXCEPTION_REGISTRATION 結構體時,我們需要將前一個註冊的結構體地址存入prev欄位。
那麼異常回撥函式是什麼樣子呢?Windows要求異常處理器的函式簽名如下,它在EXCPT.h中定義:
1 2 3 4 5 |
EXCEPTION_DISPOSITION (*handler)( _EXCEPTION_RECORD *ExcRecord, void * EstablisherFrame, _CONTEXT *ContextRecord, void * DispatcherContext); |
目前你可以忽略所有的引數和返回值型別。下面的程式向作業系統註冊異常處理器,並通過除以0產生了一個異常。這個異常被異常處理器捕捉到。處理器沒做多餘的工作,只是輸出了一條資訊,然後退出程式。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 |
#include <iostream> #include <windows.h> using std::cout; using std::endl; struct EXCEPTION_REGISTRATION { EXCEPTION_REGISTRATION *prev; DWORD handler; }; EXCEPTION_DISPOSITION myHandler( _EXCEPTION_RECORD *ExcRecord, void * EstablisherFrame, _CONTEXT *ContextRecord, void * DispatcherContext) { cout << "In the exception handler" << endl; cout << "Just a demo. exiting..." << endl; exit(0); return ExceptionContinueExecution; //will not reach here } int g_div = 0; void bar() { //initialize EXCEPTION_REGISTRATION structure EXCEPTION_REGISTRATION reg, *preg = ® reg.handler = (DWORD)myHandler; //get the current head of the exception handling chain DWORD prev; _asm { mov EAX, FS:[0] mov prev, EAX } reg.prev = (EXCEPTION_REGISTRATION*) prev; //register it! _asm { mov EAX, preg mov FS:[0], EAX } //generate the exception int j = 10 / g_div; //Exception. Divide by 0. } int main() { bar(); return 0; } /*-------output------------------- In the exception handler Just a demo. exiting... ---------------------------------*/ |
請注意,Windows有一個嚴格的規則要求:EXCEPTION_REGISTRATION 結構體應該放在棧上,並且它的記憶體地址應該比前一個節點要小。如果Windows發現不滿足這個規則,將會結束程式。
函式與棧
棧是一塊連續的記憶體區域,用來儲存函式的區域性變數。具體來說,每個函式都對應著一個棧幀(stack frame),用來儲存這個函式的所有區域性變數,以及函式內表示式產生的中間值。請注意下圖是一個典型的示例。真實情況下,編譯器為了達到快速訪問的目的,可能會把部分或者全部變數儲存到暫存器中。棧是處理器級別的概念。處理器提供內部暫存器和操作暫存器的特殊指令。
圖2展示了函式foo呼叫函式bar,而bar呼叫函式widget時棧的典型情況。注意,此時棧是向下增長的。這意味著,後壓入棧的變數地址會比先壓入棧的變數地址要小。
編譯器使用EBP暫存器來標識當前活動棧幀。在這個例子中,widget函式將被執行,因此EBP暫存器指向widget的棧幀,如圖所示。函式通過偏移這個幀指標來獲取區域性變數。在編譯階段,編譯器將區域性變數的名稱繫結到相對於幀指標的一個固定偏移值。例如,widget函式的一個區域性變數,會通過棧指標向下偏移固定位元組數來訪問,稱作EBP-24。
圖中也展示了ESP暫存器,它是棧指標,指向棧的最後一個資料。在這個例子中,ESP指向widget幀的尾部。下一個幀會在這個位置建立。
處理器支援兩種棧操作:壓棧和出棧。如:
pop EAX
它的意思是從ESP指向的位置讀取4個位元組,然後ESP增加4(記住,這裡棧是向下增長的)。同樣地,
push EBP
它的意思是ESP遞減4,然後將EBP暫存器的值寫入到ESP指向的位置。
當編譯器編譯函式時,它會在函式開始的地方加入一些程式碼,稱作初始化段(prologue),它負責建立並初始化函式的棧幀。同樣地,編譯器在函式尾部也新增一些程式碼,稱作清理段(epilogue),它負責彈出當前函式的棧幀。
編譯器一般會為初始化段生成如下的程式碼序列:
1 2 3 |
Push EBP ; save current frame pointer on stack Mov EBP, ESP ; Activate the new frame Sub ESP, 10 ; Subtract. Set ESP at the end of the frame |
第一行語句把當前幀指標EBP儲存到棧上。第二行語句通過修改呼叫函式幀位置的EBP暫存器,啟用被呼叫函式的幀。第三行語句通過把ESP減去函式所建立的所有區域性變數與中間值的大小,將ESP暫存器移動到當前幀的尾部。在編譯階段,編譯器知道函式的所有區域性變數的型別與大小,因此它能夠計算出幀的大小。
清理段的工作與初始化段相反,它把當前幀從棧上移除:
1 2 3 |
Mov ESP, EBP Pop EBP ; activate caller's frame Ret ; return to the caller |
它將呼叫函式儲存的幀指標恢復到ESP(即被呼叫函式幀指標指向的位置),將它彈出到EBP,因此啟用了呼叫函式的棧幀,然後執行ret指令。
當處理器遇到ret指令時,它做下面的工作:它從棧上彈出返回地址,然後將控制轉移到這個地址。在呼叫函式執行call指令時,返回地址被壓到棧上。Call指令首先將下一條指令的地址,即控制返回時要執行的指令,壓到棧上,然後跳轉到被呼叫函式的起始處。圖3展示了一個更詳細的棧幀圖。如圖示,函式引數也是函式棧幀的一部分。呼叫函式將被呼叫函式的引數壓到棧上。當函式返回時,呼叫函式通過給ESP加上引數的大小,從棧上移除被呼叫函式的引數。這個大小在編譯期就可以確定。
Add ESP, args_size
同樣地,被呼叫函式通過給ret指令指定所有引數的大小,也能夠移除引數,這個大小也可以在編譯期確定。假設引數總大小為24,下面的指令在返回到呼叫函式之前,從棧上移除24個位元組:
Ret 24
上面兩種方式,一次函式呼叫只能使用其中一種,它取決於被呼叫函式的呼叫約定。另外請注意程式中的每個執行緒都有自己的棧。
C++和異常
我在第一節中曾經提到過EXCEPTION_REGISTRATION結構體。它被用來向作業系統註冊異常回撥函式,這個回撥函式在異常發生時被呼叫。
VC++通過在其尾部增加兩個欄位,擴充套件了它的語義:
1 2 3 4 5 6 7 |
struct EXCEPTION_REGISTRATION { EXCEPTION_REGISTRATION *prev; DWORD handler; int id; DWORD ebp; }; |
VC++為每個函式建立一個EXCEPTION_REGISTRATION結構體,作為函式的區域性變數,少數函式除外[3]。結構體的最後一個欄位與幀指標EBP指向的位置重疊。函式的初始化段在其棧幀上建立這個結構體,並將其註冊給作業系統。清理段程式碼恢復呼叫者的EXCEPTION_REGISTRATION。我將在下一節中討論id欄位的作用。
當VC++編譯一個函式時,它會為函式生成兩類資料:
A、異常回撥函式
B、一個包含函式重要資訊的資料結構,如catch塊,catch希望接收的異常型別資訊等。我把這個資料結構稱作funcinfo,下一節中我會做詳細介紹。
圖4展示了棧執行時包含異常處理程式碼的結構。Widget函式的異常回撥函式位於異常鏈的頭部,FS:[0]指向它(在widget函式的初始化段設定)。異常處理器將widget函式的funcinfo結構體地址傳遞給__CxxFrameHandler函式,此函式檢測這個資料結構,檢視是否有希望接收當前異常型別的catch塊。如果沒有發現,它把ExceptionContinueSearch值返回給作業系統。作業系統從異常處理鏈中取出下一個節點,然後呼叫其異常處理器(即當前函式的呼叫者的處理器)。
這個過程一直持續到異常處理器找到了能夠接收當前異常的catch塊,這種情況下它不會將控制權返回給作業系統。但是在呼叫catch塊之前(它能夠從funcinfo結構體獲取到catch塊的地址,見圖4),異常處理器必須執行棧展開:清理這個函式幀之下的所有函式的棧幀。清理棧幀的過程有些複雜:異常處理器必須找到異常發生時函式幀上所有存活的區域性變數,然後呼叫它們的析構器。我後面會詳細介紹。
這個異常處理器將清理幀的任務委託給這個幀的異常處理器。從FS:[0]所指向的異常處理連結串列的頭部開始,依次呼叫每個節點的異常處理器,通知它棧即將被展開。作為迴應,節點的異常處理器呼叫所有區域性變數的析構器,然後返回。這樣一直到達自己所在的節點。
由於catch塊是函式的一部分,它與所在的函式共用同一個棧幀。因此異常處理器需要在呼叫catch塊之前啟用其棧幀。另外,每個catch塊都會接收一個引數,就是它希望捕獲的異常型別。異常處理器應該複製異常物件或其引用到catch塊的幀上面。它能夠從funcinfo結構體獲取到異常物件。編譯器非常慷慨地生成了這些資訊。
在複製異常與啟用幀之後,異常處理器呼叫了catch塊。Catch塊將try-catch塊執行結束後的下一條指令地址返回給異常處理器。注意,在此刻,雖然棧已經展開,幀已經清理完畢,但是它們在物理上仍然佔據著棧的空間,沒有被移除。這是因為異常處理器仍然在執行,和其他普通函式一樣,它也使用棧來儲存其臨時變數,它的幀在發生異常的函式幀下面。當catch塊返回時,它需要析構異常物件。這發生在異常處理器移除了所有幀(包括異常處理器自身),並且將控制權轉交給try-catch塊後面程式碼之後。移除所有幀的方法是將ESP指向函式幀的尾部(它轉交控制權的那個幀)。它如何確定這個函式幀的尾部呢?它沒有辦法確定。但是編譯器已經通過函式的初始化段把它儲存到棧上了,只需要異常處理器找到就行了。再看圖4,它的位置在幀指標EBP下面16個位元組。
Catch塊可能會丟擲一個新的異常,或者把異常重新丟擲。異常處理器必須檢測到這種情形,並作出適當的處理。如果catch塊丟擲了一個新的異常,異常處理器必須析構舊的異常物件;如果catch塊重新丟擲異常,那麼異常處理器必須複製舊的異常物件。
有一個重點需要指出:因為每個執行緒都有自己的棧,所以每個執行緒都擁有自己獨立的EXCEPTION_REGISTRATION連結串列。
C++和異常 – 2
圖5描述了funcinfo結構體的佈局。注意,不同的VC++編譯器下欄位的名稱是不同的。另外,我只列出了部分相關的欄位。展開表(unwind table)的結構體在下一節中討論。
當異常處理器在函式中查詢catch塊時,它要確定的第一件事是在異常發生的程式碼處是否被try塊所包圍。如果沒有找到try塊,那麼它就會返回。否則,它會在try塊的對應catch塊列表中進行查詢。
首先,讓我們看看它是如何查詢try塊的。在編譯階段,編譯器給每個try塊分配一個起始id和結束id。異常處理器通過funcinfo結構體能夠訪問到這兩個id。看圖5。編譯器為函式中的每個try塊生成了名稱為trydata的資料結構。
在上一節中,我談到了VC++擴充套件了EXCEPTION_REGISTRATION結構體,增加了id欄位。並且,這個資料結構是出現在函式的棧幀上的。看圖4。在異常發生時,異常處理器從幀上讀取這個id值,判斷這個id是否在tryblock結構體的起始id與結束id範圍內。如果在,那麼異常就發生在這個try塊內。否則,異常處理器在tryblocktable的下一個tryblock結構體中查詢。
那麼是誰在棧上寫這個id值呢?這個id值應該是多少呢?編譯器會在函式的不同位置新增程式碼,用來更新能夠反映出當前執行狀態的id值。例如,編譯器會在進入try塊時新增程式碼,而這個程式碼能夠向棧幀上寫入此try塊的起始id。
一旦異常處理器站到了try塊,它能夠遍歷這個try塊對應的catchblock表,來檢查是否有catch塊希望捕獲當前異常。請注意,為了處理巢狀的try塊,在內部try塊中出現的異常,也會在外部try塊中出現。異常處理器應該先查詢內部try塊的catch塊。如果沒有找到,它再查詢外部try塊的catch塊。在初始化tryblock表時,VC++將內部try塊放在外部try塊之前。
異常處理器是如何確定一個catch塊(從catchblock結構體獲取)希望捕獲當前異常呢?通過比較異常型別與catch塊的型別引數。如:
1 2 3 4 5 6 7 8 9 |
void foo() { try { throw E(); } catch(H) { //. } } |
當H和E型別完全相同時,Catch塊能夠捕捉這個異常。異常處理必須在執行時比較這兩個型別。通常,像C這樣的語言不提供執行時物件型別資訊。而C++提供執行時型別識別機制(RTTI),有執行時比較型別的標準方法。它在標準標頭檔案<typeinfo>中定義了一個type_info類,它能夠在執行時表示一種型別。Catchblock結構體的第二個欄位(見圖5)是一個指向type_info的指標,它能夠表示catch塊引數的執行時型別。type_info有operator ==符號過載,可以確定兩個型別是否完全相同。因此,所有的異常處理器要做的是,從catchblock結構體中得到引數型別的type_info,與異常物件的type_info進行比較(呼叫 operator ==),來確定catch塊是否希望捕獲當前異常。
異常處理器從funcinfo結構體知道catch塊的引數型別了,那麼它是怎麼知道異常的funcinfo呢?當編譯器遇到類似下面的語句時:
throw E();
它會為被丟擲的異常生成excpt_info結構體。看圖6。請注意在不同的VC++編譯器上名稱可能不同,並且我只列出了部分相關的欄位。如圖所示,異常的type_info可以通過excpt_info結構體獲取到。在某個時間點,異常處理器需要析構異常物件(在catch塊執行後)。為了幫助異常處理器做這個工作,編譯器把異常的析構器,複製構造器和物件大小寫入excpt_info結構體,供異常處理器使用。
如果catch塊的引數型別是一個基類,而異常是其基類,那麼異常處理器應該能夠觸發這個catch塊。然而,在這裡比較兩者的typeinfo會得到錯誤的結果,因為它們的型別並不相等。type_info類也沒有提供任何成員函式能夠告訴一個類是另一個類的基類。但是,異常處理器必須能夠觸發這個catch塊。為了做到這個效果,編譯器為異常處理器生成了更多的資訊。如果異常是一個派生類,那麼可以從excpt_info結構體中獲取到etypeinfo_table,它包含etype_info(type_info的擴充套件)指標,這個指標能夠指向繼承層次的所有類。因此異常處理器將catch塊引數的type_info,與excpt_info結構體中的所有type_info進行比較。有任何一個比較成功,就會觸發catch塊。
在我結束這一節之前,還有最後一個問題:異常處理器如何能夠感知到異常和except_info結構體?我將在接下來的討論中嘗試回答這個問題。
VC++將throw語句翻譯成類似下面的程式碼:
1 2 3 |
//throw E(); //compiler generates excpt_info structure for E. E e = E(); //create exception on the stack _CxxThrowException(&e, E_EXCPT_INFO_ADDR); |
_CxxThrowException將控制權轉交給作業系統(通過軟體中斷,見RaiseException函式),同時會傳遞兩個引數。作業系統將這兩個引數打包成_EXCEPTION_RECORD結構體,然後呼叫異常回撥函式。從FS:[0]指向的EXCEPTION_REGISTRATION列表的頭部節點開始,呼叫此節點的異常處理器。指向這個EXCEPTION_REGISTRATION的指標也是異常處理器的第二個引數。回憶之前的內容,在VC++中,每個函式會在其棧幀上建立自己的EXCEPTION_REGISTRATION並註冊。將第二個引數傳遞給異常處理器,使得它能夠獲取到重要的資訊,如EXCEPTION_REGISTRATION的id欄位(用來查詢catch塊)。此引數同時也使異常處理器感知到函式的棧幀(用來清除棧幀),以及EXCEPTION_REGISTRATION節點在異常列表中的位置(用來棧展開)。第一個引數是指向_EXCEPTION_RECORD結構體的指標,通過它可以獲取到異常物件指標,以及excpt_info結構體。異常處理器的函式簽名在EXCPT.H中宣告:
1 2 3 4 5 |
EXCEPTION_DISPOSITION (*handler)( _EXCEPTION_RECORD *ExcRecord, void * EstablisherFrame, _CONTEXT *ContextRecord, void * DispatcherContext); |
你可以忽略最後的兩個引數。返回值型別是一個列舉值(定義見EXCPT.H)。我前面說過,如果異常處理器不能找到catch塊,它會返回ExceptionContinueSearch值給作業系統。這種情況下,其他的返回值並不重要。_EXCEPTION_RECORD結構體在WINNT.H中定義:
1 2 3 4 5 6 7 8 9 |
struct _EXCEPTION_RECORD { DWORD ExceptionCode; DWORD ExceptionFlags; _EXCEPTION_RECORD *ExcRecord; PVOID ExceptionAddress; DWORD NumberParameters; DWORD ExceptionInformation[15]; } EXCEPTION_RECORD; |
ExceptionInformation陣列中的資料數量以及入口型別取決於ExceptionCode欄位。如果ExceptionCode是一個C++異常(異常程式碼為0xe06d7363,一般是通過throw丟擲的異常),那麼ExceptionInformation陣列中包含異常物件指標以及excpt_info結構體。對於其他型別的異常,幾乎都沒有任何入口。其他型別的異常可以是除零錯誤,記憶體訪問錯誤等,它們的值可以在WINNT.H中找到。
異常處理器判斷_EXCEPTION_RECORD結構體的ExceptionFlags欄位,決定採取哪種動作。如果它的值是EH_UNWINDING(在Except.inc中定義),這就表明棧即將被展開,異常處理器應該清理棧幀,然後返回。清理過程涉及到查詢在異常發生時,幀上所有存活的區域性物件,並呼叫它們的析構器。下一節中將討論這個過程。否則,異常處理器應該在函式中查詢catch塊,如果找到的話,執行它。
清理棧幀
C++標準要求,棧展開之前,所有在發生異常時存活的區域性物件的析構器都應該被呼叫。考慮:
1 2 3 4 5 6 7 8 9 10 11 |
int g_i = 0; void foo() { T o1, o2; { T o3; } 10/g_i; //exception occurs here T o4; //... } |
當異常發生時,區域性物件o1和o2在foo的幀上,而o3已經結束了生存週期。O4還沒有被建立。異常處理器應該知道這個狀況,並呼叫o1和o2的析構器。
我前面寫過,編譯器會在函式的不同位置新增程式碼,在函式執行過程中記錄執行時的狀態。它給函式的特殊程式碼段賦id值。例如,try塊的入口點是一個特殊的位置。就像我們前面討論過的那樣,編譯器會在函式內try塊進入的位置新增語句,向函式的幀上寫入try塊的起始id。
函式中另一個特殊的位置是區域性物件建立或者釋放的地方。用另一句話說,編譯器為每個區域性物件賦一個唯一的id值。當編譯器遇到如下的物件定義時:
1 2 3 4 5 |
void foo() { T t1; //. } |
它在定義之後增加語句(在物件建立之後)來向幀上寫id值:
1 2 3 4 5 6 |
void foo() { T t1; _id = t1_id; //statement added by the compiler //. } |
編譯器建立一個隱藏的區域性變數(即上面程式碼中的_id),它與EXCEPTION_REGISTRATION結構體的id欄位重疊。同樣地,編譯器也在物件的解構函式之前增加程式碼,來寫入前面的id。
當異常處理器需要清空幀時,它從幀上讀取id值(EXCEPTION_REGISTRATION結構體的id欄位,或者幀指標EBP下面的4個位元組)。這個id值表明,在此id對應位置之前的所有程式碼沒有產生異常。此位置之前的所有物件都已經建立了。此位置之前的所有或者部分物件的析構器需要被呼叫。請注意這些物件的一部分可能已經被析構了,因為它們可能位於子程式碼塊內。這些物件的析構器不應該被呼叫。
編譯器還為函式生成了另一資料結構,unwindtable(我取的名字),這是一個展開結構體的陣列。可以通過funcinfo結構體獲取到它。看圖5。函式中的每個特殊程式碼段,都對應一個展開結構體。Unwindtable中展開結構體的排列順序,與它們對應的程式碼在函式中出現的順序相同。展開結構體對應的物件是我們所關注的(記住,每個物件定義表示一個特殊程式碼段,並關聯一個id)。它也包含析構這個物件的資訊。當編譯器遇到物件定義,它生產一小段程式碼,這段程式碼能夠知道此物件在幀上的地址(或者相對於幀指標的偏移量),並釋放這個物件。展開結構體的欄位之一包含這段程式碼的地址。
1 2 3 4 5 6 |
typedef void (*CLEANUP_FUNC)(); struct unwind { int prev; CLEANUP_FUNC cf; }; |
Try塊的展開結構體的第二個欄位值為0。Prev欄位表明unwindtable是一個展開結構體的連結串列。當異常處理器需要清理幀時,它從幀上讀取id值,並將其作為展開表的索引。它從這個索引處讀取展開結構體,呼叫其第二個欄位的清理函式。這將釋放此id對應的物件。然後異常處理器通過prev欄位讀取前一個展開結構體,如此迴圈,直到連結串列結束(prev等於-1)。圖7展示了函式中展開表的樣子。
考慮new操作符的例子:
T* p = new T();
系統首先為T申請記憶體,然後呼叫構造器。如果構造器丟擲一個異常,那麼系統必須釋放已經申請的記憶體。為了達到這個目的,VC++也為每個new操作符生成一個id,當然只針對有非空構造器的物件。這樣在展開表中new操作就會有對應入口,以及釋放記憶體的清理程式碼。在呼叫構造器之前,它在EXCEPTION_REGISTRATION結構體中為記憶體申請寫入id。當構造器成功返回後,它恢復之前寫入的id。
另外,物件的建構函式丟擲異常前,可以已經部分初始化了。如果在異常發生時,物件的子成員物件,或者基類的子成員物件已經構造完成,那麼必須呼叫這些物件的析構器。編譯器會為構造器生成一組與前面普通函式中相同的資料,來完成這個任務。
請注意,當棧展開時異常處理器會呼叫使用者自定義的析構器。析構器中可能會丟擲異常。C++標準要求,在棧展開時,析構器不能丟擲異常。如果真的發生了,系統將呼叫std::terminate。
實現
這一節將討論3個問題:
A、安裝異常處理器;
B、catch塊重新丟擲異常,或者丟擲新的異常;
C、支援每個執行緒的異常處理。
請閱讀原始碼中的readme.txt檔案,獲取構建說明[1]。其中也包含了一個demo專案。
第一個任務是安裝異常處理庫,或者說,替換掉VC++提供的庫。從上面的討論中已經知道,VC++提供__CxxFrameHandler函式,它是處理所有異常的入口點。對每個函式,編譯器為函式內發生的異常生成異常處理程式碼。這個程式碼把funcinfo指標傳遞給__CxxFrameHandler函式。
install_my_handler()函式在__CxxFrameHandler開始處插入程式碼,能夠跳轉到my_exc_handler()。但是__CxxFrameHandler駐留在只讀內碼表,任何寫入操作的嘗試都會引發禁止訪問錯誤。因此第一步要通過Windows API函式VirtualProtectEx改變這個頁許可權為讀寫許可權。在記憶體寫入後,我們再恢復成原來的保護許可權。這個函式將jmp_instr結構體的內容寫入到__CxxFrameHandler的開始處。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 |
//install_my_handler.cpp #include <windows.h> #include "install_my_handler.h" //C++'s default exception handler extern "C" EXCEPTION_DISPOSITION __CxxFrameHandler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ); namespace { char cpp_handler_instructions[5]; bool saved_handler_instructions = false; } namespace my_handler { //Exception handler that replaces C++'s default handler. EXCEPTION_DISPOSITION my_exc_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ) throw(); #pragma pack(1) struct jmp_instr { unsigned char jmp; DWORD offset; }; #pragma pack() bool WriteMemory(void * loc, void * buffer, int size) { HANDLE hProcess = GetCurrentProcess(); //change the protection of pages containing range of memory //[loc, loc+size] to READ WRITE DWORD old_protection; BOOL ret; ret = VirtualProtectEx(hProcess, loc, size, PAGE_READWRITE, &old_protection); if(ret == FALSE) return false; ret = WriteProcessMemory(hProcess, loc, buffer, size, NULL); //restore old protection DWORD o2; VirtualProtectEx(hProcess, loc, size, old_protection, &o2); return (ret == TRUE); } bool ReadMemory(void *loc, void *buffer, DWORD size) { HANDLE hProcess = GetCurrentProcess(); DWORD bytes_read = 0; BOOL ret; ret = ReadProcessMemory(hProcess, loc, buffer, size, &bytes_read); return (ret == TRUE && bytes_read == size); } bool install_my_handler() { void * my_hdlr = my_exc_handler; void * cpp_hdlr = __CxxFrameHandler; jmp_instr jmp_my_hdlr; jmp_my_hdlr.jmp = 0xE9; //We actually calculate the offset from __CxxFrameHandler+5 //as the jmp instruction is 5 byte length. jmp_my_hdlr.offset = reinterpret_cast<char*>(my_hdlr) - (reinterpret_cast<char*>(cpp_hdlr) + 5); if(!saved_handler_instructions) { if(!ReadMemory(cpp_hdlr, cpp_handler_instructions, sizeof(cpp_handler_instructions))) return false; saved_handler_instructions = true; } return WriteMemory(cpp_hdlr, &jmp_my_hdlr, sizeof(jmp_my_hdlr)); } bool restore_cpp_handler() { if(!saved_handler_instructions) return false; else { void *loc = __CxxFrameHandler; return WriteMemory(loc, cpp_handler_instructions, sizeof(cpp_handler_instructions)); } } } |
jmp_instr結構體定義處的#pragma pack(1)指令告訴編譯器,這個結構體的記憶體佈局不需要對齊。如果沒有這個指令,這個結構體的尺寸將是8個位元組。我們定義了這個指令之後,它的大小是5個位元組。
回到異常處理,當異常處理器呼叫catch塊時,catch塊可能重新丟擲這個異常,或者丟擲一個新的異常。如果catch塊丟擲了一個新的異常,那麼異常處理器必須釋放掉前一個異常物件。如果catch塊決定要重新丟擲,那麼異常處理器必須複製當前異常物件。此時,異常處理器必須解決兩個問題:它如何知道catch塊中一個新的異常產生?它又如何跟蹤舊的異常物件?我解決這個問題的方法是,在異常處理器呼叫catch塊之前,它把當前異常物件儲存在exception_storage物件內,並註冊一個有特殊目的的異常處理器,catch_block_protector。exception_storage物件可以通過呼叫函式get_exception_storage()得到:
1 2 3 4 5 |
exception_storage* p = get_exception_storage(); p->set(pexc, pexc_info); register catch_block_protector call catch block //.... |
如果在catch塊中異常被(重新)丟擲,控制權進入catch_block_protector。它可以從exception_storage物件中取出前一個異常物件,當catch塊丟擲了新異常時釋放它。如果catch塊重新丟擲(可以通過檢查ExceptionInformation陣列的前兩個入口判斷,這兩個都是0,見下面的程式碼),那麼處理器需要在ExceptionInformation陣列中複製它得到當前異常的拷貝。下面是catch_block_protector()函式:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
//------------------------------------------------------------------- // If this handler is calles, exception was (re)thrown from catch // block. The exception handler (my_handler) registers this // handler before calling the catch block. Its job is to determine // if the catch block threw new exception or did a rethrow. If // catch block threw a new exception, then it should destroy the // previous exception object that was passed to the catch block. If // the catch block did a rethrow, then this handler should retrieve // the original exception and save in ExceptionRecord for the // exception handlers to use it. //------------------------------------------------------------------- EXCEPTION_DISPOSITION catch_block_protector( _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ) throw() { EXCEPTION_REGISTRATION *pFrame; pFrame = reinterpret_cast<EXCEPTION_REGISTRATION*> (EstablisherFrame);if(!(ExceptionRecord->ExceptionFlags & ( _EXCEPTION_UNWINDING | _EXCEPTION_EXIT_UNWIND))) { void *pcur_exc = 0, *pprev_exc = 0; const excpt_info *pexc_info = 0, *pprev_excinfo = 0; exception_storage *p = get_exception_storage(); pprev_exc= p->get_exception(); pprev_excinfo= p->get_exception_info();p->set(0, 0); bool cpp_exc = ExceptionRecord->ExceptionCode == MS_CPP_EXC; get_exception(ExceptionRecord, &pcur_exc); get_excpt_info(ExceptionRecord, &pexc_info); if(cpp_exc && 0 == pcur_exc && 0 == pexc_info) //rethrow {ExceptionRecord->ExceptionInformation[1] = reinterpret_cast<DWORD> (pprev_exc);ExceptionRecord->ExceptionInformation[2] = reinterpret_cast<DWORD>(pprev_excinfo); } else { exception_helper::destroy(pprev_exc, pprev_excinfo); } } return ExceptionContinueSearch; } |
考慮get_exception_storage()函式的一個可能實現方式:
1 2 3 4 5 |
exception_storage* get_exception_storage() { static exception_storage es; return &es; } |
這將是一個完美的實現,除了多執行緒環境。在超過一個執行緒的情況下考慮儲存這個物件並在其中儲存異常物件,將是一個災難。每個執行緒有自己的棧,以及異常處理鏈。我們需要的是一個執行緒相關的exception_storage物件。每個執行緒都有自己的物件,線上程啟動時建立,並線上程結束時釋放。Windows提供了執行緒區域性儲存(thread local storage)實現這個功能。執行緒區域性儲存使一個物件能夠在每個執行緒內有自己獨立的私有拷貝,並通過全域性的介面來訪問。系統提供了TLSGetValue()和TLSSetValue()函式來實現這個特性。
Excptstorage.cpp檔案定義了get_exception_storage()函式。這個檔案被編譯成DLL。這樣做的原因是,它可以確保我們知道任意一個執行緒的建立與銷燬。每次一個執行緒被建立或銷燬,Windows會呼叫每個DLL(已經被載入到程式的地址空間)的DllMain()函式。這個函式在新建立的執行緒中被呼叫。這給了我們一個初始化執行緒私有資料的機會,即我們例子中的exception_storage物件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
//excptstorage.cpp #include "excptstorage.h" #include <windows.h> namespace { DWORD dwstorage; } namespace my_handler { __declspec(dllexport) exception_storage* get_exception_storage() throw() { void *p = TlsGetValue(dwstorage); return reinterpret_cast<exception_storage*>(p); } } BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved ) { using my_handler::exception_storage; exception_storage *p; switch(ul_reason_for_call) { case DLL_PROCESS_ATTACH: //For the first main thread, this case also contains implicit //DLL_THREAD_ATTACH, hence there is no DLL_THREAD_ATTACH for //the first main thread. dwstorage = TlsAlloc(); if(-1 == dwstorage) return FALSE; p = new exception_storage(); TlsSetValue(dwstorage, p); break; case DLL_THREAD_ATTACH: p = new exception_storage(); TlsSetValue(dwstorage, p); break; case DLL_THREAD_DETACH: p = my_handler::get_exception_storage(); delete p; break; case DLL_PROCESS_DETACH: p = my_handler::get_exception_storage(); delete p; break; } return TRUE; } |
總結
如上面討論過的,C++編譯器和執行時異常庫,以及作業系統的支援,共同完成異常處理。
備註與參考文章
1、 此文進行過程中,Visual Studio 7.0已經發布。我編譯與測試異常處理庫,主要是在奔騰處理器,Windows 2000系統,VC++ 6.0環境中。我也在VC++ 5.0和VC++ 7.0 beta環境下測試過。在6.0與7.0之間有一些很小的差別。6.0在呼叫catch塊之前,首先在catch塊的幀上覆制異常(或者其引用),然後執行棧展開。7.0的庫先執行棧展開。在這點上,我的庫與6.0的庫相似。
2、 參看MSDN上Matt Pietrek的精彩文章結構化異常處理。
3、 當一個函式沒有try塊,並且沒有定義任何包含析構非空解構函式的物件時,編譯器可能不會生成任何與異常相關的資料。