在调试程序时,输出调试信息是一种普遍、有效的方法。本节仅局限于简单的输出方法。
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] = 12date[1][0] = 13 date[1][1] = 14 date[1][2] = 15date[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 =2i =1 j =0 i =1 j =1 i =1 j =2i =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, …) {}#endifint 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] = 12date[1][0] = 13 date[1][1] = 14 date[1][2] = 15date[2][0] = 16 date[2][1] = 17 date[2][2] = 18sum = 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 permitted2 No such file or directory3 No such process4 Interrupted function5 I/O error6 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 directory2 No such file or directory
如果有这个文件,假设文件内容为“Fine!Thank you.”,则输出:
No error0 No errorFine! 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处理函数尽可能地简单,并将它们组织在一起。这样一来,当需要适应一个新系统时,可以很容易地修改它。