C++ Exception Handling ABI, part 2
话不多说,这一篇争取把上一篇不够详尽的部分补齐。
personality
首先,我们从上一篇没有详细介绍的 personality 开始。在栈展开的过程中,libgcc 或者 libunwind 作为 unwinder 会逐帧调用 personality,它作为连接 Level 1 Base ABI 和 Level 2 C++ ABI 的桥梁,需要告知 unwinder 以下信息:
- 在栈展开的搜索阶段,告知
unwinder当前帧是否有匹配的catch块来处理该异常 - 在栈展开的清理阶段,告知
unwinder当前帧是否需要执行相应的清理,如果需要,对应的landing pad地址是什么,以便后续unwinder进行实际跳转。
每一帧的清理逻辑,也就是上一篇所说的 landing pad。根据具体函数逻辑,它会完成以下三项之一:
- 无法捕获对应异常,调用已离开作用域变量的析构函数,或者调用通过
__attribute__((cleanup(...)))注册的回调,然后使用_Unwind_Resume回到清理阶段 - 能捕获对应异常,先析构已离开作用域的变量,再调用
__cxa_begin_catch,执行catch块里的代码,最后调用__cxa_end_catch - 如果在
catch中有rethrow,则会先析构catch子句里定义的局部变量,再调用__cxa_end_catch,然后通过_Unwind_Resume继续清理阶段
不同的语言、实现或架构可能会使用不同的 personality 程序。对于 C++ 而言,在 ELF 中最常见的 personality 实现是 __gxx_personality_v0。
在进一步介绍 __gxx_personality_v0 之前,我们需要再补充一些背景知识。
.gcc_except_table
ELF 中,将具体语言处理异常所需的信息,例如某个 IP 指令寄存器是否位于 try-catch 范围内、是否存在需要执行的离开作用域变量析构等,保存到 .gcc_except_table 数据段中。这个数据段是 ELF 中一整块连续的字节区域,里面存放了所有函数的异常处理数据,这些数据就是上一篇所提到的 LSDA (Language-specific Data Area)。整体上逻辑关系如下:
.gcc_except_table
├── LSDA(func1)
├── LSDA(func2)
├── LSDA(func3)
└── ...
每一个 LSDA 中又包含以下部分:
header:landing pad的基地址,type table的编码格式,call site table的编码格式,action table的起始位置call site table:表中每个条目都保存[start, length, landing_pad_offset, action_record_offset]四个字段。当地址在[start, start + length)这段代码中出现异常时,对应的landing pad入口地址偏移量,以及第一个action在action table中的偏移量(如果没有action则为 0)。action table:每个条目有两个字段[switch_value, next_action_offset],用于表明给定范围内抛出异常对应的action,比如cleanup/catch/noexcept。其中switch_value用来保存每个catch的具体类型在type table中的下标(0 代表是一个cleanup action),next_action_offset表示下一个action在action table中的偏移量(0表示没有后续)。注意一段代码对应的所有action被组织成了一个单链表。type table:保存各个类型的RTTI指针,用于检查异常对象类型是否匹配。如果指针为空,代表匹配所有类型catch (...)。
几张表的关联关系如下:
函数会按 try 语句分割成多个代码范围,call site table 保存的是给定代码地址范围内出现异常时的 landing pad 和相应的 action,而每个条目中 landing_pad_offset 和 action_record_offset 可能的组合有:
landing_pad_offset为 0,则action_record_offset也 0,代表没有landing pad。landing_pad_offset不为 0,代表有landing pad,其中包含这段代码的所有可能的异常操作,即包括所有的catch(无论是否能捕获),以及额外的cleanup逻辑。此时若:action_record_offset为 0,代表当前栈帧需要进行额外清理(比如局部变量的析构)action_record_offset不为 0,代表有对应的action,此时action table中action_record_offset对应条目即为第一个action。
而 action table 条目中的 switch_value 大于 0 代表指向 type table 中的一个条目,等于 0 代表当前栈帧需要进行局部变量清理(对应上面 call site table 中 action_record_offset 为 0 的情况),小于 0 则是 exception specification,已经在现代 C++ 中很少见。
比如,如果某个代码范围内中有两个 catch,但都无法捕获当前异常,且需要额外清理时,LSDA 中的相关数据示意图如下:
call site table:
start
length
landing_pad_offset: 指向入口地址 其中包含两个catch以及cleanup
action_record_offset: 假设为x 指向第一个action
action table:
; 第x个条目 对应第一个catch (对应type table中第m个类型)
[switch_value = m, next_action_offset = y]
...
; 第y个条目 对应第二个catch (对应type table中第n个类型)
[switch_value = n, next_action_offset = z]
...
; 第z个条目 switch_value为0代表是cleanup next_action_offset为0代表没有后续action
[switch_value = 0, next_action_offset = 0]
type table:
; 第m个条目
第一个catch类型的RTTI指针
...
; 第n个条目
第二个catch类型的RTTI指针
本质上 LSDA 只是一段字节流,没有显式结构体。在栈展开过程中,需要由__gxx_personality_v0 解析 LSDA 中的内容(至于是哪个 LSDA 则是由 unwinder 来负责查找并传递),从而确定当前帧能否处理对应异常:
- 根据
throw异常时的指令寄存器 IP,去查call site table,确定当前调用点对应call site table中的哪一个条目,以及第一个action是什么 - 遍历对应的
action链表,读取每一个catch对应的类型下标。通过比较当前异常的类型信息和type table中对应的类型信息,如果匹配则表示当前帧可以处理该异常。否则根据next_action_offset跳转到下一个action。 - 如果当前帧所有
action遍历完后仍不能处理该异常(next_action_offset为0),则通过返回值告知unwinder当前栈帧无法处理,由unwinder在_Unwind_RaiseException中继续展开上一个栈帧,并重复上述过程。
换而言之,不同代码块对应的 landing_pad_offset 和 action_record_offset 如下:
- 没有局部变量析构的非
try块:landing_pad_offset==0 && action_record_offset==0 - 有局部变量析构的非
try块:landing_pad_offset!=0 && action_record_offset==0,栈展开的清理阶段需要先对当前栈帧进行清理,才能继续 - 有
__attribute__((cleanup(...)))的非try块:landing_pad_offset!=0 && action_record_offset==0,同上 try块:landing_pad_offset!=0 && action_record_offset!=0。landing_pad_offset指向由多个catch块拼接的一段代码。action table对应的条目中switch_value > 0,指向type table中一个非空类型的 RTTI 指针- 有
catch (...)的try块:同上。action table对应的条目中switch_value > 0,type table对应条目中 RTTI 指针为空(表示catch (...)) - 在有
noexcept说明符的函数中,异常可能向调用方传播:landing_pad_offset!=0 && action_record_offset!=0。landing pad指向调用std::terminate的代码块,action table对应的条目中switch_value > 0,且type table对应条目中 RTTI 指针为空(表示catch (...))
__gxx_personality_v0
到这我们就可以总结 __gxx_personality_v0 的具体功能了:
- 通过读取当前栈帧的 LSDA 在栈展开过程中检查每个栈帧是否有匹配的
catch - 搜索阶段:
- 返回
_URC_CONTINUE_UNWIND:当前栈帧无法处理该异常 - 返回
_URC_HANDLER_FOUND:当前栈帧能处理该异常
- 返回
- 清理阶段:
- 返回
_URC_CONTINUE_UNWIND:没有对应landing pad,不需要额外处理 - 返回
_URC_INSTALL_CONTEXT:有对应landing pad,由unwinder跳转到该地址继续执行
- 返回
上述流程没有描述各种错误路径,文章最后会涉及到一些
在将控制权转移到 landing pad 之前,personality 会调用 _Unwind_SetGR 设置两个寄存器,分别存储 _Unwind_Exception * 和 switchValue。
这两个寄存器,与架构相关,实际上是通过
__builtin_eh_return_data_regno(0)和__builtin_eh_return_data_regno(1)设置,x86_64下是%rax和%rdx,可以参照上一篇中的例子。
对于 native exception,当 personality 在搜索阶段返回 _URC_HANDLER_FOUND 时,栈帧的 LSDA 相关信息会被缓存。当 personality 在清理阶段被再次调用,且参数为 actions == (_UA_CLEANUP_PHASE | _UA_HANDLER_FRAME) 时,personality 会加载缓存,无需再解析 .gcc_except_table。
在其他三种情况下,personality 必须解析 .gcc_except_table:
actions & _UA_SEARCH_PHASEactions & _UA_CLEANUP_PHASE && actions & _UA_HANDLER_FRAME && !is_nativeactions & _UA_CLEANUP_PHASE && !(actions & _UA_HANDLER_FRAME)
一个简化的 __gxx_personality_v0 实现如下:
_Unwind_Reason_Code __gxx_personality_v0(int version, _Unwind_Action actions, uint64_t exceptionClass, _Unwind_Exception *exc, _Unwind_Context *ctx) {
scan_results results;
if (actions == (_UA_CLEANUP_PHASE | _UA_HANDLER_FRAME) && is_native) {
auto *hdr = (__cxa_exception *)(exc+1) - 1;
// Load cached results from phase 1.
results.switchValue = hdr->handlerSwitchValue;
results.actionRecord = hdr->actionRecord;
results.languageSpecificData = hdr->languageSpecificData;
results.landingPad = reinterpret_cast<uintptr_t>(hdr->catchTemp);
results.adjustedPtr = hdr->adjustedPtr;
_Unwind_SetGR(...);
_Unwind_SetGR(...);
_Unwind_SetIP(ctx, results.landingPad);
return _URC_INSTALL_CONTEXT;
}
scan_eh_tab(results, actions, native_exception, unwind_exception, context);
if (results.reason == _URC_CONTINUE_UNWIND ||
results.reason == _URC_FATAL_PHASE1_ERROR)
return results.reason;
if (actions & _UA_SEARCH_PHASE) {
auto *hdr = (__cxa_exception *)(exc+1) - 1;
// Cache LSDA results in hdr.
hdr->handlerSwitchValue = results.switchValue;
hdr->actionRecord = results.actionRecord;
hdr->languageSpecificData = results.languageSpecificData;
hdr->catchTemp = reinterpret_cast<void *>(results.landingPad);
hdr->adjustedPtr = results.adjustedPtr;
return _URC_HANDLER_FOUND;
}
// _UA_CLEANUP_PHASE
_Unwind_SetGR(...);
_Unwind_SetGR(...);
_Unwind_SetIP(ctx, results.landingPad);
return _URC_INSTALL_CONTEXT;
}
__gxx_personality_v0 的完整实现可以参照:
- gcc/libstdc++-v3/libsupc++/eh_personality.cc at master · gcc-mirror/gcc
- llvm-project/libcxxabi/src/cxa_personality.cpp at main · llvm/llvm-project
.eh_frame
了解了 personality 后,我们再完善上一篇没有说清楚的另一个细节。即 unwinder 通过 personality 发现当前帧不能处理该异常时,该如何从当前栈帧获取到上一个栈帧,过程中相关的寄存器又该如何恢复。这部分栈展开的相关信息都保存在 ELF 的 .eh_frame 和 .eh_frame_hdr 中。
.eh_frame 里保存的是如何“从当前栈帧恢复到上一个栈帧”的规则(称为 CFI 指令),并不会直接保存上一个栈帧的相关寄存器是多少。换而言之,可以理解为,给定当前寄存器状态,通过这些规则,就能算出上一个栈帧的相关寄存器值。.eh_frame 由若干条记录组成,分为两类:
- CIE(Common Information Entry):描述一类函数通用的规则
- FDE(Frame Description Entry):描述某个具体函数(或代码区间)的信息
结构关系如下:
.eh_frame:
[CIE]
[FDE -> 指向某个 CIE]
[FDE -> 指向某个 CIE]
...
.eh_frame_hdr是一个二分索引加速结构,用于给定 IP 快速找到对应的 FDE,这里不展开介绍。
一个 CIE 包含以下字段:
lengthCIE_id:对于 CIE 而言总是 0,用于区分 CIE 和 FDEversionaugmentation stringcode_alignment_factor:指令地址对齐单位data_alignment_factor:栈对齐的单位return_address_register:哪个寄存器代表返回地址(%rip)augmentation datainitial instructions:CFI 指令,定义函数刚进入时的“初始栈布局规则”
每个 FDE 都有一个关联的 CIE,FDE 包含以下字段:
lengthCIE_pointer:从当前位置减去CIE_pointer得到关联的 CIEinitial_location:FDE 描述的起始代码地址address_range:FDE 描述的范围为[initial_location, address_range)augmentation dataCFI instructions:CFI 指令
每个 FDE 中可能会有一个关联的 LSDA 指针, 指向 .gcc_except_table 中对应的 LSDA。当异常发生时,unwinder 在栈展开过程中会通过 .eh_frame 找到当前 IP 对应的 FDE,然后从 FDE 中取出 LSDA 指针传给 __gxx_personality_v0,由 personality 去解析 LSDA。
之所以说可能是与 CIE 中的
augmentation string有关,略过
CFI
CIE 和 FDE 其中很多字段跟我们的问题并没有太大关系,对于栈展开,我们最关心的部分就是 FDE 中的 instructions 字段,即 CFI 指令(Call Frame Information instructions)。CFI 指令用来描述“在函数执行到不同位置时,如何从当前栈帧恢复出上一层栈帧的寄存器值(尤其是返回地址)”,也就是 unwinder 在栈展开过程中进行栈帧回溯所需的信息。汇编器会利用这些指令,组装出 .eh_frame 中的 CIE 和 FDE,以供 unwinder 使用。
首先我们理解一个核心概念 CFA(Canonical Frame Address),其定义是调用当前函数前,调用方 caller 的 %rsp。而 .eh_frame 的核心任务就是:不管执行到了函数的哪条指令,如何通过当前栈帧的各个寄存器,以及 .eh_frame 中的 CFI 指令计算出 CFA,最终计算出上一个栈帧的相关寄存器值(这里主要关心 %rip,%rbp 和 %rsp)。
我们用一个最简单的例子来理解下上述的流程。
void bar() {
throw 1;
}
void foo() {
bar();
}
通过 g++ -S -O0 test.cpp,可以获取到对应汇编代码。其中 bar 的汇编如下:
_Z3barv:
.LFB0:
.cfi_startproc
endbr64
# prologue
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
# function body
movl $4, %edi
call __cxa_allocate_exception@PLT
movl $1, (%rax)
movl $0, %edx
leaq _ZTIi(%rip), %rcx
movq %rcx, %rsi
movq %rax, %rdi
call __cxa_throw@PLT
.cfi_endproc
有一点基础知识需要提前说明:在 DWARF 规范(也就是 .cfi_* 使用的规范)中,寄存器是通过编号来表示的。在 x86-64 下:
- 寄存器 6 代表
%rbp - 寄存器 7 代表
%rsp
.cfi_startproc 会标记函数开始。汇编器会在 .eh_frame 中新建一个 FDE。注意调用方在调用 bar 时,会额外将调用方 foo 的返回地址压栈。此时 CFA = %rsp + 8。(再次强调,CFA 是调用方的 %rsp)
之后进入 prologue。pushq %rbp 将上一层函数的 %rbp 压入栈,此时 %rsp 减 8,此时 CFA = %rsp + 16。在执行完 pushq %rbp 后,需要告知 unwinder CFA 的计算方式发生了改变,对应 CFI 指令为 .cfi_def_cfa_offset 16,代表更新偏移量为 16。
另外也需要告知 unwinder 原先的 %rbp 被压栈(对应 DWARF 规范中的寄存器 6),即 %rbp 被保存在 CFA - 16 处(CFA - 8 是返回地址),对应 CFI 指令为 .cfi_offset 6, -16。这样当栈展开时,依靠这个信息就可以把上一个栈帧(我们例子 foo 的 %rbp)恢复出来。
在 movq %rsp, %rbp 更新当前栈帧的 %rsp 后,需要告诉 unwinder,计算 CFA 的基址寄存器由调用方的 %rsp 换成了寄存器 6(也就是当前栈帧的 %rbp)。对应 CFI 指令是 .cfi_def_cfa_register 6,偏移量保持上一次设置的 16 不变。之后不管 %rsp 怎么变化(例如压入临时变量等),寻找 CFA 只需要 CFA = %rbp + 16 即可得到。
后续具体抛异常的代码略过。最终 .cfi_endproc 会标记函数结束,对应 FDE 也就完成了。
foo 的情况也类似,我们只补充一下 epilogue 部分:在 popq %rbp 之后,%rsp 加 8,此时 CFA = %rsp + 8(由于 %rbp 出栈,CFA = %rbp + 16 不再成立了)。.cfi_def_cfa 7, 8 指令能告知 unwinder,CFA = %rsp + 8,寄存器 7 代表 %rsp。
_Z3foov:
.LFB1:
.cfi_startproc
endbr64
# prologue
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
call _Z3barv
nop
# epilogue
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
当 bar 函数 throw 时,会调用到 __cxa_throw 函数。unwinder 会获取当前 CPU 的 IP 寄存器 %rip,根据 %rip 找到对应的 FDE 记录。接下来 unwinder 会回放一遍从该函数开头(.cfi_startproc)一直到当前抛出异常所在的 IP 地址为止所有的 .cfi_* 指令。通过回放,unwinder 可以算出当前的 CFA 是多少。知道 CFA 之后,如何获取调用方 foo 在调用该函数时的相关寄存器状态呢?
答案是 unwinder 通过 .cfi_offset 6, -16 这条指令就能算出 %rbp 和 %rip:
%rbp:调用者的%rbp(寄存器 6)被保存在内存中CFA - 16的位置。%rip:而对于调用者的%rip,也就是返回地址是在进入到被调用函数之前就已经被压栈,因此调用者的返回地址保存在CFA - 8的位置。
unwinder 将相关寄存器恢复到调用方调用当前函数前的状态,也就是 foo 调用 bar 之前的状态:
%rbp = CFA - 16%rip = CFA - 8%rsp = CFA
此时 unwinder 已经从 bar 回溯到了 foo。之后可以继续进行栈展开,即使用更新过的 %rip,去恢复 foo 的调用方的相关寄存器状态。
到这我们就理解了 unwinder 如何进行栈帧回溯了。这里我们再进一步思考这样一个问题:为什么栈展开过程中必须恢复 callee-saved 相关寄存器?又为什么无需恢复 caller-saved 相关寄存器?
当 foo 调用 bar 时,会保存 caller-saved 相关寄存器,例如 %rax、%rcx、%r8-%r11 等。当越过 call 这条边界后,这些寄存器里的数据都变成垃圾了。而当从 bar 栈展开回到 foo 时,对于 foo 而言只不过相当于又跨回了这条边界,这些寄存器对于调用方 foo 无关紧要,unwinder 也不需要去恢复它们。
而 callee-saved 相关寄存器就不一样了。调用方 foo 期望无论何时,无论被调用方 bar 正常返回还是异常发生时,callee-saved 寄存器都能保持不变。然而当异常发生时,bar 的正常流程被打断了,bar 并没有机会去执行 epilogue 来恢复这些寄存器。这也就是 unwinder 需要通过 .eh_frame 中的 CFI 指令,代替被调用方 bar 来完成它没有完成的义务,即将这些调用方所期望的 callee-saved 寄存器一一恢复。
如何查看.eh_frame
可以通过如下方式比对查看.eh_frame 数据段:
$ readelf -wF a.out
000000a8 000000000000001c 00000024 FDE cie=00000088 pc=0000000000001169..0000000000001198
LOC CFA rbp ra
0000000000001169 rsp+8 u c-8
000000000000116e rsp+16 c-16 c-8
0000000000001171 rbp+16 c-16 c-8
000000c8 000000000000001c 000000cc FDE cie=00000000 pc=0000000000001198..00000000000011a8
LOC CFA rbp ra
0000000000001198 rsp+8 u c-8
000000000000119d rsp+16 c-16 c-8
00000000000011a0 rbp+16 c-16 c-8
00000000000011a7 rsp+8 c-16 c-8
上面两段就是 bar 和 foo 解析之后的 FDE,可以对照最终二进制文件的地址加深理解(汇编代码都是一致的,只不过起始地址有所不同)。
$ objdump -d a.out
0000000000001169 <_Z3barv>:
1169: f3 0f 1e fa endbr64
116d: 55 push %rbp
116e: 48 89 e5 mov %rsp,%rbp
1171: bf 04 00 00 00 mov $0x4,%edi
1176: e8 e5 fe ff ff call 1060 <__cxa_allocate_exception@plt>
117b: c7 00 01 00 00 00 movl $0x1,(%rax)
1181: ba 00 00 00 00 mov $0x0,%edx
1186: 48 8d 0d 13 2c 00 00 lea 0x2c13(%rip),%rcx # 3da0 <_ZTIi@CXXABI_1.3>
118d: 48 89 ce mov %rcx,%rsi
1190: 48 89 c7 mov %rax,%rdi
1193: e8 d8 fe ff ff call 1070 <__cxa_throw@plt>
0000000000001198 <_Z3foov>:
1198: f3 0f 1e fa endbr64
119c: 55 push %rbp
119d: 48 89 e5 mov %rsp,%rbp
11a0: e8 c4 ff ff ff call 1169 <_Z3barv>
11a5: 90 nop
11a6: 5d pop %rbp
11a7: c3 ret
Misc
最后再补充一些零碎的信息。
exception propagation
一些会影响异常传播的编译器参数:
fno-exceptions -fno-asynchronous-unwind-tables:.eh_frame和.gcc_except_table都不存在fno-exceptions -fasynchronous-unwind-tables:.eh_frame存在,.gcc_except_table不存在fexceptions:.eh_frame和.gcc_except_table都存在(默认情况)
当一个异常从当前函数向调用方传播时(无论是 Level 1 的 libgcc/libunwind,还是 Level 2 的 libstdc++/libc++abi):
- 没有
.eh_frame:_Unwind_RaiseException返回_URC_END_OF_STACK。__cxa_throw调用std::terminate - 有
.eh_frame但当前栈帧没有对应 LSDA:透传,不调用局部变量析构函数 - 有
.eh_frame且当前栈帧有对应 LSDA,但call site table中找不到抛异常处 IP 对应条目:__gxx_personality_v0调用__cxa_call_terminate或者std::terminate。这表明当前 IP 不在可抛出异常的范围内,找不到对应的landing pad,只能退出 - 有
.eh_frame且当前栈帧有对应 LSDA,call site table中找到了抛异常处 IP 对应条目:执行可能的清理并展开到父帧。此时landing pad为 0 表明当前栈帧无需额外处理,继续栈展开。而landing pad非 0 则表示有清理或者catch,如果无法捕获异常则会调用_Unwind_Resume继续栈展开。
而当一个异常从当前 noexcept 函数向调用方传播时:
fno-exceptions -fno-asynchronous-unwind-tables:调用std::terminatefno-exceptions -fasynchronous-unwind-tables:透传,不会调用局部变量析构函数fexceptions: 调用std::terminate
当 std::terminate 被调用时,会有一个诊断信息,形如 terminate called after throwing an instance of 'int'。此时没有 stack trace,如果进程会处理 SIGABRT 信号,signal handler 可能会获得 stack trace。
noexcept
最后再看一下 noexcept 的一些示例。我们仍然用刚才示例代码:
void bar() {
throw 1;
}
void foo() {
bar();
}
int main() {
foo();
}
bar 抛出的异常,最终调用到 __cxa_throw,调用 _Unwind_RaiseException 开始栈展开,过程中会调用 __gxx_personality_v0 查看是否有栈帧能处理这个异常。由于我们代码中压根没有 try/catch 语句,也不需要额外清理,因此编译器并不会生成 LSDA。搜索阶段一路回溯到栈底也找不到能捕获异常的 catch handler,_Unwind_RaiseException 返回 _URC_END_OF_STACK,由 __cxa_throw 调用 std::terminate。core dump 如下:
>>> bt
#0 0x00007c2fa169eb2c in pthread_kill () from /lib/x86_64-linux-gnu/libc.so.6
#1 0x00007c2fa164527e in raise () from /lib/x86_64-linux-gnu/libc.so.6
#2 0x00007c2fa16288ff in abort () from /lib/x86_64-linux-gnu/libc.so.6
#3 0x00007c2fa1aa5ff5 in ?? () from /lib/x86_64-linux-gnu/libstdc++.so.6
#4 0x00007c2fa1abb0da in ?? () from /lib/x86_64-linux-gnu/libstdc++.so.6
#5 0x00007c2fa1aa5a55 in std::terminate() () from /lib/x86_64-linux-gnu/libstdc++.so.6
#6 0x00007c2fa1abb391 in __cxa_throw () from /lib/x86_64-linux-gnu/libstdc++.so.6
#7 0x000060248ae80198 in bar() ()
#8 0x000060248ae801a5 in foo() ()
#9 0x000060248ae801b5 in main ()
而如果我们把 bar 函数添加上 noexcept 关键字,可以发现 core dump 有所不同。
void bar() noexcept {
throw 1;
}
void foo() {
bar();
}
int main() {
foo();
}
在栈展开过程中,unwinder 调用 __gxx_personality_v0 处理这个栈帧时,它发现一个 noexcept 函数中抛出了异常,会直接调用 __cxa_call_terminate。
>>> bt
#0 0x00007f164189eb2c in pthread_kill () from /lib/x86_64-linux-gnu/libc.so.6
#1 0x00007f164184527e in raise () from /lib/x86_64-linux-gnu/libc.so.6
#2 0x00007f16418288ff in abort () from /lib/x86_64-linux-gnu/libc.so.6
#3 0x00007f1641ca5ff5 in ?? () from /lib/x86_64-linux-gnu/libstdc++.so.6
#4 0x00007f1641cbb0da in ?? () from /lib/x86_64-linux-gnu/libstdc++.so.6
#5 0x00007f1641ca58e6 in __cxa_call_terminate () from /lib/x86_64-linux-gnu/libstdc++.so.6
#6 0x00007f1641cba8ba in __gxx_personality_v0 () from /lib/x86_64-linux-gnu/libstdc++.so.6
#7 0x00007f1641bf4b06 in ?? () from /lib/x86_64-linux-gnu/libgcc_s.so.1
#8 0x00007f1641bf51f1 in _Unwind_RaiseException () from /lib/x86_64-linux-gnu/libgcc_s.so.1
#9 0x00007f1641cbb384 in __cxa_throw () from /lib/x86_64-linux-gnu/libstdc++.so.6
#10 0x0000579e7a320198 in bar() ()
#11 0x0000579e7a3201a5 in foo() ()
#12 0x0000579e7a3201b5 in main ()
要理解这个路径,我们需要先看看对应程序的 .gcc_except_table。
$ readelf -x .gcc_except_table a.out
Hex dump of section '.gcc_except_table':
0x00002154 ffff0100 ....
可以看到只有四个字节,实际是 LSDA 的 header:
ff:landing pad的基地址 —— 表示没有特定的 landing pad 基址ff:type table的编码格式 —— 表示没有类型信息表(没有catch,不需要做 RTTI 类型匹配)。01:call site table编码,01表示采用uleb128编码。00:call site table长度为 0
- In GCC, for a
noexceptfunction, a possibly-throwing call site unhandled by a try block does not get an entry in the.gcc_except_tablecall site table. If the function has no try block, it gets a header-only.gcc_except_table(4 bytes)- In Clang, there is a call site entry calling
__clang_call_terminate. The size overhead is larger than GCC’s scheme. Improving this requires LLVM IR work
由于 call site table 中没有任何有效条目,在两阶段栈展开过程中,__gxx_personality_v0 都会将该帧的搜索结果设置为 found_terminate。
while (p < info.action_table) {
_Unwind_Ptr cs_start, cs_len, cs_lp;
_uleb128_t cs_action;
// Note that all call-site encodings are "absolute" displacements.
p = read_encoded_value(0, info.call_site_encoding, p, &cs_start);
p = read_encoded_value(0, info.call_site_encoding, p, &cs_len);
p = read_encoded_value(0, info.call_site_encoding, p, &cs_lp);
p = read_uleb128(p, &cs_action);
// The table is sorted, so if we've passed the ip, stop.
if (ip < info.Start + cs_start)
p = info.action_table;
else if (ip < info.Start + cs_start + cs_len) {
if (cs_lp)
landing_pad = info.LPStart + cs_lp;
if (cs_action)
action_record = info.action_table + cs_action - 1;
goto found_something;
}
}
// If ip is not present in the table, call terminate. This is for
// a destructor inside a cleanup, or a library routine the compiler
// was not expecting to throw.
found_type = found_terminate;
goto do_something;
完整流程是:
-
在搜索阶段,设置为
found_terminate,此时landing_pad为 0,将当前结果缓存,并返回_URC_HANDLER_FOUND,代表找到了catch handler(尽管实际的landing pad是 terminate)。if (actions & _UA_SEARCH_PHASE) { if (found_type == found_cleanup) CONTINUE_UNWINDING; // For domestic exceptions, we cache data from phase 1 for phase 2. if (!foreign_exception) { save_caught_exception(ue_header, context, thrown_ptr, handler_switch_value, language_specific_data, landing_pad, action_record); } return _URC_HANDLER_FOUND; } -
在清理阶段,通过读取缓存结果,再次设置为
found_terminate,最终也就调用了 __cxa_call_terminate。// Shortcut for phase 2 found handler for domestic exception. if (actions == (_UA_CLEANUP_PHASE | _UA_HANDLER_FRAME) && !foreign_exception) { restore_caught_exception(ue_header, handler_switch_value, language_specific_data, landing_pad); found_type = (landing_pad == 0 ? found_terminate : found_handler); goto install_context; }
最后,我们可以从 core dump 看到整个 Itanium C++ ABI 异常处理的各个关键组件:
__cxa_throw是 Itanium C++ ABI 定义的接口,libstdc++提供了具体实现。_Unwind_RaiseException是 Itanium Base ABI 定义的栈展开接口,libgcc_s提供了具体实现,基于 DWARF 展开信息。__gxx_personality_v0负责:- 在栈展开过程中检查每个栈帧是否有匹配的 catch
- 决定是否执行 landing pad
__cxa_call_terminate- 最终
abort(),则是在glibc中
相关内容整理得差不多了,大多数内容都是通过阅读 MaskRay 的博客重新消化输出的,不免会有不少疏漏错误。但整个过程还是学到了不少,有点意思。
Reference
-
[C++ exception handling ABI MaskRay](https://maskray.me/blog/2020-12-12-c++-exception-handling-abi) -
[Stack unwinding MaskRay](https://maskray.me/blog/2020-11-08-stack-unwinding) - CppCon 2017: Dave Watson “C++ Exceptions and Stack Unwinding”