摘要:为了更好的理解C++中异常处理的实现,本文简单描述了Itanium ABI中异常处理的流程和llvm/libsdc++简要实现。
关键字:C++,exception,llvm,clang
C++他提供了异常处理机制来对程序中的错误进行处理,避免在一些异常情况下无法恢复现场而导致额外的损失。虽然在很多Google C++ Guide中禁止使用异常,但是作为C++的一部分我们仍然有足够的理由去了解其技术细节。
由于C++的ABI并不统一,主流的ABI有两种Itanium ABI和MSVC的ABI,也就是常用的clang/gcc、msvc编译器采用的两种ABI。因此具体的异常实现也不同。比如clang/gcc使用SJLJ(SetJmp/LongJmp)实现,而Windows上虽然大体流程相似但是细节上存在一些差异。本为主要以llvm/Itanium ABI为参考描述,对Windows实现有兴趣的可以参考Exception Handling using the Windows Runtime。
再次在在此之前需要了解几个概念:
landing pad:A section of user code intended to catch, or otherwise clean up after, an exception. It gains control from the exception runtime via the personality routine, and after doing the appropriate processing either merges into the normal user code or returns to the runtime by resuming or raising a new exception.
Itanium ABI中将实现相关和实现无关的内容拆分开,来保证ABI的灵活度。因此,Itanium C++ ABI中Exception Handling分成Level 1 Base ABI and Level 2 C++ ABI两部分。Base ABI描述了语言无关的stack unwinding部分,定义了_Unwind_* API。Level 2 则和C++实现相关,定义了__cxa_* API(__cxa_allocate_exception, __cxa_throw, __cxa_begin_catch等)。
首先简单说一下,C++ 异常处理的基本流程:
- 调用
__cxa_allocate_exception
创建异常需要的一些对象,比如exception object等; - 调用
cxa_throw
抛出异常; - 异常抛出后开始栈展开并搜索匹配的异常类型;
- 阶段一,不断展开栈直到搜索到匹配的异常类型,并执行personality routine;
- 阶段二,从抛出异常的位置开始执行每个栈帧的cleanup内容;
- 如果没有匹配到则调用std::terminate终止程序,否则执行根据表格指引执行landing pad;
- 最后执行清理操作销毁异常对象,还原现场。
1 数据结构
1.1 Exception Objects
一个完整的 C++ 异常对象由一个头部组成,这个头部是围绕着一个带有额外的 C++ 特定信息的 unwind 对象头部的包装器,然后是抛出的 C++ 异常对象本身。头部的结构如下:
struct __cxa_exception {
std::type_info * exceptionType;
void (*exceptionDestructor) (void *);
unexpected_handler unexpectedHandler;
terminate_handler terminateHandler;
__cxa_exception * nextException;
int handlerCount;
int handlerSwitchValue;
const char * actionRecord;
const char * languageSpecificData;
void * catchTemp;
void * adjustedPtr;
_Unwind_Exception unwindHeader;
};
exceptionType
字段编码了抛出的异常的类型。exceptionDestructor
字段包含了一个指向被抛出类型的析构函数的函数指针,可能为空。这些指针必须存储在异常对象中,因为非多态和内置类型可以被抛出。unexpectedHandler
和terminateHandler
字段包含了指向异常抛出点处的未预期和终止处理程序的指针。nextException
字段用于创建异常的链表(每个线程)。handlerCount
字段包含捕获此异常对象的处理程序数量。它还用于确定异常的生存时间。handlerSwitchValue
、actionRecord
、languageSpecificData
、catchTemp
和adjustedPtr
字段缓存在第一遍计算时得到的信息,但在第二遍时也很有用。通过将这些信息存储在异常对象中,清理阶段可以避免重新检查操作记录。这些字段保留供包含要调用的处理程序的栈帧的自定义函数使用。unwindHeader
结构用于在多种语言或相同语言的多个运行时存在的情况下正确操作异常。
按照惯例,一个 __cxa_exception 指针指向抛出的 C++ 异常对象的表示,紧随其后是头部。头部结构可以通过从 __cxa_exception 指针的负偏移访问。这种布局允许对来自不同语言(或同一语言的不同实现)的异常对象进行一致的处理,并允许在保持二进制兼容性的同时对头部结构进行将来的扩展。
下面是llvm/libstdc++中的定义,具体实现在llvm-project/libcxxabi/src/cxa_exception.h
中。可以看到大体上的layout和ABI中定义的相同,但是在头部多了一些字段就是上面提及的为了兼容性实际使用时需要通过负偏移来读取。
struct _LIBCXXABI_HIDDEN __cxa_exception {
#if defined(__LP64__) || defined(_WIN64) || defined(_LIBCXXABI_ARM_EHABI)
// Now _Unwind_Exception is marked with __attribute__((aligned)),
// which implies __cxa_exception is also aligned. Insert padding
// in the beginning of the struct, rather than before unwindHeader.
void *reserve;
// This is a new field to support C++11 exception_ptr.
// For binary compatibility it is at the start of this
// struct which is prepended to the object thrown in
// __cxa_allocate_exception.
size_t referenceCount;
#endif
// Manage the exception object itself.
std::type_info *exceptionType;
#ifdef __USING_WASM_EXCEPTIONS__
// In Wasm, a destructor returns its argument
void *(_LIBCXXABI_DTOR_FUNC *exceptionDestructor)(void *);
#else
void (_LIBCXXABI_DTOR_FUNC *exceptionDestructor)(void *);
#endif
std::unexpected_handler unexpectedHandler;
std::terminate_handler terminateHandler;
__cxa_exception *nextException;
int handlerCount;
#if defined(_LIBCXXABI_ARM_EHABI)
__cxa_exception* nextPropagatingException;
int propagationCount;
#else
int handlerSwitchValue;
const unsigned char *actionRecord;
const unsigned char *languageSpecificData;
void *catchTemp;
void *adjustedPtr;
#endif
#if !defined(__LP64__) && !defined(_WIN64) && !defined(_LIBCXXABI_ARM_EHABI)
// This is a new field to support C++11 exception_ptr.
// For binary compatibility it is placed where the compiler
// previously added padding to 64-bit align unwindHeader.
size_t referenceCount;
#endif
_Unwind_Exception unwindHeader;
};
1.2 Caught Exception Stack
c+±rt中每个线程都包含一个全局的对象来描述当前线程异常的状况。
struct __cxa_eh_globals {
__cxa_exception * caughtExceptions;
unsigned int uncaughtExceptions;
};
caughtExceptions
字段是一个活动异常列表,按照最近的异常排在前面,通过异常头部的nextException
字段链接成stack。uncaughtExceptions
字段是未捕获异常的计数,供 C++ 库的uncaught_exceptions
使用。
这些信息是基于每个线程维护的。因此,caughtExceptions
是当前线程抛出并捕获的异常列表,uncaughtExceptions
是当前线程抛出但尚未捕获的异常计数。(这包括重新抛出的异常,它们可能仍然具有活动的处理程序,但不被视为已捕获。)
下面是llvm/libstdc++中的定义,具体实现在llvm-project/libcxxabi/src/cxa_exception.h
中。
struct _LIBCXXABI_HIDDEN __cxa_eh_globals {
__cxa_exception * caughtExceptions;
unsigned int uncaughtExceptions;
#if defined(_LIBCXXABI_ARM_EHABI)
__cxa_exception* propagatingExceptions;
#endif
};
2 抛异常
实现抛出异常所需的处理可能包括以下步骤:
- 调用
__cxa_allocate_exception
来创建一个异常对象。 - 评估被抛出的表达式,并将其复制到由
__cxa_allocate_exception
返回的缓冲区中,可能使用复制构造函数。如果评估被抛出的表达式通过抛出异常退出,那么异常将传播而不是表达式本身。清理代码必须确保在刚刚分配的异常对象上调用__cxa_free_exception
。(如果复制构造函数本身通过抛出异常退出,将调用 terminate()。) - 调用
__cxa_throw
将异常传递给运行时库。
2.1 创建异常对象
抛出异常时需要存储对象,而对象必须存储在具体的内存空间中。这个存储空间必须在堆栈unwind时必须保证其生命周期,并且必须是线程安全的。因此,异常对象的存储空间通常将在堆中分配,尽管实现可能提供紧急缓冲区以支持在低内存条件下抛出bad_alloc
异常。
内存由__cxa_allocate_exception
分配,传递了要抛出的异常对象的大小(不包括__cxa_exception
头部的大小),并返回指向异常对象临时空间的指针。如果可能的话,它将在堆上分配异常内存。如果堆分配失败,实现可以使用其他备份机制。
C++ 运行时库应为每个潜在任务分配至少4K字节的静态紧急缓冲区,最多64KB。该缓冲区仅在异常对象动态分配失败的情况下使用。它应以 1KB 块的形式分配。任何时候最多有 16 个任务可以使用紧急缓冲区,最多4个嵌套异常,每个异常对象(包括Header)的大小最多为1KB。其他线程将被阻塞,直到16个线程之一取消分配其紧急缓冲存储。紧急缓冲区的接口是实现定义的,并且仅由异常库使用。
如果在这些约束条件下__cxa_allocate_exception
无法分配异常对象,它将调用terminate()
终止程序。
void * __cxa_allocate_exception(size_t thrown_size);
一旦空间被分配,throw 表达式必须根据 C++ 标准指定的抛出值初始化异常对象。临时空间将由__cxa_free_exception
释放,该函数传递了前一个__cxa_allocate_exception
返回的地址。
void __cxa_free_exception(void *thrown_exception