文章目录
- 第24章 错误处理
- 24.1 <assert.h>: 诊断
- 24.2 <errno.h>: 错误
- 24.2.1 perror函数和strerror函数
- 24.3 <signal.h>: 信号处理
- 24.3.1 信号宏
- 24.3.2 signal函数
- 24.3.3 预定义的信号处理函数
- 24.3.4 raise函数
- 24.4 <setjmp.h>: 非局部跳转
- 问与答
- 写在最后
第24章 错误处理
——编写无错程序的方法有两种,但只有第三种写程序的方法才行得通。
学习C语言的学生所编写的程序在遇到异常输入时经常无法正常运行,但真正商业用途的程序却必须“非常稳健”,即能够从错误中恢复正常而不至于崩溃。为了使程序非常稳健,我们需要能够预见程序执行时可能遇到的错误,包括对每个错误进行检测,并提供错误发生时的合适行为。
本章讲述两种在程序中检测错误的方法:调用
assert宏以及测试errno变量。24.1节介绍了<assert.h>头,assert宏就是在这里定义的。24.2节讨论了<errno.h>头,其中定义了errno变量。这一节还包含perror函数与strerror函数,这两个函数分别来自<stdio.h>和<string.h>,它们与errno变量紧密相关。
24.3节讲解如何检测并处理称为信号的条件,一些信号用于表示错误。处理信号的函数在<signal.h>头中声明。
最后,
24.4节探讨setjmp/longjmp机制,它们经常用于响应错误。setjmp和longjmp都属于<setjmp.h>头。
错误的检测和处理并不是C语言的强项。C语言对运行时错误以多种形式表示,而没有提供一种统一的方式。而且,在C程序中,需要由程序员编写检测错误的代码。因此,很容易忽略一些可能发生的错误。一旦发生某个被忽略的错误,程序经常可以继续运行,虽然这样也不是很好。C++、Java和C#等较新的语言具有“异常处理”特性,可以更容易地检测和响应错误。
24.1 <assert.h>: 诊断
void assert(scalar expression);
assert定义在<assert.h>中。它使程序可以监控自己的行为,并尽早发现可能会发生的错误。
虽然assert实际上是一个宏,但它是按照函数的使用方式设计的。assert有一个参数,这个参数必须是一种“断言”——一个我们认为在正常情况下一定为真的表达式。每次执行assert时,它都会检查其参数的值。如果参数的值不为0,assert什么也不做;如果参数的值为0,assert会向stderr(标准误差流,22.1节)写一条消息,并调用abort函数(26.2节)终止程序执行。
例如,假定文件
demo.c声明了一个长度为10的数组a,我们关心的是demo.c程序中的语句
a[i] = 0;
可能会由于i不在0~9之间而导致程序失败。可以在给a[i]赋值前使用assert宏检查这种情况:
assert(0 <= i && i < 10) ; /* checks subscript first */
a[i] = 0; /* now does the assignment */
如果i的值小于0或者大于等于10,程序在显出类似下面的消息后会终止:
Assertion failed: 0 <= i && i < 10, file demo.c, line 109
C99对assert做了两处小修改。C89标准指出,assert的参数必须是int类型的。C99放宽了要求,允许参数为任意标量类型(因此在assert的原型中出现了单词scalar)。例如,现在参数可以为浮点数或指针。此外,C99要求失败的assert显示其所在的函数名。(C89只要求assert以文本格式显示参数、源文件及源文件中的行号。)C99建议的消息格式为
Assertion failed: expression, function abc, file xyz, line nnn.
根据编译器的不同,
assert生成的消息格式也不尽相同,但它们都应包含标准要求的信息。例如,GCC在上述情况下给出如下的消息:
a.out: demo.c:109: main: Assertion '0 <= i && i < 10' failed.
assert有一个缺点:因为它引入了额外的检查,所以会增加程序的运行时间。偶尔使用一次assert可能对程序的运行速度没有很大影响,但在实时程序中,这么小的运行时间增加可能也是无法接受的。因此,许多程序员在测试过程中会使用assert,但当程序最终完成时就会禁止assert。要禁止assert很容易,只需要在包含<assert.h>之前定义宏NDEBUG即可:
#define NDEBUG
#include <assert.h>
NDEBUG宏的值不重要,只要定义了NDEBUG宏即可。一旦之后程序又有错误发生,就可以去掉NDEBUG宏的定义来重新启用assert。
请注意!!不要在
assert中使用有副作用的表达式(包括函数调用)。万一某天禁止了assert,这些表达式将不会再被求值。考虑下面的例子:assert((p = malloc(n)) != NULL);一旦定义了
NDEBUG,assert就会被忽略并且malloc不会被调用。
函数assert是在程序运行期间做诊断工作,从C11开始引入的静态断言_Static_assert可以把检查和诊断工作放在程序编译期间进行(18.7节)。
24.2 <errno.h>: 错误
标准库中的一些函数通过向
<errno.h>中声明的int类型errno变量存储一个错误码(正整数)来表示有错误发生。[errno可能实际上是个宏。如果确实是宏,C标准要求它表示左值(4.2节),以便像变量一样使用。]大部分使用errno变量的函数集中在<math.h>,但也有一些在标准库的其他部分中。
假设我们需要使用一个库函数,该库函数通过给errno赋值来产生程序运行出错的信号。在调用这个函数之后,我们可以检查errno的值是否为零。如果不为零,则表示在函数调用过程中有错误发生。举例来说,假如需要检查sqrt函数(23.3节)的调用是否出错,可以使用类似下面的代码:
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):函数的返回值太大,无法用返回类型表示。例如,用1000作为exp函数(23.3节)的参数就经常会导致取值范围错误,因为 e 1000 {e^{1000}} e1000太大导致无法在大多数计算机上用double类型表示。
一些函数可能会同时导致这两种错误。可以用errno分别与EDOM和ERANGE比较,然后确定究竟发生了哪种错误。
C99在<errno.h>中增加了EILSEQ宏。特定头(尤其是<wchar.h>头,25.5节)中的库函数在发生编码错误(22.3节)时把EILSEQ的值存储到errno中。
24.2.1 perror函数和strerror函数
void perror(const char *s); //来自<stdio.h>
char *strerror(int errnum); //来自<string.h>
下面看两个与变量errno有关的函数,不过这两个函数都不属于<errno.h>。
当库函数向
errno存储了一个非零值时,可能会希望显示一条描述这种错误的消息。一种实现方式是调用perror函数(在<stdio.h>中声明),它会按顺序显示以下信息:(1)调用perror的参数;(2)一个冒号;(3)一个空格;(4)一条出错消息,消息的内容根据errno的值决定;(5)一个换行符。perror函数会输出到stderr流(22.1节)而不是标准输出。下面是一个使用perror的例子:
errno = 0;
y = sqrt(x);
if (errno != 0) { perror("sqrt error"); exit(EXIT_FAILURE);
}
如果sqrt调用因定义域错误而失败,perror会产生如下输出:
sqrt error:Numerical argument out of domain
perror函数在sqrt error后所显示的出错消息是由实现定义的。在这个例子中,Numerical argument out of domain是与EDOM错误相对应的消息。ERANGE错误通常会对应于不同的消息,例如Numerical result out of range。
strerror函数属于<string.h>。当以错误码为参数调用strerror时,函数会返回一个指针,它指向一个描述这个错误的字符串。例如,调用
puts(strerror(EDOM));
可能会显示
Numerical argument out of domain
strerror函数的参数通常是errno的值,但以任意整数作为参数时strerror都能返回一个字符串。
strerror与perror函数密切相关。如果strerror的参数为errno,那么perror所显示的出错消息与strerror所返回的消息是相同的。
24.3 <signal.h>: 信号处理
<signal.h>提供了处理异常情况(称为信号)的工具。信号有两种类型:运行时错误(例如除以0)和发生在程序以外的事件。例如,许多操作系统都允许用户中断或终止正在运行的程序,C语言把这些事件视为信号。当有错误或外部事件发生时,我们称产生了一个信号。大多数信号是异步的:它们可以在程序执行过程中的任意时刻发生,而不仅是在程序员所知道的特定时刻发生。由于信号可能会在任何意想不到的时刻发生,因此必须用一种独特的方式来处理它们。
本节按C标准中的描述来介绍信号。这里对信号谈得很有限,但实际上信号在UNIX中的作用很大。这里不作详细讨论。
24.3.1 信号宏
<signal.h>定义了一系列的宏,用于表示不同的信号。表24-1中列出了这些宏以及它们的含义。每个宏的值都是一个正整型常量。C语言的实现可以提供更多的信号宏,只要宏的名字以SIG和一个大写字母开头就行。(特别地,UNIX实现提供许多额外的信号宏。)
表24-1 信号
| 宏名 | 含义 |
|---|---|
| SIGABRT | 异常终止(可能由于调用abort导致) |
| SIGFPE | 在算术运算中发生错误(可能是除以0或溢出) |
| SIGILL | 无效指令 |
| SIGINT | 中断 |
| SIGSEGV | 无效存储访问 |
| SIGTERM | 终止请求 |
C标准并不要求表24-1中列出的信号都自动产生,因为对于某个特定的计算机或操作系统,不是所有的信号都有意义。大多数C语言的实现都至少支持其中的一部分。
24.3.2 signal函数
void (*signal(int sig, void (*func)(int)))(int);
<signal.h>提供了两个函数:raise和signal。这里先讨论signal函数,它会安装一个信号处理函数,以便将来给定的信号发生时使用。signal函数的使用比它的原型看起来要简单得多。它的第一个参数是特定信号的编码,第二个参数是一个指向会在信号发信生时处理这一号的函数的指针。例如,下面的signal函数调用为SIGINT信号安装了一个处理函数:
signal(SIGINT, handler);
handler就是信号处理函数的函数名。一旦随后在程序执行过程中出现了SIGINT信号,handler函数就会自动被调用。
每个信号处理函数都必须有一个int类型的参数,且返回类型为void。当一个特定的信号产生并调用相应的处理函数时,信号的编码会作为参数传递给处理函数。知道是哪种信号导致了处理函数被调用是十分有用的,尤其是它允许我们对多个信号使用同一处理函数。
信号处理函数可以做许多事。这可能包含忽略该信号、执行一些错误恢复或终止程序。然而,除非信号是由调用
abort函数(26.2节)或raise函数引发的,否则信号处理函数不应该调用库函数或试图使用具有静态存储期(18.2节)的变量。(但这些规则也有例外。)
一旦信号处理函数返回,程序就会从信号发生点恢复并继续执行,但有2种例外情况:
- 如果信号是
SIGABRT,当处理函数返回时程序会(异常地)终止; - 如果处理的信号是
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 */
24.3.3 预定义的信号处理函数
除了编写自己的信号处理函数,还可以选择使用
<signal.h>提供的预定义的处理函数。有两个这样的函数,每个都是用宏表示的。
-
SIG_DFL。SIG_DFL按“默认”方式处理信号。可以使用下面的调用安装SIG_DFL:signal(SIGINT, 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函数。(我的理解是,raise不能连续发生)
24.3.4 raise函数
int raise(int sig);
通常信号是由于运行时错误或外部事件而产生的,但有时候如果程序可以触发信号会非常方便。raise函数就可以实现这一目的。raise函数的参数指定所需信号的编码:
raise(SIGABRT); /* raises the SIGABRT signal */
raise函数的返回值可以用来测试调用是否成功:0代表成功,非0则代表失败。
下面的程序说明了如何使用信号。首先,给
SIGINT信号安装了一个惯用的处理函数(并小心地保存了原先的处理函数),然后调用raise_sig产生该信号;接下来,程序将SIG_IGN设置为SIGINT的处理函数并再次调用raise_sig;最后,它重新安装信号SIGINT原先的处理函数,并最后调用一次raise_sig。
/*
tsignal.c
--Tests signals
*/
#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 %d\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,而且SIGINT原先的处理函数一定是SIG_DFL。(如果是SIG_IGN,应该会看到信息Program terminates normally。)最后,我们注意到SIG_DFL会导致程序终止,但不会显示出错消息。
24.4 <setjmp.h>: 非局部跳转
int setjmp(jmp_buf env);
_Noreturn void longjmp(jmp_buf env, int val);
通常情况下,函数会返回到它被调用的位置。我们无法使用
goto语句(6.4节)使它转到其他地方,因为goto只能跳转到同一函数内的某个标号处。但是<setjmp.h>可以使一个函数直接跳转到另一个函数,不需要返回。
在<setjmp.h>中最重要的内容就是setjmp宏和longjmp函数。setjmp宏“标记”程序中的一个位置,随后可以使用longjmp跳转到该位置。虽然这一强大的机制可以有多种潜在的用途,但它主要被用于错误处理。
如果要为将来的跳转标记一个位置,可以调用
setjmp宏,调用的参数是一个jmp_buf类型(在<setjmp.h>中声明)的变量。setjmp宏会将当前“环境”(包括一个指向setjmp宏自身位置的指针)保存到该变量中,以便将来可以在调用longjmp函数时使用,然后返回0。
要返回setjmp宏所标记的位置可以调用longjmp函数,调用的参数是调用setjmp宏时使用的同一个jmp_buf类型的变量。longjmp函数会首先根据jmp_buf变量的内容恢复当前环境,然后从setjmp宏调用中返回——这是最难以理解的。这次setjmp宏的返回值是val,就是调用longjmp函数时的第二个参数。(如果val的值为0,那么setjmp宏会返回1。)
请注意!!一定要确保作为
longjmp函数的参数之前已经被setjmp调用初始化了。还有一点很重要:包含setjmp最初调用的函数一定不能在调用longjmp之前返回。如果两个条件都不满足,调用longjmp会导致未定义的行为。(程序很可能会崩溃。)
总而言之,setjmp会在第一次调用时返回0;随后,longjmp将控制权重新转给最初的setjmp宏调用,而setjmp在这次调用时会返回一个非零值。明白了吗?我们可能需要一个例子。
下面的程序使用
setjmp宏在main函数中标记一个位置,然后函数f2通过调用longjmp函数返回到这个位置。
/*
tsetjmp.c
--Tests setjmp/longjmp
*/
#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 returns\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使用longjmp函数将控制权重新转给main函数,而不是返回到f1。当longjmp函数被执行时,控制权重新回到setjmp宏调用。这一次setjmp宏返回1(就是在longjmp函数调用时所指定的值)。
问与答
问1:书上说,在调用可能修改
errno的库函数之前把errno设置为0是很重要的。但是,我见过一些UNIX程序在没有把errno设置为0的情况下就对其进行测试。这是什么缘故呢?
答:UNIX程序通常包含对操作系统函数的调用。这些系统调用需要用到errno,但使用方法与本节提到的方法略有不同。当这样的调用失败时,除了在errno中存储一个值之外,还会返回一个特殊的值(例如-1或空指针)。程序不需要在这些调用之前往errno中存储0,因为函数的返回值本身就可以表明发生了错误。C标准库中的一些函数也是这样的:errno更多地用于指明错误类型而不是用于发出出错信号。
问2:我使用的
<errno.h>版本中除了EDOM和ERANGE以外,还定义了其他的宏。这是合法的吗?
答:是合法的。C标准允许使用宏表示其他错误条件,只要宏的名字以字母E开头并且其后有一个数字或大写字母。UNIX实现中通常会定义许多这样的宏。
问3:一些表示信号的宏的名字含义比较模糊,比如
SIGFPE和SIGSEGV。这些名字是如何得来的呢?
答:信号的名字可以追溯到早期的C编译器,这些编译器运行在DECPDP-11计算机上。PDP-11的硬件可以检测一些错误,诸如“Floating Point Exception”和“Segmentation Violation”。
问4:我很好奇。书上说除非信号是由
abort函数或raise函数引发的,否则信号处理函数不应该调用库函数。但你又说有例外情况,是什么例外呢?
答:信号处理函数可以调用singal函数,只要第一个参数是当前正在处理的信号就可以。这一限定条件很重要,因为它允许信号处理函数自身进行重新安装。在C99中,信号处理函数还可以调用abort函数或_Exit函数(26.2节)。
问5:接着上一个问题,信号处理函数通常不能访问具有静态存储期的变量。这个规则的例外是什么?
答:这个问题要难回答一些。答案涉及<signal.h>头中声明的一个名为sig_atomic_t的类型。根据C标准,sig_atomic_t是一个可以作为一个“原子实体”访问的整型。换句话说,CPU可以用一条指令从内存中取出sig_atomic_t的值或将其存放到内存中,而不需要用两条或更多条指令。通常把sig_atomic_t定义为int,因为大多数CPU可以用一条指令存取int类型的值。
下面谈谈信号处理函数不可以访问静态变量这一规则的例外情况。C标准允许信号处理函数在sig_atomic_t类型的变量中存储值(即使该变量具有静态存储期也可以),前提是该变量声明为volatile。为了了解这一不可思议的规则产生的原因,考虑信号处理函数要修改一个类型比sig_atomic_t宽一些的静态变量的情况。如果程序在信号发生之前从内存中取出了该变量的一部分,并在信号处理完毕后取完该变量,那么这个值就没有价值了。sig_atomic_t类型的变量可以一步取出,所以不会出现这种问题。把变量声明为volatile会警告编译器,变量的值随时可能改变。(信号可能突然产生,并调用信号处理函数来修改该变量。)
问6:程序
tsignal.c在信号处理函数内调用了printf函数。这不是非法的吗?
答:如果信号处理函数是由raise或abort调用的,那么就可以调用库函数。tsignal.c使用raise来调用信号处理函数。
问7:
setjmp会如何修改传递给它的参数呢?C语言不是始终以值的形式传递参数吗?
答:C标准要求jmp_buf必须是一个数组类型,因此传递给setjmp的实际上是一个指针。
问8:我在使用
setjmp时遇到一些问题。使用setjmp有什么限制吗?
答:按照C标准,只有2种使用setjmp的方式是合法的。
-
作为表达式语句中的表达式(可能会强制转换成
void)。 -
作为
if、swtich、while、do或for语句中控制表达式的一部分。整个控制表达式必须符合下面的形式之一,其中constexp是一个整型常量表达式,而op是关系或判等运算符。setjmp(...)!setjmp(...)constexpr op setjmp(...)setjmp(...) op constexpr
其他的用法会导致未定义的行为。
问9:调用
longjmp函数后,程序中变量的值是什么?
答:大部分变量的值保留了longjmp函数被调用时的值。然而,包含setjmp宏的函数中,自动变量的值是不确定的,除非该变量被声明为volatile或者在执行setjmp时没有被修改过。
问10:在信号处理函数里调用
longjmp函数合法吗?
答:是合法的,只要该信号处理函数的调用不是由某个信号处理函数执行过程中触发的信号引发的。(C99删除了这一限制。)
写在最后
本文是博主阅读《C语言程序设计:现代方法(第2版·修订版)》时所作笔记,日后会持续更新后续章节笔记。欢迎各位大佬阅读学习,如有疑问请及时联系指正,希望对各位有所帮助,Thank you very much!