原文出处:How a C++ compiler implements exception handling
译者注:本文在网上已经有几个译本,但都不完整,所以我决定自己把它翻译过来。虽然力求信、雅、达,但鉴于这是我的第一次翻译经历,不足之处敬请谅解并指出。
与传统语言相比,C++的一项革命性创新就是它支持异常处理。传统的错误处理方式经常满足不了要求,而异常处理则是一个极好的替代解决方案。它将正常代码和错误处理代码清晰的划分开来,程序变得非常干净并且容易维护。本文讨论了编译器如何实现异常处理。我将假定你已经熟悉异常处理的语法和机制。本文还提供了一个用于VC++的异常处理库,要用库中的处理程序替换掉VC++提供的那个,你只需要调用下面这个函数:
install_my_handler();
之后,程序中的所有异常,从它们被抛出到堆栈展开(stack unwinding),再到调用catch块,最后到程序恢复正常运行,都将由我的异常处理库来管理。
与其它C++特性一样,C++标准并没有规定编译器应该如何来实现异常处理。这意味着每一个编译器的提供商都可以用它们认为恰当的方式来实现它。下面我会描述一下VC++是怎么做的,但即使你使用其它的编译器或操作系统①,本文也应该会是一篇很好的学习材料。VC++的实现方式是以windows系统的结构化异常处理(SEH)②为基础的。
结构化异常处理—概述
在本文的讨论中,我认为异常或者是被明确的抛出的,或者是由于除零溢出、空指针访问等引起的。当它发生时会产生一个中断,接下来控制权就会传递到操作系统的手中。操作系统将调用异常处理程序,检查从异常发生位置开始的函数调用序列,进行堆栈展开和控制权转移。Windows定义了结构“EXCEPTION_REGISTRATION”,使我们能够向操作系统注册自己的异常处理程序。
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
};
注册时,只需要创建这样一个结构,然后把它的地址放到FS段偏移0的位置上去就行了。下面这句汇编代码演示了这一操作:mov FS:[0], exc_regpprev字段用于建立一个EXCEPTION_REGISTRATION结构的链表,每次注册新的EXCEPTION_REGISTRATION时,我们都要把原来注册的那个的地址存到prev中。
EXCEPTION_DISPOSITION (*handler)(
_EXCEPTION_RECORD *ExcRecord,
void* EstablisherFrame,
_CONTEXT *ContextRecord,
void* DispatcherContext);
不要管它的参数和返回值,我们先来看一个简单的例子。下面的程序注册了一个异常处理程序,然后通过除以零产生了一个异常。异常处理程序捕获了它,打印了一条消息就完事大吉并退出了。#include <iostream>
#include <windows.h>
using std::cout;
using std::endl;
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
};
EXCEPTION_DISPOSITION myHandler(
_EXCEPTION_RECORD *ExcRecord,
void * EstablisherFrame,
_CONTEXT *ContextRecord,
void * DispatcherContext)
{
cout << "In the exception handler" << endl;
cout << "Just a demo. exiting..." << endl;
exit(0);
return ExceptionContinueExecution; //不会运行到这
}
int g_div = 0;
void bar()
{
//初始化一个EXCEPTION_REGISTRATION结构
EXCEPTION_REGISTRATION reg, *preg = ®
reg.handler = (DWORD)myHandler;
//取得当前异常处理链的“头”
DWORD prev;
_asm
{
mov EAX, FS:[0]
mov prev, EAX
}
reg.prev = (EXCEPTION_REGISTRATION*) prev;
//注册!
_asm
{
mov EAX, preg
mov FS:[0], EAX
}
//产生一个异常
int j = 10 / g_div; //异常,除零溢出
}
int main()
{
bar();
return 0;
}
/*-------输出-------------------
In the exception handler
Just a demo. exiting...
---------------------------------*/
注意EXCEPTION_REGISTRATION必须定义在栈上,并且必须位于比上一个结点更低的内存地址上,Windows对此有严格要求,达不到的话,它就会立刻终止进程。
pop EAX
的作用是从ESP所指的位置读出4字节放到EAX寄存器中,并把ESP加上(记住,栈是向下增长的)4(在32位处理器上);类似的,
push EBP
的作用是把ESP减去4,然后将EBP的值放到ESP指向的位置中去。
编译器编译一个函数时,会在它的开头添加一些代码来为其创建并初始化栈桢,这些代码被称为序言(prologue);同样,它也会在函数的结尾处放上代码来清除栈桢,这些代码叫做尾声(epilogue)。
一般情况下,序言是这样的:
Push EBP ; 把原来的栈桢指针保存到栈上 Mov EBP, ESP ; 激活新的栈桢 Sub ESP, 10 ; 减去一个数字,让ESP指向栈桢的末尾
第一条指令把原来的栈桢指针EBP保存到栈上;第二条指令通过让EBP指向主调函数的EBP的保存位置来激活被调函数的栈桢;第三条指令把ESP减去了一个数字,这样ESP就指向了当前栈桢的末尾,而这个数字是函数要用到的所有局部对象和临时对象的大小。编译时,编译器知道函数的所有局部对象的类型和“体积”,所以,它能很容易的计算出栈桢的大小。
尾声所做的正好和序言相反,它必须把当前栈桢从栈上清除掉:
Mov ESP, EBP Pop EBP ; 激活主调函数的栈桢 Ret ; 返回主调函数
它让ESP指向主调函数的栈桢指针的保存位置(也就是被调函数的栈桢指针指向的位置),弹出EBP从而激活主调函数的栈桢,然后返回主调函数。
一旦CPU遇到返回指令,它就要做以下两件事:把返回地址从栈中弹出,然后跳转到那个地址去。返回地址是主调函数执行call指令调用被调函数时自动压栈的。Call指令执行时,会先把紧随在它后面的那条指令的地址(被调函数的返回地址)压入栈中,然后跳转到被调函数的开始位置。图2更详细的描绘了运行时的堆栈。如图所示,主调函数把被调函数的参数也压进了堆栈,所以参数也是栈桢的一部分。函数返回后,主调函数需要移除这些参数,它通过把所有参数的总体积加到ESP上来达到目的,而这个体积可以在编译时知道:
Add ESP, args_size
当然,也可以把参数的总体积写在被调函数的返回指令的后面,让被调函数去移除参数,下面的指令就在返回主调函数前从栈中移去了24个字节:
Ret 24
取决于被调函数的调用约定(call convention),这两种方式每次只能用一个。你还要注意的是每个线程都有自己独立的堆栈。
C++和异常
回忆一下我在第一节中介绍的EXCEPTION_REGISTRATION结构,我们曾用它向操作系统注册了发生异常时要被调用的回调函数。VC++也是这么做的,不过它扩展了这个结构的语义,在它的后面添加了两个新字段:
struct EXCEPTION_REGISTRATION
{
EXCEPTION_REGISTRATION* prev;
DWORD handler;
int id;
DWORD ebp;
};
VC++会为绝大部分函数③添加一个EXCEPTION_REGISTRATION类型的局部变量,它的最后一个字段(ebp)与栈桢指针指向的位置重叠。函数的序言创建这个结构并把它注册给操作系统,尾声则恢复主调函数的EXCEPTION_REGISTRATION。id字段的意义我将在下一节介绍。

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块是不是愿意捕获当前异常了。 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(&e, E_EXCPT_INFO_ADDR);__CxxThrowException会把控制权连带它的两个参数都交给操作系统(控制权转移是通过软件中断实现的,请参见RaiseException)。而操作系统,在为调用异常回调函数做准备时,会把这两个参数打包到一个_EXCEPTION_RECORD结构中。接着,它从EXCEPTION_REGISTRATION链表的头结点(由FS:[0]指向)开始,依次调用各节点的异常处理程序。而且,指向当前EXCEPTION_REGISTRATION结构的指针也会作为异常处理程序的第二个参数出现。前面已经说过,VC++中的每个函数都在栈上创建并注册了EXCEPTION_REGISTRATION结构。所以传递这个参数可以让处理程序知道很多重要信息,比如说:EXCEPTION_REGISTRATION的id字段(用于查找catch块)、函数的栈桢(用于清理栈桢)和EXCEPTION_REGISTRATION结点在异常链表中的位置(用于堆栈展开)等。第一个参数是指向_EXCEPTION_RECORD结构的指针,通过它可以找到异常和它的excpt_info结构。下面是excpt.h中定义的异常回调函数的原型:
EXCEPTION_DISPOSITION (*handler)(
_EXCEPTION_RECORD* ExcRecord,
void* EstablisherFrame,
_CONTEXT *ContextRecord,
void* DispatcherContext);
后两个参数和我们的讨论关系不大。函数的返回值是一个枚举类型(也在excpt.h中定义),我前面已经说过,如果处理程序找不到catch块,它就会向系统返回ExceptionContinueSearch,对本文而言,我们只要知道这一个返回值就行了。_EXCEPTION_RECORD结构是在winnt.h中定义的: struct _EXCEPTION_RECORD
{
DWORD ExceptionCode;
DWORD ExceptionFlags;
_EXCEPTION_RECORD* ExcRecord;
PVOID ExceptionAddress;
DWORD NumberParameters;
DWORD ExceptionInformation[15];
}EXCEPTION_RECORD;
ExceptionInformation数组中元素的个数和类型取决于ExceptionCode字段。如果是C++异常(异常代码是0xe06d7363,源于throw语句),那么数组中将包含指向异常和excpt_info结构的指针;如果是其他异常,那数组中基本上就不会有什么内容,这些异常包括除零溢出、访问违例等,你可以在winnt.h中找到它们的异常代码。int g_i = 0;
void foo()
{
T o1, o2;
{
T o3;
}
10/g_i; //这里会发生异常
T o4;
//...
}
foo有o1、o2、o3、o4四个局部对象,但异常发生时,o3已经“死亡”,o4还未“出生”,所以异常处理程序应该只调用o1和o2的析构函数。 void foo()
{
T t1;
//.
}
编译器会在t1的定义后面(也就是t1创建以后),把它的id写到栈桢上: void foo()
{
T t1;
_id = t1_id; //编译器插入的语句
//.
}
上面的_id是编译器偷偷创建的局部变量,它的位置与EXCEPTION_REGISTRATION的id字段重叠。类似的,在调用对象的析构函数前,编译器会恢复前一个关键区域的id。typedef void (*CLEANUP_FUNC)();
struct unwind
{
int prev;
CLEANUP_FUNC cf;
};
try块对应的unwind结构的cf字段是空值NULL,因为没有与它对应的对象,所以也没有东西需要它去销毁。通过prev字段,这些unwind结构也形成了一个链表。异常处理程序清理栈桢时,会读取当前的id值,以它为索引取得展开表中对应的项,并调用其第二个字段指向的清理代码,这样,那个与之关联的对象就被销毁了。然后,处理程序将以当前unwind结构的prev字段为索引,继续在展开表中找下一个unwind结构,调用其清理代码。这一过程将一直重复,直到链表的结尾(prev的值是-1)。图6画出了本节开始时提到的那段代码的堆栈展开表。 
T* p = new T();
系统会首先为T分配内存,然后调用它的构造函数。所以,如果构造函数抛出了异常,系统就必须释放这些内存。因此,动态创建那些拥有“有为的构造函数”的类型时,VC++也为new运算符分配了id,并且堆栈展开表中也有与其对应的项,其清理代码将释放分配的内存空间。调用构造函数前,编译器把new运算符的id存到EXCEPTION_REGISTRATION结构中,构造函数顺利返回后,它再把id恢复成原来的值。
更进一步说,构造函数抛出异常时,对象可能刚刚构造了一部分,如果它有子成员对象或子基类对象,并且发生异常时它们中的一部分已经构造完成的话,就必须调用这些对象的析构函数。和普通函数一样,编译器也给构造函数生成了相关的数据来帮助完成这个任务。
展开堆栈时,异常处理程序调用的是用户定义的析构函数,这一点你必须注意,因为它也有可能抛出异常!C++标准规定堆栈展开过程中,析构函数不能抛出异常,否则系统将调用std::terminate。
实现
本节我们讨论其他三个有待详细解释的问题:
a)如何安装异常处理程序
b)catch块重新抛出异常或抛出新异常时应该如何处理
c)如何对所有线程提供异常处理支持
随同本文,有一个演示项目,查看其中的readme.txt文件可以得到一些编译方面的帮助①。
第一项任务是安装异常处理程序,也就是把VC++的处理程序替换掉。从前面的讨论中,我们已经清楚地知道__CxxFrameHandler函数是VC++所有异常处理工作的入口。编译器为每个函数都生成一段代码,它们在发生异常时被调用,把相应的funcinfo结构的指针交给__CxxFrameHandler。
install_my_handler()函数会改写__CxxFrameHandler的入口处的代码,让程序跳转到my_exc_handler()函数。不过,__CxxFrameHandler位于只读的内存页,对它的任何写操作都会导致访问违例,所以必须首先用VirtualProtectEx把该内存页的保护方式改成可读写,等改写完毕后,再改回只读。写入的数据是一个jmp_instr结构。
//install_my_handler.cpp #include <windows.h> #include "install_my_handler.h" //C++默认的异常处理程序 extern "C" EXCEPTION_DISPOSITION __CxxFrameHandler( struct _EXCEPTION_RECORD* ExceptionRecord, void* EstablisherFrame, struct _CONTEXT* ContextRecord, void* DispatcherContext ); namespace { char cpp_handler_instructions[5]; bool saved_handler_instructions = false; } namespace my_handler { //我的异常处理程序 EXCEPTION_DISPOSITION my_exc_handler( struct _EXCEPTION_RECORD *ExceptionRecord, void * EstablisherFrame, struct _CONTEXT *ContextRecord, void * DispatcherContext ) throw(); #pragma pack(push, 1) struct jmp_instr { unsigned char jmp; DWORD offset; }; #pragma pack(pop) bool WriteMemory(void* loc, void* buffer, int size) { HANDLE hProcess = GetCurrentProcess(); //把包含内存范围[loc,loc+size]的页面的保护方式改成可读写 DWORD old_protection; BOOL ret = VirtualProtectEx(hProcess, loc, size, PAGE_READWRITE, &old_protection); if(ret == FALSE) return false; ret = WriteProcessMemory(hProcess, loc, buffer, size, NULL); //恢复原来的保护方式 DWORD o2; VirtualProtectEx(hProcess, loc, size, old_protection, &o2); return (ret == TRUE); } bool ReadMemory(void* loc, void* buffer, DWORD size) { HANDLE hProcess = GetCurrentProcess(); DWORD bytes_read = 0; BOOL ret = ReadProcessMemory(hProcess, loc, buffer, size, &bytes_read); return (ret == TRUE && bytes_read == size); } bool install_my_handler() { void* my_hdlr = my_exc_handler; void* cpp_hdlr = __CxxFrameHandler; jmp_instr jmp_my_hdlr; jmp_my_hdlr.jmp = 0xE9; //从__CxxFrameHandler+5开始计算偏移,因为jmp指令长5字节 jmp_my_hdlr.offset = reinterpret_cast编译指令#pragma pack(push, 1)告诉编译器不要在jmp_instr结构中填充任何用于对齐的空间。没有这条指令,jmp_instr的大小将是8字节,而我们需要它是5字节。(my_hdlr) - (reinterpret_cast (cpp_hdlr) + 5); if(!saved_handler_instructions) { if(!ReadMemory(cpp_hdlr, cpp_handler_instructions, sizeof(cpp_handler_instructions))) return false; saved_handler_instructions = true; } return WriteMemory(cpp_hdlr, &jmp_my_hdlr, sizeof(jmp_my_hdlr)); } bool restore_cpp_handler() { if(!saved_handler_instructions) return false; else { void* loc = __CxxFrameHandler; return WriteMemory(loc, cpp_handler_instructions, sizeof(cpp_handler_instructions)); } } }
exception_storage* p = get_exception_storage(); p->set(pexc, pexc_info);注册 catch_block_protector;
//-------------------------------------------------------------------
// 如果这个处理程序被调用了,可以断定是catch块(重新)抛出了异常。
// 异常处理程序(my_handler)在调用catch块之前注册了它。其任务是判断
// catch块抛出了新异常还是重新抛出了原来的异常,并采取相应的操作。
// 在前一种情况下,它需要销毁传递给catch块的前一个异常对象;在后一种
// 情况下,它必须找到原来的异常并将其保存到ExceptionRecord中供异常
// 处理程序使用。
//-------------------------------------------------------------------
EXCEPTION_DISPOSITION catch_block_protector(
_EXCEPTION_RECORD* ExceptionRecord,
void* EstablisherFrame,
struct _CONTEXT *ContextRecord,
void* DispatcherContext
) throw ()
{
EXCEPTION_REGISTRATION *pFrame;
pFrame= reinterpret_cast<EXCEPTION_REGISTRATION*>(EstablisherFrame);
if(!(ExceptionRecord->ExceptionFlags & (_EXCEPTION_UNWINDING | _EXCEPTION_EXIT_UNWIND)))
{
void *pcur_exc = 0, *pprev_exc = 0;
const excpt_info *pexc_info = 0, *pprev_excinfo = 0;
exception_storage* p = get_exception_storage();
pprev_exc = p->get_exception();
pprev_excinfo = p->get_exception_info();
p->set(0, 0);
bool cpp_exc = ExceptionRecord->ExceptionCode == MS_CPP_EXC;
get_exception(ExceptionRecord, &pcur_exc);
get_excpt_info(ExceptionRecord, &pexc_info);
if(cpp_exc && 0 == pcur_exc && 0 == pexc_info) //重新抛出
{
ExceptionRecord->ExceptionInformation[1] = reinterpret_cast<DWORD>(pprev_exc);
ExceptionRecord->ExceptionInformation[2] = reinterpret_cast<DWORD>(pprev_excinfo);
}
else
{
exception_helper::destroy(pprev_exc, pprev_excinfo);
}
}
return ExceptionContinueSearch;
}
下面是get_exception_storage()函数的一个实现: exception_storage* get_exception_storage()
{
static exception_storage es;
return &es;
}
在单线程程序中,这是一个完美的实现。但在多线程中,这就是个灾难了,想象一下多个线程访问它,并把异常对象保存在里面的情景吧。由于每个线程都有自己的堆栈和异常处理链,我们需要一个线程安全的get_exception_storage实现:每个线程都有自己单独的exception_storage,它在线程启动时被创建,并在结束时被销毁。Windows提供的线程局部存储(thread local storage,TLS)可以满足这个要求,它能让每个线程通过一个全局键值来访问为这个线程所私有的对象副本,这是通过TlsGetValue()和TlsSetValue这两个API来完成的。//excptstorage.cpp
#include "excptstorage.h"
#include <windows.h>
namespace
{
DWORD dwstorage;
}
namespace my_handler
{
__declspec(dllexport) exception_storage* get_exception_storage() throw ()
{
void * p = TlsGetValue(dwstorage);
return reinterpret_cast <exception_storage*>(p);
}
}
BOOL APIENTRY DllMain( HANDLE hModule, DWORD ul_reason_for_call, LPVOID lpReserved )
{
using my_handler::exception_storage;
exception_storage *p;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
//主线程(第一个线程)不会收到DLL_THREAD_ATTACH通知,所以,
//与其相关的操作也放在这了
dwstorage = TlsAlloc();
if (-1 == dwstorage)
return FALSE;
p = new exception_storage();
TlsSetValue(dwstorage, p);
break ;
case DLL_THREAD_ATTACH:
p = new exception_storage();
TlsSetValue(dwstorage, p);
break;
case DLL_THREAD_DETACH:
p = my_handler::get_exception_storage();
delete p;
break ;
case DLL_PROCESS_DETACH:
p = my_handler::get_exception_storage();
delete p;
break ;
}
return TRUE;
}





