第24章 异常处理程序和软件异常
24.0 前言
对于编程人员来说,异常是不希望存在的事。不过可惜事与愿违,异常还是会经常性的发生,只能面对。异常分为两种,一种是有CPU引起异常,称为硬件异常,还有一种是有操作系统和应用程序引发的异常,称为软件异常。
对于异常的出现,操作系统向应用程序提供了机会,以考察异常的类型,并让应用程序自己去处理异常。方式就是使用前一章提到的SEH机制(try-finally)。这里处理的方式是使用一个__try
语句块和一个__except
函数:
__try {
// Guarded body
// ...
}
__except (exception filter) {
// Exception handle
// ...
}
注意,每一个try语句块必须跟一个finally语句块或者except语句块。这些语句块可以嵌套,但是不能没有。
24.1通过例子理解异常过滤器和异常处理程序
与结束处理程序不同的是,异常过滤器和异常处理程序是有操作系统直接执行的,和编译程序没有多大关系。下面就举例说明try-catch语句块的正常执行,解释操作系统如何以及为什么计算异常过滤器,并给出操作系统执行异常处理程序中代码的环境。
24.1.1 Funcmeister1
这个是try-catch的例子:
DWORD Funcmeister1() {
DWORD dwTemp;
// 1. Do any processing here.
__try {
// 2. Perform some operation.
dwTemp = 0;
}
__except (EXCEPTION_EXECUTE_HANDLER) {
// Handle an exception; this never executes.
// ...
}
// 3. Continue processing.
return(dwTemp);
}
在try语句块中,只是将0赋值给了变量,这个操作是不会引起异常的,所以__except
块的代码也就不会执行。这里与try-finally的组合是不同的。当变量被赋值完毕,return执行将被执行。
和try-finally的另一个不同之处就是,对return、return、return和break这些语句的使用不会产生速度和代码规模方面的不良影响,这样的语句完全可以出现在和except语句块相结合的try语句块中。
24.1.2 Funcmeister2
接下来修改一下这个函数,看看会有什么不同:
DWORD Funcmeister2() {
DWORD dwTemp(0);
// 1. Do any processing here.
__try {
// 2. Perform some operation.
dwTemp = 5 / dwTemp; // Generates an exception
dwTemp += 10; // Never executes
}
__except (/* 3. Evaluate filter. */ EXCEPTION_EXECUTE_HANDLER) {
// 4. Handle an exception
MessageBeep(0);
// ...
}
// 5. Continue processing.
return(dwTemp);
}
Funcmeister2中,多了一个代码,试图用0来除5。CPU捕捉到这个事件,然后引发了一个硬件异常。当发生这个异常的时候,系统将定位到except块的开头,并计算异常过滤器表达式的值。表达式的结果只能是下面三个标识符之一,它们定义在Windows的Excpt.h头文件中。
标识符 | 值 |
---|---|
EXCEPTION_EXCUTE_HANDLE | 1 |
EXCEPTION_CONTINUE_SEARCH | 0 |
EXCEPTION_CONTINUE_EXECUTION | -1 |
下面的一张图概括了系统如何处理一个异常的情况:
24.2 EXCEPTION_EXCUTE_HANDLE
Funcmeister2中,异常过滤器表达式的值是EXCEPTION_EXCUTE_HANDLE,这个值的意思是告诉系统:“我认出了这个异常,我感觉它可能在某个时刻发生,我已经写好了代码处理这个问题,现在我想执行这些代码。”然后系统就会执行一个全局展开,然后执行except语句块中的代码。之后系统会允许应用程序继续执行。这种机制使得应用程序可以抓住问题并处理错误,再使得程序继续运行,不需要用户知道错误的发生。
但是,当except块执行后,代码将从何处恢复执行?稍加思索,我们就可以想到几种可能。
首先一种可能就是从产生异常的CPU指令后恢复执行。在Funcmeister2中执行将从变量加10的指令开始恢复。这看起来是合理的做法,但实际上很多程序的编写方式使得当前面的指令出错,后面的就无法成功运行。
Funcmeister2中的代码可以正常地执行,但这不是正常的情况。代码应该尽可能地结构化,这样在产生异常的指令之后的CPU指令就有望获得有些的返回值。例如一个指令分配了内存,后面一系列指令要对内存操作。如果内存不能够分配成功,那么后续的指令都要失败,上面这个程序重复地产生异常。
下面是另一个例子,用以替代Funcmeister2中产生异常的C代码:
malloc(5 / dwTemp);
对上面的代码,编译程序产生CPU指令来执行触发,将结果压入栈中,然后调用malloc函数。如果触发失败,代码就不能继续执行。系统必须向栈中压东西,否则栈就被破坏了。
幸运的是,微软没有让系统从产生异常的指令恢复指令的执行。这种策略使得我们免于面对那种问题。
第二种可能是从产生异常的指令恢复执行,譬如下面的代码:
dwTemp = 2;
有了这个赋值语句,可以从产生异常的指令恢复运行。这一次,用2来除5,执行继续,不会产生其他的异常。可以做些修改,使得系统重新执行产生异常的指令。这种方式将导致某些微妙的行为,后面会讨论到。
24.2.1 一些有用的例子
假如要实现一个程序,可以7天24小时不间断地运行,在这个复杂的世界里,如果不用SEH,几乎是不可能的。譬如下面的C运行时函数:
char* strcpy(char* strDestination, const char* strSource);
这样一个简单的函数,它怎么会引起一个进程的结束呢?如果调用者对其中一些参数传递NULL或者某些无效的地址,strcpy就会产生一个存取异常,并且导致整个进程结束。
使用SEH,就可以实现一个强壮的strcpy函数:
char* RobustStrCpy(char* strDestination, const char* strSource) {
__try {
strcpy(strDestination, strSource);
}
__except (EXCEPTIONG_EXECUTE_HANDLER) {
// Nothing to do here.
}
return strDestination;
}
这个代码没有更做太多的事情,也就是将strcpy函数置于SEH处理的框架中。如果strcpy执行成功就返回。如果strcpy引起一个存取异常,异常过滤器就返回EXCEPTIONG_EXECUTE_HANDLER,导致该线程执行异常处理程序的代码。在这个例子中,RobustStrCpy只是返回它的调用者,没有造成进程的结束。
另一个例子,这个函数返回一个字符串中以空格分界的符号个数:
int RobustHowManyToken(const char* str) {
int nHowManyTokens = -1; // -1 indicates failure
char* strTemp = NULL; // Assume failure
__try {
// Allocate a temporary buffer
strTemp = (char*)malloc(strlen(str) + 1);
// Copy the original string to the temporary buffer
strcpy(strTemp, str);
// Get the first token
char* pszToken = strtok(strTemp, " ");
// Iterate through all the token s
for (; pszTokens != NULL; pszTokens = strtok(NULL, " "))
nHowManyTokens++;
nHowManyTokens++; // Add 1 since we start from -1
}
__except (EXCEPTIONG_EXECUTE_HANDLER) {
// Nothing to do here.
}
// Free the temporary buffer guaranteed
free(strTemp);
return(nHowManyTokens);
}
在正常的情况下,这个函数分配一个临时的缓冲区,并将字符串str拷贝进去,然后调用strtok来获取字符串的符号。临时的缓存是很必要的,因为strtok会修改它操作的串。多亏有了SEH,这个函数非常简单的就处理了所有的可能性。现在来看看几个不同的情况:
首先,如果调用者向函数传递了一个有效的地址,并且对malloc的调用也成功了。那么此时,其余的代码也会成功地计算符号的数量。在try块的末尾,异常过滤器不会被求值,except中的代码也不会被执行,缓冲区将被释放,也向调用者返回了nHowManyTokens。
使用SEH的感觉很好,RobustHowManyToken这个函数说明了如何在不使用try-finally的情况下保证释放资源。在异常处理程序之后的代码也都能保证被执行(假设函数没有从try块中返回——应该避免的事)。
再看一个特别有用的SEH的例子。这个函数重复一个内存块:
PBYTE RobustMemDup(PBYTE pbSrc, size_t cb) {
PBYTE pbDup = NULL; // Assume failure
__try {
// Allocate a buffer for the duplicate memory block
pbDup = (PBYTE) malloc(cb);
memcpy(pbDup, pbSrc, cb);
}
__except (EXCEPTIONG_EXECUTE_HANDLER) {
free(pbDup);
pbDup = NULL;
}
return pbDup;
}
这个函数的功能并不复杂,也就是申请了一段内存,然后将参数中的缓存区拷贝过去,最后把这段内存返回给调用者。如果失败则返回NULL。下面是不同条件下这个函数的运行情况:
- 如果调用者对pbSrc传递了一个无效的地址,或者对malloc的调用失败,则memcpy就会引发一个存取异常。然后存取异常执行过滤器,将控制转移到except语句块。在except中,内存缓存区被释放,pbDup被设置成NULL以便让调用的程序知道函数的失败。注意,这里即使是ANSI C也允许对free传递NULL。
- 如果调用程序给函数传递一个有效地址,并且对malloc的调用成功,则新分配的内存的地址就返回给调用程序。
24.2.2 全局展开
当一个异常过滤器的值是EXCEPTIONG_EXECUTE_HANDLER的时候,系统必须执行一个全局展开(global unwind)。这个全局展开使得所有那些在处理异常的try-except块之后开始执行但未完成的try-finally块恢复执行。下面的图是描述系统如何执行全局展开的流程图,在解释后面的例子时,请参阅这个图。
函数Funcstimpy1和函数FuncOren1结合起来可以解释SEH最令人疑惑的方面。程序中注释的标号给出了执行的次序,我们现在开始做一些分析。
Funcstimpy1开始执行,进入他的try块并调用FuncOren1。FuncOren1开始执行,进入它的try块,并等待获得信标。当它得到信标,FuncOren1试图改变全局数据g_dwProctectedData。但由于除以0而产生一个异常。系统因此获得控制,开始搜索一个与except块相配的try块,然后找到FuncOren1函数中的一个try块与之相配。在这里系统发现finally块是FuncOren1中包含的finally块。
当系统执行FuncOren1的finally块中的代码时,可以清楚地看到SEH的作用了。FuncOren1释放新标,使得另一个线程恢复执行。如果这个finally块中不包含ReleaseSemaphore的调用,则信标不会被释放。
在finally块中包含的代码执行完之后,系统继续上溯,查找需要执行的未完成的finally块。在这个例子中已经没有这样的finally块了,系统到达要处理异常的try-except块就停止上溯。这时,全局展开结束,系统可以执行except块中包含的代码。
结构化异常处理的工作就是这样。SEH比较难于理解,是在因为代码的执行当中与系统牵扯太多。程序代码不再是从头到尾执行,系统是代码按照它的规定次序执行。这种执行次序虽然复杂,但可以预料。按照本章中的两个流程图,就可以有把握地使用SEH
void Funcstimpy1() {
// 1. Do any processing here.
// ...
__try {
// 2. Call another function
FuncORen1();
// Code here never executes.
}
__except (/*6. Evaluate filter. */ EXCEPTION_EXECUTE_HANDLER) {
// 8. After the unwind, the exception handler executes.
MessageBox(...);
}
// 9. Exception handler-continue execution.
// ...
}
void FuncORen1() {
DWORD dwTemp(0);
// 3.Do any processing here.
// ...
__try {
// 4. Request permission to access protect data.
WaitForSingleObject(g_Sem, INFINITE);
// 5. Modify the data.
// An excetion is generated here.
g_dwProtectedData = 5 / dwTemp;
}
__finally {
// 7. Global unwind occurs because filter evaluated
// to EXCEPTION_EXECUTE_HANDLER
// Allow other to use protected data.
ReleaseSemaphore(g_Sem, 1, NULL);
}
// Continue processing-never executes.
// ...
}
为了更好地理解这个执行次序,我们再从不同的角度来看发生的事情。当一个过滤器返回EXCEPTION_EXECUTE_HANDLER的时候,过滤器是在告诉系统,线程的指令指针应该指向except中的代码。但这个指令在FuncORen1的try块中。回忆一下try-finally,当每一个线程要从一个try-finally中离开的时候,必须保证执行finally中的代码。在发生异常的时候,全局展开就是保证这条规则的机制。
24.2.3 暂停全局展开
通过在finally块中放入一个return语句,可以阻止系统去完成全局展开。请看下面的代码:
void FuncMonkey() {
__try {
FuncFish();
}
__except (EXCEPTION_EXECUTE_HANDLER) {
MessageBeep(0);
}
MessageBox(...);
}
void FuncFish() {
FuncPheasant();
MessageBox(...);
}
void FuncPheasant() {
__try {
strcpy(NULL, NULL);
}
__finally {
return;
}
}
在FuncPheasant的try块中,当调用strcpy函数,会引发一个内存存取异常。当异常发生时,系统开始查看是否有一个过滤器可以处理这个异常。系统会发现在FuncMonkey中的异常过滤器是处理这个异常的,并且系统开始了一个全局展开。
全局展开启动,先执行FuncPheasant的finally块中的代码。这个代码包含一个return语句,这个语句使得系统停止做展开,FuncPheasant将实际返回到FuncFish。然后FuncFish又返回到函数FuncMonkey。FuncMonkey中的代码继续执行,调用MessagBox。
注意FuncMoney的异常块中的代码不会知道对MessageBeep的调用。FuncPheasant的finally块中的return语句使得系统完全停止了展开,继续执行,就好像什么都没发生。
微软专门设计SEH按这种方式工作。程序员有可能希望使展开停止,让代码执行下去。这种方法为程序员提供了一种手段。原则上,应该小心避免在finally块中安排return语句。
24.3 EXCEPTION_CONTINUE_EXECUTION
关于异常过滤器,实际上不一定是EXCEPTION_CONTINUE_EXECUTION这个值,上面的例子Funcmeister2中只是简单地硬编码了这个标识符,异常过滤器是可以调用函数来确定应该是哪一个标识符的,下面是另一个例子:
char g_szBuffer[100];
void FunclinRooseveltl() {
int x = 0;
char *pchBuffer = NULL;
__try {
*pchBuffer = 'J';
x = 5 / x;
}
__except (OiFilter1(&pchBuffer)) {
MessageBox(NULL, "An exception occured", NULL, MB_OK);
}
MessageBox(NULL, "Function completed.", NULL, MB_OK);
}
LONG OiFilter1(char **ppchBuffer) {
*ppchBuffer = g_szBuffer;
return(EXCEPTION_CONTINUE_EXECUTION)
}
首先有一个全局的字符串,接着到了一个函数,里面有两个变量,一个是值为0的int变量、一个是未被初始化的字符串指针。然后就进入了try语句块,开始了SEH的流程。进入try块中后马上就将上面提到的字符串指针的第一个字节设置成了‘J'字符,但是字符串这个时候是没有被初始化的,这样的话CPU就会产生一个异常,并计算这个异常所在的try块所关联的except块中的异常过滤器。在这个过滤器中,对OiFilter1函数传递了上面那个未初始化的字符串指针。
这次,函数OiFilter1获得了控制权。它首先要查看参数中的字符串是不是NULL,如果是,就把它设置成全局的缓冲区g_szBuffer。然后这个过滤器返回EXCEPTION_CONTINUE_EXECUTION。当系统看到这个返回值,系统就跳转回到产生异常的指令,试图再执行一次。此时,由于字符串已经有缓冲区,字符串g_szBuffer的第一个字符也就被设置成了字母“J”。
然后代码继续执行,又在try块中遇到除0的问题。系统又要计算过滤器的值。这次OiFilter1函数看到的参数不再是NULL,就返回EXCEPTION_EXECUTION_HANDLER,告诉系统去执行except块中的代码。接下来,就会显示一个消息框,用文本框报告发生了异常。
可以看到在异常过滤器中可以做很多事情。当然过滤器必须返回三个异常标识符之一,但可以执行任何其他你想要执行的任务。
使用带警告的EXCEPTION_CONTINUE_EXECUTION
现在可以确定,修改FunclinRoosevelt1函数中的问题可以使得系统继续执行,也可以不执行。取决于程序的目标CPU,取决于编译程序为CC++语句产生的指令,取决于编译程序的编译选项。 一条简单的代码*pchBuffer = ‘J’;
可能生成2条指令:
MOV EAX, [pchBuffer] // Move the address into a register
MOV [EAX], 'J' // Move 'J' into the address
第二行的指令产生异常,异常过滤器可以捕获它,修改pchBuffer的值,并告诉操作系统重新执行第二行的指令。可是寄存器的值可能没变,不能反映装入到pchBuffer的新值,这样重新执行CPU指令又产生了另一个异常,这就发生了死循环。
这是一个非常难以修复的bug,需要检查源代码生成的汇编语言才能确定出了什么错。因此在使用EXCEPTION_CONTINUE_EXECUTION的时候,要特别的小心。
还是有一种情况,可以保证EXCEPTION_CONTINUE_EXECUTION总能成功的:当离散地向一个保留区域提交存储区之时。 在第15章讨论过如何保存一个大的地址空间,并向这个地址空间离散地提交存储区。VMAlloc示例程序就说明了这个做法。编写VMAlloc程序的一种更好的办法是必要时使用SEH提交存储区,而不是每次都调用Virtual Alloc函数。
在第16章讨论线程堆栈的时候,显示出系统是如何为线程的栈保留一个1MB的地址空间范围,以及在线程需要内存区之时,系统如何自动向堆栈提交新的内存区。为此,系统在内部建立了一个SEH框架。当一个线程试图去存取一个并不存在的栈存储区的时候,就产生一个异常。系统的异常过滤器可以确定这个异常是为了试图存储栈的保留地址空间。异常过滤器调用VirtualAlloc想线程的栈提交更多存储区,然后过滤器返回 EXCEPTION_CONTINUE_EXECUTION。此时,试图存取堆栈的存储区的CPU指令可以成功执行,线程可以继续运行。
将虚拟内存技术和结构化异常处理结合起来,可以编写一些效率很高的程序。下一章的SpreadSheet显示程序示例程序说明如何使用SEH有效地实现内存管理。
24.5 EXCEPTION_CONTINUE_SEARCH
迄今为止我们看到的例子都很平常。通过增加一个函数调用,让我们来看看其他方面的问题:
char g_szBuffer[100];
void FunclinRoosevelt2() {
char *pchBuffer = NULL;
__try {
FunAtude2(pchBuffer);
}
__except (OilFilter2(&pchBuffer)) {
MessageBox(..);
}
}
void FunAtude2(char *sz) {
*sz = 0;
}
LONG OilFilter2(char **ppcBuffer) {
if (*ppcBuffer == NULL) {
*ppcBuffer = g_szBuffer;
return(EXCEPTION_CONTINUE_EXECUTION);
}
return(EXCEPTION_EXECUTION_HANDLE);
}
进入SEH框架之后,FunclinRoosevelt2就调用了FunAtude2函数,并传递参数pchBuffer。注意pchBuffer的值是NULL,因此进了FunAtude2函数后,一旦对参数数组的第一个字符赋值为0的之后,就会引发一个异常。同前面的例子一样,系统计算和这个和这个异常所在的try块关联的最近的异常过滤器的值。在这里例子中的try块就是最近的try块,和它关联的except中的函数就是OilFilter2,也是系统计算异常过滤器的值的函数——尽管这个异常是在FunAtude2函数中产生的。
现在我们让问题变得更复杂一点,在程序中再增加一个try_except块。
char g_szBuffer[100];
void FunclinRoosevelt3() {
char *pchBuffer = NULL;
__try {
FunAtude3(pchBuffer);
}
__except (OilFilter3(&pchBuffer)) {
MessageBox(..);
}
}
void FunAtude3(char *sz) {
__try {
*sz = 0;
}
__except (EXCEPTION_CONTINUE_SEARCH) {
// This never executes.
// ...
}
}
LONG OilFilter3(char **ppcBuffer) {
if (*ppcBuffer == NULL) {
*ppcBuffer = g_szBuffer;
return(EXCEPTION_CONTINUE_EXECUTION);
}
return(EXCEPTION_EXECUTION_HANDLE);
}
现在,当FunAtude3试图向地址NULL存放0时,会引发一个异常。但这次将执行FuncAtude3函数的异常过滤器。这个异常过滤器的逻辑不复杂,只是取值EXCEPTION_CONTINUE_SEARCH,让系统继续去查找一个和except相配的try块,并调用这个try块的异常处理器。
根据这个逻辑,这回找到的异常过滤器在FunclinRoosevelt3函数,就是OilFilter3函数。它看到参数ppcBuffer的值是NULL,就将参数设定为指向全局缓冲区,然后告诉系统恢复产生异常的指令。这将使得FuncAtude3函数中try块的代码继续执行,但是这个try块中的局部变量sz没有变化,于是就造成了死循环。
前面说过,系统要查找最近执行的与except相配的try块,并计算异常过滤器的值。这就是说,系统在查找过程当中,将略过那些与finally块相匹配而不是与except块相匹配的try块。这样做的理由很明显:finally块没有异常过滤器,系统没有什么要计算的。如果前面例子中FunAtude3包含一个finally块而不是except块,系统将在一开始就通过FunclinRoosevelt3的OilFilter3计算异常过滤器的值。
24.6 GetExceptionCode
一个异常过滤器在确定要返回什么值之前,必须分析具体情况。例如,异常处理程序可能知道发生了除以0引起的异常时该怎么做,但是不知道该如何处理一个内存存取异常。异常过滤器负责检查实际情况并返回适当的值。
下面的代码举例说明了一种方法,指出所发生异常的类别:
__try {
x = 0;
y = 4 / x;
}
__except ((GetExcetionCode() == EXCEPTION_INT_DIVIDE_BY_ZERO) ?
EXCEPTION_EXECUTION_HANDLE : EXCEPTION_CONTINUE_EXECUTION);
// Handle divide by zero execution.
内部函数返回一个值,这个值指明了发生的的异常的种类:
DWORD GetExceptionCode();
下面列出所有预定义的异常和相应的含意,这些内容取自Platform SDK文档。这些异常标识符可以在winbase.h文件中找到。我们对这些异常做了分类,很多是顾名思义的。
- 与内存有关的异常:
- EXCEPTION_ACCESS_VIOLATION
- EXCEPTION_DATATYPE_MISALIGNMENT
- EXCEPTION_ARRAY_BOUNDS_EXCEEDED
- EXCEPTION_IN_PAEG_ERROR
- EXCEPTION_GUARD_PAGE
- EXCEPTION_STACK_OVERFLOW
- EXCEPTION_ILLEGAL_INSTRUCTION
- EXCEPTION_PRIV_INSTRUCTION
- 与异常相关的异常
- EXCEPTION_INVALID_DISPOSITION。一个异常过滤器返回一个值,这个值不是EXCEPTION_EXECUTION_HANDLE、EXCEPTION_CONTINUE_SEARCH、EXCEPTION_CONTINUE_EXECUTION“之一”
- EXCEPTION_NONCONTINUABLE_EXECUTION。一个异常过滤器对一个不能继续的异常返回这个值。
- 与调试相关的异常
- EXCEPTION_BREAKPOINT
- EXCEPTION_SINGAL_STEP
- EXCEPTION_INVALID_HANDLE
- 与整数有关的异常
- EXCEPTION_INT_DIVIDE_BY_ZERO
- EXCEPTION_INT_OVERFLOW
- 与浮点数有关的异常
- EXCEPTION_FLT_DENORMAL_OPERAND
- EXCEPTION_FLT_DIVIDE_BY_ZERO
- EXCEPTION_FLT_INEXACT_RESULT
- EXCEPTION_FLT_INVALID_OPERATION
- EXCEPTION_FLT_OVERFLOW
- EXCEPTION_FLT_STACK_CHECK
- EXCEPTION_FLT_UNDERFLOW
内部函数GetExceptionCode只能在一个过滤器(就是–except后面的括号)中调用,或者在一个异常处理函数程序中被调用。下面的代码是合法的:
__try {
y = 0;
x = 4 / y;
}
__except (
GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ||
GetExceptionCode() == EXCEPTION_DIVIDE_BY_ZERO ?
EXCEPTION_EXECUTION_HANDLE : EXCEPTION_CONTINUE_SEARCH
)
{
switch(GetExceptionCode()) {
case EXCEPTION_EXECUTION_HANDLE :
// Handle an invalid violation
// ...
break;
case EXCEPTION_CONTINUE_SEARCH :
// Hanlde the integer divide by 0
// ...
break;
}
}
但是,不能在一个异常过滤器函数里面调用GetExceptionCode。编译程序会捕捉这样的错误。当编译下面的代码时,将产生编译错。
__try {
y = 0;
x = 4 / y;
}
__except (CoffeeFilter()) {
// Handle the exception
}
LONG CoffeeFilter(void) {
// Compilation error: illegal call to GetExceptionCode
return (GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTION_HANDLE : EXCEPTION_CONTINUE_SEARCH);
}
可以按下面的形式改写代码:
__try {
y = 0;
x = 4 / y;
}
__except (CoffeeFilter(GetExceptionCode())) {
// Handle the exception
}
LONG CoffeeFilter(DWORD dwExceptionCode) {
return (GetExceptionCode() == EXCEPTION_ACCESS_VIOLATION ?
EXCEPTION_EXECUTION_HANDLE : EXCEPTION_CONTINUE_SEARCH);
}
异常代码遵循在WinError.h文件中定义的有关错误代码的规则。每个DWROD被划分如下表所示。
位 | 31-30 | 29 | 28 | 27-16 | 15-0 |
---|---|---|---|---|---|
内容 | 严重系数 | 微软/客户 | 保留 | 设备代码 | 异常代码 |
意义 | 0=成功 | 0=微软定义的代码 | 必须为0 | 微软定义,见下表 | 微软/客户定义 |
1=信息 | 0=微软定义的代码 | ||||
2=警告 | 1=客户定义的代码 | ||||
3=错误 | 1=客户定义的代码 |
微软定义的一些设备代码:
设备代码 | 值 | 设备代码 | 值 |
---|---|---|---|
FACILITY_NULL | 0 | FACILITY_CONTROL | 10 |
FACILITY_PRC | 1 | FACILITY_CERT | 11 |
FACILITY_DISPATCH | 2 | FACILITY_INTERNET | 12 |
FACILITY_STORAGE | 3 | FACILITY_MEDIASERVER | 13 |
FACILITY_ITF | 4 | FACILITY_MSMQ | 14 |
FACILITY_WIN32 | 7 | FACILITY_SETUPAPI | 15 |
FACILITY_WINDOWS | 8 | FACILITY_SCARD | 16 |
FACILITY_SECURITY | 9 | FACILITY_COMPLUS | 17 |
我们将EXCEPTION_ACCESS_VIOLATION异常代码拆开来,看各位 (bit)都是什么。在WinBase.h中找到EXCEPTION_ACCESS_VIOLATION。它的值为0xC0000005,在32位的二进制数中就是第31和30位都是1,表示存储异常是一个错误;第28位是0,留待后用;第16到27位是0,代表FACILITY_NULL(存储异常可能发生在系统的任何地方,不是使用特定设备才发生的异常);第0到15位包含一个数字5,表示微软将存取异常这种异常的代码定义为5。
24.7 GetExceptionInformation
当一个异常发生时,操作系统要向引起异常的线程的栈里压入三个结构,这三个结构是EXCEPTION_RECORD结构、CONTEXT结构和EXCEPTION_POINTERS结构。
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_POINTERS ExceptionRecord;
PCONTEXT ContextRecord
}EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
为了取得这些信息并在你自己的程序中使用这些信息,需要调用GetExceptionInformation函数:
PEXCEPTION_POINTERS GetExceptionInformation();
这个内部函数返回一个指向EXCEPTION_POINTERS的指针。关于GetExceptionInformation函数,要记住的最重要事情是它只能在异常过滤器中调用,因为仅仅在处理异常过滤器时,CONTEXT、EXCEPTION_RECORD和EXCEPTION_POINTERS才是有效的。一旦控制被转移到异常处理程序,栈中的数据就被删除。
如果需要在你的异常处理程序块里面存取这些异常信息(虽然很少有必要这样做),必须将EXCEPTION_POINTERS结构所指向的CONTEXT数据结构和/或EXCEPTION_RECORD数据结构保存在你所建立的一个或多个变量里。下面的代码说明了如何保存这两个结构。
void FuncSkunk() {
// Declare variable that we can use to save the exception
// record and the context if an exception should occur.
EXCEPTION_RECORD SavedExceptRec;
CONTEXT SavedContext;
// ...
__try {
// ...
}
__except (SavedExceptRec = *((GetExceptionInformation())->ExceptionRecord,
SavedContext = *((GetExceptionInformation())->ContextRecord, EXCEPTION_EXECUTION_HANDLER)
{
// We can use the SavedExceptRec and SavedContext
// variables inside the handler code block.
switch (SavedExceptRec.ExceptionCode) {
//...
default:
break;
}
}
}
注意在异常过滤器中C语言逗号(,)操作符的使用。许多程序员不习惯使用这个操作符。它告诉编译程序从左到右执行以逗号分隔的各表达式。当所有的表达式被求值之后,返回最后的(或最右的)表达式的结果。
在FuncSkunk中,左边的表达式将执行,将栈中的EXCEPTION_RECORD结构保存在
SavedExceptRec局部变量里。这个表达式的结果是SavedExceptRec的值。这个结果被丢弃,再计算右边下一个表达式。第二个表达式将栈中的CONTEXT结构保存在SavedContext局部变量里。第二个表达式的结果是SavedContext,同样当计算第三个表达式时丢弃第二个表达式的结果。第三个表达式很简单,只是一个数值EXCEPTION_EXECUTE_HANDLER。这个最右边的表达式的结果就是整个由逗号分隔的表达式组的结果。
由于异常过滤器的值是EXCEPTION_EXECUTE_HANDLER,except块中的代码要执行。这时,已被初始过的SavedExceptRec和SavedContext变量可以在except块中使用。要记住,SavedExceptRec和SavedContext变量要在try块之外说明,这一点很重要。
我们都可以猜到,EXCEPTION_POINTERS结构的ExceptionRecord成员指向EXCEPTION_RECORD结构:
typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode;
DWORD ExceptionFlags;
struct _EXCEPTION_RECORD *ExceptionRecord;
PVOID ExceptionAddress;
DWORD NumberParameter;
ULONG_PTR ExceptionInformation[EXCEPTION_MAMIMUM_PARAMETERS];
} EXCEPTION_RECORD;
EXCEPTION_RECORD结构包含有关最近发生的异常的详细信息,这些信息独立于CPU:
- ExceptionCode包含异常的代码。这同内部函数GetExceptionCode返回的信息是一样的。
- ExceptionFlags包含有关异常的标志。当前只有两个值,分别是0(指出一个可以继续的
异常)和EXCEPTION_NONCONTINUABLE(指出一个不可继续的异常)。在一个不可
继续的异常之后,若要继续执行,会引发一个EXCEPTION_NONCONTINUABLE_
EXCEPTION异常。 - ExceptionRecord指向另一个未处理异常的EXCEPTION_RECORD结构。在处理一个异常
的时候,有可能引发另外一个异常。例如,异常过滤器中的代码就可能用零来除一个数。
当嵌套异常发生时,可将异常记录链接起来,以提供另外的信息。如果在处理一个异常
过滤器的过程当中又产生一个异常,就发生了嵌套异常。如果没有未处理异常,这个成
员就包含一个NULL。 - ExceptionAddress指出产生异常的CPU指令的地址。
- NumberParameters规定了与异常相联系的参数数量(0到15)。这是在
ExceptionInformation数组中定义的元素数量。对几乎所有的异常来说,这个值都是零。 - ExceptionInformation规定一个附加参数的数组,用来描述异常。对大多数异常来说,数
组元素是未定义的。
EXCEPTION_RECORD结构的最后两个成员,NumberParameters和ExceptionInformation向异常过滤器提供一些有关异常的附加信息。目前只有一种类型的异常提供附加信息,就是EXCEPTION_ACCESS_VIOLATION。所有其他可能的异常都将NumberParameters设置成零。
我们可以检验ExceptionInformation的数组成员来查看关于所产生异常的附加信息。
对于一个EXCEPTION_ACCESS_VIOLATION异常来说,ExceptionInformation[0]包含一个标志,指出引发这个存取异常的操作的类型。如果这个值是0,表示线程试图要读不可访问的数据。如果这个值是1,表示线程要写不可访问的数据。ExceptionInformation[1]指出不可访问数据的地址。
通过使用这些成员,我们可以构造异常过滤器,提供大量有关程序的信息。例如,可以这样编写异常过滤器:
__try {
// ...
}
__except (ExpFltr(GetExceptionInformation()->ExceptionRecord)) {
// ...
}
LONG ExpFltr (PEXCEPTION_RECORD pER) {
char szBuf[300], *p;
DWORD dwExceptionCode = pER->ExceptionCode;
sprint(szBuf, "Code = %x, Address = %p",
dwExceptionCode, pER->ExceptionAddress);
// Find the end of the string.
p = strchr(szBuf, 0);
// I used a switch statement in case Micrsoft adds
// information for other exception codes in the fucture.
swtich (dwExceptionCode) {
case EXECUTION_ACCESS_VIOLATION:
sprintf(p, "Attempt to %s data at address %p",
pER->ExceptionInformation[0] ? "write" : "read",
pER->ExceptionInformation[1]);
break;
default:
break;
}
MessageBox(NULL, szBuf, "Exception", MB_OK | MB_ICONNEXCLAMATION);
return(EXECUTION_CONTINUE_SEARCH);
}
EXCEPTION_POINTERS结构的ContextRecord成员指向一个CONTEXT结构(第7章讨论
过)。这个结构是依赖于平台的,也就是说,对于不同的CPU平台,这个结构的内容也不一样。
24.8 软件异常本质上,对C P U上每一个可用的寄存器,这个结构相应地包含一个成员。当一个异常被引
发时,可以通过检验这个结构的成员找到更多的信息。遗憾的是,为了得到这种可能的好处,
要求程序员编写依赖于平台的代码,以确认程序所运行的机器,使用适当的 C O N T E X T结构。
最好的办法是在代码中安排一个 # i f d e f s指令。Wi n d o w s支持的不同C P U的C O N T E X T结构定义
在Wi n N T. h文件中。
到现在为止,我们讨论的都是硬件的异常,也就是CPU捕获了一个事件并引发的异常。在代码中也有办法强制引发一个异常,这是一个函数向它的调用者报告失败的一种办法。传统的做法是函数失败的时候返回一个特殊的值来指出失败的原因。这种错误代码随着函数的逐层传递使得程序便变得难以编写和维护。
另一个种方式是在函数失败的时候引发一场。使用这种方法,代码变得容易编写和魏如,而且也执行得更好,因为通常不需要执行那些错误测试的代码。实际上,仅当发生失败时也就是异常时才执行错误测试代码。
令人遗憾的是,许多开发人员不习惯在错误处理的时候使用异常。这有2方面的原因,第一是多数开发人员不熟悉SEH。即使有程序员熟悉它,但其他程序员可能不熟悉它。如果一个程序员编写了一个引发异常的函数,但其他程序员并不编写SEH框架来捕获这个异常,那么进程间会被操作系统结束。
开发人员不使用SEH的第二个原因是它不能移植到其他操作系统。许多公司的产品要面向多种操作系统,因此希望有单一的源代码作为产品的基础,这是可以理解的。SEH是专门针对Windows的技术。
本段讨论通过异常返回错误有关的内容。首先,让我们看一看Windows Heap函数,例如HeapCreate和HeapAlloc等。回顾18章的内容,我们知道这些函数向开发人员提供一种选择。通常当某个堆函数失败的时候,它会返回NULL开指出失败。然和可以对这些对函数传递HEAP_GENERATE_EXCEPTION标志。如果使用了这个标志并且函数失败了,函数将不会返回NULL,而是引发一个名叫STATUS_NO_MEMORY的软件异常,程序代码的其他部分可以用SEH框架来捕获这个异常。
如果想利用这个异常,可以编写你的try块。如果内存分配失败,可以利用except块来处理这个异常。通过try-finally的组合也可以。用这种方法清除函数的错误是非常方便的。
程序捕获软件异常的方式和捕获硬件异常完全一样,同样可以用try-except的组合捕获并处理这种异常。如果想要引发一个软件异常,只需要调用函数RaiseException:
VOID RaiseException(
DWORD dwExceptionCode,
DWORD dwExceptionFlags,
DWORD nNumberOfArguments,
CONST ULONG_PTR *pArguments
);
第一个参数dwExceptionCode是标识所引发异常的值。HeapAlloc函数对这个参数设定
STATUS_NO_MEMORY。如果程序员要定义自己的异常标识符,应该遵循标准Windows错误代码的格式,像WinError.h文件中定义的那样。
如果要建立你自己的异常代码,要填充DWORD的4个部分:
- 第31位和第30位包含严重系数(severity)。
- 第29位是1(0表示微软建立的异常,如HeapAlloc的STATUS_NO_MEMORY)
- 第28位是0
- 第27到第16位数某个微软定义的设备代码
- 第15到第0位数一个任意值,表示引起异常的程序段。
RaiseException的第二个参数dwExceptionFlags,必须是0或EXCEPTION_NONCONTINUABLE。本质上,这个标志是用来规定异常过滤器返回EXCEPTION_CONTINUE_EXECUTION来响应所引发的异常是否合法。如果没有向RaiseException传递EXCEPTION_NONCONTINUABLE参数值,则过滤器可以返回EXCEPTION_CONTINUE_EXECUTION。正常情况下,这将导致线程重新执行引发软件异常的同一CPU指令。但微软已做了一些动作,所以在调用RaiseException函数之后,执行会继续进行。
如果你向RaiseException传递了EXCEPTION_NONCONTINUABLE标志,你就是在告诉系统,你引发异常的类型是不能被继续执行的。这个标志在操作系统内部被用来传达致命(不可恢复)的错误信息。另外,当HeapAlloc引发STATUS_NO_MEMORY软件异常时,它使用EXCEPTION_NONCONTINUABLE标志来告诉系统,这个异常不能被继续。意思就是没有办法强制分配内存并继续运行。
如果一个过滤器忽略EXCEPTION_NONCONTINUABLE并返回EXCEPTION_CONTINUE_
EXECUTION,系统会引发新的异常:EXCEPTION_NONCONTINUABLE_EXCEPTION。
当程序在处理一个异常的时候,有可能又引发另一个异常。比如说,一个无效的内存存取有可能发生在一个finally块、一个异常过滤器、或一个异常处理程序里。当发生这种情况时,系统压栈异常。回忆一下GetExceptionInformation函数。这个函数返回EXCEPTION_POINTERS结构的地址。EXCEPTION_POINTERS的ExceptionRecord成员指向一个EXCEPTION_RECORD结构,这个结构包含另一个ExceptionRecord成员。这个成员是一个指向另外的EXCEPTION_RECORD的指针,而这个结构包含有关以前引发异常的信息。
通常系统一次只处理一个异常,并且ExceptionRecord成员为NULL。然而如果处理一个异常
的过程中又引发另一个异常,第一个EXCEPTION_RECORD结构包含有关最近引发异常的信息,
并且这个EXCEPTION_RECORD结构的ExceptionRecord成员指向以前发生的异常的EXCEPTION_
RECORD结构。如果增加的异常没有完全处理,可以继续搜索这个EXCEPTION_RECORD结
构的链表,来确定如何处理异常。
RaiseException的第三个参数nNumberOfArguments和第四个参数pArguments,用来传递有关
所引发异常的附加信息。通常,不需要附加的参数,只需对pArguments参数传递NULL,这种
情况下,RaiseException函数忽略nNumberOfArguments参数。如果需要传递附加参数,
nNumberOfArguments参数必须规定由pArguments参数所指向的ULONG_PTR数组中的元素数
目。这个数目不能超过EXCEPTION_MAXIMUM_PARAMETERS,EXCEPTION_MAXIMUM_
PARAMETERS在WinNT.h中定义成15。
在处理这个异常期间,可使异常过滤器参照EXCEPTION_RECORD结构中的NumberParameters和ExceptionInformation成员来检查nNumberOfArguments和pArguments参数中的信息。
你可能由于某种原因想在自己的程序中产生自己的软件异常。例如,你可能想向系统的事件日志发送通知消息。每当程序中的一个函数发现某种问题,你可以调用RaiseException并让某些异常处理程序上溯调用树查看特定的异常,或者将异常写到日志里或弹出一个消息框。你还可能想建立软件异常来传达程序内部致使错误的信息。