前面一篇博文簡單介紹了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就可以做幾乎任何事情。但是從數據的組織上來看,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文件中,當異常發生時,根據當前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或者沒有可以處理當前exception的catch語句,則意味著異常還要從當前函數繼續往上拋,因而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() 由此可見當程序中有多個地方可能拋異常的話,該函數的出口將更復雜,這也算是異常處理的一個overhead了。 總結 至此,關於GCC處理異常的具體流程及方式,各個細節都已寫完,涉及很多比較瑣碎的東西,只有反復閱讀源碼及相關文檔才能搞明白,也不容易啊,只是古人說的好,紙上得來終覺淺,為了加深印象及驗證所學的內容,我根據前面了解的這些知識,簡單仿著GCC寫了一個簡化版的c++ ABI,代碼放到了github上這裡,有興趣的讀者們可以參考一下,原本是打算把unwinder也寫一遍的,但DWARF的格式實在太過復雜,已經超出了異常處理這個范圍,就作罷了。