深入了解CLR异常处理机制
CLR实现的异常处理具有以下特点:
(1)处理异常时不用考虑生成异常的语言或处理异常的语言。换句话说,可以在C#程序中捕获用Visual Basic.NET编写的组件中引发的异常。
(2)异常处理时不要求任何特定的语言语法,而是允许每种语言定义自己的语法。
(3)允许跨进程甚至跨计算机边界引发异常。
(4)以一致的方式处理托管和非托管代码引发的异常。
任何一种.NET编程语言所实现的异常捕获功能,本质上都是CLR异常处理系统所提供的功能的一个子集。
如果使用IL编写程序,则可以使用CLR异常处理系统的所有功能。
显然直接使用IL编程不太现实,但如果希望能深入地了解CLR异常处理系统,分析编译器生成的IL指令代码是一个好方法。
1 方法的异常处理表
请看以下这个简单的C#程序(参见示例项目CatchException):
class Program
{
static void Main(string[] args)
{
try
{
int number = Convert.ToInt32(Console.ReadLine());
}
catch (FormatException ex)
{
Console.WriteLine(ex.Message);
}
finally
{
Console.WriteLine("finally");
}
}
}
使用ilasm工具反汇编出来的代码框架如图 1所示:
图 1 CLR级别实现异常处理的代码框架
上述代码中涉及到的与异常处理相关的IL指令有两条:
(1)leave.s <int32>:离开受保护块,从当前位置转移并执行指定地址处的IL指令。
(2)endfinally:标识finally语句块结束。
从上述代码中我们可以知道:
C#编程语言中“单层”的try…catch…finally结构会被转换为“两层嵌套”的类似结构,CLR通过执行leave指令在IL汇编程序的.try、catch和finally指令块间跳转,实现应用程序所定义的异常捕获和处理工作。
图 1所示Main()方法IL代码是经过ildasm程序出于易于阅读的目的而调整过的,它实际上“隐瞒”了真正的IL指令代码序列。
请从ildasm的“View”菜单中取消“Expand try/catch”选项(默认情况下此选项是选中的),可以看到C#编译器生成的IL代码的“真面目”(图 2)。
图 2“真实”的IL指令代码序列
如图 2所示,具体功能代码被统一地放置在方法IL代码的前半部分,而用于实现异常捕获的代码放在方法IL代码的后半部分,我们将其称为“异常处理表(Exception Handling Table)”,“ret”指令是两部分的天然分界线。
C#编译器通过在合适的地方插入leave.s指令使得在无异常情况下永远不会执行到异常处理代码。
异常处理表中的每个表项是一个“异常处理子句(Exception Handling Clause)”,IL汇编程序使用.try、catch、handler和finally关键字,配合相应地址给前面的功能代码“自然分块”。
位于方法IL代码后半部分的异常处理表是CLR实现异常捕获的关键。
下面我们简要介绍一下CLR如何使用异常处理表捕获并处理异常。
2 CLR如何捕获并处理异常
对于任何一个.NET应用程序中的类,其所包容的方法都包容着一个异常处理表,如果此方法中没有使用try…catch…finally,则此表为空(即此方法生成的IL指令中不包容任何的异常处理子句)。
当.NET应用程序运行时,如果正在执行的某个方法引发了一个异常,CLR会首先将相应的异常对象推入计算堆栈,然后扫描此方法所包容的异常处理表查找处理程序,其处理过程可以简述如下:
CLR获取引发异常的IL指令地址,然后从上到下地扫描异常处理表,取出每个catch子句中“.try”关键字后面跟着的用于定位“块”的起始和结束地址,判断一下引发异常的IL指令地址是否“落”入此地址范围中。如果是,取出“catch”关键字后跟着的异常类型,比对一下是否与抛出的异常对象类型一致(或相兼容),如果这个条件得到满足,CLR取出handler后的两个IL地址,“准备”执行这两个地址指定范围的IL指令(这就是catch指令块中的异常处理代码)。
如果本方法所包容的异常处理表中找不到合适的catch子句,CLR会依据引发异常的线程所关联的方法调用堆栈,查找此方法的调用者所包容的异常处理表。
此过程将一直进行下去,直到找到了一个可以处理此异常的处理程序为止。
假设CLR在整个方法调用链的某个“环节”(即调用此方法的某个“祖先”方法)所包容的异常处理表中找到了可处理此异常的catch异常处理子句,它就作好了执行此子句所定义的异常处理指令代码块的“准备”。
“扫描并查找相匹配的catch子句”过程,是CLR异常处理流程的第一轮。
当找到了合适的异常处理代码后,CLR再“回到原地”,再次扫描引发异常方法所包容的异常处理表,这回,CLR关注的不再是catch子句,而是finally子句,如果找到了合适的finally子句(只需判断一下引发异常的IL指令地址是否“落入”某finally子句所监视的IL指令地址范围之内即可),CLR执行finally子句所指定的处理指令(即其handler部分所定范围内的IL指令)。
“扫描并查找相匹配的finally子句”过程,是CLR处理异常流程的第二轮。
这“第二轮”的扫描,开始于引发异常的方法,结束于最顶层的包容了那个引发异常的方法的方法(这句话很拗口,举个例子就清楚了,比如,如果你有一个嵌套了很深的函数调用语句,并且在被调用的最底层的函数中引发了异常,而你在顶层Main()函数中又用try...catch...finally包围了这一函数调用语句,则第2轮扫描会“直达”最顶层Main()方法的异常处理表,不会中途停止于找到了合适catch子句的那个中间“站”。
在所有“下层”finally子句执行结束之后,相应的catch子句所指定的异常处理代码块才开始执行。之后,与此catch子句“同层”的finally子句所指定的异常处理代码块得到执行。
但事情还没完,现在轮到所有包容被执行catch子句所在方法的“父辈”方法中的finally子句执行。
经过两轮的扫描,CLR就完成了对.NET应用程序引发异常的捕获与处理工作。
这里还遗留着一个问题:
CLR找不到合适的catch异常处理子句怎么办?
如果某.NET应用程序中根本没有定义处理某种异常类型的代码,而此程序在运行时又真的引发了这种类型的异常(真是哪壶不开提哪壶),那么CLR在第一轮扫描过程中,会一直“上溯”到Main()方法所包容的异常处理表,然后“无功而返”。
紧接CLR会进行第二轮的扫描,执行所有“应该被执行”的finally子句。
故事的尾声是:在执行完了所有finally代码后,CLR强制中止此进程所创建的所有线程(哪怕它们运行正常),由操作系统显示一个“出错”对话框,等用户响应后,或结束或附加一个调试器来调试这个进程。
3 CLR的异常筛选和故障响应
上一小节介绍了.NET应用程序中的异常处理表,并介绍了构成异常处理表中的两种类型的异常处理子句(catch和finally)。事实上,CLR异常处理表中还可以包容另两种类型的子句:异常筛选(filter)子句和故障(fault)响应子句。
我们分别来看看这两种子句有什么特殊性。
1 异常筛选
在Visual Basic.NET中,有一个When关键字用于控制是否捕获特定的异常。
请看以下代码(示例程序VBException):
Module Module1
Sub Main()
Dim ShouldCatch As Boolean = True
Try
Dim number As Integer = Convert.ToInt32(Console.ReadLine())
Catch ex As FormatException When ShouldCatch
Console.WriteLine(ex.Message)
End Try
End Sub
End Module
当ShouldCatch=True时,FormatException 对象将被捕获被处理,否则,此异常将导致进程被CLR强行中止。
下面列出ildasm反汇编示例程序集得到的IL代码,代码较长,为了方便阅读,我用“====”划分出了其中的代码块,并加了详细的注释,对于不熟悉IL指令或没有耐心看这些枯燥代码的读者,可以直接看代码后的文字说明:
.method public static void Main() cil managed
{
.entrypoint
.custom instance void [mscorlib]System.STAThreadAttribute::.ctor() = ( 01 00 00 00 )
// Code size 60 (0x3c)
.maxstack 3
.locals init ([0] bool ShouldCatch,
[1] int32 number,
[2] class [mscorlib]System.FormatException ex)
//=================================================
// IL_0000到0001:初始化ShouldCatch=True
IL_0000: ldc.i4.1
IL_0001: stloc.0
//================================================
//从0002到000d:“保护块”
IL_0002: call string [mscorlib]System.Console::ReadLine()
IL_0007: call int32 [mscorlib]System.Convert::ToInt32(string)
IL_000c: stloc.1
IL_000d: leave.s IL_003b //没有异常,则跳去执行ret指令结束
//=====================================================
//从IL_000f到0026:异常筛选块,最终结果(为0或1)将会被压入到计算堆栈中
//判断异常是否是FormatException
IL_000f: isinst [mscorlib]System.FormatException
IL_0014: dup //复制计算堆栈栈顶值,再压入堆栈
IL_0015: brtrue.s IL_001b //如果异常是FormatException,跳转到IL_001b
//如果异常不是FormatException,将0压入堆栈,然后跳转到IL_0026
IL_0017: pop //出栈
IL_0018: ldc.i4.0
IL_0019: br.s IL_0026
IL_001b: dup //复制计算堆栈栈顶值,再压入堆栈
IL_001c: stloc.2 //保存异常对象到方法局部变量ex中
IL_001d: call void [Microsoft.VisualBasic]
Microsoft.VisualBasic.CompilerServices.ProjectData::SetProjectError(
class [mscorlib]System.Exception)
// IL_0022到IL_0024:将ShouldCatch值和常量0先后压入计算堆栈,
// 比较这两个值谁大谁小,结果(为0或1)再压入计算堆栈
IL_0022: ldloc.0
IL_0023: ldc.i4.0
IL_0024: cgt.un
//依据两数比较结果决定是否调用IL_ 0028到003b所定义的异常处理块
IL_0026: endfilter
//======================================================
//IL_ 0028到003b:异常处理块
IL_0028: pop
IL_0029: ldloc.2
IL_002a: callvirt instance string [mscorlib]System.Exception::get_Message()
IL_002f: call void [mscorlib]System.Console::WriteLine(string)
IL_0034: call void [Microsoft.VisualBasic]
Microsoft.VisualBasic.CompilerServices.ProjectData::ClearProjectError()
IL_0039: leave.s IL_003b
IL_003b: ret //方法运行结束
//=========================================
//异常处理表
.try IL_0002 to IL_000f filter IL_000f handler IL_0028 to IL_003b
} // end of method Module1::Main
解析一下上述IL代码中的关键点。
在方法最后的“异常处理表”中包容了一个“异常筛选”子句,其中明确地定义从IL_0002到IL_000f是“保护块”,如果在此范围内的IL指令引发了异常,则将跳去执行IL_000f处的指令:
IL_000f: isinst [mscorlib]System.FormatException
isinst指令将判断一下程序抛出的异常是不是FormatException。如果不是,后面的IL指令会将0压入堆栈,否则,依据ShouldCatch变量的值,将0或1压入堆栈。
IL_000f到IL_0026构成了“异常筛选块”,此代码块的执行结果不是0就是1(注意此结果会被压入计算堆栈)。
“异常筛选块”中最后endfilter指令非常关键,它检查保存在计算堆栈中的值,如果是1,则结束第一轮扫描,并为在第2轮扫描中执行异常处理表中所定义的“异常处理块(示例程序是从IL_ 0028到003b范围内的IL指令块)”做好了准备,此代码块其实对应着Visual Basic.NET示例程序中放在Catch语句块中的VB代码。
如果计算堆栈中的值是0,CLR将跳过本方法中定义的异常处理块,转去搜索上一级“父辈”方法的异常处理表,重复上述处理过程,如果还找不到合适的异常处理子句,再去搜索“父辈的父辈”,最后可能会搜索到最远古的“北京猿人”级别的方法(比如Main()方法)才结束。这就是CLR使用filter子句的第一轮搜索过程。
紧接着CLR会进行第二轮搜索,执行合适的finally子句(其实还包括后面马上要介绍的fault子句)所定义的指令代码块。其处理流程与上一小节介绍的一样,就不再废话了。
注意:
由于C#编译器不生成使用CLR“异常筛选”功能的IL指令,因此,C#语言不能使用CLR提供的“异常筛选”功能。
2 故障响应
除了catch、finally和filter三种类型的异常处理子句,CLR还支持一种名为“fault”异常处理子句。
它的样子是这样的:
.try 起始地址 to 结束地址 fault handler 起始地址 to 结束地址
fault异常处理子句的功能与finally子句非常类似,不同之处在于:
无论被保护块是否引发了异常,finally子句所定义的处理指令块都会被执行。而fault子句所定义的处理指令块“仅当”被保护块引发异常(不管是什么类型的异常)时被执行,如果被保护块未引发任何异常,则不会执行此fault子句所定义的处理指令块。
由此可知,可以使用 fault异常处理子句让CLR“响应”应用程序引发的任何一种异常,所以我们可将fault子句称为“故障响应”子句。fault子句的功能类似于消防员的职责(平时无事,一旦有突发火灾发生,灭火就是消防员义不容辞的责任)。
3 小结
本文深入介绍了CLR所提供的异常处理机制,可以看到, C#和Visual Basic.NET等编程语言所提供的异常处理机制都只是CLR异常处理系统功能的子集。
应该来说,在实际开发中很少有这个必要去深入探究CLR异常处理的内部工作原理,大多数情况下,程序员们只要了解清楚所使用的编程语言所提供的异常处理功能,并会用就行了。
然而,如果您的好奇心还没有被中国的应试教育所泯灭的话,不满足于“知其然”,而且要“知其所以然”,那么,我相信本文的内容能部分地满足您对技术“刨根问底”的需求。