1. Microsoft对异常处理方法的扩展
前次,我概述了异常的分类和C标准库支持的处理方法。这次讨论Microsoft对这些方法的扩展:结构化异常处理(SEH)和Microsoft Foundation Class (MFC)异常处理。SEH对C和C++都有效,MFC异常体系只对C++有效。
1.1 机构化异常处理
机构化异常处理是Windows提供的服务功能并对所有语言写的程序有效。在Visual C++中,Microsoft封装和简化了这些服务(通过非标准的关键字和库程序)。Windows平台的其它编译器可能选择不同的方式来到达相似的结果。在这个专栏中,名词“Structured Exception Handling”和“SEH”专指Visual C++对Windows异常服务的封装。
1.2 关键字
为了支持SEH,Micorsoft用四个新关键字扩展了C和C++语言:
l __except
l __finally
l __leave
l __try
因为这是非标关键字,必须打开扩展选项后再编译(关掉/Fa)。
为什么这些关键字带下划线?C++标准(条款17.4.3.1.2,“Global names”)规定:
下列名字和函数总是保留给编译器:
l 所有带双下划线(__)或以一个下划线加一个大写字母开始的名字保留给编译器随意使用。
l 所有以一个下划线开始的名字保留给编译器作全局名称用。
C标准有类似的申明。
既然SEH的关键字符合上面的规则,Microsoft就有权这样使用它们。这也表明,你不被允许在自己的程序中使用保留的名字。你必须避免定义名字类似__MYHEADER_H__或_FatalError的标识符。
有趣而又不幸地,Visual C++的application wizards产生的源代码使用了保留的标识符。例如,如果你用ATL COM App Wizard生成一个新的service,结果框架代码定义了如_Handler和_twinMain的名字--标准所说的你的程序不能使用的保留名称。
要减少这个不合规定行为,你当然可以手工更改这些名称。还好,这些有疑问的名字都是类的私有变量,在类的定义外面是不可见的,在.h和.cpp中进行全局替换是可行的。不幸的是,有一个函数(_twinMain)和一个对象(_Module)被申明了extern,也就是说程序的其它部分会假定你使用了这些名字。(事实上,Visual C++库libc.lib在连接时需要名字_twinMain可用。)
我建议你保留Wizard生成的名字,不要在你自己的代码中定义这样的名字就可以了。另外,你应该将所有不合标准的定义写入文档并留给程序的维护人员;记住,Visual C++以后的版本(和现有的其它C++编译器)可能以另外的方式使用这些名字,从而破坏了你的代码。
1.3 标识符
Microsoft也在非标头文件excpt.h中定义了几个SEH的标识符,并且包含入windows.h中。在其内部,定义了:
l 供__except的过滤表达式使用的过滤结果宏。
l Win32对象和函数的别名宏,用于查询异常信息和状态。
l 伪关键字宏,和前面谈到的四个关键字有着相同名字和含义,但没有下划线。(例如,宏leave对应SEH关键字__leave。)
Microsoft用这些宏令我抓狂。他们对同一个函数了定义多个别名。例如,excpt.h有如下申明和定义:
unsigned long __cdecl _exception_code(void);
#define GetExceptionCode _exception_code
#define exception_code _exception_code
也就是说,你可以用三种方法调用同一函数。你用哪个?并且,这些别名会如你所期望地被维护吗?
在Microsoft的文档中,它看起来偏爱GetExceptionCode,它的名字和其它全局Windows API函数风格一致。我在MSDN中搜索到33处GetExceptionCode,两个_exception_code,而exception_code个数为0。根据Microsoft的引导,推荐使用GetExceptionCode及类似名称的其它函数。
因为_exception_code的两个别名是宏,所以你不能再使用同样的名字了。我曾经犯过这个错,当我在为这个专栏写例程的时候。我定义了一个局部对象叫exception_code(大概是吧)。实际上我就是定义了一个局部对象叫_exception_code,这是我无意中使用的宏exception_code展开的结果。当我一想到是这个问题,解决方案就是简单地将我的对象名字从exception_code改为code。
最后,excpt.h定义了一个特别的宏--“try”--已经成为C++真正的关键字的东西。这意味着你不能在包含了excpt.h的编译单元中简单地混合SEH和标准C++的异常块,除非你愿意#undef这个try宏。当这样undef而露出真正的try关键字时,要冒搞乱SEH的维护人员大脑的危险。另一方面,精通标准C++的程序员会将try理解为一个关键字而不是宏。
我认为,包含一个头文件(即使是象excpt.h这样的非标头文件)不应该改变符合语言标准的代码的行为。我更坚持掩盖或重定义掉语言标准定义的关键字是个坏习惯。我建议:#undef try,同样不使用其它的伪关键字宏,直接使用真正的关键字(如__try)。
1.4 语法
最基本的SEH语法是try块。如下形式:
__try compound-statement handler
处理体:
__except ( filter-expression ) compound-statement
或:
__finally compound-statement
完整一点看,try块如下:
__try
{
...
}
__except(filter-expression)
{
...
}
或:
__try
{
...
}
__finally
{
...
}
在__try里面你必须使用一个leave语句:
__try
{
...
__leave;
...
}
在更大的程序块中,一个try块被认为是个单条语句:
if (x)
{
__try
{
...
}
__finally
{
...
}
}
等价于:
if (x)
__try
{
...
}
__finally
{
...
}
其它注意点:
l 在给定的try块中你必须有一个正确的异常处理函数。
l 所有的语句必须合并。即使只有一条语句跟在__try、__except或__finally后面也必须将它放入{}中。
l 在异常处理函数中,相应的过滤表达式必须有一个或能转换为一个int型的值。
1.5 基本语意
上次我列举了异常生命期的5个阶段。在SEH体系下,这些阶段实现如下:
l 操作系统上报了一个硬件错误或检测到了一个软件错误,或用户代码检测到一个错误(阶段1)。
l (通常是由用户调用Win32函数RasieException启动,)操作系统产生并触发一个异常对象(阶段2)。这个对象是一个结构,其属性对异常处理函数可见。
l 异常处理函数“看到”异常,并且有机会捕获它(阶段3和4)。取决于处理函数的意愿,异常将或者恢复或者终止。(阶段5)。
一个简单的例子:
int filter(void)
{
/* Stage 4 */
}
int main(void)
{
__try
{
if (some_error) /* Stage 1 */
RaiseException(...); /* Stage 2 */
/* Stage 5 of resuming exception */
}
__except(filter()) /* Stage 3 */
{
/* Stage 5 of terminating exception */
}
return 0;
}
Microsoft调用定义在__except中的异常处理函数,和定义在__finally中的终止函数。
一旦异常被触发,由__except开始的异常处理函数被异常发生点顺函数调用链向外面询问。每个被发现的异常处理函数,其过滤表达式都被求值。每次求值后发生什么取决于其返回结果。
excpt.h定义了3个过滤结果的宏,都是int型的:
l EXCEPTION_CONTINUE_EXECUTION = -1
l EXCEPTION_CONTINUE_SEARCH = 0
l EXCEPTION_EXECUTE_HANDLER = 1
前面我说过,过滤表达式必须兼容int型,所以它们和这3个宏的值匹配。这个说法太保守了:我的经验显示Visual C++接受的过滤表达式可以具有所有的整型、指针型、结构、数组甚至是void型!(但我在尝试浮点指针时遇到了编译错误。)
更进一步,所有求出的值看来都有效(至少对整型如此)。所有非零且符号位为0的值效果相当于EXCEPTION_EXECUTE_HANDLER,而符号位为1的相当于EXCEPTION_CONTINUE_EXECUTION。这大概是按位取模的结果。
如果一个异常处理函数的过滤求值结果是EXCEPTION_CONTINUE_SEARCH,这个处理函数拒绝捕获异常,将继续搜索下一个异常处理函数。
通过由过滤表达式产生一个非EXCEPTION_CONTINUE_SEARCH来捕获异常,一旦捕获,程序就恢复。怎么恢复仍然由过滤表达式的值决定:
l EXCEPTION_CONTINUE_EXECUTION:表现为恢复异常。从发生异常处下面开始执行。异常处理函数本身的代码不执行。
l EXCEPTION_EXECUTE_HANDLER:表现为终止异常。从异常发生处开始退栈,一路上所遇到终止函数都被执行。栈退到捕获异常的处理函数所在的一级为止。进入处理函数体并执行。
如名所示,终止处理函数(以__finally开始的代码)在终止异常时被调用。里面是clean up代码,它们就象C标准库中的atexit()函数和C++的析构函数。终止处理函数在正常执行流程也会进入,就象不是捕获型代码。相反,异常处理函数总表现为捕获型:它们只在其过滤表达式求值为EXCEPTION_EXECUTE_HANDLER时才进入。
终止处理函数并不明确知道自己是从正常流程进入的还是在一个try块异常终止时进入的。要判断这点,可以调用AbnormalTermination函数。此函数返回一个int,0表明是从正常流程进入的,其它值表明在异常终止时进入的。
AbnormalTermination实际上是个指向_abnormal_termination()的宏。Visual C++将_abnormal_termination()设计为环境敏感的函数,就象一个关键字。你不能随便调用这个函数,只能在终止处理函数中调用。这意味着你不能在终止处理函数中调用一个中间函数,再在此中间函数中调用_abnormal_termination(),这样做会得到一个编译期错误。
1.6 例程
下面的C例子显示了不同的过滤表达式值和处理函数本身类型的相互作用。第一个版本是个小的完整程序,以后的版本都在它前面一个上有小小的改动。所有的版本都自解释的,你能看清流程和行为。
程序通过RaiseException()触发一个异常对象。RaiseException()函数的第一个参数是异常的代码,类型是32位无符号整型(DWORD);Microsoft为用户自定义的错误保留了[0xE0000000,0xEFFFFFFF]的范围。其它参数一般填0。
这里使用的异常过滤器很简单。实际使用中,大概要调用GetExceptionCode()和GetExceptionInformation()来查询异常对象的属性。
1.7 Version #1: Terminating Exception
用Visual C++生成一个空的Win32控制台程序,命名为SEH_test,选项为默认。将下列C源码加入工程文件:
#include <stdio.h>
#include "windows.h"
#define filter(level, status)
(
printf("%s:%*sfilter => %s\n",
#level, (int) (2 * (level)), "", #status),
(status)
)
#define termination_trace(level)
printf("%s:%*shandling %snormal termination\n",
#level, (int) (2 * (level)), "",
AbnormalTermination() ? "ab" : "")
static void trace(int level, char const *message)
{
printf("%d:%*s%s\n", level, 2 * level, "", message);
}
extern int main(void)
{
DWORD const code = 0xE0000001;
trace(0, "before first try");
__try
{
trace(1, "try");
__try
{
trace(2, "try");
__try
{
trace(3, "try");
__try
{
trace(4, "try");
trace(4, "raising exception");
RaiseException(code, 0, 0, 0);
trace(4, "after exception");
}
__finally
{
termination_trace(4);
}
end_4:
trace(3, "continuation");
}
__except(filter(3, EXCEPTION_CONTINUE_SEARCH))
{
trace(3, "handling exception");
}
trace(2, "continuation");
}
__finally
{
termination_trace(2);
}
trace(1, "continuation");
}
__except(filter(1, EXCEPTION_EXECUTE_HANDLER))
{
trace(1, "handling exception");
}
trace(0, "continuation");
return 0;
}
现在编译代码。(可能会得到label end_4未用的警告;先忽略。)
注意:
l 程序有四个嵌套try块,两个有异常处理函数,两个有终止处理函数。为了更好地显示嵌套和控制流程,我把它们全部放入同一个函数中。实际编程中可能是放在多个函数或多个编译单元中的。
l 追踪运行情况,输出结果显示当前块的嵌套层次。
l 异常过滤器被实现为宏。第一个参数是嵌套层次,第二个才是实际要处理的值。
l 终止处理函数通过termination_trace宏跟踪其执行情况,显示出调用它们的原因。(记住,终止处理函数即使没有发生异常也会进入的。)
运行此程序,将看到如下输出:
0:before first try
1: try
2: try
3: try
4: try
4: raising exception
3: filter => EXCEPTION_CONTINUE_SEARCH
1: filter => EXCEPTION_EXECUTE_HANDLER
4: handling abnormal termination2: handling abnormal termination
1: handling exception
0:continuation
事件链:
l 第四层try块触发了一个异常。这导致顺嵌套链向上搜索,查找愿意捕获这个异常的异常过滤器。
l 碰到的第一个异常过滤器(在第三层)得出了EXCEPTION_CONTINUE_SEARCH,所以拒绝捕获这个异常。继续搜索下一个异常处理函数。
l 碰到的下一个异常过滤器(在第一层)得出了EXCEPTION_EXECUTE_HANDLER。这次,这个过滤器捕获这个异常。因为它求得的值,异常将被终止。
l 控制权回到异常发生点,开始退栈。沿路所有的终止处理函数被运行,并且所有的处理函数都知道异常终止发生了。一直退栈到控制权回到捕获异常的异常处理函数(在第一层)。在退栈时,只有终止处理函数被执行,中间的其它代码被忽略。
l 控制权一回到捕获异常的异常处理函数(在第一层),将以正常状态继续执行。
注意,控制权在同一嵌套层传递了两次:第一次异常过滤表达式求值,第二次在退栈和执行终止处理函数时。这造成了一种危害可能:如果一个异常过滤表达式以某种终止处理函数不期望的方式修改了的什么。一个基本原则就是,你的异常过滤器不能有副作用;如果有,则必须为你的终止处理函数保存它们。
1.8 版本2:未捕获异常
将例程中的这行:
__except(filter(1, EXCEPTION_EXECUTE_HANDLER))
改为
__except(filter(1, EXCEPTION_CONTINUE_SEARCH))
于是没有异常过滤器捕获这个异常。执行修改后的程序,你将看到:
0:before first try
1: try
2: try
3: try
4: try
4: raising exception
3: filter => EXCEPTION_CONTINUE_SEARCH
1: filter => EXCEPTION_CONTINUE_SEARCH
接着出现这个对话框:
1. 用户异常对话框
点“Details”将其展开
2. 用户异常对话框的详细信息
在出错信息中可看到:出错程序是SEH_TEST,通过RaiseException抛出的原始异常码是e0000001H。
这个异常漏出了程序,最后被操作系统捕获和处理。有些象你的程序是这么写的:
__try
{
int main(void)
{
...
}
}
__except(exception_dialog(), EXCEPTION_EXECUTE_HANDLER)
{
}
按对话框上的“Close”,所有的终止处理函数被执行,并退栈,直到控制权回到捕获异常的处理函数。你可以明显看到这些信息:
4: handling abnormal termination
2: handling abnormal termination
它们出现在关闭对话框之后。注意,你没有看到:
0:continuation
因为它的实现代码在终止处理函数之外,而退栈时只有终止处理函数被执行。
对我们的试验程序而言,捕获异常的处理函数在main之外,这意味着传递异常的行为到了程序范围外仍然在继续。其结果是,程序被终止了。