引言
开始文章之前,先声明几件重要事情。本文不是一篇介绍如何在x86架构上详细地实现异常处理(exception handling)的手册,只是一篇入门的讨论性文章。本文只是讨论一些处理步骤在理论上应该如何实现,其具体的实现步骤可能跟文章所讨论的并不一致。如果你关注异常处理的细节,并实现它,这可能要花费你数年之功。一些程序员可能(大部分是Java程序员)并不清楚一些异常不能被catch到,可能是因为她们在函数中已经被处理了。这类程序员可能以为程序中的try-catch代码段在程序被编译器编译时,就已经被分析完了,所以程序运行期间并不受异常影响。为了澄清这些疑惑,我会说明微软的编译器(cl.exe)是如何处理try、catch和throw的。 首先,先说明编译器如何处理throw语句块。有代码段如下:
int main()
{
try
{
throw 2;
}
catch(...)
{
}
}
Throw
现在,把关注点放在"throw 2"这个语句上。当编译器处理这个语句时,它只是检查语法错误与否并作语句解析,并不晓得这个语句块应该由exception handler来处理,因为这并不是应该由编译器该干的活。这行语句会变成对函数_CxxThrowException (函数来自MSVCR100.dll或其他类似版本的dll)的调用。 这个函数有编译器内部构建。你喜欢的话,你可以自己调用它。这个函数的第一个参数是指向抛出的异常对象的指针。 所以,上面的代码经过编译后,就成了如下形式:
int main()
{
try
{
int throwObj = 2;
throw throwObj;
}
catch(...)
{
}
}
函数_CxxThrowException的第二个参数是指向_ThrowInfo类型的对象的指针。_ThrowInfo也是编译器内部的一种数据类型。它是一个结构体,包含了所抛出的异常对象的各种相关的详细信息。其形式如下:
typedef const struct _s__ThrowInfo
{
unsigned int attributes;
_PMFN pmfnUnwind;
int (__cdecl*pForwardCompat)(...);
_CatchableTypeArray *pCatachableTypeArray;
} _ThrowInfo;
结构体中重要的成员是_CatchableTypeArray。它包含了程序运行时抛出对象的类新信息(RTTI)。本文中示例非常简单,相关的类型只有typeid(int)。假如你有一个类my_exception,继承自std::exception。如果你的程序运行时抛出一个my_exception类型的对象,那么抛出的数据参数pCatchableTypeArray包含了两个重要子数据信息。一个是typeid(my_exception),另外一个是typeid(std::exception)。编译器会创建一个全局变量,这个全局变量的类型是_ThrowInfo。根据以上讨论,一个语句throw 2被编译后,就成了以下形式:
_TypeDescriptor tDescInt = typeid(int);
_CatchableType tcatchInt =
{
0,
&tDescInt,
0,
0,
0,
0,
NULL,
};
_CatchableTypeArray tcatchArrInt =
{
1,
&tcatchInt,
};
_ThrowInfo tiMain1 =
{
0,
NULL,
NULL,
&tcatchArrInt
};
你看,一句throw 2编译后就存储了如此之多的信息。 所以,最终的代码扩展成了如下形式:
_TypeDescriptor tDescInt = typeid(int);
_CatchableType tcatchInt =
{
0,
&tDescInt,
0,
0,
0,
0,
NULL,
};
_CatchableTypeArray tcatchArrInt =
{
1,
&tcatchInt,
};
_ThrowInfo tiMain1 =
{
0,
NULL,
NULL,
&tcatchArrInt
};
int main()
{
try
{
int throwObj = 2;
_CxxThrowException(&throwObj, &tiMain1);
}
catch(...)
{
}
}
函数_CxxThrowException内部,它会调用函数RaiseException,一般被称作抛出异常原语。调用这个原语之前,首先要为这个函数创建相关的参数。函数_CxxThrowException抛出的异常码是0xE06D7363。除了异常码,RaiseException还需要另外三个参数。一个异常标识、抛出的对象的数目和抛出的对象的数组的首地址。其代码示例如下:
__declspec(noreturn) void __stdcall __CxxThrowException(void* pObj, _ThrowInfo* pInfo)
{
struct { unsigned int magic; void* object, _ThrowInfo* info } Params;
Params throwParams =
{
0x19930520,
pObj,
pInfo
}
RaiseException(0xE06D7363, 1, 3, (const ULONG_PTR*)&throwParams);
}
现在,关于编译器如何处理抛出异常语句,我们还看到最后如果有访问违例,则仍然调用原语RaiseException。Catch
Ok,如果我们现在进一步检查try和catch,你可能突然意识到什么事情,你可能会说“等等!!你说throw变成了对RaiseException的调用,例如访问违例、除以0等异常发生时,也是这么调用么?可是这类异常并不能被try-catch调用啊?”是的,你说对了,这类异常不能用try-catch处理,对待这类异常编译器会把它们变成__try __except形式。在代码中,它会变成如下形式(再次声明,如下只是理论上如此处理):
unsigned long __stdcall mainHandler1(LPEXCEPTION_POINTERS info)
{
if(info->ExceptionRecord->ExceptionCode != 0xE06D7363)
return EXCEPTION_CONTINUE_SEARCH; //非C++类异常,则继续寻找SEH链表的下个结构进行处理
if(WeHaveAHandlerForThisTypeSomeWhere(info->ExceptionRecord))
return EXCEPTION_EXECUTE_HANDLER; //执行处理
return EXCEPTON_CONTINUE_SEARCH;
}
/* The stuff with _ThrowInfo comes here, omitted for readability */
int main()
{
__try
{
int throwObj = 2;
_CxxThrowException(&throwObj, &tiMain1);
}
__except(mainHandler1(GetExceptionInfo())
{
}
}
还没完呢。还需要一些数据结构存储我们能够处理的异常的类型,这些可以通过catch语句块实现。实际上,catch(int)语句块变成了一个函数(实际上只是一个函数语句块,通过jmp实现运行时的函数语句跳转,并不是实际中通过call实现的函数调用)形式如下(这些也是伪码,如果用C实现,则整个代码会很长):
_s_FuncInfo* info = mainCatchBlockInfo1;
__asm { mov eax, info } //通过eax为下面这个函数提供参数
// and passed through eax
goto CxxFrameHandler3;
类型_s_FuncInfo也是编译器内建的一种结构体。它类似于_ThrowInfo,里面也包含了各种与异常有关的信息。简单的说,就是它包含了异常发生时的异常环境信息。它由各种运行现场的类型信息和catch块中的处理信息。Ok,你现在可能要问CxxFrameHandler3是做什么的?很简单,它完成以下工作:
1. 它拒绝处理那些异常码不为0xE06D7363的异常(这个异常码代表了当前异常是C++类型的异常)。
2. 它搜寻_s_FuncInfo结构,以寻找一个与_CatchableTypeArray异常数组中类型相匹配的异常对象。
3. 如果匹配成功,它会指出处理异常的地方(exception handler)。
4. 如果匹配不成功,就把搜索权限交还给OS,到下一个块继续搜索。
上面的对异常应该由那个catch块完成的搜索工作完成后,最后一步就是在exception handler的code中应该如何处理这个异常。这些代码也会被编译一个函数功能块(当然并不是一个完整的函数)。它实际上变成了一个怎么结束函数执行的代码块。代码形式如下:
// execute handler code
return addressWhereToContinueAfterCatch;
OS得到跳转地址后,它会重建异常发生时的运行环境,然后进行跳转。
catch(...)
{
}
MessageBox(0, L"Ello!", L"", MB_OK);
编译后的汇编语句如下:
.text:00401088 $LN16:
.text:00401088 mov eax, offset $LN9
.text:0040108D retn
.text:0040108E ; ------------------------------------------------------------------------
.text:0040108E
.text:0040108E $LN9: ; DATA XREF: _main:$LN16 o
.text:0040108E push 0 ; uType
.text:00401090 push offset Caption ; lpCaption
.text:00401095 push offset Text ; "Ello!"
.text:0040109A push 0 ; hWnd
.text:0040109C call ds:__imp__MessageBoxW@16 ; MessageBoxW(x,x,x,x)
你可以看到寄存器eax的值$LN9,通过这个地址可以调用函数MessageBox。地址$LN16是catch语句块的地址,它实际上是结构体_s_FuncInfo的地址。Try
剩剩余部分就是try了。这里的“trye”就不再是编译器决定如果工作了,而是应该由操作系统来做出决定了。在线程信息块Thread Information Block里,第一部分(fs:[0])保存了异常处理链的指针。
(在我提供的例子中,就是CxxFrameHandler3的地址)。try的工作就是把把catch-block添加到这个链表中。
调用RaiseException后,接下来调用的函数是KiUserExceptionDispatcher。这个函数有很多工作要做,其最后的重要工作就是从处理链表中寻找能对异常进行处理的合适的handler,函数用FS:[0]从TIB中得到链表的首地址,
如果你相遍历当前的handlers,你可以用以下步骤实现:
struct LinkedExceptionFrame
{
LinkedExceptionFrame* pPrevious;
void* pFunction;
};
LinkedExceptionFrame* pCur = NULL;
__asm
{
mov eax, fs:[0]
mov pCur, eax
}
while((DWORD)pCur != 0xFFFFFFFF)
{
std::cout << pCur->pFunction << std::endl;
pCur = pCur->pPrevious;
}
上面把异常处理过程有关的基本概念都介绍完了,异常处理并不是想一些人想象地那么琐碎麻烦,并且许多工作都是在程序运行时完成的,当然是通过编译器补充添加的大量的补充数据和函数,并由这些函数依据这些数据来寻找正确的handler来完成异常的处理。其实深入下去讨论的话,异常处理还有很多细节需要讨论,如果有多个try-catch块,或者异常发生时,frame的那些部分应该受到保护,而另外一些不应该受到保护。但是有关异常处理的大部分重要话题我们都已经讨论了。如果还想了解异常处理的各种细节(tips),你需要使用反汇编器(disassembler)和调试程序(debugger)。
可以使用release模式进行编译连接,但是要把所有的代码优化选项关闭掉。这样你就不用再程序的开头看到那么多注册信息,在程序最后看到那么多函数调用,而且代码也不会被优化器给重新排列,你可以把编译后的代码与原始代码一一比对进行阅读。而且,最好也把连接选项的Dynamic Base (ASLR)禁用。