C++ 异常是如何实现的
文章目录
本文内容主要来源于 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
程序),看下会有什么事情发生:
|
|
先正常编译 C 和 C++ 的源代码:
|
|
然后在链接期间就会出现以下错误:
|
|
说明编译器暗中插入了对异常机制进行处理的函数。
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、自定义简单实现
有了以上这些信息,我们就可以写个简单的代码来提供这些接口:
|
|
2.6、汇编查看
用汇编看下编译器所进行的暗中操作:
|
|
我们看到了对那两个函数的调用,但是编译器还不知道应该怎么处理异常,所以需要能够选择到对应的异常处理函数才行。
3、catch
|
|
同样采用纯 C 的链接器去链接 C++ 的 catch
程序:
|
|
在执行 catch 块代码的时候,会先要调用 __cxa_begin_catch
函数对异常对象进行调整(计数器、放置到栈顶),执行完后会调用 __cxa_end_catch
函数进行异常对象的销毁。
4、Unwinder
__cxa_throw
会准备好一大堆东西,然后把异常递交给 _Unwind_
,是一系列的 libstdc 里的函数进行真正的栈展开操作。
那么它是怎么找到对应的 catch 块的呢?
异常捕获需要有一定程度的反射(reflexion)的支持(即程序有能力分析它自己的代码)。
用汇编探索下实际的调用情况,为了更加直观,只保留重要的汇编代码。
先看下 raise
函数做了什么:
|
|
正常地对 throw
异常机制的两个函数进行了调用。
再看下 try_but_dont_catch
函数的情况:
|
|
链接器会根据 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) 的数据对,但为了减少其大小,只保存指令当中被改变的数据。
|
|
4.4、CIE & FDE
CFI 表可以用 objdump
导出为两张表:CIE(Common Information Entry) 和 FDE(Frame Description Entry)。
CIE 表包含所有函数的基本信息:
|
|
FDE 表包含函数的 CFI 指令信息:
|
|
4.5、try-catch 块
用汇编来看下函数 try-catch 块的行为:
|
|
如果 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。
|
|
那么如何找到对应的 landing pad,这就要求 _Unwind_
遍历调用栈,看哪个调用具有合适的带 landing pad 的 try 块可以捕获异常。
4.6、__gcc_except_table
那么 _Unwind_
是怎么找到合适的 landing pad 的?这时候就需要类似反射的信息的辅助了。
为了知晓 landing pads 在哪里,就用到了 __gcc_except_table,在函数的末尾可以找到:
|
|
它会帮助我们来定位 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/cxx-abi/exceptions.pdf
文章作者 calssion
上次更新 2021-09-04