嵌入式系統C編程之錯誤處理
一 錯誤概念
1.1 錯誤分類
從嚴重性而言,程序錯誤可分為致命性和非致命性兩類。對於致命性錯誤,無法執行恢復動作,最多只能在用戶屏幕上打印出錯消息或將其寫入日志文件,然後終止程序;而對於非致命性錯誤,多數本質上是暫時的(如資源短缺),一般恢復動作是延遲一些時間後再次嘗試。
從交互性而言,程序錯誤可分為用戶錯誤和內部錯誤兩類。用戶錯誤呈現給用戶,通常指明用戶操作上的錯誤;而程序內部錯誤呈現給程序員(可能攜帶用戶不可接觸的數據細節),用於查錯和排障。
應用程序開發者可決定恢復哪些錯誤以及如何恢復。例如,若磁盤已滿,可考慮刪除非必需或已過期的數據;若網絡連接失敗,可考慮短時間延遲後重建連接。選擇合理的錯誤恢復策略,可避免應用程序的異常終止,從而改善其健壯性。
1.2 處理步驟
錯誤處理即處理程序運行時出現的任何意外或異常情況。典型的錯誤處理包含五個步驟:
1) 程序執行時發生軟件錯誤。該錯誤可能產生於被底層驅動或內核映射為軟件錯誤的硬件響應事件(如除零)。
2) 以一個錯誤指示符(如整數或結構體)記錄錯誤的原因及相關信息。
3) 程序檢測該錯誤(讀取錯誤指示符,或由其主動上報);
4) 程序決定如何處理錯誤(忽略、部分處理或完全處理);
5) 恢復或終止程序的執行。
上述步驟用C語言代碼表述如下:
復制代碼
1 int func()
2 {
3 int bIsErrOccur = 0;
4 //do something that might invoke errors
5 if(bIsErrOccur) //Stage 1: error occurred
6 return -1; //Stage 2: generate error indicator
7 //...
8 return 0;
9 }
10
11 int main(void)
12 {
13 if(func() != 0) //Stage 3: detect error
14 {
15 //Stage 4: handle error
16 }
17 //Stage 5: recover or abort
18 return 0;
19 }
復制代碼
調用者可能希望函數返回成功時表示完全成功,失敗時程序恢復到調用前的狀態(但被調函數很難保證這點)。
二 錯誤傳遞
2.1 返回值和回傳參數
C語言通常使用返回值來標志函數是否執行成功,調用者通過if等語句檢查該返回值以判斷函數執行情況。常見的幾種調用形式如下:
復制代碼
1 if((p = malloc(100)) == NULL)
2 //...
3
4 if((c = getchar()) == EOF)
5 //...
6
7 if((ticks = clock()) < 0)
8 //...
復制代碼
Unix系統調用級函數(和一些老的Posix函數)的返回值有時既包括錯誤代碼也包括有用結果。因此,上述調用形式可在同一條語句中接收返回值並檢查錯誤(當執行成功時返回合法的數據值)。
返回值方式的好處是簡便和高效,但仍存在較多問題:
1) 代碼可讀性降低
沒有返回值的函數是不可靠的。但若每個函數都具有返回值,為保持程序健壯性,就必須對每個函數進行正確性驗證,即調用時檢查其返回值。這樣,代碼中很大一部分可能花費在錯誤處理上,且排錯代碼和正常流程代碼攪在一起,比較混亂。
2) 質量降級
條件語句相比其他類型的語句潛藏更多的錯誤。不必要的條件語句會增加排障和白盒測試的工作量。
3) 信息有限
通過返回值只能返回一個值,因此一般只能簡單地標志成功或失敗,而無法作為獲知具體錯誤信息的手段。通過按位編碼可變通地返回多個值,但並不常用。字符串處理函數可參考IntToAscii()來返回具體的錯誤原因,並支持鏈式表達:
復制代碼
1 char *IntToAscii(int dwVal, char *pszRes, int dwRadix)
2 {
3 if(NULL == pszRes)
4 return "Arg2Null";
5
6 if((dwRadix < 2) || (dwRadix > 36))
7 return "Arg3OutOfRange";
8
9 //...
10 return pszRes;
11 }
復制代碼
4) 定義沖突
不同函數在成功和失敗時返回值的取值規則可能不同。例如,Unix系統調用級函數返回0代表成功,-1代表失敗;新的Posix函數返回0代表成功,非0代表失敗;標准C庫中isxxx函數返回1表示成功,0表示失敗。
5) 無約束性
調用者可以忽略和丟棄返回值。未檢查和處理返回值時,程序仍然能夠運行,但結果不可預知。
新的Posix函數返回值只攜帶狀態和異常信息,並通過參數列表中的指針回傳有用的結果。 回傳參數綁定到相應的實參上,因此調用者不可能完全忽略它們。通過回傳參數(如結構體指針)可返回多個值,也可攜帶更多的信息。
綜合返回值和回傳參數的優點,可對Get類函數采用返回值(含有用結果)方式,而對Set類函數采用返回值+回傳參數方式。對於純粹的返回值,可按需提供如下解析接口:
復制代碼
1 typedef enum{
2 S_OK, //成功
3 S_ERROR, //失敗(原因未明確),通用狀態
4
5 S_NULL_POINTER, //入參指針為NULL
6 S_ILLEGAL_PARAM, //參數值非法,通用
7 S_OUT_OF_RANGE, //參數值越限
8 S_MAX_STATUS //不可作為返回值狀態,僅作枚舉最值使用
9 }FUNC_STATUS;
10
11 #define RC_NAME(eRetCode) \
12 ((eRetCode) == S_OK ? "Success" : \
13 ((eRetCode) == S_ERROR ? "Failure" : \
14 ((eRetCode) == S_NULL_POINTER ? "NullPointer" : \
15 ((eRetCode) == S_ILLEGAL_PARAM ? "IllegalParas" : \
16 ((eRetCode) == S_OUT_OF_RANGE ? "OutOfRange" : \
17 "Unknown")))))
復制代碼
2.2 全局狀態標志(errno)
Unix系統調用或某些C標准庫函數出錯時,通常返回一個負值,並設置全局整型變量errno為一個含有錯誤信息的值。例如,open函數出錯時返回-1,並設置errno為EACESS(權限不足)等值。
C標准庫頭文件<errno.h>中定義errno及其可能的非零常量取值(以字符'E'開頭)。在ANSI C中已定義一些基本的errno常量,操作系統也會擴展一部分(但其對錯誤描述仍顯匮乏)。Linux系統中,出錯常量在errno(3)手冊頁中列出,可通過man 3 errno命令查看。除EAGAIN和EWOULDBLOCK取值相同外,POSIX.1指定的所有出錯編號取值均不同。
Posix和ISO C將errno定義為一個可修改的整型左值(lvalue),可以是包含出錯編號的一個整數,或是一個返回出錯編號指針的函數。以前使用的定義為:
1 extern int errno;
但在多線程環境中,多個線程共享進程地址空間,每個線程都有屬於自己的局部errno(thread-local)以避免一個線程干擾另一個線程。例如,Linux支持多線程存取errno,將其定義為:
1 extern int *__errno_location(void);
2 #define errno (*__errno_location())
函數__errno_location在不同的庫版本下有不同的定義,在單線程版本中,直接返回全局變量errno的地址;而在多線程版本中,不同線程調用__errno_location返回的地址則各不相同。
C運行庫中主要在math.h(數學運算)和stdio.h(I/O操作)頭文件聲明的函數中使用errno。
使用errno時應注意以下幾點:
1) 函數返回成功時,允許其修改errno。
例如,調用fopen函數新建文件時,內部可能會調用其他庫函數檢測是否存在同名文件。而用於檢測文件的庫函數在文件不存在時,可能會失敗並設置errno。這樣, fopen函數每次新建一個事先並不存在的文件時,即使沒有任何程序錯誤發生(fopen本身成功返回),errno也仍然可能被設置。
因此,調用庫函數時應先檢測作為錯誤指示的返回值。僅當函數返回值指明出錯時,才檢查errno值:
1 //調用庫函數
2 if(返回錯誤值)
3 //檢查errno
2) 庫函數返回失敗時,不一定會設置errno,取決於具體的庫函數。
3) errno在程序開始時設置為0,任何庫函數都不會將errno再次清零。
因此,在調用可能設置errno的運行庫函數之前,最好先將errno設置為0。調用失敗後再檢查errno的值。
4) 使用errno前,應避免調用其他可能設置errno的庫函數。如:
1 if (somecall() == -1)
2 {
3 printf("somecall() failed\n");
4 if(errno == ...) { ... }
5 }
somecall()函數出錯返回時設置errno。但當檢查errno時,其值可能已被printf()函數改變。若要正確使用somecall()函數設置的errno,必須在調用printf()函數前保存其值:
1 if (somecall() == -1)
2 {
3 int dwErrSaved = errno;
4 printf("somecall() failed\n");
5 if(dwErrSaved == ...) { ... }
6 }
5) 使用現代版本的C庫時,應包含使用<errno.h>頭文件;在非常老的Unix 系統中,可能沒有該頭文件,此時可手工聲明errno(如extern int errno)。
C標准定義strerror和perror兩個函數,以幫助打印錯誤信息。
#include <string.h>
char *strerror(int errnum);
該函數將errnum(即errno值)映射為一個出錯信息字符串,並返回指向該字符串的指針。可將出錯字符串和其它信息組合輸出到用戶界面,或保存到日志文件中,如通過fprintf(fp, "somecall failed(%s)", strerror(errno))將錯誤消息打印到fp指向的文件中。
perror函數將當前errno對應的錯誤消息的字符串輸出到標准錯誤(即stderr或2)上。
#include <stdio.h>
void perror(const char *msg);
該函數首先輸出由msg指向的字符串(用戶自己定義的信息),後面緊跟一個冒號和空格,然後是當前errno值對應的錯誤類型描述,最後是一個換行符。未使用重定向時,該函數輸出到控制台上;若將標准錯誤輸出重定向到/dev/null,則看不到任何輸出。
注意,perror()函數中errno對應的錯誤消息集合與strerror()相同。但後者可提供更多定位信息和輸出方式。
兩個函數的用法示例如下:
復制代碼
1 int main(int argc, char** argv)
2 {
3 errno = 0;
4 FILE *pFile = fopen(argv[1], "r");
5 if(NULL == pFile)
6 {
7 printf("Cannot open file '%s'(%s)!\n", argv[1], strerror(errno));
8 perror("Open file failed");
9 }
10 else
11 {
12 printf("Open file '%s'(%s)!\n", argv[1], strerror(errno));
13 perror("Open file");
14 fclose(pFile);
15 }
16
17 return 0;
18 }
復制代碼
執行結果為:
復制代碼
1 [wangxiaoyuan_@localhost test1]$ ./GlbErr /sdb1/wangxiaoyuan/linux_test/test1/test.c
2 Open file '/sdb1/wangxiaoyuan/linux_test/test1/test.c'(Success)!
3 Open file: Success
4 [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h
5 Cannot open file 'NonexistentFile.h'(No such file or directory)!
6 Open file failed: No such file or directory
7 [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h > test
8 Open file failed: No such file or directory
9 [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h 2> test
10 Cannot open file 'NonexistentFile.h'(No such file or directory)!
復制代碼
也可仿照errno的定義和處理,定制自己的錯誤代碼:
復制代碼
1 int *_fpErrNo(void)
2 {
3 static int dwLocalErrNo = 0;
4 return &dwLocalErrNo;
5 }
6
7 #define ErrNo (*_fpErrNo())
8 #define EOUTOFRANGE 1
9 //define other error macros...
10
11 int Callee(void)
12 {
13 ErrNo = 1;
14 return -1;
15 }
16
17 int main(void)
18 {
19 ErrNo = 0;
20 if((-1 == Callee()) && (EOUTOFRANGE == ErrNo))
21 printf("Callee failed(ErrNo:%d)!\n", ErrNo);
22 return 0;
23 }
復制代碼
借助全局狀態標志,可充分利用函數的接口(返回值和參數表)。但與返回值一樣,它隱含地要求調用者在調用函數後檢查該標志,而這種約束同樣脆弱。
此外,全局狀態標志存在重用和覆蓋的風險。而函數返回值是無名的臨時變量,由函數產生且只能被調用者訪問。調用完成後即可檢查或拷貝返回值,然後原始的返回對象將消失而不能被重用。又因為無名,返回值不能被覆蓋。
2.3 局部跳轉(goto)
使用goto語句可直接跳轉到函數內的錯誤處理代碼處。以除零錯誤為例:
復制代碼
1 double Division(double fDividend, double fDivisor)
2 {
3 return fDividend/fDivisor;
4 }
5 int main(void)
6 {
7 int dwFlag = 0;
8 if(1 == dwFlag)
9 {
10 RaiseException:
11 printf("The divisor cannot be 0!\n");
12 exit(1);
13 }
14 dwFlag = 1;
15
16 double fDividend = 0.0, fDivisor = 0.0;
17 printf("Enter the dividend: ");
18 scanf("%lf", &fDividend);
19 printf("Enter the divisor : ");
20 scanf("%lf", &fDivisor);
21 if(0 == fDivisor) //不太嚴謹的浮點數判0比較
22 goto RaiseException;
23 printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));
24
25 return 0;
26 }
復制代碼
執行結果如下:
復制代碼
1 [wangxiaoyuan_@localhost test1]$ ./test
2 Enter the dividend: 10
3 Enter the divisor : 0
4 The divisor cannot be 0!
5 [wangxiaoyuan_@localhost test1]$ ./test
6 Enter the dividend: 10
7 Enter the divisor : 2
8 The quotient is 5.00
復制代碼
雖然goto語句會破壞代碼結構性,但卻非常適用於集中錯誤處理。偽代碼示例如下:
復制代碼
1 CallerFunc()
2 {
3 if((ret = CalleeFunc1()) < 0);
4 goto ErrHandle;
5 if((ret = CalleeFunc2()) < 0);
6 goto ErrHandle;
7 if((ret = CalleeFunc3()) < 0);
8 goto ErrHandle;
9 //...
10
11 return;
12
13 ErrHandle:
14 //Handle Error(e.g. printf)
15 return;
16 }
復制代碼
2.4 非局部跳轉(setjmp/longjmp)
局部goto語句只能跳到所在函數內部的標號上。若要跨越函數跳轉,需要借助標准C庫提供非局部跳轉函數setjmp()和longjmp()。它們分別承擔非局部標號和goto的作用,非常適用於處理發生在深層嵌套函數調用中的出錯情況。“非局部跳轉”是在棧上跳過若干調用幀,返回到當前函數調用路徑上的某個函數內。
#include <setjmp.h>
int setjmp(jmp_buf env);
void longjmp(jmp_buf env,int val);
函數setjmp()將程序運行時的當前系統堆棧環境保存在緩沖區env結構中。初次調用該函數時返回值為0。longjmp()函數根據setjmp()所保存的env結構恢復先前的堆棧環境,即“跳回”先前調用setjmp時的程序執行點。此時,setjmp()函數返回longjmp()函數所設置的參數val值,程序將繼續執行setjmp調用後的下一條語句(仿佛從未離開setjmp)。參數val為非0值,若設置為0,則setjmp()函數返回1。
可見,setjmp()有兩類返回值,用於區分是首次直接調用(返回0)和還是由其他地方跳轉而來(返回非0值)。對於一個setjmp可有多個longjmp,因此可由不同的非0返回值區分這些longjmp。
舉個簡單例子說明 setjmp/longjmp的非局部跳轉:
復制代碼
1 jmp_buf gJmpBuf;
2 void Func1(){
3 printf("Enter Func1\n");
4 if(0)longjmp(gJmpBuf, 1);
5 }
6 void Func2(){
7 printf("Enter Func2\n");
8 if(0)longjmp(gJmpBuf, 2);
9 }
10 void Func3(){
11 printf("Enter Func3\n");
12 if(1)longjmp(gJmpBuf, 3);
13 }
14
15 int main(void)
16 {
17 int dwJmpRet = setjmp(gJmpBuf);
18 printf("dwJmpRet = %d\n", dwJmpRet);
19 if(0 == dwJmpRet)
20 {
21 Func1();
22 Func2();
23 Func3();
24 }
25 else
26 {
27 switch(dwJmpRet)
28 {
29 case 1:
30 printf("Jump back from Func1\n");
31 break;
32 case 2:
33 printf("Jump back from Func2\n");
34 break;
35 case 3:
36 printf("Jump back from Func3\n");
37 break;
38 default:
39 printf("Unknown Func!\n");
40 break;
41 }
42 }
43 return 0;
44 }
復制代碼
執行結果為:
1 dwJmpRet = 0
2 Enter Func1
3 Enter Func2
4 Enter Func3
5 dwJmpRet = 3
6 Jump back from Func3
當setjmp/longjmp嵌在單個函數中使用時,可模擬PASCAL語言中嵌套函數定義(即函數內中定義一個局部函數)。當setjmp/longjmp跨越函數使用時,可模擬面向對象語言中的異常(exception) 機制。
模擬異常異常機制時,首先通過setjmp()函數設置一個跳轉點並保存返回現場,然後使用try塊包含那些可能出現錯誤的代碼。可在try塊代碼中或其調用的函數內,通過longjmp()函數拋出(throw)異常。拋出異常後,將跳回setjmp()函數所設置的跳轉點並執行catch塊所包含的異常處理程序中。
以除零錯誤為例:
復制代碼
1 jmp_buf gJmpBuf;
2 void RaiseException(void)
3 {
4 printf("Exception is raised: ");
5 longjmp(gJmpBuf, 1); //throw,跳轉至異常處理代碼
6 printf("This line should never get printed!\n");
7 }
8 double Division(double fDividend, double fDivisor)
9 {
10 return fDividend/fDivisor;
11 }
12 int main(void)
13 {
14 double fDividend = 0.0, fDivisor = 0.0;
15 printf("Enter the dividend: ");
16 scanf("%lf", &fDividend);
17 printf("Enter the divisor : ");
18 if(0 == setjmp(gJmpBuf)) //try塊
19 {
20 scanf("%lf", &fDivisor);
21 if(0 == fDivisor) //也可將該判斷及RaiseException置於Division內
22 RaiseException();
23 printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));
24 }
25 else //catch塊(異常處理代碼)
26 {
27 printf("The divisor cannot be 0!\n");
28 }
29
30 return 0;
31 }
復制代碼
執行結果為:
1 Enter the dividend: 10
2 Enter the divisor : 0
3 Exception is raised: The divisor cannot be 0!
通過組合使用setjmp/longjmp函數,可對復雜程序中可能出現的異常進行集中處理。根據longjmp()函數所傳遞的返回值來區分處理各種不同的異常。
使用setjmp/longjmp函數時應注意以下幾點:
1) 必須先調用setjmp()函數後調用longjmp()函數,以恢復到先前被保存的程序執行點。若調用順序相反,將導致程序的執行流變得不可預測,很容易導致程序崩潰。
2) longjmp()函數必須在setjmp()函數的作用域之內。在調用setjmp()函數時,它保存的程序執行點環境只在當前主調函數作用域以內(或以後)有效。若主調函數返回或退出到上層(或更上層)的函數環境中,則setjmp()函數所保存的程序環境也隨之失效(函數返回時堆棧內存失效)。這就要求setjmp()不可該封裝在一個函數中,若要封裝則必須使用宏(詳見《c語言接口與實現》“第4章 異常與斷言”)。
3) 通常將jmp_buf變量定義為全局變量,以便跨函數調用longjmp。
4) 通常,存放在存儲器中的變量將具有longjmp時的值,而在CPU和浮點寄存器中的變量則恢復為調用setjmp時的值。因此,若在調用setjmp和longjmp之間修改自動變量或寄存器變量的值,當setjmp從longjmp調用返回時,變量將維持修改後的值。若要編寫使用非局部跳轉的可移植程序,必須使用volatile屬性。
5) 使用異常機制不必每次調用都檢查一次返回值,但因為程序中任何位置都可能拋出異常,必須時刻考慮是否捕捉異常。在大型程序中,判斷是否捕捉異常會是很大的思維負擔,影響開發效率。相比之下,通過返回值指示錯誤有利於調用者在最近出錯的地方進行檢查。此外,返回值模式中程序的運行順序一目了然,對維護者可讀性更高。因此,應用程序中不建議使用setjmp/longjmp“異常處理”機制(除非庫或框架)。
2.5 信號(signal/raise)
在某些情況下,主機環境或操作系統可能發出信號(signal)事件,指示特定的編程錯誤或嚴重事件(如除0或中斷等)。這些信號本意並非用於錯誤捕獲,而是指示與表示與正常程序流不協調的外部事件。
為處理信號,需要使用以下信號相關函數:
#include <signal.h>
typedef void (*fpSigFunc)(int);
fpSigFunc signal(int signo, fpSigFunc fpHandler);
int raise(int signo);
其中,參數signo是Unix系統定義的信號編號(正整數),不允許用戶自定義信號。參數fpHandler是常量SIG_DFL、常量SIG_IGN或當接收到此信號後要調用的信號處理函數(signal handler)的地址。若指定SIG_DFL,則接收到此信號後調用系統的缺省處理函數;若指定SIG_ IGN,則向內核表明忽略此信號(SIGKILL和SIGSTOP不可忽略)。某些異常信號(如除數為零)不太可能恢復,此時信號處理函數可在程序終止前正確地清理某些資源。信號處理函數所收到的異常信息僅是一個整數(待處理的信號事件),這點與setjmp()函數類似。
signal()函數執行成功時返回前次掛接的處理函數地址,失敗時則返回SIG_ERR。信號通過調raise()函數產生並被處理函數捕獲。
以除零錯誤為例:
復制代碼
1 void fphandler(int dwSigNo)
2 {
3 printf("Exception is raised, dwSigNo=%d!\n", dwSigNo);
4 }
5 int main(void)
6 {
7 if(SIG_ERR == signal(SIGFPE, fphandler))
8 {
9 fprintf(stderr, "Fail to set SIGFPE handler!\n");
10 exit(EXIT_FAILURE);
11 }
12
13 double fDividend = 10.0, fDivisor = 0.0;
14 if(0 == fDivisor)
15 {
16 raise(SIGFPE);
17 exit(EXIT_FAILURE);
18 }
19 printf("The quotient is %.2lf\n", fDividend/fDivisor);
20
21 return 0;
22 }
復制代碼
執行結果為"Exception is raised, dwSigNo=8!"。
若將被除數(Dividend)和除數(Divisor)改為整型變量:
復制代碼
1 int main(void)
2 {
3 if(SIG_ERR == signal(SIGFPE, fphandler))
4 {
5 fprintf(stderr, "Fail to set SIGFPE handler!\n");
6 exit(EXIT_FAILURE);
7 }
8
9 int dwDividend = 10, dwDivisor = 0;
10 double fQuotient = dwDividend/dwDivisor;
11 printf("The quotient is %.2lf\n", fQuotient);
12
13 return 0;
14 }
復制代碼
則執行後將循環輸出"Exception is raised, dwSigNo=8!"。這是因為除0異常不可恢復。每次系統調用信號處理函數後,異常控制流還會返回除0指令繼續執行。
規避方法有兩種:
1) 將SIGFPE信號變成系統默認處理,即signal(SIGFPE, SIG_DFL)。
此時執行輸出為"Floating point exception"。
2) 利用setjmp/longjmp跳過引發異常的指令:
復制代碼
1 jmp_buf gJmpBuf;
2 void fphandler(int dwSigNo)
3 {
4 printf("Exception is raised, dwSigNo=%d!\n", dwSigNo);
5 longjmp(gJmpBuf, 1);
6 }
7 int main(void)
8 {
9 if(SIG_ERR == signal(SIGFPE, SIG_DFL))
10 {
11 fprintf(stderr, "Fail to set SIGFPE handler!\n");
12 exit(EXIT_FAILURE);
13 }
14
15 int dwDividend = 10, dwDivisor = 0;
16 if(0 == setjmp(gJmpBuf))
17 {
18 double fQuotient = dwDividend/dwDivisor;
19 printf("The quotient is %.2lf\n", fQuotient);
20 }
21 else
22 {
23 printf("The divisor cannot be 0!\n");
24 }
25
26 return 0;
27 }
復制代碼
注意,在信號處理程序中還可使用sigsetjmp/siglongjmp函數進行非局部跳轉。相比setjmp函數,sigsetjmp函數增加一個信號屏蔽字參數。
三 錯誤處理
3.1 終止(abort/exit)
致命性錯誤無法恢復,只能終止程序。例如,當空閒堆管理程序無法提供可用的連續空間時(調用malloc返回NULL),用戶程序的健壯性將嚴重受損。若恢復的可能性渺茫,則最好終止或重啟程序。
標准C庫提供exit()和abort()函數,分別用於程序正常終止和異常終止。兩者都不會返回到調用者中,且都導致程序被強行結束。
exit()及其相似函數原型聲明如下:
#include <stdlib.h>
void exit(int status);
void _Exit(int status);
#include <unistd.h>
void _exit(int status);
其中,exit和_Exit由ISO C說明,而_exit由Posix.1說明。因此使用不同的頭文件。
ISOC定義_Exit旨在為進程提供一種無需運行終止處理程序(exit handler)或信號處理程序(signal handler)而終止的方法,是否沖洗標准I/O流則取決於實現。Unix系統中_Exit 和_exit同義,兩者均直接進入內核,而不沖洗標准I/O流。_exit函數由exit調用,處理Unix特定的細節。
exit()函數首先調用執行各終止處理程序,然後按需多次調用fclose函數關閉所有已打開的標准I/O流(將所有緩沖的輸出數據沖洗寫到文件上),然後調用_exit函數進入內核。
標准函數庫中有一種“緩沖I/O(buffered I/O)”機制。該機制對於每個打開的文件,在內存中維護一片緩沖區。每次讀文件時會連續讀出若干條記錄,下次讀文件時就可直接從內存緩沖區中讀取;每次寫文件時也僅僅寫入內存緩沖區,等滿足一定條件(如緩沖區填滿,或遇到換行符等特定字符)時再將緩沖區內容一次性寫入文件。通過盡可能減少read和write調用的次數,該機制可顯著提高文件讀寫速度,但也給編程帶來某些麻煩。例如,向文件內寫入一些數據時,若未滿足特定條件,數據會暫存在緩沖區內。開發者並不知曉這點,而調用_exit()函數直接關閉進程,導致緩沖區數據丟失。因此,若要保證數據完整性,必須調用exit()函數,或在調用_exit()函數前先通過fflush()函數將緩沖區內容寫入指定的文件。
例如,調用printf函數(遇到換行符'\n'時自動讀出緩沖區中內容)函數後再調用exit:
復制代碼
1 int main(void)
2 {
3 printf("Using exit...\n");
4 printf("This is the content in buffer");
5 exit(0);
6 printf("This line will never be reached\n");
7 }
復制代碼
執行輸出為:
1 Using exit...
2 This is the content in buffer(結尾無換行符)
調用printf函數後再調用_exit:
復制代碼
1 int main(void)
2 {
3 printf("Using _exit...\n");
4 printf("This is the content in buffer");
5 fprintf(stdout, "Standard output stream");
6 fprintf(stderr, "Standard error stream");
7 //fflush(stdout);
8 _exit(0);
9 }
復制代碼
執行輸出為:
1 Using _exit...
2 Standard error stream(結尾無換行符)
若取消fflush句注釋,則執行輸出為:
1 Using _exit...
2 Standard error streamThis is the content in bufferStandard output stream(結尾無換行符)
通常,標准錯誤是不帶緩沖的,打開至終端設備的流(如標准輸入和標准輸出)是行緩沖的(遇換行符則執行I/O操作);其他所有流則是全緩沖的(填滿標准I/O緩沖區後才執行I/O操作)。
三個exit函數都帶有一個整型參數status,稱之為終止狀態(或退出狀態)。該參數取值通常為兩個宏,即EXIT_SUCCESS(0)和EXIT_FAILURE(1)。大多數Unix shell都可檢查進程的終止狀態。若(a)調用這些函數時不帶終止狀態,或(b)main函數執行了無返回值的return語句,或(c) main函數未聲明返回類型為整型,則該進程的終止狀態未定義。但若main函數的返回類型為整型,且執行到最後一條語句時返回(隱式返回),則該進程的終止狀態為0。
exit系列函數是最簡單直接的錯誤處理方式,但程序出錯終止時無法捕獲異常信息。ISO C規定一個進程可以登記32個終止處理函數。這些函數可編寫為自定義的清理代碼,將由exit()函數自動調用,並可使用atexit()函數進行登記。
#include <stdlib.h>
int atexit(void (*func)(void));
該函數的參數是一個無參數無返回值的終止處理函數。exit()函數按登記的相反順序調用這些函數。同一函數若登記多次,則被調用多次。即使不調用exit函數,程序退出時也會執行atexit登記的函數。
通過結合exit()和atexit()函數,可在程序出錯終止時拋出異常信息。以除零錯誤為例:
復制代碼
double Division(double fDividend, double fDivisor)
{
return fDividend/fDivisor;
}
void RaiseException1(void)
{
printf("Exception is raised: \n");
}
void RaiseException2(void)
{
printf("The divisor cannot be 0!\n");
}
int main(void)
{
double fDividend = 0.0, fDivisor = 0.0;
printf("Enter the dividend: ");
scanf("%lf", &fDividend);
printf("Enter the divisor : ");
scanf("%lf", &fDivisor);
if(0 == fDivisor)
{
atexit(RaiseException2);
atexit(RaiseException1);
exit(EXIT_FAILURE);
}
printf("The quotient is %.2lf\n", Division(fDividend, fDivisor));
return 0;
}
復制代碼
執行結果為:
1 Enter the dividend: 10
2 Enter the divisor : 0
3 Exception is raised:
4 The divisor cannot be 0!
abort()函數原型聲明如下:
#include <stdlib.h>
void abort(void);
該函數將SIGABRT信號發送給調用進程(進程不應忽略此信號)。
ISO C規定,調用abort將向主機環境遞送一個未成功終止的通知,其方法是調用raise(SIGABRT)函數。因此,abort()函數理論上的實現為:
1 void abort(void)
2 {
3 raise(SIGABRT);
4 exit(EXIT_FAILURE);
5 }
可見,即使捕捉到SIGABRT信號且相應信號處理程序返回,abort()函數仍然終止程序。Posix.1也說明abort()函數並不理會進程對此信號的阻塞和忽略。
進程捕捉到SIGABRT信號後,可在其終止之前執行所需的清理操作(如調用exit)。若進程不在信號處理程序中終止自己,Posix.1聲明當信號處理程序返回時,abort()函數終止該進程。
ISO C規定,abort()函數是否沖洗輸出流、關閉已打開文件及刪除臨時文件由實現決定。Posix.1則要求若abort()函數終止進程,則它對所有打開標准I/O流的效果應當與進程終止前對每個流調用fclose相同。為提高可移植性,若希望沖洗標准I/O流,則應在調用abort()之前執行這種操作。
3.2 斷言(assert)
abort()和exit()函數無條件終止程序。也可使用斷言(assert)有條件地終止程序。
assert是診斷調試程序時經常使用的宏,定義在<assert.h>內。該宏的典型實現如下:
復制代碼
1 #ifdef NDEBUG
2 #define assert(expr) ((void) 0)
3 #else
4 extern void __assert((const char *, const char *, int, const char *));
5 #define assert(expr) \
6 ((void) ((expr) || \
7 (__assert(#expr, __FILE__, __LINE__, __FUNCTION__), 0)))
8 #endif
復制代碼
可見,assert宏僅在Debug版本(定義NDEBUG)中有效,且調用__assert()函數。該函數將輸出發生錯誤的文件名、代碼行、函數名以及條件表達式:
復制代碼
1 void __assert(const char *assertion, const char * filename,
2 int linenumber, register const char * function)
3 {
4 fprintf(stderr, " [%s(%d)%s] Assertion '%s' failed.\n",
5 filename, linenumber,
6 ((function == NULL) ? "UnknownFunc" : function),
7 assertion);
8 abort();
9 }
復制代碼
因此,assert宏實際上是一個帶有錯誤說明信息的abort(),並做了前提條件檢查。若檢查失敗(斷言表達式為邏輯假),則報告錯誤並終止程序;否則繼續執行後面的語句。
使用者也可按需定制assert宏。例如,另一實現版本為:
復制代碼
1 #undef assert
2 #ifdef NDEBUG
3 #define assert(expr) ((void) 0)
4 #else
5 #define assert(expr) ((void) ((expr) || \
6 (fprintf(stderr, "[%s(%d)] Assertion '%s' failed.\n", \
7 __FILE__, __LINE__, #expr), abort(), 0)))
8 #endif
復制代碼
注意,expr1||expr2表達式作為單獨語句出現時,等效於條件語句if(!(expr1))expr2。這樣,assert宏就可擴展為一個表達式,而不是一條語句。逗號表達式expr2返回最後一個表達式的值(即0),以符合||操作符的要求。
使用斷言時應注意以下幾點:
1) 斷言用於檢測理論上絕不應該出現的情況,如入參指針為空、除數為0等。
對比以下兩種情況:
復制代碼
1 char *Strcpy(char *pszDst, const char *pszSrc)
2 {
3 char *pszDstOrig = pszDst;
4 assert((pszDst != NULL) && (pszSrc != NULL));
5 while((*pszDst++ = *pszSrc++) != '\0');
6 return pszDstOrig;
7 }
8 FILE *OpenFile(const char *pszName, const char *pszMode)
9 {
10 FILE *pFile = fopen(pszName, pszMode);
11 assert(pFile != NULL);
12 if(NULL == pFile)
13 return NULL;
14
15 //...
16 return pFile;
17 }
復制代碼
Strcpy()函數中斷言使用正確,因為入參字符串指針不應為空。OpenFile()函數中則不能使用斷言,因為用戶可能需要檢查某個文件是否存在,而這並非錯誤或異常。
2)assert是宏不是函數,在調試版本和非調試版本中行為不同。因此必須確保斷言表達式的求值不會產生副作用,如修改變量和改變方法的返回值。不過,可根據這一副作用測試斷言是否打開:
復制代碼
1 int main(void)
2 {
3 int dwChg = 0;
4 assert(dwChg = 1);
5 if(0 == dwChg)
6 printf("Assertion should be enabled!\n");
7 return 0;
8 }
復制代碼
3) 不應使用斷言檢查公共方法的參數(應使用參數校驗代碼),但可用於檢查傳遞給私有方法的參數。
4) 可使用斷言測試方法執行的前置條件和後置條件,以及執行前後的不變性。
5) 斷言條件不成立時,會調用abort()函數終止程序,應用程序沒有機會做清理工作(如關閉文件和數據庫)。
3.3 封裝
為減少錯誤檢查和處理代碼的重復性,可對函數調用或錯誤輸出進行封裝。
1) 封裝具有錯誤返回值的函數
通常針對頻繁調用的基礎性系統函數,如內存和內核對象操作等。舉例如下:
復制代碼
1 pid_t Fork(void) //首字母大寫,以區分系統函數fork()
2 {
3 pid_t pid;
4 if((pid = fork())<0)
5 {
6 fprintf(stderr, "Fork error: %s\n", strerror(errno));
7 exit(0);
8 }
9 return pid;
10 }
復制代碼
Fork()函數出錯退出時依賴系統清理資源。若還需清理其他資源(如已創建的臨時文件),可增加一個負責清理的回調函數。
注意,並非所有系統函數都可封裝,應根據具體業務邏輯確定。
2) 封裝錯誤輸出
通常需要使用ISO C變長參數表特性。例如《Unix網絡編程》中將輸出至標准出錯文件的代碼封裝如下:
復制代碼
1 #include <stdarg.h>
2 #include <syslog.h>
3 #define HAVE_VSNPRINTF 1
4 #define MAXLINE 4096 /* max text line length */
5 int daemon_proc; /* set nonzero by daemon_init() */
6 static void err_doit(int errnoflag, int level, const char * fmt, va_list ap)
7 {
8 int errno_save, n;
9 char buf[MAXLINE + 1];
10
11 errno_save = errno; /* Value caller might want printed. */
12 #ifdef HAVE_VSNPRINTF
13 vsnprintf(buf, MAXLINE, fmt, ap);
14 #else
15 vsprintf(buf, fmt, ap); /* This is not safe */
16 #endif
17 n = strlen(buf);
18 if (errnoflag) {
19 snprintf(buf + n, MAXLINE - n, ": %s", strerror(errno_save));
20 }
21 strcat(buf, "\n");
22
23 if (daemon_proc) {
24 syslog(level, buf);
25 } else {
26 fflush(stdout); /* In case stdout and stderr are the same */
27 fputs(buf, stderr);
28 fflush(stderr);
29 }
30
31 return;
32 }
33
34 void err_ret(const char * fmt, ...)
35 {
36 va_list ap;
37
38 va_start(ap, fmt);
39 err_doit(1, LOG_INFO, fmt, ap);
40 va_end(ap);
41
42 return;
43 }