C 和异常
回忆一下我在第一节中介绍的EXCEPTION_REGISTRATION结构,我们曾用它向操作系统注册了发生异常时要被调用的回调函数。VC 也是这么做的,不过它扩展了这个结构的语义,在它的后面添加了两个新字段:
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
int id;
DWORD ebp;
};
VC 会为绝大部分函数③添加一个EXCEPTION_REGISTRATION类型的局部变量,它的最后一个字段(ebp)与栈桢指针指向的位置重叠。函数的序言创建这个结构并把它注册给操作系统,尾声则恢复主调函数的EXCEPTION_REGISTRATION。id字段的意义我将在下一节介绍。
VC 编译函数时会为它生成两部分数据:
a)异常回调函数
b)一个包含函数重要信息的数据结构,这些信息包括catch块、这些块的地址和这些块所关心的异常的类型等等。我把这个结构称为funcinfo,有关它的详细讨论也在下一节。
图3是考虑了异常处理之后的运行时堆栈。widget的异常回调函数位于由FS:[0]指向的异常处理链的开始位置(这是由widget的序言设置的)。异常处理程序把widget的funcinfo结构的地址交给函数__CxxFrameHandler,__CxxFrameHandler会检查这个结构看函数中有没有catch块对当前的异常感兴趣。如果没有的话,它就返回ExceptionContinueSearch给操作系统,于是操作系统会从异常处理链表中取得下一个结点,并调用它的异常处理程序(也就是调用当前函数的那个函数的异常处理程序)。

(图片较大,请拉动滚动条观看)
这一过程将一直进行下去——直到处理程序找到一个能处理当前异常的catch块为止,这时它就不再返回操作系统了。但是在调用catch块之前(由于有funcinfo结构,所以知道catch块的入口,参见图3),必须进行堆栈展开,也就是清理掉当前函数的栈桢下面的所有其他的栈桢。这个操作稍微有点复杂,因为:异常处理程序必须找到异常发生时生存在这些栈桢上的所有局部对象,并依次调用它们的析构函数。后面我将对此进行详细介绍。
异常处理程序把这项工作委托给了各个栈桢自己的异常处理程序。从FS:[0]指向的异常处理链的第一个结点开始,它依次调用每个结点的处理程序,告诉它堆栈正在展开。与之相呼应,这些处理程序会调用每个局部对象的析构函数,然后返回。此过程一直进行到与异常处理程序自身相对应的那个结点为止。
由于catch块是函数的一部分,所以它使用的也是函数的栈桢。因此,在调用catch块之前,异常处理程序必须激活它所隶属的函数的栈桢。
其次,每个catch块都只接受一个参数,其类型是它希望捕获的异常的类型。异常处理程序必须把异常对象本身或者是异常对象的引用拷贝到catch块的栈桢上,编译器在funcinfo中记录了相关信息,处理程序根据这些信息就能知道到哪去拷贝异常对象了。
拷贝完异常并激活栈桢后,处理程序将调用catch块。而catch块将把控制权下一步要转移到的地址返回来。请注意:虽然这时堆栈已经展开,栈桢也都被清除了,但它们占据的内存空间并没有被覆盖,所有的数据都还好好的待在栈上。这是因为异常处理程序仍在执行,象其他函数一样,它也需要栈来存放自己的局部对象,而其栈桢就位于发生异常的那个函数的栈桢的下面。catch块返回以后,异常处理程序需要“杀掉”异常对象。此后,它让ESP指向目标函数(控制权要转移到的那个函数)的栈桢的末尾——这样就把(包括它自己的在内的)所有栈桢都删除了,然后再跳转到catch块返回的那个地址去,就胜利的完成整个异常处理任务了。但它怎么知道目标函数的栈桢末尾在哪呢?事实上它没法知道,所以编译器把这个地址保存到了栈桢上(由前言来完成),如图3所示,栈桢指针EBP下面第16个字节就是。
当然,catch块也可能抛出新异常,或者是将原来的异常重新抛出。处理程序必须对此有所准备。如果是抛出新异常,它必须杀掉原来的那个;而如果是重新抛出原来的异常,它必须能继续传播(propagate)这个异常。
这里我要特别强调一点:由于每个线程有自己独立的堆栈,所以每个线程也都有自己独立的、由FS:[0]指向的EXCEPTION_REGISTRATION链。
C 和异常—2
图4是funcinfo的布局,注意这里的字段名可能与VC 编译器实际使用的不完全一致,而且我也只给出了和我们的讨论相关的字段。堆栈展开表(unwind table)的结构留到下节再讨论。

(图片较大,请拉动滚动条观看)
异常处理程序在函数中查找catch块时,它首先要判断异常发生的位置是否在当前函数(发生异常的那个函数)的一个try块中。是则查找与此try块相关的catch块表,否则直接返回。
先来看看它怎样找try块。编译时,编译器给每个try块都分配了start id和end id。通过funcinfo结构,异常处理程序可以访问这两个id,见图4。编译器为函数中的每个try块都生成了相关的数据结构。
上一节中,我说过VC 给EXCEPTION_REGISTRATION结构加上了一个id字段。回忆一下图3,这个结构位于函数的栈桢上。异常发生时,处理程序读出这个值,看它是否在try块的两个id确定的区间[start id,end id]中。是的话,异常就发生在这个try块中;否则继续查看try块表中的下一个try块。
谁负责更新id的值,它的值又应该是什么呢?原来,编译器会在函数的多个位置安插代码来更新id的值,以反应程序的实时运行状态。比如说,编译器会在进入try块的地方加上一条语句,把try块的start id写到栈桢上。
找到try块后,处理程序就遍历与其关联的catch块表,看是否有对当前异常感兴趣的catch块。在try块发生嵌套时,异常将既源于内层try块,也源于外层try块。这种情况下,处理程序应该按先内后外的顺序查找catch块。但它其实没必要关心这些,因为,在try块表中,VC 总是把内层try块放在外层try块的前面。
异常处理程序还有一个难题就是“如何根据catch块的相关数据结构判断这个catch块是否愿意处理当前异常”。这是通过比较异常的类型和catch块的参数的类型来完成的。例如下面这个程序:
void foo()
{
try
{
throw E();
}
catch(H)
{
//.
}
}
如果H和E的类型完全相同的话,catch块就要捕获这个异常。这意味着处理程序必须在运行时进行类型比较,对C等语言来说,这是不可能的,因为它们无法在运行时得到对象的类型。C 则不同,它有了运行时类型识别(runtime type identification,RTTI),并提供了运行时类型比较的标准方法。C 在标准头文件中定义了type_info类,它能在运行时代表一个类型。catch块数据结构的第二个字段(ptype_info,见图4)是一个指向type_info结构的指针,它在运行时就代表catch块的参数类型。type_info也重载了==运算符,能够指出两种类型是否完全相同。这样,异常处理程序只要比较(调用==运算符)catch块参数的type_info(可以通过catch块的相关数据结构来访问)和异常的type_info是否相同,就能知道catch块是不是愿意捕获当前异常了。
catch块的参数类型可以通过funcinfo结构得到,但异常的type_info从哪来呢?当编译器碰到throw E();这条语句时,它会为异常生成一个excpt_info结构,如图5所示。还是要提醒你注意这里用的名字可能与VC 使用的不一致,而且仍然只有与我们的讨论相关的字段。从图中可以看出,异常的type_info可以通过excpt_info结构得到。由于异常处理程序需要拷贝异常对象(在调用catch块之前),也需要消除掉它(在调用catch块之后),所以编译器在这个结构中同时提供了异常的拷贝构造函数、大小和析构函数的信息。

(图片较大,请拉动滚动条观看)
在catch块的参数是基类,而异常是派生类时,异常处理程序也应该调用catch块。然而,这种情况下,比较它们的type_info绝对是不相等,因为它们本来就不是相同的类型。而且,type_info类也没有提供任何其他函数或运算符来指出一个类是另一个类的基类。但异常处理程序还必须得去调用catch块!为了解决这个问题,编译器只能为处理程序提供更多的信息:如果异常是派生类,那么etypeinfo_table(通过excpt_info访问)将包含多个指向etype_info(扩展了type_info,这个名字是我启的)的指针,它们分别指向了各个基类的etype_info。这样,处理程序就可以把catch块的参数和所有这些type_info比较,只要有一个相同,就调用catch块。
在结束这一部分之前,还有最后一个问题:异常处理程序是怎么知道异常和excpt_info结构的?下面我就要回答这个问题。
VC 会把throw语句翻译成下面的样子:
//throw E(); //编译器会为E生成excpt_info结构
E e = E(); //在栈上创建异常
_CxxThrowException(