c++ 异常处理(2)

前面一篇博文简单介绍了 c++ 异常处理的流程,但在一些细节上一带而过了,好比,_Unwind_RaiseException 是怎样重建函数现场的,Personality routine 是怎样清理栈上变量的等,这些细节涉及到不少与语言层面无关的东西,本文尝试介绍一下这些细节的具体实现。html

相关的数据结构

如前所述,unwind 的进行须要编译器生成必定的数据来支持,这些数据保存了与每一个可能抛异常的函数相关的信息以供运行时查找,那么,编译器都保存了哪些信息呢?根据 Itanium ABI 的定义,主要包括如下三类linux

1)unwind table,这个表记录了与函数相关的信息,共三个字段:函数的起始地址,函数的结束地址,一个 info block 指针。ios

2)unwind descriptor table,这个列表用于描述函数中须要unwind的区域的相关信息。c++

3)语言相关的数据(language specific data area),用于上层语言内部的处理。git

以上数据结构的描述来自 Itanium ABI 的标准定义,但在具体实现时,这些数据是怎么组织以及放到了哪里则是由编译器来决定的,对于 GCC 来讲,全部与 unwind 相关的数据都放到了 .eh_frame 及 .gcc_except_table 这两个 section 里面了,并且它的格式与内容和标准的定义稍稍有些不一样。github

.eh_frame区域

.eh_frame 的格式与 .debug_frame 是很类似的(不彻底相同),属于 DWARF 标准中的一部分。全部由 GCC 编译生成的须要支持异常处理的程序都包含了 DWARF 格式的数据与字节码,这些数据与字节码的主要做用有两个:网络

1)描述函数调用栈的结构(layout)数据结构

2)异常发生后,指导 unwinder 怎么进行 unwind。app

DWARF 字节码功能很强大,它是图灵完备的,这意味着仅仅经过 DWARF 就能够作几乎任何事情(therotically)。可是从数据的组织上来看,DWARF 实在略显复杂晦涩,所以不多有人愿意去碰,本文也只是简单介绍其中与异常处理相关的东西。本质上来讲,eh_frame 像是一张表,它用于描述怎样根据程序中某一条指令来设置相应的寄存器,从而返回到当前函数的调用函数中去,它的做用能够用以下表格来形象地描述。ide

program counter CFA ebp  ebx eax return address
0xfff0003001 rsp+32 *(cfa-16) *(cfa-24) eax=edi *(cfa-8) 
0xfff0003002 rsp+32 *(cfa-16)   eax=edi *(cfa-8)
0xfff0003003 rsp+32 *(cfa-16) *(cfa-32) eax=edi *(cfa-8

上表中,CFA(canonical frame address) 表示一个基地址,用于做为当前函数中的其它地址的起始地址,使得其它地址能够用与该基地址的偏移来表示,因为这个表可能要覆盖不少程序指令,所以这个表的体积有多是很大的,甚至比程序自己的代码量还要大。而在实际中,为了减小这个表的体积,GCC 一般会对它进行压缩编码,以及尽量减小要覆盖的指令的数量,好比,只对会抛异常的函数里的特定区域指令进行记录。

具体的实现上,eh_frame 由一个CIE (Common Information Entry) 及多个 FDE (Frame Description Entry) 组成,它们在内存中是连续存放的:

 CIE 及 FDE 格式的定义能够参看以下:

 CIE结构: 

Length

Required
Extended Length Optional
CIE ID Required
Version Required
Augmentation String Required
EH Data Optional
Code Alignment Factor Required
Data Alignment Factor Required
Return Address Register Required
Augmentation Data Length Optional
Augmentation Data Optional
Initial Instructions Required
Padding  

FDE结构:

Length Required
Extended Length Optional
CIE Pointer Required
PC Begin Required
PC Range Required
Augmentation Data Length Optional
Augmentation Data Optional
Call Frame Instructions Required
Padding  

注意其中标注红色的字段:

1)Initial Instructions,Call Frame Instructions 这两字段里放的就是所谓的 DWARF 字节码,好比:DW_CFA_def_cfa R OFF,表示经过寄存器 R 及位移 OFF 来计算 CFA,其功能相似于前面的表格中第二列指明的内容。

2)PC begin,PC range,这两个字段联合起来表示该 FDE 所能覆盖的指令的范围,eh_frame 中全部的 FDE 最后会按照 pc begin 排序进行存放。

3)若是 CIE 中的 Augmentation String 中包含有字母 "P",则相应的 Augmentation Data 中包含有指向 personality routine 的指针。

4)若是 CIE 中的 Augmentation String 中包含有有字母“L”,则 FDE 中 Aumentation Data 包含有 language specific data 的指针。

 

对一个elf文件经过以下命令:readelf -Wwf xxx,能够读取其中关于 .eh_frame 的数据:

The section .eh_frame contains: 00000000 0000001c 00000000 CIE Version: 1 Augmentation: "zPL" Code alignment factor: 1 Data alignment factor: -8 Return address column: 16 Augmentation data: 00 d8 09 40 00 00 00 00 00 00 DW_CFA_def_cfa: r7 ofs 8 ##如下为字节码 DW_CFA_offset: r16 at cfa-8

00000020 0000002c 00000024 FDE cie=00000000 pc=00400ac8..00400bd8 Augmentation data: 00 00 00 00 00 00 00 00 
#如下为字节码 DW_CFA_advance_loc:
1 to 00400ac9 DW_CFA_def_cfa_offset: 16 DW_CFA_offset: r6 at cfa-16 DW_CFA_advance_loc: 3 to 00400acc DW_CFA_def_cfa_reg: r6 DW_CFA_nop DW_CFA_nop DW_CFA_nop

对于由 GCC 编译出来的程序来讲,CIE, FDE 是其在 unwind 过程当中恢复现场时所依赖的所有东西,并且是完备的,这里所说的恢复现场指的是恢复调用当前函数的函数的现场,好比,func1 调用 func2,而后咱们能够在 func2 里经过查询 CIE,FDE 恢复 func1 的现场。CIE,FDE 存在于每个须要处理异常的 ELF 文件中,当异常发生时,runtime 根据当前 PC 值调用 dl_iterate_phdr() 函数就能够把当前程序所加载的全部模块轮询一遍,从而找到该 PC 所在模块的 eh_frame。

for (n = info->dlpi_phnum; --n >= 0; phdr++) { if (phdr->p_type == PT_LOAD) { _Unwind_Ptr vaddr = phdr->p_vaddr + load_base; if (data->pc >= vaddr && data->pc < vaddr + phdr->p_memsz) match = 1; } else if (phdr->p_type == PT_GNU_EH_FRAME) p_eh_frame_hdr = phdr; else if (phdr->p_type == PT_DYNAMIC) p_dynamic = phdr; }

找到 eh_frame 也就找到 CIE,找到了 CIE 也就能够去搜索相应的 FDE,找到FDE及CIE后,就能够从这两数据表中提取相关的信息,并执行DWARF 字节码,从而获得当前函数的调用函数的现场,参看以下用于重建函数帧的函数:

static _Unwind_Reason_Code uw_frame_state_for (struct _Unwind_Context *context, _Unwind_FrameState *fs) { struct dwarf_fde *fde; struct dwarf_cie *cie; const unsigned char *aug, *insn, *end; memset (fs, 0, sizeof (*fs)); context->args_size = 0; context->lsda = 0; // 根据context查找FDE。
  fde = _Unwind_Find_FDE (context->ra - 1, &context->bases); if (fde == NULL) { /* Couldn't find frame unwind info for this function. Try a target-specific fallback mechanism. This will necessarily not provide a personality routine or LSDA. */ #ifdef MD_FALLBACK_FRAME_STATE_FOR MD_FALLBACK_FRAME_STATE_FOR (context, fs, success); return _URC_END_OF_STACK; success: return _URC_NO_REASON; #else
      return _URC_END_OF_STACK; #endif } fs->pc = context->bases.func; // 获取对应的CIE.
  cie = get_cie (fde); // 提取出CIE中的信息,如personality routine的地址。
  insn = extract_cie_info (cie, context, fs); if (insn == NULL) /* CIE contained unknown augmentation. */
    return _URC_FATAL_PHASE1_ERROR; /* First decode all the insns in the CIE. */ end = (unsigned char *) next_fde ((struct dwarf_fde *) cie); // 执行dwarf字节码,从而恢复相应的寄存器的值。
 execute_cfa_program (insn, end, context, fs); // 定位到fde的相关数据
  /* Locate augmentation for the fde. */ aug = (unsigned char *) fde + sizeof (*fde); aug += 2 * size_of_encoded_value (fs->fde_encoding); insn = NULL; if (fs->saw_z) { _Unwind_Word i; aug = read_uleb128 (aug, &i); insn = aug + i; } // 读取language specific data的指针
  if (fs->lsda_encoding != DW_EH_PE_omit) aug = read_encoded_value (context, fs->lsda_encoding, aug, (_Unwind_Ptr *) &context->lsda); /* Then the insns in the FDE up to our target PC. */
  if (insn == NULL) insn = aug; end = (unsigned char *) next_fde (fde); // 执行FDE中的字节码。
 execute_cfa_program (insn, end, context, fs); return _URC_NO_REASON; }

经过如上的操做,unwinder 就已经把调用函数的现场给重建起来了,这些现场信息包括:

struct _Unwind_Context { void *reg[DWARF_FRAME_REGISTERS+1];  //必要的寄存器。
    void *cfa; // canoniacl frame address, 前面提到过,基地址。
    void *ra;// 返回地址。
    void *lsda;// 该函数对应的language specific data,若是存在的话。
    struct dwarf_eh_bases bases; _Unwind_Word args_size; };

实现 Personality routine 

Peronality routine 的做用主要有两个:

1)检查当前函数是否有相应的 catch 语句。

2)清理当前函数中的局部变量。

十分不巧,这两件事情仅仅依靠运行时也是无法完成的,必须依靠编译器在编译时创建起相关的数据进行协助。对于 GCC 来讲,这些与抛异常的函数具体相关的信息所有放在 .gcc_except_table 区域里去了,这些信息会做为Itanium ABI 接口中所谓的 language specific data 在 unwinder 与 c++ ABI 之间传递,根据前面的介绍,咱们知道在 FDE 中保存有指向 language specific data 的指针,所以 unwinder 在重建现场的时候就已经把这些数据读取了出来,c++ 的 ABI 只要调用 _Unwind_GetLanguageSpecificData() 就能够获得指向该数据的指针。

关于 GCC 下 language specific data 的格式,在网上几乎找不到什么权威的文档,我只在 llvm 的官网上找到一个相关的连接,这个文档对 gcc_except_table 做了很详细的说明,我对比了一下 GCC 源码里的 personality routine 的相关实现,发现二者仍是有些许出入,所以本文接下来的介绍主要基于对 GCC 相关源码的我的解读,若有错误欢迎指正。

 

下图来源于网络,展现了gcc_except_table 及 language specific data 的格式:

  

由上图所示,LSDA 主要由一个表头,及其后紧跟着的三张表组成。

1.LSDA Header:

该表头主要用来保存接下来三张表的相关信息,如编码,及表的位移等,该表头主要包含六个域:

1)Landing pad 起始地址的编码方式,长度为一个字节。

2)landing pad 起始地址,这是可选的,只有当前面指明的编码方式不等于 DW_EH_PE_omit 时,这个字段才存在,此时读取这个字段就须要根据前面指定的编码方式进行读取,长度不固定,若是这个字段不存在,则 landing pad 的起始地址须要经过调用 _Unwind_GetRegionStart() 来得到,获得其实就是当前模块加载的起始地址,这是最多见的形式。

3)type table 的编码方式,长度为一个字节。

4)type table 的位移,类型为 unsigned LEB128,这个字段是可选的,只有3)中编码方式不等于 DW_EH_PE_omit 时,这个才存在。

5)call site table 的编码方式,长度为一个字节。

6)call site table 的长度,一个 unsigned LEB128 的值。

2.call site table

LSDA 表头以后紧跟着的是 call site table,该表用于记录程序中哪些指令有可能会抛异常,表中每条记录共有4个字段:

1)可能会抛异常的指令的地址,该地址是距 Landing pad 起始地址的偏移,编码方式由 LSDA 表头中第一个字段指明。

2)可能抛异常的指令的区域长度,该字段与 1)一块儿表示一系列连续的指令,编码方式与 1)相同。

3)用于处理上述指令的 Landing pad 的位移,这个值若是为 0 则表示不存在相应的 landing pad。

4)指明要采起哪些 action,这是一个 unsigned LEB128 的值,该值减1后做为下标获取 action table 中相应记录。

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 中去进一步加以判断:

1)若是 action 字段为 0,则代表当前函数没有 catch 语句,但有局部变量须要清理。

2)若是 action 字段不为 0,则代表当前函数中存在 catch 语句,又由于 catch 是可能存在多个的,怎么知道哪一个可以 catch 当前的异常呢?所以须要去检查 action table 中的表项。

3. Action table

action table 中每一条记录是一个二元组,表示一个 catch 语句所对应的异常,或者表示当前函数所容许抛出的异常 (exception specification),该列表每条记录包含两个字段:

1)filter type,这是一个 unsigned LEB128 的数值,用于指向 type table 中的记录,该值有多是负数。

2)指向下一个 action table 中的下一条记录,这是当函数中有多个 catch 或 exception specification 有多个时,将各个 action 记录连接起来。

4. Type Table

type table 中存放的是异常类型的指针:

std::type_info* type_tables[];

这个表被分红两部分,一部分是各个 catch 所对应的异常的类型,另外一部分是该函数容许抛出的异常类型:

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 指的是程序的执行流程在进入当前函数后,最后要转到这里去,很恰当的描述。当 landing pad 是 catch 语句时,这个比较好理解,前面咱们一直说清理局部变量的代码,这是什么意思呢?这些清理代码又放在哪里?为了说明这个问题,咱们看一下以下代码:

#include <iostream>
#include <stddef.h>
using namespace std;

class cs
{
    public:

        explicit cs(int i) :i_(i) { cout << "cs constructor:" << i << endl; }
        ~cs() { cout << "cs destructor:" << i_ << endl; }

    private:

        int i_;
};

void test_func3()
{
    cs c(33);
    cs c2(332);

    throw 3;

    cs c3(333);
    cout << "test func3" << endl;
}

void test_func3_2()
{
    cs c(32);
    cs c2(322);

    test_func3();

    cs c3(323);

    test_func3();
}

void test_func2()
{
    cs c(22);

    cout << "test func2" << endl;
    try
    {
        test_func3_2();

        cs c2(222);
    }
    catch (int)
    {
        cout << "catch 2" << endl;
    }
}

void test_func1()
{
    cout << "test func1" << endl;
    try
    {
        test_func2();
    }
    catch (...)
    {
        cout << "catch 1" << endl;
    }
}

int main()
{
    test_func1();
    return 0;
}

对于函数 test_func3_2() 来讲,当 test_func3() 抛出异常后,在 unwind 的第二阶段,咱们知道 test_func3_2() 中的局部变量 c 及 c2 是须要清理的,而 c3 则不用,那么编译器是怎么生成代码来完成这件事情的呢?当异常发生时,运行时是没有办法知道当前哪些变量是须要清理的,由于这个缘由编译器在生成代码的时候,在函数的末尾设置了多个出口,使得当异常发生时,能够直接跳到某一段代码就能清理相应的局部变量,咱们看看 test_func3_2() 编译后生成的对应的汇编代码:

void test_func3_2()
{
  400ca4:    55                     push   %rbp
  400ca5:    48 89 e5               mov    %rsp,%rbp
  400ca8:    53                     push   %rbx
  400ca9:    48 83 ec 48            sub    $0x48,%rsp
    cs c(32);
  400cad:    48 8d 7d e0            lea    0xffffffffffffffe0(%rbp),%rdi
  400cb1:    be 20 00 00 00         mov    $0x20,%esi
  400cb6:    e8 9f 02 00 00         callq  400f5a <_ZN2csC1Ei>
    cs c2(322);
  400cbb:    48 8d 7d d0            lea    0xffffffffffffffd0(%rbp),%rdi
  400cbf:    be 42 01 00 00         mov    $0x142,%esi
  400cc4:    e8 91 02 00 00         callq  400f5a <_ZN2csC1Ei>

    test_func3();
  400cc9:    e8 5a ff ff ff         callq  400c28 <_Z10test_func3v>

    cs c3(323);
  400cce:    48 8d 7d c0            lea    0xffffffffffffffc0(%rbp),%rdi
  400cd2:    be 43 01 00 00         mov    $0x143,%esi
  400cd7:    e8 7e 02 00 00         callq  400f5a <_ZN2csC1Ei>

    test_func3();
  400cdc:    e8 47 ff ff ff         callq  400c28 <_Z10test_func3v>
  400ce1:    eb 17                  jmp    400cfa <_Z12test_func3_2v+0x56>
  400ce3:    48 89 45 b8            mov    %rax,0xffffffffffffffb8(%rbp)
  400ce7:    48 8b 5d b8            mov    0xffffffffffffffb8(%rbp),%rbx
  400ceb:    48 8d 7d c0            lea    0xffffffffffffffc0(%rbp),%rdi #c3的this指针
  400cef:    e8 2e 02 00 00         callq  400f22 <_ZN2csD1Ev>
  400cf4:    48 89 5d b8            mov    %rbx,0xffffffffffffffb8(%rbp)
  400cf8:    eb 0f                  jmp    400d09 <_Z12test_func3_2v+0x65>
  400cfa:    48 8d 7d c0            lea    0xffffffffffffffc0(%rbp),%rdi #c3的this指针
  400cfe:    e8 1f 02 00 00         callq  400f22 <_ZN2csD1Ev>
  400d03:    eb 17                  jmp    400d1c <_Z12test_func3_2v+0x78>
  400d05:    48 89 45 b8            mov    %rax,0xffffffffffffffb8(%rbp)
  400d09:    48 8b 5d b8            mov    0xffffffffffffffb8(%rbp),%rbx
  400d0d:    48 8d 7d d0            lea    0xffffffffffffffd0(%rbp),%rdi #c2的this指针
  400d11:    e8 0c 02 00 00         callq  400f22 <_ZN2csD1Ev>
  400d16:    48 89 5d b8            mov    %rbx,0xffffffffffffffb8(%rbp)
  400d1a:    eb 0f                  jmp    400d2b <_Z12test_func3_2v+0x87> 
  400d1c:    48 8d 7d d0            lea    0xffffffffffffffd0(%rbp),%rdi #c2的this指针
  400d20:    e8 fd 01 00 00         callq  400f22 <_ZN2csD1Ev>
  400d25:    eb 1e                  jmp    400d45 <_Z12test_func3_2v+0xa1>
  400d27:    48 89 45 b8            mov    %rax,0xffffffffffffffb8(%rbp)
  400d2b:    48 8b 5d b8            mov    0xffffffffffffffb8(%rbp),%rbx
  400d2f:    48 8d 7d e0            lea    0xffffffffffffffe0(%rbp),%rdi #c的this指针
  400d33:    e8 ea 01 00 00         callq  400f22 <_ZN2csD1Ev>
  400d38:    48 89 5d b8            mov    %rbx,0xffffffffffffffb8(%rbp)
  400d3c:    48 8b 7d b8            mov    0xffffffffffffffb8(%rbp),%rdi
  400d40:    e8 b3 fc ff ff         callq  4009f8 <_Unwind_Resume@plt>  #c的this指针
  400d45:    48 8d 7d e0            lea    0xffffffffffffffe0(%rbp),%rdi
  400d49:    e8 d4 01 00 00         callq  400f22 <_ZN2csD1Ev>
}
  400d4e:    48 83 c4 48            add    $0x48,%rsp
  400d52:    5b                     pop    %rbx
  400d53:    c9                     leaveq 
  400d54:    c3                     retq   
  400d55:    90                     nop    


注意其中标红色的代码,_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_Resume()

因而可知当程序中有多个可能抛异常的地方时,landing pad 也相应地会有多个,该函数的出口将更复杂,这也算是异常处理的一个 overhead 了。

总结

至此,关于 GCC 处理异常的具体流程及方式,各个细节都已写完,涉及不少比较琐碎的东西,只有反复阅读源码及相关文档才能搞明白,也不容易,只是古人说的好,纸上得来终觉浅,为了加深印象及验证所学的内容,我根据前面了解的这些知识,简单仿着 GCC 写了一个简化版的 c++ ABI,代码放到了 github 上这里,有兴趣的读者们能够参考一下,本来是打算把 unwinder 也写一遍的,但 DWARF 的格式实在太过复杂,已经超出了异常处理这个范围,就做罢了。

 

【引用】:

http://www.intel.com/content/dam/www/public/us/en/documents/guides/itanium-software-runtime-architecture-guide.pdf

http://mentorembedded.github.io/cxx-abi/abi-eh.html

http://refspecs.linuxfoundation.org/LSB_3.0.0/LSB-Core-generic/LSB-Core-generic/ehframechpt.html

https://www.opensource.apple.com/source/gcc/gcc-5341/gcc/

http://www.cs.dartmouth.edu/~sergey/battleaxe/hackito_2011_oakley_bratus.pdf

http://mentorembedded.github.io/cxx-abi/exceptions.pdf

http://www.airs.com/blog/archives/464

相关文章
相关标签/搜索