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
程序),看下会有什么事情发生:
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 returnsexit(0);
}} // extern "C"
2.6、汇编查看
用汇编看下编译器所进行的暗中操作:
.LFB3:[...]call __cxa_allocate_exceptionmovl $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_exceptioncall __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], edimov 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 表包含所有函数的基本信息:
… CIEVersion: 1Augmentation: "zR"Code alignment factor: 1Data alignment factor: -8Return address column: 16Augmentation data: 1bDW_CFA_def_cfa: r7 (rsp) ofs 8DW_CFA_offset: r16 (rip) at cfa-8
FDE 表包含函数的 CFI 指令信息:
… FDE cie=…DW_CFA_advance_loc: 1 to 0000000000000001DW_CFA_def_cfa: r7 (rsp) ofs 16DW_CFA_advance_loc: 3 to 0000000000000004DW_CFA_def_cfa: r6 (rbp) ofs 16DW_CFA_advance_loc: 11 to 000000000000000fDW_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, 4ret.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** @_ZTIifilter [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++ 异常是如何实现的相关推荐
- Java | kotlin 手动注入bean,解决lateinit property loginService has not been initialized异常
kotlin.UninitializedPropertyAccessException: lateinit property loginService has not been initialized ...
- JS Uncaught SyntaxError:Unexpected identifier异常报错原因及其解决方法
最近在写ajax的时候,调用js方法,遇到了Uncaught SyntaxError:Unexpected identifier异常报错,开始搞不清原因,很苦恼. 以为是js方法参数个数和长度的问题, ...
- JVM 常见异常及内存诊断
栈内存溢出 栈内存大小设置:-Xss size 默认除了window以外的所有操作系统默认情况大小为 1MB,window 的默认大小依赖于虚拟机内存. 栈帧过多导致栈内存溢出 下述示例代码,由于递归 ...
- java通过异常处理错误,java基础之通过错误处理异常
我们在编程过程中,通常需要时刻关注可能遇到的问题,此时可以把问题分为两类:普通问题与异常问题.普通问题:我们可以通过从当前环境中获取到的信息来解决这个问题:而异常问题:在当前环境中获取到的信息并不能解 ...
- Ajax接收Java异常_java – 处理来自Servlet的Jquery AJAX响应中的异常
我的servlet代码是 try{ //something response.setStatus(201); out.print("Data successfully saved" ...
- 消除安卓SDK更新时的“https://dl-ssl.google.com refused”异常的方法
消除安卓SDK更新时的"https://dl-ssl.google.com refused"异常的方法 消除安卓SDK更新时的"https://dl-ssl.google ...
- java 捕获异常并存入数据库_java异常处理,报异常的话怎么处理对象值,并持久化到数据库中...
展开全部 //没看到有人回e68a843231313335323631343130323136353331333365646233答你,我还没学到框架,不知道那个是不是可以很便捷操作你说的这样过程 / ...
- python异常机制
python异常处理机制 1.1python的内置异常 当我们在运行代码的时候一旦程序报错,就会终止运行,并且有的异常是不可避免的,但是我们可以对异常进行捕获,防止程序终止. python的内置异常是 ...
- Bad credentials异常
在spring-security中出现Bad credentials异常,可能是如下情况: 一.username和password错误 二.访问权限不够 三.密码加密问题,对于密码加密问题可能是如下情 ...
- java.lang.NullPointerException异常原因及解决
java.lang.NullPointerException异常原因是因为创建了一个引用类型的变量却没有指向任何对象而又去通过这个引用类型变量加点的形式去访问非静态的方法及属性. 给出三种情况, 第一 ...
最新文章
- 原生CSS设置预加载图片之前的默认背景图
- IDC发布制造业预测,AI风险决策因何上榜?
- php空格是什么,php删除空格函数是什么
- ios4--UIView的常见属性(尺寸和位置)
- OpenCV使用GDAL读取地理空间栅格文件
- 改变自己------每天进步一点点
- Windows环境下的NodeJS+NPM+Bower安装配置步骤
- poj 2559 Largest Rectangle in a Histogram dp!!!
- eclipse中如何搜索带\的字串
- charles抓包工具使用及手机抓包教程
- Discuz!如何实现为版块设定自定义logo,实现不同的版块不同的logo!
- c语言中期报告程序,课题中期报告
- 2018华为网络技术大赛课程-服务器操作系统基础原理自测题答案
- matlab定义对角块矩阵,Matlab中的扩展块对角矩阵
- JSON格式校验工具
- Install: pymongo
- python查询12306余票_python自动查询12306余票并发送邮箱提醒脚本
- 台式电脑怎么调分辨率_电脑屏幕分辨率调节方法
- electron (二) 暗黑模式
- LiveGBS国标流媒体-摄像头网络直播方案部署问题
热门文章
- FFmpeg开发(1)从mp4中提取aac音频
- 2022新轻量级个人免签支付源码+手动审核邮件短信推送
- 微信小程序请求接口提示Provisional headers are shown
- docker端口映射后连不上的问题
- 克隆硬盘后进不去系统_硬盘克隆后重启找不到操作系统所在分区问题解决
- 软件测试面试中项目介绍宝典
- 错过等一年!物流与交通的先锋碰撞,点击进入这场大佬云集的学术盛宴
- html5广告的版式设计,页面版式设计
- 浙江大学计算机学院科研团队,科研团队
- android 计时器工具类,Android实现计时器功能