Skip to content

Latest commit

 

History

History
463 lines (272 loc) · 20.2 KB

24-错误处理.md

File metadata and controls

463 lines (272 loc) · 20.2 KB

错误处理

There will always be things we wish to say in our programs that in all known languages can only be said poorly. 1

目录

[[TOC]]

[toc]

错误处理

零 前言

编写无错程序的方法有两种,但只有第三种写程序的方法才行得通。学习 C 语言的学生所编写的程序在遇到异常输入时经常无法正常运行,但真正商业用途的程序却必须“非常强壮”能够从错误中恢复正常而不至于崩溃。 为了使程序非常强壮,我们需要能够预见程序执行时可能遇到的错误,包括对每个错误进行检测,并提供错误发生时的合适行为。

本章讲述两种在程序中检测错误的方法:调用assert宏以及测试errno变量;如何检测并处理称为信号的条件,一些信号用于表示错误;最后探讨 setjmp/longjmp 机制,它们经常用于响应错误。

错误的检测和处理并不是C语言的强项。C语言对运行时错误以多种形式表示,而没有提供一种统一的方式。而且,在 C 程序中,需要由程序员编写检测错误的代码。因此,很容易忽略一些可能发生的错误。一 旦发生某个被略掉的错误,程序经常可以继续运行,虽然不是很好。C++、 Java 和 C# 等较新的语言具有“异常处理"特性,可以更容易地检测和响应错误。

<assert.h>诊断

void assert (scalar expression) ;

assert 定义在<assert.h>中。它使程序可以监控自己的行为,并尽早发现可能会发生的错误。

虽然 assert 实际上是一个宏, 但它是按照函数的使用方式设计的。assert有一个参数,这个参数必须是一种“断言”一个我们认为在正常情况 下定为真的表达式。 每次执行 assert时,它都会检查其参数的值。如果参数的值不为 0, assert什么也不做;如果参数 的值为 0, assert 会向stderr (标准错误流) 写条消息, 并调用 abort 函数 终止程序执行。

例如,假定文件 demo.c 声明了一个长度为 10 的数组 a,我们关心的是 demo.c 程序中的语句:

ali] = 0;

可能会由于 i 不在 0- 9 之间而导致程序失败。可以在给a[i]赋值前使用 assert 宏检查这种情况:

assert(0 <= i && i < 10); /* checks subscript first */
a[i] = 0; /* now does the assignment */

如果的值小于0或者大于等于10,程序在显出类似下面的消息后会终止:

Assertion failed: 0 <= i && i < 10, file demo.c, line 109

C99 对asset做了两处小修改。C89 标准指出,assert 的参数必须是 int 类型的。C99 放宽了要求,允许参数为任意标量类型(C99 的原型中出现了单词 scalar)。例如,现在参数可以为浮点数或指针。 此外, C99 要求失败的 assert 显示其所在的函数名。(C89只要求 assert 以文本格式显示参数,源文件及源文件中的行号。) C99 建议的消息格式为:

Assertion failed: expression , function xxx, file xxx,line xxx.

根据编译器的不同,assert 生成的消息格式也不尽相问,但它们都应包含标准要求的信息。例如,GCC编译器在上述情况下给出如下的消息:

a.out: demo.c: 109 main: Assertion `0 <= i && i < 10` failed.

assert 有个缺点: 因为它引入了额外的检查, 因此会增加程序的运行时间。偶尔使用一下 assert 可能对程序的运行速度没有很大影响,但在实时程序中,这么小的运行时间增加可能也是无法接受的。因此许多程序员在测过程中会使用 assert,但当程序最终完成时就会禁止 assert 。要禁止 assert 很容易,只需要在包含 <assert.h> 之前定义宏 NDEBUC 即可。

#define NDEBUG
#include <assert.h>

NDEBUC的值不重要,只要定义了 NDEDG 宏即可。一旦之后程序又有错误发生,可以去掉 NDEBUG 宏的定义来重新启用 assert 。 信号的

注意: 不要在 assert 中使用有副作用的表达式(包括函数调用)。一旦后来某天禁止了 assert ,这些表达式将不再会被求值。考虑下面的例子:

assert((p = malloc(n)) != NULL);

一旦定义了 NDEBUG, assert 会被忽略并且 malloc 不会被调用。

<errno.h>:错误

标准库中的一些函数通过向 <errno . h> 中声明的 int 类型 errno 变量存储一个错误码(正整数)来表示有错误发生。(errno可能实际上是个宏。如果确实是宏,C标准要求它表示左值,以便可以像变量一样使用。) 大部分使用 errno 变量的函数集中在 <math.h> ,但也有一些在标准库的其他部分。

假设我们需要使用一个库函数,该库函数通过给 errno 赋值来产生程序运行出错的信号。在调用这个函数之后,我们可以检查errno的值是否为零。如果不为零,则表示在函数调用过程中有错误发生。举例来说,假如需要检查 sqrt 函数的调用是否出错,可以使用类 似下面的代码:

errno = 0;
y = sqrt(x);
if (errno != 0 ) {
	fprintf (stderr, "sqrt error; program terminated.\n");
	exit (EXIT_ FAILURE);
}

当使用 errno 来检测库的数调用中的错误时,在函数调用前将 errno 置零非常重要。虽然在程序刚开始运行时 errno 的值为零,但有可能在随后的函数调用中已经被改动了。 库函数不会将 errno 清零,这是程序需要做的事情。

当错误发生时,向 errno 中存储的值通常是 EDOM 或 ERANGE. (这两个宏都定义在 <errno.h> 中。) 这两个值代表调用数学函数时可能发生的两种错误:

  • **定义域错误(EDOM):**传递给函数的一个参数超出了函数的定义域。例如,用负数作为 sqrt 的参数就会导致定义域错误。
  • 取值范围错误(ERANGE): 函数返回值太大,无法用返回类型表示。

一些函数可能会同时导致这两种错误。我们可以用 errno 分别与 EDOM 和 ERANGE 比较,然后确定究竟发生了那种错误。

perror函数和 strerror函数

void perror(const char* s); <stdio.h>

char *stderror(int errnum);<string.h>

这两个函数都不属于 <errno.h> 。

当库函数向 errno 存储了一个非零值时,可能希望显示一条描述这种错误的信息。

一种实现方式是调用 perror 函数,他会按顺序显示一下信息:

  1. 调用 perror 的参数
  2. 一个冒号
  3. 一个空格
  4. 一条出错消息,消息内容根据 errno 的值决定
  5. 一个换行符。perror 函数会输出到 stderr 流而不是标准输出。

下面是一个使用 perror 的例子:

errno = 0;
y = sqrt(x);
if(errno != 0){
    perror("sqrt error");
    exit(EXIT_FAILUARE);
}

如果 sqrt 调用因为定义域错误而失败,perror 会打印如下输出:

sqrt error: Numerical argument out of domain

perror 函数在 sqrt error 后显示处的错误消息是由实现定义的。在这个例子中,Numerical argument out of domain. 是与 EDOM 错误相对应的消息。ERANGE 错误通常会对应不同的消息。

stderror函数属 <string.h>。当以错误码为参数调用 stderror 时,函数会返回一个指针,它指向一个描述这个错误的字符串。例如,调用:

puts(stderror(EDOM));

可能会显示:

Numerical argument out of domain

stderror 的值通常是 errno 的值,但以任意整数作为参数时 stderror 都能返回一个字符串。

stderror 和 perror 紧密相关。如果 stderror 的参数为 errno,那么 perror 所显示的出错消息与 stderror 所返回的消息相同。

更多例子
#include <stdio.h>
 
int main(void)
{
    FILE *f = fopen("non_existent", "r");
    if (f == NULL) {
        perror("fopen() failed");
    } else {
        fclose(f);
    }
}

输出:

fopen() failed: No such file or directory

<signal.h>:信号处理

<signal.h> 提供了处理异常情况(称为信号)的工具。信号有两种类型:运行时错误(例如除以0)和发生在程序以外的事件。例如,许多操作系统都允许用户中断或终止正在运行的程序,C语言把这些事件视为信号。当有错误或外部事件发生时,我们称产生了一个信号。大多数信号是异步的:它们可以在程序执行过程中的任意时刻发生,而不仅是在程序员所知道的特定时刻发生。由于信号可能会在任何意想不到的时刻发生,因此必须用一种独特的方式来处理它们。

本节按 C 标准中的描述来介绍信号。这里对信号谈得很有限,但实际上信号在UNIX中的作用很大。有关UNIX信号的信息,见参考文献中列出的UNIX编程书。

1. 信号宏

<signal.h> 中定义了一系列的宏,用于表示不同的信号。每个宏的值都是正整数常量。C 语言的实现可以提供更多的信号宏,只要宏的名字以 SIG 和一个大写字母开头即可。

常量 解释
SIGTERM 发送给程序的终止请求
SIGSEGV 非法内存访问(段错误)
SIGINT 外部中断,通常为用户所发动
SIGILL 非法程序映像,例如非法指令
SIGABRT 异常终止条件,例如 abort() 所起始的
SIGFPE 错误的算术运算,如除以零

C 标准不要求表中的信号都自动产生,因为对于某个特定的计算机或操作系统,不是所有信号都是有意义的。大多数 C 的实现都支持其中的一部分。

2. signal函数

void (*signal( int sig, void (*handler) (int))) (int);

<signal.h> 提供了两个函数:raisesignal。 这里先讨论 signal 函数,它会安装一个信号处理函数,以便将来给定的信号发生时使用。signal函数的使用比它的原型看起来要简单得多。它的第一个参数是特定信号的编码,第二个参数是一个指向会在信号发生时处理这信号的函数的指针。例如,下面的signal 函数调用为SIGINT信号安装了一个处理函数:

signal (SIGINT, handler);

handler 就是信号处理函数的函数名。一旦随后在程序执行过程中出现了 SIGINT 信号,handler 函数就会自动被调用。

每个信号处理函数都必须有一个 int 类型的参数,且返回类型为void。当个特定的信号产生并调用相应的处理函数时,信号的编码会作为参数传递给处理函数。知道是哪种信号导致了处理函数被调用是十分有用的,尤其是,它允许我们对多个信号使用同一个处理函数。

信号处理函数可以做许多事。这可能包含忽略该信号、执行一些错误恢复或终止程序。然而,除非信号是由调用 abort 函数或 raise函数引发的,否则信号处理函数不应该调用库函数或试图使用具有静态存储期限的变量。(但这些规则也有例外。)

一旦信号处理函数返回,程序会从信号发生点恢复并继续执行,但有两种例外情况: (1)如果信号是 SIGABRT ,当处理函数返回时程序会(异常地)终止: (2)如果处理的信号是 SIGFPE ,那么处理函数返回的结果是未定义的。(也就是说,不要处理它。)

虽然 signal 函数有返回值,但经常被丢弃。返回值是指向指定信号的前一个处理函数的指针。如果需要,可以将它保存在变量中。特别是,如果打算恢复原来的处理函数,那么就需要保留 signal 函数的返回值:

void (*orig_handler)(int); /* function pointer variable */
...;
orig_handler = signal (SIGINT, handler);

这条语句将 handler 函数安装为 SIGINT 的处理函数,并将指向原来的处理函数的指针保存在变量 orig_handler 中。如果要恢复原来的处理函数,我们需要使用下面的代码:

signal (SIGINT, orig_handler);/* restores original handler */

3. 预定义的信号处理函数

除了编写自己的信号处理函数,还可以选择使用 <signal .h> 提供的预定义的处理函数。有两个这样的函数,每个都是用宏表示的。

  • SIG_DFL: SIG_DFL 按“默认”方式处理信号。可以使用下面的调用安装 SIG_DFL:

    signal(SIGIT, SIG_DFL); /* use default handler */

    调用 SIG_DFL 的结果是由实现定义的,但大多数情况下会导致程序终止。

  • SIG_IGN:调用

      signal(SIGINT, SIG_IGN); /* ignore SIGINT signal */

    指明随后当信号 SIGINT 产生时,忽略该信号。

除了 SIG_DFL 和 SIG_IGN, <signal .h> 可能还会提供其他的信号处理函数;其函数名必须是以SIG_和一个大写字母开头。当程序刚开始执行时,根据不同的实现,每个信号的处理函数都会被初始化为 SIG_ DFL 或 SIG_ IGN。

<signal.h> 还定义了另一个宏:SIG_ ERR,它看起来像是个信号处理函数。实际上,SIG_ERR 是用来在安装处理函数时检测是否发生错误的。如果一个 signal 调用失败(即不能对所指定的信号安装处理函数),就会返回 SIG_ERR 并在 errno 中存入一个正值。因此,为了测试 signal 调用是否失败,可以使用如下代码:

  if (signal(SIGINT, handler) == SIG_ERR) {
	  perror ("signal (SIGINT, handler) failed");
  }

在整个信号处理机制中,有一个棘手的问题:如果信号是由处理这个信号的函数引发的会怎样呢?为了避免无限递归,C89标准为程序员安装的信号处理函数引发信号的情况规定了一个两步的过程。首先,要么把该信号对应的处理函数重置为 SIG_DFL (默认处理函数),要么在处理函数执行的时候阻塞该信号。(SIGILL是 一个特殊情况,当 SIGILL发生时这两种行为都不需要。) 然后,再调用程序员提供的处理函数。

信号处理完之后,处理函数是否需要重新安装是由实现定义的。UNIX实现通常会在使用处理函数之后保持其安装状态,但其他实现可能会把处理函数重置为SIG_DFL。在后一种情况下,处理函数可以通过在其返回前调用 signal 函数来实现自身的重新安装。

C99 对信号处理过程做了一些小的改动。 当信号发生时,实现不仅可以禁用该信号,还可以禁用别的信号。对于处理 SIGILL或SIGSEGV 信号( 以及SIGFPE信号)的信号处理函数,函数返回的结果是未定义的。C99还增加了一条限制:如果信号是因为调用 abort函数或 raise函数而产生的,信号处理函数本身一定不能调用raise函数。

4. raise函数

int raise( int sig );

通常信号都是由于运行时错误或外部事件产生的,但是有时候如果程序可以触发信号会非常方便。raise 函数就可以用于这一目的。raise 函数的参数指定所需信号的编码:

raise(SIGABRT);

raise 函数的返回值:成功时为 0 ,失败时为非零。

程序:测试信号

下面的程序说明了如何使用信号。首先,给 SIGINT 信号安装了一个惯用的处理函数(并小心地保存了原先的处理函数),然后调用raise_sig 产生该信号:接下来,程序将 SIG_IGN设置为SIGINT的处理函数并再次调用 raise_sig;最后,它重新安装信号 SIGINT原先的处理函数,并最后调用一次 raise_sig。

tsignal.c

#include <signal.h>
#include <stdio.h>

void handler(int sig) ;
void raise_sig(void) ;

int main(void){

	void (*orig_handler) (int);
    
	printf("Installing handler for signal %d\n"SIGINT) ;

	orig_ handler = signal(SIGINT, handler);
	raise_sig();
    
	printf("Changing handler to SIG_IGN\n");
	signal (SIGINT, SIG_IGN) ;
	raise_ sig();
    
	printf("Restoring original handler\n");
	signal (SIGINT, orig_handler);
	raise_ sig();

	printf("Program terminates normally\n");
	return 0
}

void handler(int sig){
	printf("Handler called for signal ad\n", sig);
}

void raise_sig(void){
	raise (SIGINT);
}

当然,调用 raise 并不需要在单独的函数中。这里定义 raise_sig 函数只是为了说明一点:无论信号是从哪里产生的(无论是在main函数中还是在其他函数中),它都会被最近安装的该信号的处理函数捕获。

这段程序的输出可能会有多种。下面是一 种可能的输出形式:

Installing handler for signal 2
Handler called for signal 2
Changing handler to SIG_IGN
Restoring original handler

这个输出结果表明,我们的实现把 SIGINT 的值定义为 2,而且 SIGIN原先的处理函数一定是 SIG_DFL。(如果是 SIG_IGN,应该会看到信息 Program terminates normally) 最后, 我们注意到 SIG_DFL 会导致程序终止,但不会显示出错消息。

<setjmp.h>:非局跳转

int set jmp(jmp_buf env); void longimp(jmp_buf env, int val);

通常情况下,函数会返回到它被调用的位置。我们无法使用 goto语句使它转到其他地方,因为 goto 只能跳转到同一函数内的某个标号处。但是 <setjmp.h> 可以使一个函数直接跳转到另一个函数,而不需要返回。

<setjmp.h> 中最重要的内容就是 setjmp宏和1ongjmp函数。setjmp宏 “标记”程序中的一个位置:随后可以使用 longjmp跳转到该位置。虽然这一强大的机制可以有多种潜在的用途,但它主要被用于错误处理。

如果要为将来的跳转标记一个位置, 可以调用 setjmp 宏,调用的参数是一个 jmp_buf类型 (在<set imp . h>中声明) 的变量。setjmp 宏会将当前“环境”(包括一个 指向 setjmp 宏自身位置的指针) 保存到该变量中以便将来可以在调用 longjmp 函数时使用,然后返回 0

要返回 setjmp 宏所标记的位置可以调用 1ongjmp 函数,调用的参数是调用 setjmp宏时使用的同一个 jmp_buf 类型的变量。longjmp函数会首先根据 jmp_buf 变量的内容恢复当前环境,然后从 setjmp 宏调用中返回。这是最难以理解的。这次 setjmp宏的返回值是 val,就是调用 longjmp 函数时的第 2 个参数。 (如果val的值为0, 那么set jmp宏会返回 1 。)

一定要确保作为 1ongjmp 函数的参数之前已经被 setjmp 调用初始化了。还有一点很重要:包含 setjmp 最初调用的函数一定不能在调用 longjmp 之前返回。如果两个条件都不满足,调用longjmp会导致未定义的行为。( 程序很可能会崩溃。)

总而言之,setjmp 会在第一次调用时返回 0; 随后,longjmp将控制权重新转给最初的 setjmp 宏调用,而setjmp在这次调用时会返回一个非零值。明白了吗?我们可能需要一个例子。

程序测试setjmp和1ongjmp

下面的程序使用 setjmp 宏在 main 函数中标记一个位置,然后函数 f2 通过调用 1ongjmp 函数返回到这个位置。

tsetjmp.c

/* Tests setjmp/1ongjmp */
#include<setjmp.h>
#include<stdio.h>

jmp_buf env;

void f1(void);
void f2(void);

int main(void) {

	if (setjmp(env) == 0)
		printf("setjmp returned 0\n");
	else {
		printf("Program terminates: longjmp called\n");
		return 0;
	}

	f1();
	printf("Program terminates normally\n");
	
	return 0;
}
void f1(void) {
	printf("f1 begins\n");
	f2();
	printf("f1 returs\n");
}
void f2(void) {
	printf("f2 begins\n");
	longjmp(env, 1);
	printf("f2 returns\n");
}

这段程序的输出如下:

setjmp returned 0
f1 begins
f2 begins
Program terminates: longjmp called

setjmp 宏的最初调用返回 0,因此main函数会调用 f1。接着,f1 调用 f2,f2 使用1ongjmp 函数将控制权重新转给 main 函数,而不是返回到 f1 。当 longjmp 函数被执行时,控制权重新回到 setjmp 宏调用。这次 setjmp宏返回 1 (就是在longjmp函数调用时所指定的值)。

参考资料:《C语言程序设计:现代方法》

Footnotes

  1. 程序中总有些话,所有已知的语言都不能很好的表达。Epigrams on Programming 编程警句