1. 实例剖析EH
到现在为止,我仍然逗留在C和C++的范围内,但这次要稍微涉及一下汇编语言。目标:初步揭示Visual C++对EH的throw和catch的实现。本文不是巨细无遗的,毕竟我的原则是只关注(C/C++)语言本身。然而,简单的揭示EH的实现对理解和信任EH大有帮助。
1.1 我们所害怕的唯一一件事
在throw过程中退栈时,EH追踪哪个局部对象需要析构,预先安排必须的析构函数的调用,并且将控制权交给正确的异常处理函数。为了完成EH所需的记录和管理工作,编译器暗中在生成的代码中注入了数据、指令和库引用。
不幸的是,很多程序员(以及他们的经理)讨厌这种注入行为导致过分的代码膨胀。他们感到恐慌,认为EH会削弱程序的使用价值。所以,我认为EH触及了人们对未知的恐惧:因为源码中没有明确地表露出EH的工作,他们将作最坏的估算。
为了战胜这种恐惧,让我们通过短小的Visual C++代码剖析EH。
1.2 例1:基线版本
生成一个新的C++源文件EH.cpp如下:
class C
{
public:
C()
{
}
~C()
{
}
};
void f1()
{
C x1;
}
int main()
{
f1();
return 0;
}
然后,创建一个新的Visual C++控制台项目,并包含EH.CPP为唯一的源文件。使用默认项目属性,但打开“生成源码/汇编混合的.asm文件”选项。编译出Debug版本。在我机器上,得到的EH.exe是23,040字节。
打开EH.asm文件,你将发现f1()函数非常接近预料:设置栈框架,调用xl的构造和析构函数,然后重设栈框架。特别地,你将注意到没有任何EH产物或记录――并不奇怪,因为程序没有抛出或捕获任何异常。
1.3 例2:单异常处理函数
现在将f1改为如下形式:
void f1()
{
C x1;
try
{
}
catch(char)
{
}
}
重新编译EH.exe,然后注意文件大小。在我机器上,大小从23,040字节增到29,696字节。有些心跳吧,EH导致了29%的文件大小的增加。但看一下绝对增加,才6,656字节,并且绝大部分是来自于固定大小的库开销。剩下的少量才是额外注入到EH.obj中的代码和数据。
在EH.asm中,可以找到符号__$EHRec$定义了一个常量值,它表示对于栈框架的偏移量。每个函数都在其生成的代码中引用了__$EHRec$,编译器暗中定义了一个局部的“EH记录”记录对象。
EH记录是暂时的:和需要在代码中有个永久的静态记录相比,它们存在于栈中,在函数被进入时产生,在函数退出是消失。在且仅在函数需要提早析构局部对象时,编译器增加了EH记录(并且由局部代码维护它)。
隐含意思是,有些函数不需要EH记录。看这个,增加的第二个函数:
void f2()
{
}
没有涉及对象和异常。重新编译程序。EH.asm显示f1()的栈中和以前一样包括一个EH记录,但f2()的栈中没有。然而,如果将代码改成这样:
void f2()
{
C x2;
f1();
}
f2()现在定义了一个局部的EH记录,即使f2()自己没有try块。为什么?因为f2()调用了f1(),而f1()可能抛出异常而终止f2(),因此需要提早析构x2。
结论:如果一个包含局部对象的函数没有明确处理异常,但可能传递一个别人抛的异常,那么函数仍然需要一个EH记录和相应的维护代码。
这使你苦恼了吗?只要短路异常链就可以了。在我们的例子中,将f1()的定义改成:
void f1() throw()
{
C x1;
try
{
}
catch(char)
{
}
}
现在f1()承诺不抛异常。结果,f2()不需要传递f1()的异常,也就不需要EH记录了。你可以重新编译程序来核实,查看EH.asm并发现f2()的代码不再提到__$EHRec$。
1.4 例3:多个异常处理函数
EH记录及其支撑代码不是编译所引入的唯有的记录。对给定try块的每个处理函数,编译器也都创建了入口表。想看得清楚些,将现在的EH.asm改名另存,并将f1()扩展为:
void f1() throw()
{
C x1;
try
{
}
catch(char)
{
}
catch(int)
{
}
catch(long)
{
}
catch(unsigned)
{
}
}
重新编译,然后比较两次的EH.asm。
(提醒:下面列出的EH.asm,我没有忽略不相关的东西,也没有用省略号代替什么。精确的标号名在你的系统上可能不一样。并且不要以汇编语言分析器的眼光看这些代码。)
在我的EH.asm中,相关的名字、描述符和注释如下:
PUBLIC ??_R0D@8 ; char `RTTI Type Descriptor'
PUBLIC ??_R0H@8 ; int `RTTI Type Descriptor'
PUBLIC ??_R0J@8 ; long `RTTI Type Descriptor'
PUBLIC ??_R0I@8 ; unsigned int `RTTI Type Descriptor'
_DATA SEGMENT
??_R0D@8 DD FLAT:??_7type_info@@6B@ ; char `RTTI Type Descriptor'
DD ...
DB '.D', ...
_DATA ENDS
_DATA SEGMENT
??_R0H@8 DD FLAT:??_7type_info@@6B@ ; int `RTTI Type Descriptor'
DD ...
DB '.H', ...
_DATA ENDS
_DATA SEGMENT
??_R0J@8 DD FLAT:??_7type_info@@6B@ ; long `RTTI Type Descriptor'
DD ...
DB '.J', ...
_DATA ENDS
_DATA SEGMENT
??_R0I@8 DD FLAT:??_7type_info@@6B@ ; unsigned int `RTTI Type Descriptor'
DD ...
DB '.I', ...
_DATA ENDS
(对于“RTTI Type Descriptor”和“type_info”的注释提示我,Visual C++在EH和RTTI时使用了同样的类型名描述符。)
编译器同样生成了对在xdata@x段中定义的类型描述符的引用。每个类型对应一个捕获这种类型的异常处理函数的地址。这种描述符/处理函数对构成了EH库代码分发异常时的分发表。这些也是从我的EH.asm下摘抄的,加上了注释和图表:
xdata$x SEGMENT
$T214 DD ...
DD ...
DD FLAT:$T217 ;---+
DD ... ; |
DD FLAT:$T218 ;---|---+
DD 2 DUP(...) ; | |
ORG $+4 ; | |
; | |
$T217 DD ... ;<--+ |
DD ... ; |
DD ... ; |
DD ... ; |
; |
$T218 DD ... ;<------+
DD ...
DD ...
DD 04H ; # of handlers
DD FLAT:$T219 ;---+
ORG $+4 ; |
; |
$T219 DD ... ;<--+
DD FLAT:??_R0D@8 ; char RTTI Type Descriptor
DD ...
DD FLAT:$L206 ; catch(char) address
DD ...
DD FLAT:??_R0H@8 ; int RTTI Type Descriptor
DD ...
DD FLAT:$L207 ; catch(int) address
DD ...
DD FLAT:??_R0J@8 ; long RTTI Type Descriptor
DD ...
DD FLAT:$L208 ; catch(long) address
DD ...
DD FLAT:??_R0I@8 ; unsigned int RTTI Type Descriptor
DD ...
DD FLAT:$L209 ; catch(unsigned int) address
xdata$x ENDS
分发表表头(标号$T214、 $T217和 $T218处的代码)是f1()专属的,并为f1()的所有异常处理函数共享。$T219出的分发表的每一个入口项都特属于f1()的一个特定的异常处理函数。
更一般地,编译器为每一带try块的函数生成一个分发表表头,为每一个异常处理函数增加一个入口项。类型描述符为程序的所有分发表共享。(例如,程序中所有catch(long)的处理函数引用同样的??_R0J@8类型描述符。)
提要:要减小EH的空间开销,应该将程序中捕获异常的函数数目减到最小,将函数中异常处理函数的数目减到最小,将异常处理函数所捕获的异常类型减到最小。
1.5 例四:抛异常
用“抛一个异常”来将所有东西融会起来。将f1()的try语句改成这样:
try
{
throw 123; // type 'int' exception
}
重新编译程序,打开EH.asm,注意新出现的东西(我同样加了的注释和图表)。
; in these exported names, 'H' is the RTTI Type Descriptor
; code for 'int' -- which matches the data type of
; the thrown exception value 123
PUBLIC __TI1H
PUBLIC __CTA1H
PUBLIC __CT??_R0H@84
; EH library routine that actually throws exceptions
EXTRN __CxxThrowException@8:NEAR
; new static data blocks used by library
; when throwing 'int' exception
xdata$x SEGMENT
__CT??_R0H@84 DD ... ;<------+
DD FLAT:??_R0H@8 ; | ??_R0H@8 is RTTI 'int'
; | Type Descriptor
DD ... ; |
DD ... ; |
ORG $+4 ; |
DD ... ; |
DD ... ; |
; |
__CTA1H DD ... ;<--+ |
DD FLAT:__CT??_R0H@84 ;---|---+
; |
__TI1H DD ... ; | __TI1H is argument passed to
DD ... ; | __CxxThrowException@8
DD ... ; |
DD FLAT:__CTA1H ;---+
xdata$x ENDS
和类型描述符一样,这些新的数据块为全部程序共享,例如,所有抛int异常代码引用__TI1H. 。同样要注意:相同的类型描述符被异常处理函数和throw语句引用。
翻到f1()处,相关部分如下:
;void f1() throw()
; {
; try
; {
...
push $L224 ; Address of code to adjust stack frame via handler
; dispatch table. Invoked by __CxxThrowException@8.
...
; throw 123;
push OFFSET FLAT:__TI1H ; Address of data area diagramed
; above
mov DWORD PTR $T213[ebp], 123 ; 123 is the exception's value
lea eax, DWORD PTR $T213[ebp]
push eax
call __CxxThrowException@8 ; Call into EH library, which in
; turn eventually calls $L224
; and $L216 a.k.a. 'catch(int)'
; }
; // ...
; catch(int)
$L216:
; {
mov eax, $L182 ; Return to EH library, which jumps to $L182
ret 0
; }
; // ...
$L182:
; // Call local-object destructors, clean up stack, return
; }
$L224: ; This label referenced by 'try' code.
mov eax, OFFSET FLAT:$T223 ; $T223 is handler dispatch table, what
; had previously been label $T214
; before we added 'throw 123'
jmp ___CxxFrameHandler ; internal library routine
当程序运行时,__CxxThrowException@8(EH的库函数)调用了$L216,catch(int)处理函数的地址。当处理函数一结束,程序就继续顺EH库中的代码向下运行,跳到$L224,继续向下并最终跳到$L182。这个标号是f1()的终止和cleanup代码的地址,在其中调用了x1的析构函数。你可以在调试器下用单步进行验证。
1.6 小结
所有的异常处理体系都导致开销。除非你愿意在没有任何异常安全体系的情况下执行代码,你必须同意付出速度和空间的代价。EH作为语言的特性有优点的:编译器明确知道EH的实现并可以据此优化它。
除了编译器的优化,你自己还有很多方法来优化。在以后的文章中,我将揭示特定的方法来将EH的代价减到最小。有些方法是基于标准C++的,其它则依赖于Visual C++的具体实现。