c++ 異常處理(2)


前面一篇博文簡單介紹了 c++ 異常處理的流程,但在一些細節上一帶而過了,比如,_Unwind_RaiseException 是怎樣重建函數現場的,Personality routine 是怎樣清理棧上變量的等,這些細節涉及到很多與語言層面無關的東西,本文嘗試介紹一下這些細節的具體實現。

相關的數據結構

如前所述,unwind 的進行需要編譯器生成一定的數據來支持,這些數據保存了與每個可能拋異常的函數相關的信息以供運行時查找,那么,編譯器都保存了哪些信息呢?根據 Itanium ABI 的定義,主要包括以下三類

1)unwind table,這個表記錄了與函數相關的信息,共三個字段:函數的起始地址,函數的結束地址,一個 info block 指針。

2)unwind descriptor table,這個列表用於描述函數中需要 unwind 的區域的相關信息。

3)語言相關的數據(language specific data area),用於上層語言內部的處理。

以上數據結構的描述來自 Itanium ABI 的標准定義,但在具體實現時,這些數據是怎么組織以及放到了哪里則是由編譯器來決定的,對於 GCC 來說,所有與 unwind 相關的數據都放到了 .eh_frame 及 .gcc_except_table 這兩個 section 里面了,而且它的格式與內容和標准的定義稍稍有些不同。

.eh_frame區域

.eh_frame 的格式與 .debug_frame 是很相似的(不完全相同),屬於 DWARF 標准中的一部分。所有由 GCC 編譯生成的需要支持異常處理的程序都包含了 DWARF 格式的數據與字節碼,這些數據與字節碼的主要作用有兩個:

1)描述函數調用棧的結構(layout)

2)異常發生后,指導 unwinder 怎么進行 unwind。

DWARF 字節碼功能很強大,它是圖靈完備的,這意味着僅僅通過 DWARF 就可以做幾乎任何事情(therotically)。但是從數據的組織上來看,DWARF 實在略顯復雜晦澀,因此很少有人願意去碰,本文也只是簡單介紹其中與異常處理相關的東西。本質上來說,eh_frame 像是一張表,它用於描述怎樣根據程序中某一條指令來設置相應的寄存器,從而返回到當前函數的調用函數中去,它的作用可以用如下表格來形象地描述。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM