本文内容主要来源于 C++ exceptions under the hood,环境为 gcc/x86,原文非常长且专注于实现自己的异常机制,感兴趣可以看原文,本文只针对于原理介绍与术语讲解。
throw
语句翻译成一对 libstdc++
库里的函数,包括为异常处理分配内存、调用 libstdc
来进行栈展开(stack unwinding)。catch
语句的存在,编译器会在函数末尾加上一些特殊信息,包括当前函数可以捕获的异常表,以及清理表(cleanup table)。libstdc++
提供的特殊函数(称为 personality routine),会检查栈上的所有函数哪个异常可以被捕获。std::terminate
就会被调用。catch
语句当中。尝试在 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
说明编译器暗中插入了对异常机制进行处理的函数。
该函数接受一个 size_t
类型的参数,然后为抛出的异常分配内存。
这里的内存分配到哪里是有讲究的,比如说:
一旦异常被创建,该函数就会被调用。
该函数负责进行栈展开的操作,它永远不会返回(return
),要么就是跳转到对应的 catch
块去处理异常,要么就是默认地调用 std::terminate
终止程序。
该函数会准备好一大堆东西,然后把异常递交给 _Unwind_
,是一系列的 libstdc 里的函数进行真正的栈展开操作。
这个明显就是 RTTI(Run-Time Type Identification) 里的一种,它是用来在运行时判断两种类型是否一致。
在这里,是用来判断一个 catch
是否能够处理(handle)一个 throw
。
有了以上这些信息,我们就可以写个简单的代码来提供这些接口:
#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"
用汇编看下编译器所进行的暗中操作:
.LFB3: [...] call __cxa_allocate_exception movl $0, 8(%esp) movl $_ZTI9Exception, 4(%esp) movl %eax, (%esp) call __cxa_throw [...]
我们看到了对那两个函数的调用,但是编译器还不知道应该怎么处理异常,所以需要能够选择到对应的异常处理函数才行。
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
函数进行异常对象的销毁。
__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 函数使用,用来知悉哪个函数(块)可以处理该异常。
LSDA 的内容包含有:
每个来自于 C++ 代码的程序片段都会有自己的 LSDA,它会被加到 .gcc_except_table 当中。
由于在处理异常时,不同编程语言会存在不同的处理行为,所以异常处理 ABI 提供了一个机制来满足不同的 personality(性格)。
一个异常处理的 personality 会被 personality 函数所定义,比如 C++ 是 __gxx_personality_v0
,它会接收异常的上下文,一个异常结构体包含有异常对象的类型和值,以及指向当前函数的异常表(exception table)的引用。
对于当前的编译单元,personality 函数会在异常的栈帧中被指明。
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 中
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
用汇编来看下函数 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。
The term used to define the place where an invoke
continues after an exception is called a landing pad.
术语 landing pad 代表:在异常处理当中应该去执行(跳转到)的位置。
landing pads 会有三种:
__attribute__((cleanup(...)))
注册的 callbacks,然后调用 _Unwind_Resume
跳转到清理操作__cxa_begin_catch
调用,然后是 catch
块,最后是 __cxa_end_catch
调用__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 块可以捕获异常。
那么 _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
。
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