Visual C++ 异常(Exception)常见问题

 

版权:Doug Harrison 2001 – 2007

翻译:magictong(童磊) 2011年3月

 

本文讨论了一些在Visual C++中实现的,关于 try{} catch(…) 和异常的问题。本文使用一问一答交流的方式来逐步深入的讨论,因此如果你以一个整体来读完全文将会获得更多的知识。为了让你对下面的讨论有一个大体上的认识,我们可以先浏览一下问题列表:

Q1 对于下面的代码,我不明白当我使用release编译模式或其他的编译模式,但是使用了优化选项后(例如:/O1或者O2),为什么 try{} catch(…) 就不能捕获win32的结构化异常了。

Q2 同样是上面的代码,令我很不能理解的是如果我使用debug编译模式或者编译选项加上/EHa后,win32的结构化异常可以被捕获了(SE)。而且,有时候我发现在release编译模式下,如果你使用/GX编译选项 try{} catch(…) 居然也可以捕获win32(SE)结构化异常。难道 try{} catch(…) 不是仅仅支持C++异常吗?

Q3 如果在 try{} catch(…) 里面捕获win32结构化异常(SE),它的影响是什么?

Q4 _set_se_translator是什么?

Q5 我应该怎样正确的处理这些问题?

Q6 在一个MFC程序中,我应该怎样安全的使用 _com_error,std::exception,和其他的非MFC异常类?

 

本文适用于Visual C++ 5、Visual C++ .NET 2003以及后续的版本。 即将发布的Visual C++ 2005(译者注:本文的写作时间已经有几年了,不用纠结这个),代号“Whidbey”,纠正了下面讨论的一个问题,并且这部分影响到问题Q1、Q2和Q5,它们已经相应地更新了。其它的问题和解答适用于Visual C++ 5和以后的版本。

 

Q1、对于下面的代码,我不明白当我使用release编译模式或其他的编译模式,但是使用了优化选项后(例如:/O1或者O2),为什么 try{} catch(…) 就不能捕获win32的结构化异常了。

 

#include <stdio.h>

int main()

{

   try

   {

     int* p = 0;

     *p = 0; // Cause access violation

   }

   catch (...)

   {

      puts("Caught access violation");

   }

   return 0;

}

 

A、从Visual C++ 5到Visual C++ .NET 2003的系列中,如果你用/GX或者/EHs编译选项进行编译,而这两个编译选项的含义是开启编译器的同步异常模式。在这种模式下仅仅捕获通过throw语句抛出的异常,显然,上面的代码中没有throw语句。如果你仔细检查这个程序的汇编代码,你会发现编译器优化了整个异常处理机制,整个异常处理无影无踪了,因为编译器可以确定try中的代码不会抛出一个C++异常。这是多么牛逼的优化!特别是当有大量的模板代码存在时,这种优化特别有效。然而不幸的是,这里有一个bug,造成在某些情况下 try{} catch(…) 会捕获住win32的结构化异常,这直接导致了下一个问题的产生。

 

Q2 同样是上面的代码,令我很不能理解的是如果我使用debug编译模式或者编译选项加上/EHa后,win32的结构化异常可以被捕获了(SE)。而且,有时候我发现在release编译模式下,如果你使用/GX编译选项 try{} catch(…) 居然也可以捕获win32(SE)结构化异常。难道 try{} catch(…) 不是仅仅支持C++异常吗?

A、根据Stroustrup的说法,C++ 异常处理(EH)并没有打算处理信号或者其它的低级的​​操作系统​​特殊事件,譬如算术异常之类的。win32结构化异常(SEs)明确的归入了这个类别,从某种意义上来讲,try{} catch(…) 捕获到win32结构化异常是不可能的。然而,C++标准并没有明确的禁止这种行为,任何时候抛出一个结构化异常(SE)都是一种未定义的行为,因此看起来 try{} catch(…) 捕获结构化异常(SE)也是“合法”的行为。从技术的角度来说,C++ 标准也并未对这种未定义行为(譬如间接引用一个NULL指针)强加任何的要求。也就是说,使用 try{} catch(…) 正确的捕获所有的异常看起来是很方便的,捕获了结构化异常(SE)就捕获了大量问题的根源,在讨论我为什么这么说之前,我们先看看Visual C++的文档对于这种行为是怎么说的。

Visual ​​C++​​5及其之后的版本定义了两种异常处理模型,分别称之为同步模型和异步模型。模型的选择可以通过/EH编译命令行来控制,/EHs表示使用同步模型,而/EHa表示使用异步模型。在MFC和其他的一些应用程序框架中默认定义了一个/GX编译命令行,/GX和/EHsc是等价的,所以它的意思是选择同步模型(c的意思是说被 extern “C” 修饰的函数是不抛出异常的)。VC++的文档中是这样定义异步模型的:

在Visual C++的之前版本中,C++的异常处理机制默认是支持处理异步异常(硬件异常)的,在异步模型下,编译器假设任何一条指令都可能引发一个异常。

(译注:所谓同步异常和异步异常我的理解是,同步异常就是程序中通过throw语句显示抛出的异常,比如IO错误,内存分配失败,数组越界等等,哪里会抛出异常,可以提前预知,而异步异常一般是指系统的硬件异常,访问空指针,除0错等等,这种异常完全无法预知,在VC的debug模式下编译器对try中语句会捕获异步异常,而release模式则不会。当然这些是可以通过修改编译选项来控制的)

在异步模型下, try{} catch(…) 是可以捕获结构化异常的,如果你真的想这样做,你必须设置编译选项/EHa。另外,你可以使用函数_set_se_translator()把结构化异常转化为C++异常,从而像捕获C++异常一样捕获结构化异常,但是你也必须使用/EHa编译选项(可以参考Q4)。

同步模型可以参考下面的描述:

默认情况下,新的同步异常模型只能通过throw语句抛出。因此,编译器就可以作出一个假设,异常只可能在显示调用throw语句或者调用函数的时候发生。如果对象的生命期和函数调用或者throw语句不重叠的话,在这种模型下,编译器可以完全删除为了对像进行unwind操作而跟踪对象生命期的代码,从而显著的减少代码的大小。

同步异常模型本来准备想按Stroustrup说的那样来仅仅支持C++异常,不过,从Visual C++ 5到Visual C++ .NET2003,这点都没有用文档进行明确说明,所以,如果你编译的时候没有使用优化,或者使用了编译优化但是优化器不能判断try语句中是否会抛出一个C++异常,那么 try{} catch(…) 仍然可能捕获到一个结构化异常的(SEs)。举个例子,在VC5中,如果try语句中调用了一个函数,那么优化器就假定它可能抛出一个C++异常,但是到了VC6中,这个函数可能是在另一个编译单元(源文件)中,这些使优化器感到迷茫不堪(译注:用流行的说话就是杯具)。最终,在Visual C++ .NET2005中才纠正了同步模型的这些缺陷。

 

Q3 如果在 try{} catch(…) 里面捕获win32结构化异常(SE),它的影响是什么?

A、要回答这个问题,我们首先需要搞清楚C++异常和结构化异常(SEs)的特点和特征。根据Stroustrup的说法,C++异常处理就是错误处理。譬如,申请内存失败或者写文件时磁盘空间不足,这些错误最好的通知的方法就是抛出一个异常,特别是在正常情况下这些资源都是足够的。这种方法可以极大的简化那些需要检查函数的返回值的代码,而且它可以使你能够集中处理一些错误。这类错误很有可能是在一个正确的程序中发生的(译注:就里是指这不是程序bug,而是可以预知的错误),而这正是C++异常处理想要达到的目标。

另一方面,结构化异常(SEs)一般就是标识着一个典型的程序bug了。大家应该对引用NULL指针而造成访问违例耳熟能详。硬件能够检测到这种错误并且能够对其捕获(译注:产生一个中断),然后windows把这种特殊的硬件事件转化为一个结构化异常(SE)。出现结构化异常基本上就表示程序写的有点问题了,一个正确的程序不应该产生这种错误。SEs(结构化异常)也用于系统的一些常规运行,举个例子,你使用VirtualAlloc()在内存中申请预留了一片地址空间并动态的给一些页面开放访问权限,就有可能使程序访问到了未经授权的内存地址,从而导致了一个页错误。使用 __try __except 语句程序可以捕获到这种异常(SE)并解除内存的访问权限,然后从导致异常的指令处恢复程序的执行。C++异常是无法做到的,因为它不能够干预到这种行为。

C++异常和win32的结构化异常是完全不同的东西。如果在 try{}catch(...) 中一致的予以捕获会产生如下一些问题:

1、如果 try{}catch(...) 可以捕获结构化异常,可能你会自信的写下如下的代码:

 

   // Begin exception-free code

   ... Update critical data structure

   // End exception-free code

 

如果关键代码段有一个bug并且会导致一个结构化异常(SE),外层的 try{}catch(...) 块可能捕获到这个异常,但是这会把整个程序的状态流程引向一个完全意想不到的地方。然后程序可能会像一架失控的飞机一样继续往前运行,结果造成更大的破坏。幸运点的话,在造成严重的破坏之前,随后引发的一个未被捕获的结构化异常将使程序异常终止。但是,如果try{}catch(...) 没有捕获最开始的那个结构化异常,程序调试起来可能更容易点,因为最终造成程序崩溃(SE)的地方可能已经离那个真正有bug的地方很远了。操作系统会报告这个未被捕获的结构化异常,让你有机会对程序进行调试,但是这种情况下它可能把你带到了最后一个异常(SE)发生的源代码位置,而不是那个实际问题发生的地方。

2、下面的代码更是令人疑惑不解……

 

try

   {

      TheFastButResourceHungryWay();

   }

   catch (...)

   {

      TheSlowButSureWay();

   }

 

在try代码块中,如果程序有一个bug或者编译器生成的代码有bug造成了一次访问违例(access violation),因为try{}catch(...) 对结构化异常的捕获使你没法发现这个bug。而它唯一的表现可能就是运行极其的缓慢而已,而且在​​测试​​中还不一定出现。如果 try{}catch(...) 不捕获这个异常的话你肯定会在测试阶段发现这个bug(操作系统的那个经典的提示程序运行异常终止的messagebox不出意外都会蹦出来的)。

3、系统的正常运行可能会受到影响甚至破坏,例如,MFC的CPropertySheet::DoModal()的文档中明确说到不要在这个函数里面使用 try{}catch(...) 。DebugBreak(API)抛出的异常可能会被 try{}catch(...) 捕获,导致DebugBreak失去了效果。同样,如果你正在使用 __try __except 来捕获结构化异常,而内部代码又有 try{}catch(...) 代码存在,你可能会遇到麻烦,即使内部的try{}catch(...) 代码把异常重新抛了出来,如果你的结构化异常处理程序想从错误的指令处恢复执行你几乎百分百会遇到意想不到麻烦,因为你会发现 catch(...) 代码块已经进入过了而局部变量也已经全部销毁了,更杯具的是如果恢复执行的地方是在与该 catch(...) 块相匹配的 try{} 代码块里面,而随后这个 try{} 代码块又抛出一个C++异常,此时你会沮丧的发现结构化异常处理函数陷入了一个无限调用死循环中。(译注:感觉此小点原文这个地方讲得有点含糊。有兴趣的可以参照一下原文,大概就是这个意思。按我的理解是这样的,首先__try/__except语句中,在__excep后面的()中是一个表达式,表达式返回值可以是下面三个中的一个:

EXCEPTION_CONTINUE_EXECUTION(–1)则表示异常被忽略,控制流将在异常出现的指令点继续恢复运行。

EXCEPTION_CONTINUE_SEARCH(0)表示异常未被识别,也就是说当前的这个__except模块不是这个异常错误所对应的正确的异常处理模块。系统将继续到上一层的__try/__except 域中继续查找一个恰当的__except模块。

EXCEPTION_EXECUTE_HANDLER(1) 表示异常已经被识别,也即当前的这个异常错误,系统已经找到了并能够确认,这个__except模块就是正确的异常处理模块。控制流将进入到__except模块中。

如果结构化异常处理函数返回-1,也就是说希望程序在异常点恢复执行,其实是一种风险很大的行为,很有可能再次异常,然后再次进入异常处理函数,然后函数返回-1,再恢复执行……如此死循环在这里。个人觉得适合EXCEPTION_CONTINUE_EXECUTION(–1)的场合是很少的。)

4、应用程序框架使用 try{}catch(...) 来防范用户的代码变成了一个有风险的行为(基于以上的3点),而通常情况下是应该要这么做的。例如,如果MFC框架不使用 try{}catch(...) 来保护自己,结果可能由于用户代码中一个未被捕获的C++异常导致了整个MFC应用程序异常终止。

 

Q4 _set_se_translator是什么?

A、_set_se_translator是一个由C++运行时库提供的函数,可以用它来注册一个回调异常转换函数,该函数可以用来把一个win32结构化异常转换成真正的C++异常。它可以让你部分的避免在Q3中描述过的try{} catch(…) 问题,譬如你可以写下如下的代码,其中se_t是转换函数抛出的异常对象的类型。

 

catch (se_t) { throw; }

catch (...) { ... }

 

这不是一个特别好的解决方案,因为你容易忘记在每个 try{} catch(…) 里面增加像上面那样的代码。如果你要使用这种方法,你必须要在每个线程开始执行代码前设置回调异常转换函数,而且每个结构化异常的处理程序仅仅是某一个线程的属性,你在某个线程中调用_set_se_translator函数是不会影响到另外一个线程的,而且,回调异常转换函数是不能被新的线程所继承的,因此,在调用_set_se_translator之后创建的线程,这个函数对该线程是没有任何影响的。除了在实现上比较困难和容易出错之外,这种解决方法也不能对那些既不是你写的并且你也不能修改的代码负责,这对于库的使用者来说可能是个问题。

而最终,文档对于_set_se_translator函数是否可以值得信赖的使用说得也不明确,而且你必须选择Q2中讨论过的异步异常处理模型,在这种模型下,你的目标代码的大小会迅速膨胀。如果你不这么做,你的代码可能会像Q1中讨论过的那样被优化。

 

Q5 我们应该怎样去处理这些?

         A、如果你使用的是Visual C++ 5到Visual C++ .NET 2003的版本,最好的方法就是尽可能的避免使用 try{} catch(…) ,如果必须要使用的话,你最好意识到上面讨论过的一些问题。不过在Visual C++ .NET 2005中,编译选项/EHs的行为已经和文档上描述的一致了,同步异常模型的行为也正常了,在这种模型下你也不用再担心 try{} catch(…) 会捕获结构化异常了(SEs)。

 

Q6 在一个MFC程序中,怎样才能安全的使用 _com_error,std::exception,和其他的非MFC异常类?

       A、在Visual C++支持C++异常处理之前,MFC就已经被设计出来了,MFC最原始的异常设计是基于宏(macro)的,如TRY和CATCH之类,这些宏利用setjmp和longjmp函数来模拟C++的异常处理。为了简化实现,在MFC的早期版本中仅仅支持抛出一个CException的指针类型,譬如一个指向CException对象的指针或者一个从CException派生类的对象的指针。即使后来MFC更新到Visual C++2.0并且开始支持C++的异常处理之后,也从来没有支持过其他的异常类型,而且MFC的源代码中继续使用这套宏,只不过现在它是使用C++的异常处理来定义的,譬如,在MFC中,它把CATCH_ALL定义为:

 

catch (CException* e)

 

       很明显,如果try代码块中使用了C++标准库,COM库,或者是其它的外部库等等一些定义了自己的异常类型的库,这种方式是不能够捕获所有的C++异常的。MFC除了使用CException*异常类型外,没有使用任何其他的异常类型,因此很多情况下,你的代码可能被包装成下面这样:

 

TRY

{

   // Call your code

}

CATCH_ALL(e)

{

   // Clean up and perhaps report the error to the user

}

END_CATCH_ALL

 

         比如说,MFC的窗口过程函数就是使用这种方式保护起来的,因为异常是不允许跨越windows的消息边界的。不过,CATCH_ALL也只捕获MFC异常,如果一个非MFC的异常捕获失败,你的程序可能因为未被捕获的异常而异常终止。即使你自己去捕获这些异常,在哪个地方去捕获它们也是非常重要的,因为在MFC里面有许多的函数希望捕获住所有的异常,然后它们可以做一些清理工作或者通过return语句给调用者返回一个错误码。现在,如果try块中的函数里面调用到你的代码,而你的代码里面并没有立即把一个非MFC异常转化成一个MFC异常,也就是说你允许一个非MFC异常在MFC代码里面进行传播,并希望全部捕获它们,但是就像刚才说的那样,(MFC)并不会捕获它,也确实没有捕获它。即使你的非MFC异常是在同层次的外部代码上被捕获的,也可能有点晚了,你最终可能还是会跳过一些重要的清理代码。所有的这些表明,我们应该遵从下面的规则:

禁止非MFC异常在MFC代码中传播(Never allow a non-MFC exception to pass through MFC code)

         就最低限度上来说,通过使用 try{}catch(…) 也要保护每一个消息处理程序在遇到一个非MFC异常的时候能够友好的退出程序。如果一个消息处理程序对一个异常不能进行处理,并且希望把异常报告给用户,那么对于处理程序来讲友好的退出程序可能更合适。假如MFC能捕获这个异常的话,MFC将会给用户呈现一个更友好的messagebox来描述这个错误。要达到这个目的,你需要把一个非MFC异常转换成一个MFC异常,你可以借助宏(macro)来实现。例如,可以看一下下面的代码:

 

class MfcGenericException : public CException

{

public:

 

   // CException overrides

   BOOL GetErrorMessage(

         LPTSTR lpszError,

         UINT nMaxError,

         PUINT pnHelpContext = 0)

   {

      ASSERT(lpszError != 0);

      ASSERT(nMaxError != 0);

      if (pnHelpContext != 0)

         *pnHelpContext = 0;

      _tcsncpy(lpszError, m_msg, nMaxError-1);

      lpszError[nMaxError-1] = 0;

      return *lpszError != 0;

   }

 

protected:

 

   explicit MfcGenericException(const CString& msg)

   :  m_msg(msg)

   {

   }

 

private:

 

   CString m_msg;

};

 

class MfcStdException : public MfcGenericException

{

public:

 

   static MfcStdException* Create(const std::exception& ex)

   {

      return new MfcStdException(ex);

   }

 

private:

 

   explicit MfcStdException(const std::exception& ex)

   : MfcGenericException(ex.what())

   {

   }

};

 

#define MFC_STD_EH_PROLOGUE  try {

#define MFC_STD_EH_EPILOGUE /

      } catch (std::exception& ex) { throw MfcStdException::Create(ex); }

 

         上面的代码定义了一个类:MfcGenericException,它是从MFC的CException继承而来,并且它是MfcStdException等其他非MFC异常类的基类(我们需要这个基类的原因是MFC并没有提供一个封装异常消息字符串的通用异常类)。底部定义的宏是为了用来方便的包含那些可能会抛出一个非MFC异常的代码,譬如你的消息处理函数或者其他被MFC调用的代码。你可以这样使用它们:

 

void MyWnd::OnMyCommand()

{

MFC_STD_EH_PROLOGUE

   ... your code which can throw std::exception

MFC_STD_EH_EPILOGUE

}

 

         在这里,通过这两个宏把你的代码包含的try代码块,而MFC_STD_EPILOGUE宏还把std::exception异常类型转换成了MFC可以捕获的异常类型,在这里是转换成了MfcStdException。注意的是MfcStdException有一个私有的构造函数,并且定义了一个静态Create函数,而后者提供的功能就是在堆上创建一个MfcStdException对象。

总之,这些宏通过try代码块来保护你的代码,并且MFC_STD_EH_EPILOGUE宏把std::exception异常转换成了MFC异常重新抛出,上面代码里面是转换成了MfcStdException异常对象。需要注意的是MfcStdException这个异常类只有一个私有(private)的构造函数并且定义了一个静态Create函数,而后者提供的功能就是在堆上创建一个MfcStdException对象。这样就确保了异常类只能在堆上被创建,而这正是我们所需要的,因为每个对象都维护着一份错误的状态信息,如果我们像AfxThrowMemoryException那样简单的抛出一个静态对象的指针,那样线程安全就是一个很大的问题,可能会发生这样的情况,这个线程正在抛出和处理异常,此时另一个线程也抛出了一个异常,那么后一个抛出的异常可能会覆盖前一次的异常信息。在这种情况下我们无任何捷径可走!无论谁捕获了异常都有责任调用异常对象从CException继承下来的Delete成员函数。该函数的作用是删除MfcStdException对象,通过禁止局部异常对象的创建,可以很好的避免抛出局部异常对象指针的错误行为。

         在进行MFC编程的时候,如果混用各种各样的异常类型,要想保持程序的健壮性,使用一种类似于上面的技术是必不可少的。这也比直接写 try{}catch(...) 来捕获更容易一点,而且它会把异常抛给最适合处理该异常的代码块。实际上,在一个良好设计的程序中直接写  try{}catch(...) 很少见的,使用这种方法写的代码能够通过堆栈的自动展开(unwinding)和局部变量的自动析构来做保证程序是正确的。因此,异常处理的最后一步往往就是简单的告诉用户哪里出了问题,通过把非MFC异常转成MFC异常,MFC就可以很轻松的搞掂最后的处理工作o(∩_∩)o 。

 

评论