本文内容主要来源于 C++ exceptions under the hood,环境为 gcc/x86,原文非常长且专注于实现自己的异常机制,感兴趣可以看原文,本文只针对于原理介绍与术语讲解。
1、太长不看版总结
- 编译器会将
throw
语句翻译成一对libstdc++
库里的函数,包括为异常处理分配内存、调用libstdc
来进行栈展开(stack unwinding)。 - 对于每个
catch
语句的存在,编译器会在函数末尾加上一些特殊信息,包括当前函数可以捕获的异常表,以及清理表(cleanup table)。 - 在进行栈展开时,会调用
libstdc++
提供的特殊函数(称为 personality routine),会检查栈上的所有函数哪个异常可以被捕获。 - 如果异常无法被捕获,那么
std::terminate
就会被调用。 - 如果找到了能够匹配的捕获操作,展开处理(unwinder)会再次在栈顶进行操作。
- unwinder 第二次遍历栈时,会要求 personality routine 去为当前函数执行清理操作。
- personality routine 会检查当前函数的清理表。如果有什么清理操作要执行的话,就会直接跳到当前栈帧(stack frame),执行清理操作(cleanup code)。这会引起每个在当前作用域分配的对象的析构操作。
- 一旦 unwinder 到达了可以处理异常的栈帧时,它会跳到对应的
catch
语句当中。 - 执行完 catch 语句后,会调用清理函数去释放掉为异常所分配的内存。
2、throw
2.1、案例分析
尝试在 C 里面用 C++ 的异常机制(即采用纯 C 的链接器来链接 C++ 的 throw
程序),看下会有什么事情发生:
struct Exception {};
extern "C" {
void seppuku() {
throw Exception();
}
}
先正常编译 C 和 C++ 的源代码:
> g++ -c -o throw.o -O0 -ggdb throw.cpp
> gcc -c -o main.o -O0 -ggdb main.c
然后在链接期间就会出现以下错误:
> gcc main.o throw.o -o app
throw.o: In function `foo()':
throw.cpp:4: undefined reference to `__cxa_allocate_exception'
throw.cpp:4: undefined reference to `__cxa_throw'
throw.o:(.rodata._ZTI9Exception[typeinfo for Exception]+0x0): undefined reference to `vtable for __cxxabiv1::__class_type_info'
collect2: ld returned 1 exit status
说明编译器暗中插入了对异常机制进行处理的函数。
2.2、__cxa_allocate_exception
该函数接受一个 size_t
类型的参数,然后为抛出的异常分配内存。
这里的内存分配到哪里是有讲究的,比如说:
- 栈上(stack) —— 异常机制需要进行栈展开,分配到栈上不合理
- 堆上(heap) —— 有时可能要抛出爆内存 OOM(out of memory) 的异常,分配到堆上也不合理
- 静态分配(static) —— 线程不安全
- 线程局部存储(local thread storage) —— 大部分实现采用这种,若 OOM,则采用特殊应急内存(一般为 static)
2.3、__cxa_throw
一旦异常被创建,该函数就会被调用。
该函数负责进行栈展开的操作,它永远不会返回(return
),要么就是跳转到对应的 catch
块去处理异常,要么就是默认地调用 std::terminate
终止程序。
该函数会准备好一大堆东西,然后把异常递交给 _Unwind_
,是一系列的 libstdc 里的函数进行真正的栈展开操作。
2.4、vtable for cxxabiv1::class_type_info
这个明显就是 RTTI(Run-Time Type Identification) 里的一种,它是用来在运行时判断两种类型是否一致。
在这里,是用来判断一个 catch
是否能够处理(handle)一个 throw
。
2.5、自定义简单实现
有了以上这些信息,我们就可以写个简单的代码来提供这些接口:
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
namespace __cxxabiv1 {
struct __class_type_info {
virtual void foo() {}
} ti;
}
#define EXCEPTION_BUFF_SIZE 255
char exception_buff[EXCEPTION_BUFF_SIZE];
extern "C" {
void* __cxa_allocate_exception(size_t thrown_size)
{
printf("alloc ex %i\n", thrown_size);
if (thrown_size > EXCEPTION_BUFF_SIZE) printf("Exception too big");
return &exception_buff;
}
void __cxa_free_exception(void *thrown_exception);
#include <unwind.h>
void __cxa_throw(
void* thrown_exception,
struct type_info *tinfo,
void (*dest)(void*))
{
printf("throw\n");
// __cxa_throw never returns
exit(0);
}
} // extern "C"
2.6、汇编查看
用汇编看下编译器所进行的暗中操作:
.LFB3:
[...]
call __cxa_allocate_exception
movl $0, 8(%esp)
movl $_ZTI9Exception, 4(%esp)
movl %eax, (%esp)
call __cxa_throw
[...]
我们看到了对那两个函数的调用,但是编译器还不知道应该怎么处理异常,所以需要能够选择到对应的异常处理函数才行。
3、catch
struct Fake_Exception {};
void raise() {
throw Exception();
}
// We will analyze what happens if a try block doesn't catch an exception
void try_but_dont_catch() {
try {
raise();
} catch(Fake_Exception&) {
printf("Running try_but_dont_catch::catch(Fake_Exception)\n");
}
printf("try_but_dont_catch handled an exception and resumed execution");
}
同样采用纯 C 的链接器去链接 C++ 的 catch
程序:
> gcc main.o throw.o mycppabi.o -O0 -ggdb -o app
throw.o: In function `try_but_dont_catch()':
throw.cpp:12: undefined reference to `__cxa_begin_catch'
throw.cpp:12: undefined reference to `__cxa_end_catch'
throw.o:(.eh_frame+0x47): undefined reference to `__gxx_personality_v0'
collect2: ld returned 1 exit status
在执行 catch 块代码的时候,会先要调用 __cxa_begin_catch
函数对异常对象进行调整(计数器、放置到栈顶),执行完后会调用 __cxa_end_catch
函数进行异常对象的销毁。
4、Unwinder
__cxa_throw
会准备好一大堆东西,然后把异常递交给 _Unwind_
,是一系列的 libstdc 里的函数进行真正的栈展开操作。
那么它是怎么找到对应的 catch 块的呢?
异常捕获需要有一定程度的反射(reflexion)的支持(即程序有能力分析它自己的代码)。
用汇编探索下实际的调用情况,为了更加直观,只保留重要的汇编代码。
先看下 raise
函数做了什么:
_Z5raisev:
call __cxa_allocate_exception
call __cxa_throw
正常地对 throw
异常机制的两个函数进行了调用。
再看下 try_but_dont_catch
函数的情况:
_Z18try_but_dont_catchv:
.cfi_startproc
.cfi_personality 0,__gxx_personality_v0
.cfi_lsda 0,.LLSDA1
链接器会根据 CFI(call frame information) 指令来进行函数的使用判断,CFI 指令信息通常用在栈展开中。
LSDA(language specific data area) 的信息会被 personality 函数使用,用来知悉哪个函数(块)可以处理该异常。
4.1、LSDA
LSDA 的内容包含有:
- 指向相关数据的指针 —— landing pad start pointer(记录偏移量)、types table pointer(type info 索引)
- 一个保存了调用点的列表 —— 可能会抛出异常的调用点(call sites)
- 一个操作记录(action table)的列表 —— catch 块信息、异常的规范
每个来自于 C++ 代码的程序片段都会有自己的 LSDA,它会被加到 .gcc_except_table 当中。
4.2、personality
由于在处理异常时,不同编程语言会存在不同的处理行为,所以异常处理 ABI 提供了一个机制来满足不同的 personality(性格)。
一个异常处理的 personality 会被 personality 函数所定义,比如 C++ 是 __gxx_personality_v0
,它会接收异常的上下文,一个异常结构体包含有异常对象的类型和值,以及指向当前函数的异常表(exception table)的引用。
对于当前的编译单元,personality 函数会在异常的栈帧中被指明。
4.3、CFI
CFI(call frame information)实际上是汇编辅助指令(非 CPU 真实指令),用来描述栈帧的结构。
我们需要 CFI,因为手写的汇编代码不会有编译器生成的调试信息,而且为了调试器能够遍历核心文件(core file),或者分析 profilers 能够正确地进行栈展开操作,CFI 都是有必要的。
在异常处理当中,CFI 信息可以用来辅助找到对应的 landing pads 和进行栈展开。
CFI 指令以 .cfi_
开头。为了进行栈展开,还需要定义 CFA(Canonical Frame Address),代表调用函数在 CALL 指令前 sp(stack pointer,栈指针)的值。我们的任务是定义数据,来使对于给定的任何指令,CFA 都能够被计算出来。
其中一种设计就是 CFI 表,会为每一条指令保存 (register, offset) 的数据对,但为了减少其大小,只保存指令当中被改变的数据。
.globl square
.type square,@function
.hidden square
square:
.cfi_startproc ; 开始 CFI 记录,.eh_frame 的入口
push rbp
.cfi_adjust_cfa_offset 8 ; 前面有入栈操作,所以更新偏移量
mov rbp, rsp
.cfi_def_cfa_register rbp ; 用寄存器来定义 CFA 的值
mov DWORD PTR [rbp-4], edi
mov eax, DWORD PTR [rbp-4]
imul eax, DWORD PTR [rbp-4]
pop rbp
.cfi_def_cfa rsp, 8 ; 用寄存器加偏移量的方式定义 CFA 的值
ret
.cfi_endproc ; 结束 CFI 记录,生成到 .eh_frame 中
4.4、CIE & FDE
CFI 表可以用 objdump
导出为两张表:CIE(Common Information Entry) 和 FDE(Frame Description Entry)。
CIE 表包含所有函数的基本信息:
… CIE
Version: 1
Augmentation: "zR"
Code alignment factor: 1
Data alignment factor: -8
Return address column: 16
Augmentation data: 1b
DW_CFA_def_cfa: r7 (rsp) ofs 8
DW_CFA_offset: r16 (rip) at cfa-8
FDE 表包含函数的 CFI 指令信息:
… FDE cie=…
DW_CFA_advance_loc: 1 to 0000000000000001
DW_CFA_def_cfa: r7 (rsp) ofs 16
DW_CFA_advance_loc: 3 to 0000000000000004
DW_CFA_def_cfa: r6 (rbp) ofs 16
DW_CFA_advance_loc: 11 to 000000000000000f
DW_CFA_def_cfa: r7 (rsp) ofs 8
4.5、try-catch 块
用汇编来看下函数 try-catch 块的行为:
[...]
call _Z5raisev ; raise 函数中调用了 __cxa_throw,正常不会返回
jmp .L8 ; 如果是正常函数,则会返回并继续执行
cmpl $1, %edx ; catch 语句对应的起始指令
je .L5 ; 检查异常是否能被处理
.LEHB1:
call _Unwind_Resume ; 不能处理则调用 栈恢复 函数,即清理操作
.LEHE1:
.L5:
call __cxa_begin_catch ; 若能处理,则开始 catch 块的执行
call __cxa_end_catch ; 中间会夹杂着 catch 块的逻辑
.L8: ; 函数的末尾
leave
.cfi_restore 5
.cfi_def_cfa 4, 4
ret
.cfi_endproc
如果 raise
函数不能正常处理异常,那么它的下一条指令 jmp .L8
就不应该执行,而是应该在异常处理(exception handers)当中,又称之为 landing pad。
4.5.1、landing pad
The term used to define the place where an invoke
continues after an exception is called a landing pad.
术语 landing pad 代表:在异常处理当中应该去执行(跳转到)的位置。
landing pads 会有三种:
- cleanup clause —— 调用 destructors of out-of-scope variables 或
__attribute__((cleanup(...)))
注册的 callbacks,然后调用_Unwind_Resume
跳转到清理操作 - catch clause —— 调用 destructors of out-of-scope variables,跳转到
__cxa_begin_catch
调用,然后是catch
块,最后是__cxa_end_catch
调用 - rethrow:调用 destructors of out-of-scope variables in the catch clause,然后调用
__cxa_end_catch
,接着用_Unwind_Resume
跳转回 cleanup phase
在 LLVM 当中,landing pads 是概念上的可选的函数入口(entry points),参数为一个对异常结构体的引用,和一个 type info 的索引。
landing pad 会保存异常结构体的引用,并且会用异常对象对应的 type info 去选择正确 catch
块。
在 LLVM’s exception handling system 当中,会有 ‘landingpad
’ 指令来指明一个代码块(basic block)是 landing pad。
;; A landing pad which can catch an integer.
%res = landingpad { i8*, i32 }
catch i8** @_ZTIi
;; A landing pad that is a cleanup.
%res = landingpad { i8*, i32 }
cleanup
;; A landing pad which can catch an integer and can only throw a double.
%res = landingpad { i8*, i32 }
catch i8** @_ZTIi
filter [1 x i8**] [@_ZTId]
那么如何找到对应的 landing pad,这就要求 _Unwind_
遍历调用栈,看哪个调用具有合适的带 landing pad 的 try 块可以捕获异常。
4.6、__gcc_except_table
那么 _Unwind_
是怎么找到合适的 landing pad 的?这时候就需要类似反射的信息的辅助了。
为了知晓 landing pads 在哪里,就用到了 __gcc_except_table,在函数的末尾可以找到:
.LFE1:
.globl __gxx_personality_v0
.section .gcc_except_table,"a",@progbits
[...]
.LLSDACSE1:
.long _ZTI14Fake_Exception
它会帮助我们来定位 landing pad 被保存到什么位置,实际上是找到 LSDA,然后 personality 函数会检查 LSDA 能不能处理异常。
ELF 文件里 LSDA 通常就保存在 .gcc_except_table 段当中,该段会由 personality 函数来进行解析。
如果为函数指定了 nothrow
的标识符,那么就不会生成该信息,可以减少代码大小,但当异常被抛出时,由于没有 LSDA 的信息,personality 函数不知道该怎么办,通常会调用默认的异常处理机制,所以大概率会调用 std::terminate
。
5、two-phase handling
personality 函数的参数含有 action 类型,代表 _Unwind_
要求执行什么样的操作,因为捕获异常分为两个阶段:lookup 和 cleanup。
Unwind 会尝试定位异常的 landing pad,而 personality 函数的返回值类型是 _Unwind_Reason_Code
,如果是 _URC_HANDLER_FOUND
则代表找到了 landing pad,否则会返回 _URC_CONTINUE_UNWIND
让 Unwind 从下一个栈帧进行尝试。
如果都没找到,那么会调用默认的异常处理机制(std::terminate
)。
找到了 landing pad 后,Unwind 会再次遍历栈,调用 personality 函数,采用 _UA_CLEANUP_PHASE
的 action 操作,而 personality 函数会再次检查是否能处理当前的异常。
如果无法处理,则会执行 LSDA 所指定的 cleanup 函数:会执行当前栈上所有对象的析构操作。
如果可以处理,则不会执行 cleanup 函数,会告诉 Unwind 在 landing pad 恢复执行。
为什么 lookup 的时候已经找到了可以处理异常的栈帧,但还要再遍历一次栈,因为这样 personality 函数就有机会对作用域内的对象进行析构操作,从而使得 RAII(Resource Acquisition Is Initialization) 是异常机制安全的操作。
参考
C++ exceptions under the hood:https://monkeywritescode.blogspot.com/p/c-exceptions-under-hood.html
C++ exception handling ABI:https://maskray.me/blog/2020-12-12-c++-exception-handling-abi
Itanium C++ ABI: Exception Handling:https://itanium-cxx-abi.github.io/cxx-abi/abi-eh.html
C++异常机制的实现方式和开销分析:http://baiy.cn/doc/cpp/inside_exception.htm?hmsr=toutiao.io&utm_medium=toutiao.io&utm_source=toutiao.io
Exception Handling in LLVM:https://llvm.org/docs/ExceptionHandling.html#overview
Personality Function:https://llvm.org/docs/LangRef.html#personalityfn
‘landingpad
’ Instruction:https://llvm.org/docs/LangRef.html#i-landingpad
CFI directives in assembly files:https://www.imperialviolet.org/2017/01/18/cfi.html
CFI directives:https://sourceware.org/binutils/docs/as/CFI-directives.html
Exception Handling Tables:https://itanium-cxx-abi.github.io