c++ 異常處理(2)

twoon發表於2014-04-07

前面一篇博文簡單介紹了 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

相關文章