在調試程序時,輸出調試信息是一種普遍、有效的方法。本節僅局限於簡單的輸出方法。
1.直接使用printf語句輸出調試信息
最簡單的方法是在需要輸出調試信息的位置使用函數printf輸出相應的調試信息,以及某些關鍵變量的值。
【例13.3】使用printf語句調試程序的例子。
#include <stdio.h> int main (void ) { int data[3][3] ,i=0 , j=0 , *p ; p=&data[0][0] ; for ( i=0 ; i<9 ; i++ ) * (p+i )=10+i ; for ( i=0 ; i<3 ; i++ ) { for ( j=0 ; j<3 ; i++ ) printf ( \"date[%d][%d] = %d \" , i , j ,data[i][j] ); printf (\"n\" ); } return 0 ; }
程序編譯通過,但產生運行時錯誤。可以增加一條輸出語句驗證初始化的數據是否正確。可以先用條件編譯將後面的輸出屏蔽。下面僅將涉及的語句摘錄如下:
//printf 語句調試 for ( i=0 ; i<9 ; i++ ) { * (p+i )=10+i ; printf ( \"date = %d \" , * (data[0] + i ) ); } #if 0 for ( i=0 ; i<3 ; i++ ) { for ( j=0 ; j<3 ; i++ ) printf ( \"date[%d][%d] = %d \" , i , j ,data[i][j] ); printf (\"n\" ); } #endif return 0 ; }
輸出結果正確,賦值語句通過。改為如下方式驗證輸出循環。
#if 1 for ( i=0 ; i<3 ; i++ ) { for ( j=0 ; j<3 ; i++ ) // printf ( \"date[%d][%d] = %d \" , i , j ,data[i][j] ); printf ( \"i = %d j=%d\" , i , j ); printf (\"n\" ); } #endif
編譯能通過,運行後不能停止,證明for循環語句的判斷語句沒有結束,要檢查「++」是否正確。仔細檢查,發現j循環中將j++錯為i++,j<3永遠成立,以至於無休止地運行下去。
改正錯誤,程序運行結果為:
date[0][0] = 10 date[0][1] = 11 date[0][2] = 12 date[1][0] = 13 date[1][1] = 14 date[1][2] = 15 date[2][0] = 16 date[2][1] = 17 date[2][2] = 18
2.自定義簡單的輸出信息宏
可以定義一個簡單的輸出信息宏,在需要輸出的地方直接調用。
#define PRINT (x ) printf (#x\" =%dn\" ,x )
這個宏可以輸出變量值,還可以輸出變量的名稱。假如整型變量value為36,則語句
PRINT ( value );
輸出為「value=36」。可以為例13.3的循環語句
for ( i=0 ; i<3 ; i++ ) { for ( j=0 ; j<3 ; j++ ) printf ( \"i = %d j=%d\" , i , j ); printf (\"n\" ); }
設計如下一個打印語句宏替代printf語句。
#define PRINT (x , y ) printf (#x\" =%d \" #y\" =%d \" ,x ,y )
則得到如下形式的輸出結果。
i =0 j =0 i =0 j =1 i =0 j =2 i =1 j =0 i =1 j =1 i =1 j =2 i =2 j =0 i =2 j =1 i =2 j =2
假如有5處使用該宏,現在最後兩處不需要使用了,可以在這兩處之前插入
#undef PRINT
語句取消後面2個語句的作用,僅使前面3個起作用。但要在後面不用的2個語句前使用「//」將其註釋掉。注意取消宏時,不要帶參數。
可以根據需要靈活地定義自己的宏。
3.使用條件編譯插入自定義DEBUG調試函數
【例13.4】使用條件編譯配合自定義DEBUG函數調試程序的例子。
#include <stdio.h> #define __DEBUG__ #ifdef __DEBUG__ #include <stdarg.h> void DEBUG (const char *fmt , …) { va_list ap ; va_start (ap , fmt ); vprintf (fmt , ap ); va_end (ap ); } #else void DEBUG (const char *fmt , …) {} #endif int main (void ) { int data[3][3] ,i=0 , j=0 , *p ; p=&data[0][0] ; for ( i=0 ; i<9 ; i++ ) * (p+i )=10+i ; for ( i=0 ; i<3 ; i++ ) { for ( j=0 ; j<3 ; j++ ) { sum = sum + data[i][j] ; DEBUG ( \"date[%d][%d] = %d \" , i , j ,data[i][j] ); } DEBUG (\"n\" ); } return 0 ; }
使用「#define__DEBUG__」定義了__DEBUG__,所以程序中的兩條DEBUG語句參加編譯並給出如下的調試信息和輸出結果。
date[0][0] = 10 date[0][1] = 11 date[0][2] = 12 date[1][0] = 13 date[1][1] = 14 date[1][2] = 15 date[2][0] = 16 date[2][1] = 17 date[2][2] = 18 sum = 126
為了不用去屏蔽這條語句而讓它不起作用,在定義時,使用
#else void DEBUG (const char *fmt , ... ) {}
重新定義DEBUG為一個空函數,所以只要取消_DEBUG_定義,如
// #define __DEBUG__
即可使重新編譯後,讓兩條DEBUG語句什麼也不做,程序只輸出sum=126。
使用條件編譯的#else語句,可以很方便地使用自定義調試函數debug。當程序要正式發佈時,在編譯時取消宏定義__DEBUG__,正式發佈的程序中就不會輸出調試信息。若又出現bug時,只要重新在編譯程序時定義宏__DEBUG__即可恢復原來的調試信息輸出。可以在編寫程序時就有目的事先插入一些調試語句,這將有益於調試程序。另外,可以根據需要編寫函數DEBUG,將調試信息輸出到除屏幕以外的其他地方,如文件或syslog服務器等。
由此可見,用戶可以根據自己的需要,選擇合適的方法進行調試。
注意:DEBUG函數的定義用到第20章的知識,可以參考20.4和20.6節。
4.使用errno檢測錯誤
很多庫函數在執行失敗時,會通過一個名為errno的全局變量,通知程序該函數調用失敗。這個變量定義在頭文件errno.h中。不過,庫函數在調用成功時,既沒有強制要求對errno清零,但也沒有禁止設置errno。既然庫函數已經調用成功,為什麼還有可能設置errno呢?
假設有一個用於檢測文件是否存在的庫函數,當檢測到文件不存在時,會設置errno。再假設用fopen函數建立一個新文件以供程序輸出時,fopen函數調用該庫函數來檢測是否存在同名文件,如果有,fopen函數先將它刪除,然後再建立新文件。由此可見,fopen函數每次新建一個事先並不存在的文件時,即使沒有任何程序發生錯誤,errno也仍然可能被設置。
因為errno的值可能是前一個調用失敗的庫函數設置的值,所以正確的做法是在調用庫函數時,首先檢查作為錯誤指示的返回值,確定程序執行已經失敗。然後再檢查errno,以便搞清出錯的原因。推薦的格式為:
// 調用庫函數 if (返回的錯誤值) // 檢查errno 得到錯誤類型
下面給出前幾個errno的值。
1 Operation not permitted 2 No such file or directory 3 No such process 4 Interrupted function 5 I/O error 6 No such device or address
5.使用strerror和perror函數輸出errno
可以使用perro和strerror函數輸出錯誤信息,但兩者用法不一樣。strerror函數是將錯誤信息轉換成字符串,所以它需要使用printf將轉換的信息輸出,並且要包含頭文件string.h。
perror函數用來將上一個函數發生錯誤的原因輸出到標準設備(stderr)。參數s所指的字符串會先被打印出來,而且這個字符串不能省略,但可以為空串,即
perror (\"\" );
是正確的,而「perror();」和「perror(\'\');」都是錯誤的。
perror輸出這個字符串後,再將錯誤原因字符串輸出其後,此錯誤原因依照全局變量errno的值來決定所要輸出的字符串。下面的程序演示了errno及strerror和perror函數的使用方法。
【例13.5】使用errno及strerror和perror函數的例子。
#include<stdio.h> #include<errno.h> #include<string.h> int main (void ) { FILE *fp ; char Line[100] ; fp=fopen (\"f :\\ct4\\cfile.txt\" ,\"r\" ); if (fp==NULL ) { perror (\"f :\\ct4\\cfile.txt\" ); // 使用字符串 printf (\"%d %sn\" ,errno ,strerror (errno )); return -1 ; } else { perror (\"\" ); // 使用空字符串 printf (\"%d %sn\" ,errno ,strerror (errno )); } fgets (Line ,100 ,fp ); // 讀文件的一行信息 puts (Line ); // 顯示 fclose (fp ); // 關閉文件 return 0 ; }
文件中使用if-else語句輸出兩種情況。如果沒有這個文件,輸出內容如下:
f :ct4cfile.txt : No such file or directory 2 No such file or directory
如果有這個文件,假設文件內容為「Fine!Thank you.」,則輸出:
No error 0 No error Fine ! Thank you.
6.使用調試斷言assert
有時會在編寫代碼過程中做一些假設,斷言就是用於在代碼中捕捉這些假設。斷言表示為一些布爾表達式,程序員相信在程序中的某個特定點該表達式值為真。可以在任何時候啟用和禁用斷言驗證,因此可以在測試時啟用斷言,而在部署時禁用斷言。同樣,程序投入運行後,最終用戶在遇到問題時可以重新起用斷言。
assert是宏,而不是函數,定義在頭文件assert.h中。在調試結束後,可以通過在包含#include<assert.h>的語句之前插入#define NDEBUG來禁用assert調用。例如:
#include <stdio.h> #define NDEBUG // 在assert.h 之前用此語句取消斷言 #include <assert.h>
注意assert是用來避免顯而易見的錯誤的,而不是處理異常的。錯誤和異常是不一樣的,錯誤是不應該出現的,異常是不可避免的。
使用斷言可以創建更穩定、品質更好且不易於出錯的代碼。當需要在一個值為FALSE時中斷當前操作的話,可以使用斷言。單元測試必須使用斷言。除了類型檢查和單元測試外,斷言還提供了一種確定各種特性是否在程序中得到維護的極好的方法。使用斷言使程序向按契約式設計更近了一步。斷言可以分為前置條件斷言(代碼執行之前必須具備的特性)、後置條件斷言(代碼執行之後必須具備的特性)和前後不變斷言(代碼執行前後不能變化的特性)。
如前所述,斷言只有在Debug模式下才有效,它可以有兩種形式:
(1 ) assert Expression1 (2 ) assert Expression1 :Expression2
其中Expression1應該總是一個布爾值,Expression2是斷言失敗時輸出的失敗消息的字符串。如果Expression1為假,則拋出一個AssertionError,這是一個錯誤,而不是一個異常,但不推薦這樣做,因為那樣會使系統進入不穩定狀態。
【例13.6】使用斷言assert的例子。
#include<stdio.h> #include<assert.h> #include<stdlib.h> int main (void ) { FILE *fp ; fp=fopen (\"test.txt\" ,\"w\" ); // 以寫方式打開一個文件 assert (fp ); // 不存在就創建,所以這裡不會出錯 fclose (fp ); fp=fopen (\"test1.txt\" ,\"r\" ); // 以只讀的方式打開一個文件 assert (fp ); // 如果不存在,這裡就出錯 fclose (fp ); return 0 ; }
文件名如果都是test.txt,因為第1個斷言正確,在建立新文件之後,第2個斷言必定也是正確的。把只讀方式打開的文件改為test1.txt,因為沒有此文件,第2個斷言出錯,終止程序運行。
下面列舉一些典型用法和注意事項。
在函數開始處檢驗傳入參數的合法性。例如:
【例13.7】在函數中使用斷言assert的例子。
#include<stdio.h> #include<assert.h> #include<stdlib.h> #define MAX_BUFFER_SIZE 512 /**************************************/ /* int resetBufferSize (int nNewSize ) */ /* 功能:改變緩衝區大小 */ /* 參數:nNewSize 緩衝區新長度 */ /* 返回值:緩衝區當前長度 */ /* 說明:保持原信息內容不變 */ /* nNewSize<=0 表示清除緩衝區 */ /**************************************/ int resetBufferSize (int nNewSize ) { assert (nNewSize >= 0 ); // 前置條件斷言 assert (nNewSize <= MAX_BUFFER_SIZE ); //... //... return 0 ; } int main (void ) { int nNewSize = 256 ; resetBufferSize (nNewSize ); // … return 0 ; }
【例13.8】在函數中使用斷言assert判斷指針的例子。
#include<stdio.h> #include<assert.h> char *copystr ( char *dest , const char *src ) { assert ( dest != NULL && src != NULL ); while ( *dest++ = *src++ ) ; return dest ; } int main () { char str1[32]=\"You are welcome !\" ; char str2[32] ; copystr ( str2 , str1 ); printf (\"%sn\" ,str2 ); return 0 ; }
在函數的開始先判別傳入的參數是否正確。如果有一個指針為空,則斷言出錯,彈出一個對話框將出錯原因和所在行輸出並詢問處理意見(如重試、跳過或取消)。
(2)每個assert只檢驗一個條件,因為同時檢驗多個條件時,如果斷言失敗,無法直觀地判斷是哪個條件失敗。斷言
assert (nOffset>=0 && nOffset+nSize<=m_nInfomationSize );
就不好,把它分寫為兩個斷言,即
assert (nOffset >= 0 ); assert (nOffset+nSize <= m_nInfomationSize );
的寫法就好多了。
(3)不能使用改變環境的語句。因為assert只在DEBUG生效,如果這麼做,會使程序在真正運行時遇到問題。例如斷言
assert (i++ < 100 )
就不正確。因為如果出錯,比如在執行之前i=100,則這條語句就不會執行,i++這條命令就沒有執行。正確的方法是:
assert (i < 100 ) i++ ;
(4)assert和後面的語句應空一行,以形成邏輯和視覺上的一致感。
(5)注意使用浮點數的格式。例如:
float pi=3.14f ; assert (pi==3.14f );
(6)在switch語句中,總是要有default子句來顯示信息(Assert)。
default : assert (false ); break ;
(7)注意assert不能代替條件過濾。
(8)一個非常簡單的使用assert的規律是:在算法或函數的開始時使用,如果在算法的中間使用則需要慎重考慮是否應該使用。算法的最開始時,還沒開始一個功能編程過程,在一個功能過程執行中出現的問題幾乎都是異常。
7.使用庫函數signal捕獲異步事件時的注意事項
C語言實現中都包括signal庫函數,作為捕獲異步事件的一種方式。該函數包含在頭文件signal.h中。signal的函數原型比較複雜。一般表示為:
void ( * signal ( int sig , void (* handler )(int )))(int );
其中的signal.h中sig代表signal.h中定義的整型常量,這些常量用來標識signal函數將要捕獲的信號類型,handler是函數指針,指向當事件發生時,將要調用的事件處理函數,事件處理函數是有一個整形參數但沒有返回值的函數。它的函數原型形如
void func ( );
的形式。signal函數會依參數sig指定的信號編號來設置該信號的處理函數。當指定的信號到達時就會跳轉到參數handler指定的函數執行。當一個信號的信號處理函數執行時,如果進程又接收到了該信號,該信號會自動被儲存而不會中斷信號處理函數的執行,直到信號處理函數執行完畢再重新調用相應的處理函數。但是如果在信號處理函數執行時進程收到了其他類型的信號,該函數的執行就會被中斷。
在下述情況下,均會產生signal信號。
(1)按下CTRL+C產生SIGINT。
(2)產生硬件中斷,如除0,非法內存訪問(SIGSEV)等。
(3)Kill函數可以對進程發送Signal。
(4)軟件中斷。
但要注意正確使用它。假設malloc函數的執行過程被一個信號中斷,此時malloc函數用來跟蹤可用內存的數據結構很可能只有部分被更新。如果signal處理函數再調用malloc函數,結果可能是malloc函數用到的數據結構完全崩潰,後果將不堪設想。鑒於信號甚至可能出現在某些複雜庫函數(如malloc)的執行過程中,所以特別強調的是,從安全的角度考慮,信號函數不應該調用這類庫函數。
基於同樣原因,從signal處理函數中使用longjmp退出,通常情況下也是不安全的。這是因為信號也可能發生在malloc或者其他庫函數開始更新某個數據結構,卻又沒有最後完成更新的過程中。由這一點看來,signal處理函數能夠做的安全事情,莫過於只設置一個標誌就返回,讓以後的主程序能夠檢查到這個標誌,發現已經發生一個信號,從而加以處理。
仔細想一下,上述方法也並不總是安全的。當一個算術運算錯誤(例如被0除或者溢出)引發一個信號時,某些機器可能在signal處理函數返回後還將重新執行失敗的操作。由此看來,對於算術運算錯誤,signal處理函數惟一安全、可移植的操作就是打印一條出錯信息,然後就用longjmp或exit立即退出程序。
結論:信號非常複雜棘手,並具有一些從本質上而言不可移植的特性。解決問題最好採取「守勢」,讓signal處理函數盡可能地簡單,並將它們組織在一起。這樣一來,當需要適應一個新系統時,可以很容易地修改它。