C++ 异常是如何实现的

本文内容主要来源于 C++ exceptions under the hood,环境为 gcc/x86,原文非常长且专注于实现自己的异常机制,感兴趣可以看原文,本文只针对于原理介绍与术语讲解。

1、太长不看版总结

  1. 编译器会将 throw 语句翻译成一对 libstdc++ 库里的函数,包括为异常处理分配内存、调用 libstdc 来进行栈展开(stack unwinding)。
  2. 对于每个 catch 语句的存在,编译器会在函数末尾加上一些特殊信息,包括当前函数可以捕获的异常表,以及清理表(cleanup table)。
  3. 在进行栈展开时,会调用 libstdc++ 提供的特殊函数(称为 personality routine),会检查栈上的所有函数哪个异常可以被捕获。
  4. 如果异常无法被捕获,那么 std::terminate 就会被调用。
  5. 如果找到了能够匹配的捕获操作,展开处理(unwinder)会再次在栈顶进行操作。
  6. unwinder 第二次遍历栈时,会要求 personality routine 去为当前函数执行清理操作。
  7. personality routine 会检查当前函数的清理表。如果有什么清理操作要执行的话,就会直接跳到当前栈帧(stack frame),执行清理操作(cleanup code)。这会引起每个在当前作用域分配的对象的析构操作。
  8. 一旦 unwinder 到达了可以处理异常的栈帧时,它会跳到对应的 catch 语句当中。
  9. 执行完 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

C++ 异常是如何实现的

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 和进行栈展开。

C++ 异常是如何实现的

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

上一篇:c – 返回投掷三元运算符?


下一篇:动态代理(一)