實現C語言的異常處理機制 Implementing Exceptions in C
網上衝浪時發現一個很有意思的文獻——《Implementing Exceptions in C》,雖然十分古老(1989),但對C語言這種幾乎不變的語言來說不存在知識過時的問題。文中講了怎麼在純C語言中實現類似C++異常處理機制的方法,並提供了庫原始碼,讓人眼前一亮,於是翻譯一番,作為自己的庫的一部分。
另,感謝作者Eric S. Roberts及Systems Research Center of Digital Equipment Corporation in Palo Alto, California。
Implementing Exceptions in C
by Eric S. Roberts
摘要:傳統地,C程式設計師使用異常返回碼的方式來告知程式執行中發生的異常。更高階的語言提供了另一種異常處理機制,這種機制把異常處理整合進了控制結構語句中。這種新方法相較於異常碼有許多優勢:它增加了程式錯誤被探測到的可能性;使得結構化一個抽象的規範(specification of an abstraction)變得簡單;並通過提供更好的語法以拆分正常和異常情況的處理,提高了程式碼的可讀性。這篇文章描述了一個支援C語言異常處理的語言擴充套件集(a set of language extensions)和一個基於前處理器的實現以闡述這個方法的可行性和移植性。
介紹
在設計一個抽象(abstraction)時,重要的是定義它的行為,不只是在“正常”狀況下的行為,還包括不正常以及異常狀況下的。比如,對於一個可能實現了函式如open
歷史上,C程式設計師遵從Unix的傳統,通過特別設計的返回碼來通知異常。比如,標準I/O庫中的fopen函式在請求的檔案無法開啟時返回NULL。相似地,getc函式通過返回特別的值 — EOF,來通知檔案結束。然而這種方法有許多缺陷,並被描述為“可能是異常處理機制最原始的形式”。許多學者描述了更高階的報告及處理異常的結構來解決這些缺點。
這篇文章描述了用在C語言中的一個通用異常處理方法(facility)。這個方法實現了重大的功能提升,使得我們能從語義和邏輯上拆分一個抽象的正常行為和可能發生的異常狀況。它基於Modula-2+和Modula-3的異常處理機制,並與Ada和CLU中的相似機制有著歷史相關性。範例本身並不是新的,這篇文章的主要貢獻在與闡述這個機制可以在不改變語言或犧牲移植性的前提下在C中實現。
儘管這個工作是獨立完成的,它與Eric Allman和David Been在1985 USENIX會議上報告的一個更早的機制很相似。目標都是提供一個C語言的可移植的異常處理實現;另外,兩個包都使用了C前處理器來實現可移植性,但之前的那個包使用了與系統相關的彙編程式碼。這篇文章中描述的工作在四個方面做出了額外的貢獻:(1)不需要彙編程式碼;(2)語法上更強調了異常處理程式碼與被其捕獲異常的程式區域的聯絡;(3)異常處理塊(exception handlers)可以訪問異常體(exception body)作用域內的本地變數;(4)這個方法還包含可以指定“最終(finalization)”行為的機制。
這篇文章中,第二章簡述了C語言中傳統使用的返回碼機制的不足,第三章介紹了一個備選方案。第4章提供了這個機制的語義定義。第5章討論了這個異常處理方法的實現。為了實現可移植性,這個實現基於C前處理器,並對執行環境做了最小程度的假設。在特定環境中,可以通過整合這個語法到編譯器中以獲得重大的效能提升,章節5.2中進行了討論。只要這些擴充套件的編譯器仍與基於前處理器的實現相容,程式設計師可以依賴於基於前處理器的實現以保留可移植性。
返回碼機制的缺陷
作為一個普通的技術,返回碼有許多不足。首先,當函式同時返回資料時,常常難以找到合適的返回碼來通知異常。一些情況下,找到這麼一個值會造成型別系統(type system)違反直覺的削弱。比如,你會覺得一個叫做getc的函式應該返回一個型別為char的值;然而為了包含EOF,getc被定義為返回一個int。
其次,使用單個返回碼來通知異常經常也意味著如果想提供額外的資料以說明造成異常的細節的話,這些資料必須在異常碼機制之外傳遞。在標準的Unix庫中,這是通過特殊變數errno完成的。不幸地是,這個策略是不可重複的(reentrant),並且使得支援在單個地址空間使用多執行緒的庫的設計變得更加複雜。
最後,最重要的是,通過返回碼來通知異常的方式使得程式設計師很容易忽視它們。在開發分層軟體包的時候這個問題特別明顯,每個層都必須明確地檢測返回值,決定是要內部處理它還是將其傳遞給呼叫者。如果抽象分層中的某一層沒有檢查返回值,那麼異常追蹤就丟失了。這個問題是許多傳統C程式碼中難以除錯的bug的元凶。
異常捕獲的控制結構
備選方案是將異常處理作為控制結構的一部分。當探測到了一個異常狀況,程式通過轉移控制權給一個動態的專門處理這個狀況的程式碼塊來通知這個事件。控制權轉移被稱作 丟擲一個異常,探測並響應異常狀況的程式碼被稱為一個 異常處理塊(exception handler)。
具體來說,假設我們設計了一個新的檔案管理包,它使用這個基於控制權的異常處理方案。客戶端可能使用以下程式碼開啟檔案 test.dat:
TRY
file = newopen(”test.dat", ”r” );
EXCEPT(OpenError)
printf(”Cannot open the file test.dat\n”);
ENDTRY;
語句格式在下一個章節進行描述,但是上例闡述了這個機制。如果檔案test.dat存在並且可讀,newopen會正常返回並傳遞迴一個handle然後賦值給file。如果,探測到了問題,newopen的實現可以丟擲OpenError異常,這會導致控制權直接移交給EXCEPT中的printf。
注意,丟擲OpenError異常的語句可以放在檔案管理包的實現的任意深度。當異常被丟擲,控制權棧(control stack)會彈出異常處理塊的控制權,然後控制權被移交給異常處理塊。如果沒有發現合適的異常處理塊,丟擲的異常會導致致命錯誤。這意味著可以被安全地忽視的狀況必須在程式碼中明確地指出,因此減少了無心之失的可能性。
如下所述,這個包提供的了比上面那個簡單的例子多的多的功能。比如,TRY-EXCEPT語句可以指定多個獨立的異常,不限於一個。每個異常有自己的處理塊,這樣,程式碼中就清晰地寫出了對每種狀況的響應。當一個異常被丟擲,是可以傳遞額外的資料給處理塊的。因此,在上例中,檔案處理包可以傳遞錯誤型別的指示符,這樣,客戶端就可以區分是不存在檔案還是保護衝突導致的異常。 這些是以可重入的方式實現的,因此不需要使用像errno這樣的全域性變數。包還提供了指定“最終”行為的機制,因為有些情況下,中間的軟體層需要確保,即使異常導致控制權傳遞到這個層之外,有些事情也必須做。在描述TRY-FINALLY語句時會討論更多細節。
語法形式
這個章節描述了為了實現C中異常處理而定義的新的“語句形式”,它們被定義為預處理巨集。為了使用這個包,需要通過包含以下行來讀取異常處理標頭檔案:
#include ”exception.h”
宣告異常
在異常包中,通過使用型別exception來宣告一個新的異常:
exception name;
就像C中的其他變數宣告一樣,可以通過關鍵詞static來限制一個異常的作用域,或者使用extern來引入另一個模組中的異常。一個異常通過它的地址唯一標識;它的值以及使用的實際型別與包的運作無關。在典型的實現中,一個異常使用的實際型別是一個結構體,以確保lint能在使用異常時探測到型別錯誤。
TRY-EXCEPT語句
一旦探測到異常,TRY-EXCEPT語句用於聯絡一個或更多個異常處理塊到一個語句序列。TRY-EXCEPT語句的格式如下:
TRY
statements
EXCEPT(name-J)
statements
EXCEPT(name-2)
statements
……
ENDTRY
可以有任意多個EXCEPT分支(多達由實現決定的最大值)。
TRY-EXCEPT語句的語法如下。首先,TRY語句塊中的語句是被評估的。如果語句序列執行完成前沒有遇到異常,異常作用域就退出了,然後控制權就傳遞到了整個塊的後面。如果任意語句(或函式中封裝的語句)丟擲了一個異常,控制權立即傳遞給與TRY-EXCEPT中異常名匹配的異常處理塊。
通過呼叫
RAISE(name, value);
來丟擲異常。
其中,name是一個宣告過的異常,而value是一個整型值,它會被傳遞給處理塊的作用域。當遇到了RAISE語句,TRY作用域的動態棧會被用於搜尋最內層的宣告處理這個異常或處理預宣告異常ANY的處理塊,ANY會匹配所有異常。如果沒有發現異常處理塊,就會發生異常退出。如果發現了合適的處理塊,控制權會返回到這個棧上下文,執行這個處理塊中的語句。這些語句是在本地異常作用域外執行的,所以在處理塊中的RAISE語句會傳遞異常回更高層。
在處理塊中,傳遞給RAISE的value值可以通過使用引數名 exception_ value 取回,它是在異常處理塊作用域中自動宣告為一個int的。大部分情況下,不需要這個值。即使這樣,還是需要在RAISE中指定value引數,因為RAISE是作為巨集而不是一個例程實現的。
TRY-FINALLY語句
TRY的第二個用法是將“最終(finalization)”程式碼聯絡到一個語句序列以確保這段程式碼即使因為異常而非正常結束也會執行。這是通過使用TRY-FINALLY語句實現的,形式如下:
TRY
statements
FINALLY
statements
ENDTRY
在這種形式中,標準流程是執行TRY語句體後執行FINALLY分支中的語句。如果TRY語句體中的語句生成了一個異常,這會導致控制權傳遞出這個作用域,FINALLY語句體也會執行,然後異常會被重新丟擲,這樣它最終會被合適的處理塊捕獲。
比如,假設acquire(res) 和 release(res)分別請求和釋放一些必須獨佔式訪問的資源。程式碼片段
acquire(res);
TRY
… 訪問資源的程式碼 ...
FINALLY
release(res);
ENDTRY
確保資源會被釋放,即使訪問資源的程式碼丟擲了異常。確保這不會破壞管理資源的資料結構的完整性是程式設計師的責任。
注意,在不改變編譯器的情況下,如果在TRY-FINALLY語句中明確地要將控制權轉移出去的話,是無法攔截的,只有能異常被正確地處理。比如,這樣寫起來很方便:
acquire(res);
TRY
return (res->data);
FINALLY
release(res);
ENDTRY
你可能希望在TRY-FINALLY語句體中return的時候能夠呼叫FINALLY分支(在Modula-2+和Modula-3中確實是這樣的),但是在可移植的實現中,這是不可行的。因此,只能把結果賦值給一個臨時變數,並將return語句寫在TRY語句體之外。
異常處理的實現
考慮下面簡單的TRY-EXCEPT示例:
#include "exception.h"
exception e;
Test(){
TRY
Body();
EXCEPT(e)
Handler();
ENDTRY
}
其擴充套件形式如下:
Test(){
{
context_block _ctx;
int _es = ES_Initialize;
_ctx.nx = 0;
_ctx.link= NULL;
_ctx.finally = 0;
_ctx.link = exceptionStack;
exceptionStack = &_ctx;
if (setjmp(_ctx.jmp) != 0) _es = ES_Exception;
while (1) {
if (_es == ES_EvalBody) {
Body();
if(_es == ES_EvalBody) exceptionStack = _ctx.link;
break;
}
if (_es == ES_Initialize) {
if (_ctx.nx >= MaxExceptionsPerScope)
exit(ETooManyExceptClauses);
_ctx.array[_ctx.nx++] = &e;
} else if (_ctx.id == &e || &e == &ANY) {
int exception_value = _ctx.value;
exceptionStack = _ctx.link;
Handler();
if (_ctx.finally && _es == ES_Exception)
_RaiseException(_ctx.id, _ctx. value);
break;
}
_es = ES_EvalBody;
}
}
}
擴充套件的TRY語句體設計為最開始在例程的棧幀上宣告一個本地上下文塊_ctx,初始化合適的欄位,然後連結這個塊到活躍異常的連結串列中。在迴圈的第一遍,變數_es被設為ES_Initialize。這個變數會被設為另外兩個值:在while迴圈的第二遍迭代時設為ES_EvalBody,如果經由呼叫RAISE導致控制權返回setjmp時設為ES_Exception。
TRY的語句體在迴圈的第一遍迭代時並沒有執行,第一遍只是簡單地初始化了由這個TRY-EXCEPT處理的異常的陣列。第二遍則執行主語句體,如果呼叫了RAISE,其會被翻譯為呼叫_Raise_Exception,這個函式會搜尋異常棧以找到合適的處理塊,然後使用longjmp來跳轉到這個上下文。當這發生時,_es被設定為ES_Exception,EXCEPT語句的擴充套件中的條件分支會選擇正確的處理塊。
基於前處理器的實現 vs. 編譯器支援
上述基於前處理器的實現並沒有生成特別高效的程式碼,主要是因為實現基於巨集擴充套件,沒有辦法實現上下文敏感的擴充套件或重排程式碼。編譯器的話能做的更好。
當然可以選擇直接在編譯器中實現這個異常處理機制。編譯器可以識別這個語法並生成效率高得多的程式碼。特別地,編譯器可以通過將大部分工作移到丟擲異常的程式碼中來大大減少進入異常作用域的開銷;因為大部分應用使用異常的情景並沒那麼頻繁,這個權衡能提升整體效能。但是,需要改變編譯器的方案犧牲了可移植性。然而,只要存在一個基於前處理器的實現,有理由擴充套件特定編譯器以提供更低的開銷。
我們使用上面給出Test例程的擴充套件形式來闡述基於前處理器的實現導致的額外開銷。大部分開銷是由於前處理器無法考慮上下文資訊。比如,對於一個帶有兩個EXCEPT分支的TRY語句,由於TRY語句體末尾所需要操作與首個EXCEPT語句末尾所需的不同,編譯器會在每個位置生成不同的程式碼。不幸地是,前處理器卻做不到。前處理器唯一可做的是用上下文無關的方式擴充套件EXCEPT巨集。這導致了冗餘的測試,比如主語句體後的
if (_es == ES_EvaIBody)
條件分支。如果程式碼執行到了這,條件肯定為真,然而當同個巨集在一個EXCEPT分支後面擴充套件開時,它會是false。將這個語法整合進編譯器就可以消除這樣的冗餘。
基於編譯器的實現的另一個優點是可以提供更好的語法錯誤檢查。如大部分基於巨集的擴充套件,依賴C前處理器意味著一些語法錯誤能夠通過檢測,而那些被探測到的錯誤會以擴充套件的形式被報告。
依賴
這個實現依賴於庫例程setjmp和longjmp來實現實際的控制權傳輸。許多非Unix的C實現上都支援這兩個例程,所以這個依賴不會大大降低這個包的可移植性。特別地,包沒有對定義在標頭檔案setjmp.h中的jmp_buf的內部結構做出任何假設。
即使這樣,也一定要意識到,一些系統在使用setjmp和longjmp時有著和實現相關的限制,特別是當編譯器試著過於聰明時。如果存在這些限制,異常處理包可能無法使用。我們覺得這樣的setjmp和longjmp實現真是腦子有毛病,我們並不認為應該改變編譯器和語言以相容這種環境。
多處理器上的實現
這篇文章中的實現使用了全域性變數exceptionStack作為指向活躍的異常塊的連結串列的指標。這在傳統的Unix環境中是合適的,但是在一個併發(concurrent)環境中會出問題,在併發環境中,多個獨立的輕量過程(執行緒)共享同個地址空間。如果作業系統支援執行緒的話,得要為每個執行緒分別維護一個這個指標的拷貝。如果併發的實現提供了執行緒獨立的資料空間,連結串列指標應該會被放在那裡。否則,需要通過其他方式進行模擬,比如對執行緒的ID進行雜湊。
其他權衡考慮
在這個實現中,context_block結構實現了一個異常的陣列,而不是一個連結串列,這樣避免了註冊異常時動態分配記憶體的開銷。但是副作用就是會在每一個作用域放一個固定上界的陣列,但在實踐中這不太可能是個大問題。
在_Raise_Exception的程式碼中,執行了兩個迴圈:一個用於確定是否存在任意異常處理塊,另一個用於執行FINALLY分支。這裡可以使用一個迴圈,但是兩個迴圈的優點是EUnhandledException錯誤發生在最初的棧上下文,這樣在偵錯程式中更容易發現未處理的異常。
結論
這個包說明,不用擴充套件編譯器,也不用非標準地假定執行環境,就能在C中實現異常處理機制。這就是說,使用異常機制寫出來的程式可以移植到許許多多架構、作業系統和編譯器。如果需要更高的效率,這個語法可以整合到編譯器中,並且還保留對基於前處理器實現的可移植性。
鳴謝
感謝Garret Swart通讀了這篇文章的多個草稿,並催促我寫下這個報告。
參考文獻
略
原始碼
譯者注:下面的原始碼基本遵循原版,但是因原版語法過於老舊,對函式宣告部分略有修改。同時由於用到了exit函式,在標頭檔案中加入了#include <stdlib.h>,原版並沒有。
/* Copyright 1989 Digital Equipment Corporation. */
/* Distributed only by permission. */
/**************************************************************************/
/* File: exception.h */
/* Last modified on Wed Mar 15 16:40:41 PST 1989 by roberts */
/* */
/* The exception package provides a general exception handling mechanism */
/* for use with C that is portable across a variety of compilers and */
/* operating systems. The design of this facility is based on the */
/* exception handling mechanism used in the Modula-2+ language at DEC/SRC */
/* and is described in detail in the paper in the documents directory. */
/* For more background on the underlying motivation for this design, see */
/* SRC Research Report #3. */
/**************************************************************************/
#include <setjmp.h>
#include <stdlib.h>
#define MaxExceptionsPerScope 10
#define ETooManyExceptClauses 101
#define EUnhandledException 102
#define ES_Initialize 0
#define ES_EvalBody 1
#define ES_Exception 2
typedef struct { char *name ;} exception;
typedef struct _ctx_block {
jmp_buf jmp;
int nx;
exception *array[MaxExceptionsPerScope];
exception *id;
int value;
int finally;
struct _ctx_block *link;
} context_block;
extern exception ANY;
extern context_block *exceptionStack;
extern void _RaiseException(exception *e, int v);
#define RAISE(e, v) _RaiseException(&e, v)
#define TRY \
{\
context_block _ctx;\
int _es = ES_Initialize;\
_ctx.nx = 0;\
_ctx.link= NULL;\
_ctx.finally = 0;\
_ctx.link = exceptionStack;\
exceptionStack = &_ctx;\
if (setjmp(_ctx.jmp) != 0) _es = ES_Exception;\
while (1) {\
if (_es == ES_EvalBody) {
#define EXCEPT(e) \
if(_es == ES_EvalBody) exceptionStack = _ctx.link;\
break;\
}\
if (_es == ES_Initialize) {\
if (_ctx.nx >= MaxExceptionsPerScope)\
exit(ETooManyExceptClauses);\
_ctx.array[_ctx.nx++] = &e;\
} else if (_ctx.id == &e || &e == &ANY) {\
int exception_value = _ctx.value;\
exceptionStack = _ctx.link;
#define FINALLY \
}\
if (_es == ES_Initialize) {\
if (_ctx.nx >= MaxExceptionsPerScope)\
exit(ETooManyExceptClauses);\
_ctx.finally = 1;\
} else {\
exceptionStack = _ctx.link;
#define ENDTRY \
if (_ctx.finally && _es == ES_Exception)\
_RaiseException(_ctx.id, _ctx.value);\
break;\
}\
_es = ES_EvalBody;\
}\
}
/* Copyright 1989 Digital Equipment Corporation. */
/* Distributed only by permission. */
/**************************************************************************/
/* File: exception.h */
/* Last modified on Wed Mar 15 16:40:42 PST 1989 by roberts */
/* */
/* Implementation of the C exception handler. Much of the real work is */
/* done in the exception.h header file. */
/**************************************************************************/
#include <stdio.h>
#include "exception.h"
context_block *exceptionStack = NULL;
exception ANY;
void _RaiseException(exception *e, int v){
context_block *cb, *xb;
exception *t;
int i, found;
found = 0;
for (xb = exceptionStack; xb != NULL; xb = xb->link) {
for (i = 0; i < xb->nx; i++) {
t = xb->array[i];
if (t == e || t == &ANY) {
found = 1;
break;
}
}
if (found) break;
}
if (xb == NULL) exit(EUnhandledException);
for (cb = exceptionStack; cb != xb && !cb->finally; cb = cb->link);
exceptionStack = cb;
cb->id = e;
cb->value = v;
longjmp(cb->jmp, ES_Exception);
}
測試
這個不是文獻內容,是譯者自己進行的簡單測試。
測試發現EXCEPT和FINALLY語句塊不能跟在同一個TRY後面,如果要同時用到兩個特性,則要寫兩個TRY語句進行巢狀,用巨集的方法還是有很多侷限的。
#include "exception.h"
#include <stdio.h>
exception e = { "假裝是個很嚴重的錯誤"};
void raiseE(exception *toRaise){
RAISE(*toRaise,100);
}
void main(){
int i = 0;
TRY
TRY
++i;
raiseE(&e);
--i;
FINALLY
printf("FINAL\n");
ENDTRY
EXCEPT(e)
printf("捕獲到異常e:%s;異常碼:%d\n",e.name,exception_value);
ENDTRY
printf("i = %d\n",i);
system("pause");
}
執行結果如下:
從結果可以看出,異常確實可以從函式內部的丟擲,並且跳過了–i這一句,所以最終i的值為1,並且運行了FINALLY語句塊後繼續丟擲,並被EXCEPT(e)語句塊捕獲,然後成功獲得了異常資訊。
不得不歎服作者構思之精妙。
還有好多要學的。。。