前言
本文主要总结嵌入式系统C语言编程中,主要的错误处理方式。文中涉及的代码运行环境如下:
一 错误概念
1.1 错误分类
从严重性而言,程序错误可分为致命性和非致命性两类。对于致命性错误,无法执行恢复动作,最多只能在用户屏幕上打印出错消息或将其写入日志文件,然后终止程序;而对于非致命性错误,多数本质上是暂时的(如资源短缺),一般恢复动作是延迟一些时间后再次尝试。
从交互性而言,程序错误可分为用户错误和内部错误两类。用户错误呈现给用户,通常指明用户操作上的错误;而程序内部错误呈现给程序员(可能携带用户不可接触的数据细节),用于查错和排障。
应用程序开发者可决定恢复哪些错误以及如何恢复。例如,若磁盘已满,可考虑删除非必需或已过期的数据;若网络连接失败,可考虑短时间延迟后重建连接。选择合理的错误恢复策略,可避免应用程序的异常终止,从而改善其健壮性。
1.2 处理步骤
错误处理即处理程序运行时出现的任何意外或异常情况。典型的错误处理包含五个步骤:
1) 程序执行时发生软件错误。该错误可能产生于被底层驱动或内核映射为软件错误的硬件响应事件(如除零)。
2) 以一个错误指示符(如整数或结构体)记录错误的原因及相关信息。
3) 程序检测该错误(读取错误指示符,或由其主动上报);
4) 程序决定如何处理错误(忽略、部分处理或完全处理);
5) 恢复或终止程序的执行。
上述步骤用C语言代码表述如下:
int func() { int bIsErrOccur = 0; //do something that might invoke errors if(bIsErrOccur) //Stage 1: error occurred return -1; //Stage 2: generate error indicator //... return 0; } int main(void) { if(func() != 0) //Stage 3: detect error { //Stage 4: handle error } //Stage 5: recover or abort return 0; } |
调用者可能希望函数返回成功时表示完全成功,失败时程序恢复到调用前的状态(但被调函数很难保证这点)。
二 错误传递
2.1 返回值和回传参数
C语言通常使用返回值来标志函数是否执行成功,调用者通过if等语句检查该返回值以判断函数执行情况。常见的几种调用形式如下:
if((p = malloc(100)) == NULL) //... if((c = getchar()) == EOF) //... if((ticks = clock()) < 0) //... |
Unix系统调用级函数(和一些老的Posix函数)的返回值有时既包括错误代码也包括有用结果。因此,上述调用形式可在同一条语句中接收返回值并检查错误(当执行成功时返回合法的数据值)。
返回值方式的好处是简便和高效,但仍存在较多问题:
1) 代码可读性降低
没有返回值的函数是不可靠的。但若每个函数都具有返回值,为保持程序健壮性,就必须对每个函数进行正确性验证,即调用时检查其返回值。这样,代码中很大一部分可能花费在错误处理上,且排错代码和正常流程代码搅在一起,比较混乱。
2) 质量降级
条件语句相比其他类型的语句潜藏更多的错误。不必要的条件语句会增加排障和白盒测试的工作量。
3) 信息有限
通过返回值只能返回一个值,因此一般只能简单地标志成功或失败,而无法作为获知具体错误信息的手段。通过按位编码可变通地返回多个值,但并不常用。字符串处理函数可参考IntToAscii()来返回具体的错误原因,并支持链式表达:
char *IntToAscii(int dwVal, char *pszRes, int dwRadix)
{
if(NULL == pszRes)
return "Arg2Null";
if((dwRadix < 2) || (dwRadix > 36))
return "Arg3OutOfRange";
//...
return pszRes;
}
|
4) 定义冲突
不同函数在成功和失败时返回值的取值规则可能不同。例如,Unix系统调用级函数返回0代表成功,-1代表失败;新的Posix函数返回0代表成功,非0代表失败;标准C库中isxxx函数返回1表示成功,0表示失败。
5) 无约束性
调用者可以忽略和丢弃返回值。未检查和处理返回值时,程序仍然能够运行,但结果不可预知。
新的Posix函数返回值只携带状态和异常信息,并通过参数列表中的指针回传有用的结果。 回传参数绑定到相应的实参上,因此调用者不可能完全忽略它们。通过回传参数(如结构体指针)可返回多个值,也可携带更多的信息。
综合返回值和回传参数的优点,可对Get类函数采用返回值(含有用结果)方式,而对Set类函数采用返回值+回传参数方式。对于纯粹的返回值,可按需提供如下解析接口:
typedef enum{ S_OK, //成功 S_ERROR, //失败(原因未明确),通用状态
S_NULL_POINTER, //入参指针为NULL
S_ILLEGAL_PARAM, //参数值非法,通用
S_OUT_OF_RANGE, //参数值越限
S_MAX_STATUS //不可作为返回值状态,仅作枚举最值使用
}FUNC_STATUS;
#define RC_NAME(eRetCode) \
((eRetCode) == S_OK ? "Success" : \
((eRetCode) == S_ERROR ? "Failure" :
\
((eRetCode) == S_NULL_POINTER ? "NullPointer"
: \
((eRetCode) == S_ILLEGAL_PARAM ? "IllegalParas"
: \
((eRetCode) == S_OUT_OF_RANGE ? "OutOfRange"
: \
"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),可以是包含出错编号的一个整数,或是一个返回出错编号指针的函数。以前使用的定义为:
但在多线程环境中,多个线程共享进程地址空间,每个线程都有属于自己的局部errno(thread-local)以避免一个线程干扰另一个线程。例如,Linux支持多线程存取errno,将其定义为:
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值:
//调用库函数 2 if(返回错误值) 3 //检查errno |
2) 库函数返回失败时,不一定会设置errno,取决于具体的库函数。
3) errno在程序开始时设置为0,任何库函数都不会将errno再次清零。
因此,在调用可能设置errno的运行库函数之前,最好先将errno设置为0。调用失败后再检查errno的值。
4) 使用errno前,应避免调用其他可能设置errno的库函数。如:
if (somecall() == -1) 2 { 3 printf("somecall() failed\n"); 4 if(errno == ...) { ... } 5 }
|
somecall()函数出错返回时设置errno。但当检查errno时,其值可能已被printf()函数改变。若要正确使用somecall()函数设置的errno,须在调用printf()函数前保存其值:
if (somecall() == -1) 2 { 3 int dwErrSaved = errno; 4 printf("somecall() failed\n"); 5 if(dwErrSaved == ...) { ... } 6 } |
类似地,当在信号处理程序中调用可重入函数时,应在其前保存其后恢复errno值。
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()相同。但后者可提供更多定位信息和输出方式。
两个函数的用法示例如下:
int main(int argc, char** argv) { errno = 0; FILE *pFile = fopen(argv[1], "r"); if(NULL == pFile) { printf("Cannot open file '%s'(%s)!\n", argv[1], strerror(errno)); perror("Open file failed"); } else { printf("Open file '%s'(%s)!\n", argv[1], strerror(errno)); perror("Open file"); fclose(pFile); } return 0; } |
执行结果为:
[wangxiaoyuan_@localhost test1]$ ./GlbErr /sdb1/wangxiaoyuan/linux_test/test1/test.c Open file '/sdb1/wangxiaoyuan/linux_test/test1/test.c'(Success)! Open file: Success [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h Cannot open file 'NonexistentFile.h'(No such file or directory)! Open file failed: No such file or directory [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h > test Open file failed: No such file or directory [wangxiaoyuan_@localhost test1]$ ./GlbErr NonexistentFile.h 2> test Cannot open file 'NonexistentFile.h'(No such file or directory)! |
也可仿照errno的定义和处理,定制自己的错误代码:
int *_fpErrNo(void) { static int dwLocalErrNo = 0; return &dwLocalErrNo; }
#define ErrNo (*_fpErrNo())
#define EOUTOFRANGE 1
//define other error macros...
int Callee(void)
{
ErrNo = 1;
return -1;
}
int main(void)
{
ErrNo = 0;
if((-1 == Callee()) && (EOUTOFRANGE ==
ErrNo))
printf("Callee failed(ErrNo:%d)!\n",
ErrNo);
return 0;
} |
借助全局状态标志,可充分利用函数的接口(返回值和参数表)。但与返回值一样,它隐含地要求调用者在调用函数后检查该标志,而这种约束同样脆弱。
此外,全局状态标志存在重用和覆盖的风险。而函数返回值是无名的临时变量,由函数产生且只能被调用者访问。调用完成后即可检查或拷贝返回值,然后原始的返回对象将消失而不能被重用。又因为无名,返回值不能被覆盖。
2.3 局部跳转(goto)
使用goto语句可直接跳转到函数内的错误处理代码处。以除零错误为例:
double Division(double fDividend, double fDivisor) { return fDividend/fDivisor; } int main(void) { int dwFlag = 0; if(1 == dwFlag) { RaiseException: printf("The divisor cannot be 0!\n"); exit(1); } dwFlag = 1;
double fDividend = 0.0, fDivisor = 0.0;
printf("Enter the dividend: ");
scanf("%lf", &fDividend);
printf("Enter the divisor : ");
scanf("%lf", &fDivisor);
if(0 == fDivisor) //不太严谨的浮点数判0比较
goto RaiseException;
printf("The quotient is %.2lf\n", Division(fDividend,
fDivisor));
return 0;
} |
执行结果如下:
[wangxiaoyuan_@localhost test1]$ ./test Enter the dividend: 10 Enter the divisor : 0 The divisor cannot be 0! [wangxiaoyuan_@localhost test1]$ ./test Enter the dividend: 10 Enter the divisor : 2 The quotient is 5.00 |
虽然goto语句会破坏代码结构性,但却非常适用于集中错误处理。伪代码示例如下:
CallerFunc() { if((ret = CalleeFunc1()) < 0); goto ErrHandle; if((ret = CalleeFunc2()) < 0); goto ErrHandle; if((ret = CalleeFunc3()) < 0); goto ErrHandle; //...
return;
ErrHandle:
//Handle Error(e.g. printf)
return;
} |
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的非局部跳转:
jmp_buf gJmpBuf; void Func1(){ printf("Enter Func1\n"); if(0)longjmp(gJmpBuf, 1); } void Func2(){ printf("Enter Func2\n"); if(0)longjmp(gJmpBuf, 2); } void Func3(){ printf("Enter Func3\n"); if(1)longjmp(gJmpBuf, 3); }
int main(void)
{
int dwJmpRet = setjmp(gJmpBuf);
printf("dwJmpRet = %d\n", dwJmpRet);
if(0 == dwJmpRet)
{
Func1();
Func2();
Func3();
}
else
{
switch(dwJmpRet)
{
case 1:
printf("Jump back from Func1\n");
break;
case 2:
printf("Jump back from Func2\n");
break;
case 3:
printf("Jump back from Func3\n");
break;
default:
printf("Unknown Func!\n");
break;
}
}
return 0;
} |
执行结果为:
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块所包含的异常处理程序。
以除零错误为例:
jmp_buf gJmpBuf; void RaiseException(void) { printf("Exception is raised: "); longjmp(gJmpBuf, 1); //throw,跳转至异常处理代码 printf("This line should never get printed!\n"); } double Division(double fDividend, double fDivisor) { return fDividend/fDivisor; } int main(void) { double fDividend = 0.0, fDivisor = 0.0; printf("Enter the dividend: "); scanf("%lf", &fDividend); printf("Enter the divisor : "); if(0 == setjmp(gJmpBuf)) //try块 { scanf("%lf", &fDivisor); if(0 == fDivisor) //也可将该判断及RaiseException置于Division内 RaiseException(); printf("The quotient is %.2lf\n", Division(fDividend, fDivisor)); } else //catch块(异常处理代码) { printf("The divisor cannot be 0!\n"); }
return 0;
} |
执行结果为:
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“异常处理”机制(除非库或框架)。
|