关于为了复现Inflated从而看了几篇异常处理文章,由于太菜,笔记几乎是复制回来,加了自己的注释)
第一篇!
C++异常处理(1)
异常抛出后,发生了什么事情?
如果当前函数没有catch,就沿着函数的调用链继续往上抛,然后出现两种情况
在某个函数中找到相应的catch
没找到相应的catch,调用 std::terminate() (这个函数是把程序abort)
如果想找到了相应的catch,执行相应的操作
- 程序中catch的代码块有个专有名词:Landing pad
从抛异常到开始 -> 执行Landing pad代码 这整个过程叫作Stack unwind
Stack unwind
- 从抛异常函数开始,对调用链上的函数逐个往前查找Landing pad
- 如果没有找到Landing pad则把程序abort,如果找到则记下Landing pad的位置,再重新回到抛异常的函数那里开始,一帧一帧地清理调用链上各个函数内部的局部变量,直到 landing pad 所在的函数为止
1 | void func1() |
stack unwind的过程可以简单看成函数调用的逆过程,这个过程在实现上由一个专门的stack unwind库来实现
stack unwind库在intel平台上
属于Itanium ABI 接口中的一部分
与具体的语言无关,由系统实现
任何上层语言都可以通过这个接口的基础实现各自的异常处理
GCC就是通过这个接口实现C++的异常处理
Itanium C++ ABI
ltanium C++ ABI定义了一系列函数以及数据结构来建立整个异常处理的流程及框架,主要函数包括以下列:
1 | _Unwind_RaiseException, |
其中 _Unwind_RaiseException() 函数进行stack unwind,它在用户执行throw的时被调用
主要功能
从当前函数开始,对调用链上的每一个函数都调用一个叫做 personality routine 的函数(__gxx_personality_v0)
- personality routine 该函数由上层的语言定义及提供实现
_Unwind_RaiseException() 会在内部把函数栈调用现场重现,然后传给 personality routine,该函数主要做两件事情
- 检查当前函数是否有相对应的catch
- 清理调用栈上的局部变量
personality routine
显然可以发现 personality routine 干的事情不就是stack unwind干的事情,所以说stack unwind主要就是通过personality routine来完成,相当于一个callback函数
- 这些参数要注意的是第二个 actions,这是用来告诉 personality routine 当前处于哪个阶段
- 其他参数主要用来传递异常相关信息和当前函数上下文
1 | _Unwind_Reason_Code (*__personality_routine) |
Stack unwind的两个阶段
具体到调用链上的函数来说,每个函数在 unwind 过程中都会被 personality routine 遍历两次
以下伪代码展示 _Unwind_RaiseException() 内部的大概实现
1 | _Unwind_RaiseException(exception) |
ABI 中的函数使用到了两个自定义的数据结构,用来传递一些内部的信息(指的是 personality routine的两个参数)
1 | struct _Unwind_Context; |
_Unwind_Context
这是第一个对调用者透明的结构,用于表示运行时的上下文
- 主要就是一些寄存器的值和函数返回地址等
(接口中没找到定义,这是gcc的源码里的定义)
1 | struct _Unwind_Context |
_Unwind_Exception
unwind库里用于表示一个异常
C++ ABI
基于前面介绍的 ltanium ABI,编译器层面也定义了一系列 ABI 与之交互
当我们在代码中写下 throw xxx,编译器会分配一个数据结构 __cxa_exception 来表示该异常,该异常也有一个头部,定义如下
1 | struct __cxa_exception |
注意最后一个变量!**_Unwind_Exception unwindHeader**,这个变量就是前面 ltanium 接口里提到的接口内部用的结构体
当用户 throw 一个异常时,编译器会帮我们调用相应的函数分配出如下的结构
其中 _cxa_exception 就是头部,exception_obj 则是 “throw xxx” 中的 xxx,这两部分在内存中是连续的。
异常对象由函数 __cxa_allocate_exception() 进行创建
最后由 __cxa_free_exception() 进行销毁
当我们在程序里执行了抛出异常的操作,编译器为我们做了如下的事情:
- 调用 __cxa_allocate_exception 函数,分配一个异常对象(数据结构如上)
- 调用 __cxa_throw 函数,这个函数会将异常对象做一些初始化
- __cxa_throw() 调用 Itanium ABI 里的 _Unwind_RaiseException() 从而开始 unwind
- _Unwind_RaiseException() 对调用链上的函数进行 unwind 时,调用 personality routine
- 该异常如能被处理(有相应的 catch),则 personality routine 会依次对调用链上的函数进行清理
- _Unwind_RaiseException() 将控制权转到相应的catch代码
- unwind 完成,用户代码继续执行
总结的太Bravo!!
从C++的角度看,一个完整的异常处理流程就完成了,当然省略了很多细节
如 personality routine
它是怎么知道当前 unwind 的函数是否有相应的 catch 语句?
又是怎么知道该如何处理这个函数内的局部变量
只需要大概明白,personality routine本身也不知道,只有编译器知道!
因此在编译阶段编译器会建立一些表项来保存相应的信息,使得 personality routine 可以在运行时通过这些事,先建立起来的信息进行相应的查询
从源码看 Unwind 过程
unwind 的过程是从 __cxa_throw() 里开始的,请看如下源码:
1 | extern "C" void |
我们可以看到 __cxa_throw 最终调用了 _Unwind_RaiseException(),stack unwind 就此开始
如前面所述,unwind 分为两个阶段,分别进行搜索 catch 及清理调用栈
1 | /* Raise an exception, passing along the given exception object. */ |
如上两个函数分别对应了 unwind 过程中的这两个阶段,注意其中的:
1 | uw_init_context() |
这几个函数主要是用来重建函数调用现场的,大概原理是:
对于调用链上的函数来说,它们的很大一部分上下文是可以从堆栈上恢复,如 ebp, esp, 返回地址等
编译器为了让 unwinder 可以从栈上获取这些信息,它的编译代码的时候,建立了很多表项用于记录每个可以抛异常的函数的相关信息,这些信息在重建上下文时将指导程序怎么去搜索栈上的东西
Some interesting things
说了一大堆,下面写个测试的程序简单回顾一下前面所说的关于异常处理的大概流程:
1 |
|
上面的程序运行起来后,我们可以在 __gxx_personality_v0 里下一个断点,可以看出流程
1 | Breakpoint 2, 0x00dd0a46 in __gxx_personality_v0 () from /usr/lib/libstdc++.so.6 |
(由于基本都不知道,所以基本是对链接文章的注释版复制)
第二篇!
https://www.cnblogs.com/catch/p/3619379.html
C++异常处理(2)
前一篇文章简单介绍C++异常处理的流程,但在一些细节 上一带而过
比如
- _Unwind_RaiseException 是怎样重建函数现场的
- Personality routine 是怎样清理栈上变量的
相关的数据结构
unwind 的进行需要编译器生成一定的数据来支持,这些数据保存了与每个可能抛异常的函数相关的信息以供运行时查询
那么,编译保存了什么信息?根据 Itanium ABI 的定义,主要包括以下三类:
- unwind table,这个表记录了与函数相关的信息,共三个字段:函数的起始地址,函数的结束地址,一个 info block 指针
- unwind descriptor table,这个列表用于描述函数中需要 unwind 的区域的相关信息
- 语言相关的数据(language specific data area),用于上层语言内部的处理
以上数据结构的描述来自 Itanium ABI 的标准定义,但在具体实现时,这些数据是怎么组织以及放到了哪里则是由编译器来决定的
对于 GCC 来说,所有与 unwind 相关的数据都放到了 .eh_frame 及 .gcc_except_table 这两个 section 里面了,而且它的格式与内容和标准的定义稍稍有些不同
.eh_frame区域
.eh_frame 的格式与 .debug_frame 是很相似的(不完全相同),属于 DWARF 标准中的一部分
所有由 GCC 编译生成的需要支持异常处理的程序都包含了 DWARF 格式的数据与字节码
这些数据与字节码的主要作用有两个:
- 描述函数调用栈的结构(layout)
- 异常发生后,指导 unwinder 怎么进行 unwind
DWARF
这个字节码功能很强大,它是图灵完备的,这意味着仅仅通过 DWARF 就可以做任何事情(理论上),由于数据结构较为复杂,这里只记录了与其异常处理相关的staff.
本质上来讲,en_frame 像一张表,它根据程序中的某一条指令来设置相关的寄存器,从而返回到当前函数的调用函数
该表中:
- CFA(canonical frame address)表示一个基地址,作为当前函数中其他地址的起始地址,使得其他地址可以用该该基地址的偏移地址来表示
- 由于这个表要覆盖很多程序指令,因此这个表的地址是很大的,甚至比程序的代码量还要大。
- 而在实际中,为了减少该表的体积,GCC通常会对他进行压缩编码
- 比如,只会对抛异常的函数里的特定区域指令进行记录
具体的实现上,en_frame 由一个CIE (Common Information Entry) 及多个 FDE (Frame Description Entry) 组成
它们在内存中是连续存放的:
CIE 及 FDE 格式的定义参考如下:
CIE 结构
FDE结构
注意标注红色的字段:
CIE->Initial Instructions, FDE->Call Frame Instructions 这两字段里放的就是所谓的 DWARF 字节码
- 比如:DW_CFA_def_cfa R OFF,表示通过寄存器 R 及位移 OFF 来计算 CFA,其功能类似于前面的表格中第二列指明的内容
FDE->PC begin, PC range, 这两个字段联合起来表示该 FDE 所能覆盖的指令的范围, eh_frame 中所有的 FDE 最后会按照 pc begin 排序进行存放
如果 CIE 中的 Augmentation String 中包含有字母 “P”,则相应的 Augmentation Data 中包含有指向 personality routine 的指针
如果 CIE 中的 Augmentation String 中包含有有字母“L”,则 FDE 中 Aumentation Data 包含有 language specific data 的指针
对一个elf文件通过如下命令:readelf -Wwf xxx,可以读取其中关于 .eh_frame 的数据:
1 | The section .eh_frame contains: |
对于由 GCC 编译出来的程序来说,CIE, FDE 是其在 unwind 过程中恢复现场时所依赖的全部东西,而且是完备的,这里所说的恢复现场指的是恢复调用当前函数的函数的现场
比如,func1 调用 func2,然后我们可以在 func2 里通过查询 CIE,FDE 恢复 func1 的现场
CIE,FDE 存在于每一个需要处理异常的 ELF 文件中,当异常发生时,runtime 根据当前 PC 值调用 dl_iterate_phdr() 函数就可以把当前程序所加载的所有模块轮询一遍,从而找到该 PC 所在模块的 eh_frame
1 | for (n = info->dlpi_phnum; --n >= 0; phdr++) |
找到 eh_frame 也就找到 CIE,找到了 CIE 也就可以去搜索相应的 FDE,找到FDE及CIE后,就可以从这两数据表中提取相关的信息
并执行DWARF 字节码,从而得到当前函数的调用函数的现场,参看如下用于重建函数帧的函数:
1 | static _Unwind_Reason_Code |
通过如上的操作,unwinder 就已经把调用函数的现场给重建起来了,这些现场信息包括:
1 | struct _Unwind_Context |
实现 Personality routine
Peronality routine 的作用主要有两个:
检查当前函数是否有相应的 catch 语句。
清理当前函数中的局部变量
然而这两件事情仅仅依靠 运行时 也是没法完成的,必须依靠编译器在 编译时 建立起相关的数据进行协助
对于GCC来说,这些与抛异常的函数具体相关的信息全部放在 .gcc_except_table 区域里去了,这些信息会作为Itanium ABI 接口中所谓的 language specific data 在 unwinder 与 C++ ABI 之间传递
根据前面的介绍,我们知道在 FDE 中保存有指向 language specific data 的指针(前面的源码中),因此 unwinder 在重建现场的时候就已经把这些数据读取了出来,c++ 的 ABI 只要调用 _Unwind_GetLanguageSpecificData() 就可以得到指向该数据的指针
下图来源于网络,展示了gcc_except_table 及 language specific data 的格式:
由上图所示,LSDA 由一个表头以及三个表组成
1.LSDA Header:
该表头主要用来保存接下来三张表的相关信息,如编码,及表的位移等,该表头主要包含六个域:
1)LPStart encoding: Landing pad 起始地址的编码方式,长度为一个字节。
2)LPStart: landing pad 起始地址,这是可选的,只有当前面指明的编码方式不等于 DW_EH_PE_omit 时,这个字段才存在,此时读取这个字段就需要根据前面指定的编码方式进行读取,长度不固定,如果这个字段不存在,则 landing pad 的起始地址需要通过调用 _Unwind_GetRegionStart() 来获得,得到其实就是当前模块加载的起始地址,这是最常见的形式。
3)TType format: type table 的编码方式,长度为一个字节。
4)TTBase: type table 的位移,类型为 unsigned LEB128,这个字段是可选的,只有3)中编码方式不等于 DW_EH_PE_omit 时,这个才存在。
5)Call Site format: call site table 的编码方式,长度为一个字节。
6)Call Site format size: call site table 的长度,一个 unsigned LEB128 的值。
(不经想起第一次学PE文件数据结构的时候,一堆名词打在脸上,这次显然比上次的接受度高多了)
2.call site table
LSDA 表头之后紧跟着的是 call site table,该表用于记录程序中哪些指令有可能会抛异常,表中每条记录共有4个字段:
1)call site position: 可能会抛异常的指令的地址,该地址是距 Landing pad 起始地址的偏移,编码方式由 LSDA 表头中第一个字段指明。
2)call the length: 可能抛异常的指令的区域长度,该字段与 1)一起表示一系列连续的指令,编码方式与 1)相同。
3)landing pad position: 用于处理上述指令的 Landing pad 的位移,这个值如果为 0 则表示不存在相应的 landing pad。
4)first action: 指明要采取哪些 action,这是一个 unsigned LEB128 的值,该值减1后作为下标获取 action table 中相应记录。
PS: call site table 中的记录按第一个字段也就是指令起始地址进行排序存放,因此 unwind 的时候可以加快对该表的搜索
unwind 的过程中:
如果当前 pc 的值不在 call site table 覆盖的范围内的话,搜索就会返回,然后就调用std::terminate() 结束程序,这通常来说是不正常的行为
如果在 call site table 中有对应的处理,但 landing pad 的位移却是 0 的话,表明当前函数既不存在 catch 语句,也不需要清理局部变量,这是一种正常情况,unwinder 应该继续向上 unwind
而如果 landing pad 不为0,则表明该函数中有 catch 语句,但是这些 catch 能否处理抛出的异常则还要结合 action 字段,到 type table 中去进一步加以判断
- 如果 action 字段为 0,则表明当前函数没有 catch 语句,但有局部变量需要清理
- 如果 action 字段不为 0,则表明当前函数中存在 catch 语句,又因为 catch 是可能存在多个的,怎么知道哪个能够 catch 当前的异常呢?因此需要去检查 action table 中的表项
(这里应该就是指向ttypeindex的关键了)
3. Action table
action table 中每一条记录是一个二元组
表示一个 catch 语句所对应的异常,或者表示当前函数所允许抛出的异常 (exception specification),该列表每条记录包含两个字段:
1)type filter: 这是一个 unsigned LEB128 的数值,用于指向 type table 中的记录,该值有可能是负数。(这就是Inflated题中的ttypeindex?)
2)offset to next action: 指向下一个 action table 中的下一条记录,这是当函数中有多个 catch 或 exception specification 有多个时,将各个 action 记录链接起来。
4. Type Table
type table 中存放的是异常类型的指针:
1 | std::type_info* type_tables[]; |
这个表被分成两部分,一部分是各个 catch 所对应的异常的类型,另一部分是该函数允许抛出的异常类型:
1 | void func() throw(int, string) |
type table中这两部分分别通过正负下标来进行索引:
有了如上这些数据,personality routine 只需要根据当前的 pc 值及当前的异常类型,不断在上述表中查找
最后就能找到当前函数是否有 landing pad,如果有则返回 _URC_INSTALL_CONTEXT,指示 unwinder 跳过去执行相应的代码。
什么是 Landing pad
在前面一篇博文里,提到了Landing pad:指的是能够 catch 当前异常的 catch 语句。这个说法其实不确切,准确来说,landing pad 指的是 unwinder 之外的“用户代码”:
1)用于 catch 相应的 exception,对于一个函数来说,如果该函数中有 catch 语句,且能够处理当前的异常,则该 catch 就是 landing pad。
2)如果当前函数没有 catch 或者 catch 不能处理当前 exception,则意味着异常还要从当前函数继续往上抛,因而 unwind 当前函数时有可能要进行相应的清理,此时这些清理局部变量的代码就是 landing pad。
从名字上来看,顾名思议,landing pad 指的是程序的执行流程在进入当前函数后,最后要转到这里去(可以是用户写的catch代码也可以是清理局部变量的代码),很恰当的描述。
当 landing pad 是 catch 语句时,这个比较好理解,前面我们一直说清理局部变量的代码,这是什么意思呢?这些清理代码又放在哪里?为了说明这个问题,我们看一下如下代码:
1 |
|
对于函数 test_func3_2() 来说,当 test_func3() 抛出异常后,在 unwind 的第二阶段,我们知道 test_func3_2() 中的局部变量 c 及 c2 是需要清理的,而 c3 则不用,那么编译器是怎么生成代码来完成这件事情的呢?
当异常发生时,运行时是没有办法知道当前哪些变量是需要清理的,因为这个原因编译器在生成代码的时候,在函数的末尾设置了多个出口,使得当异常发生时,可以直接跳到某一段代码就能清理相应的局部变量,我们看看 test_func3_2() 编译后生成的对应的汇编代码:
1 | void test_func3_2() |
注意其中标红色的代码,_ZN2csD1Ev 即是类 cs 的析构函数
Unwind_Resume() 则是当清理完成时,用来从 landing pad 返回的代码
test_func3_2() 中只有 3 个 cs 对象,但调用析构函数的代码却出现了 6 次。这里其实就是设置了多个出口函数,分别对应不同情况下,处理各个局部变量的析构,对于我们上面的代码来说,test_func3_2() 函数中的 landing pad 就是从地址:400d09 开始的,这些代码做了如下事情:
1)先析构 c2,然后 jump 到 400d2b 析构 c.
2)最后调用 _Unwind_Resum()
由此可见当程序中有多个可能抛异常的地方时,landing pad 也相应地会有多个,该函数的出口将更复杂,这也算是异常处理的一个 overhead 了
总结
太强了,实在是太强了,我感觉很久没看过这么干货满满的文章,感觉是我异常处理认识的一个升华,不过新名词太多,也需要反复阅读理解方可行
About this Post
This post is written by P.Z, licensed under CC BY-NC 4.0.