浅析 C++ 异常机制

What good can using exceptions do for me? The basic answer is: Using exceptions for error handling makes your code simpler, cleaner, and less likely to miss errors. But what’s wrong with “good old errno and if-statements”? The basic answer is: Using those, your error handling and your normal code are closely intertwined. That way, your code gets messy and it becomes hard to ensure that you have dealt with all errors.

—— Standard C++ Exception FAQ

由于 C++ 异常机制复杂的特性,编写异常安全的代码不是件轻松的事情。异常增强了语言的表达能力,但也带来了不可避免的开销。本文简单分析了 C++ 异常机制的实现原理,并总结相关注意事项。

本文主要讨论 Linux/ELF、System V AMD64 ABI、Itanium C++ ABI 下的实现,实验环境为 Clang 15。编译器、标准库、C++ ABI 运行时和 unwind 库是相互独立的组件;复现实验时还需要确认发行版、链接器及各运行库的具体实现和版本。其他平台和编译器的实现可能不同。

Stack Unwinding

call-stack-layout

计算机程序的 Call Stack(调用栈)由多层 Stack Frame(栈帧)组成。每个栈帧对应一个正在进行的子程序(函数)调用过程,子程序返回时,则弹出栈帧。图中按视觉顺序自底向上展示调用层级:程序 DrawSquare 调用子程序 DrawLine(这只是绘图方向,不代表实际内存地址的增长方向)。

x86_64 体系下,栈通常向低地址增长,Stack Pointer 保存在 RSP 寄存器中;如果 C/C++ 程序保留函数的 Frame Pointer,则该数据通常保存在 RBP 寄存器中。参考附录call 指令进入被调函数时会把返回地址压栈,因此刚进入函数且尚未执行 prologue 时,RSP 指向返回地址;执行 prologue 后,RSP 通常会继续变化。

相关概念可以进一步区分:

  • Stack Walking / Backtracing 只读取和解析调用链,主要用于 debug、监控、perf 和 crash 报告
  • Stack Unwinding 根据调用帧信息恢复上一层调用状态,异常处理时还可能转移控制权并执行清理逻辑

Frame Pointer 栈回溯

基于 frame-pointer 进行栈回溯是最简单通用的做法。需要占用一个寄存器专门存储 frame-pointer,并在栈帧上相对固定的位置存储相关数据,近似于把栈帧以链表的形式串联。x86_64 下,可以通过 frame-pointer 进行栈回溯的函数,其代码指令形如:

1
2
3
4
5
6
7
push %rbp
mov %rsp, %rbp

...

pop %rbp
retq

test1

GNU/glibc 的 <execinfo.h> 提供了 backtrace()backtrace_symbols() 栈回溯接口,它们不属于 ISO C++ 标准库。其实现通常利用 unwind table,因此不完全依赖 frame-pointer。__builtin_return_address(n) 是编译器扩展,用于尝试获取从当前函数回溯 n 层的返回地址,只有部分参数和平台组合能够可靠工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// test1.cpp

#include <cstdio>
#include <cstdlib>
#include <execinfo.h>

#ifndef NO_INLINE
#define NO_INLINE __attribute__((__noinline__))
#endif

using ULL = unsigned long long;

NO_INLINE void dump_traceback() {
const int size = 200;
void *buffer[size];
int nptrs = backtrace(buffer, size);
char **strings = backtrace_symbols(buffer, nptrs);
if (strings) {
for (int i = 0; i < nptrs; ++i) {
printf("[%d] %s\n", i, strings[i]);
}
free(strings);
}
}

template <int level> NO_INLINE void *f3() {
dump_traceback();
return __builtin_return_address(level);
}
template <int level> NO_INLINE void *f2() { return f3<level>(); }
template <int level> NO_INLINE void *f1() { return f2<level>(); }

int main(int argc, char **argv) {
if (argc > 1) {
f1<2>();
} else {
f1<0>();
}
return 0;
}

该示例只适合在普通程序上下文中演示调用栈回溯,不应直接照搬到 signal handler。backtrace_symbols() 会通过 malloc() 分配内存;backtrace_symbols_fd() 虽然不显式分配内存,但首次调用 backtrace() 时仍可能因动态加载 libgcc 而触发内存分配。如果必须在信号处理阶段采集调用栈,应提前加载相关运行库并预热回溯路径,或使用专门设计的崩溃采集方案。相关限制参考 backtrace(3)

在当前实验环境中,开启 -O3 级别编译优化后默认消除 frame-pointer,即启用 -fomit-frame-pointer;添加编译参数 -fno-omit-frame-pointer 可以要求保留 frame-pointer。-rdynamic 将程序符号加入动态符号表,便于 backtrace_symbols() 等接口解析符号名称。

1
2
3
4
5
6
7
8
> clang++ test1.cpp -O3 -rdynamic && ./a.out

[0] ./a.out(_Z14dump_tracebackv+0x1e) [0x55ccb5c1218e]
[1] ./a.out(_Z2f3ILi0EEPvv+0x6) [0x55ccb5c12276]
[2] ./a.out(main+0x14) [0x55ccb5c12204]
[3] /usr/lib64/libc.so.6(+0x3feb0) [0x7f29f083feb0]
[4] /usr/lib64/libc.so.6(__libc_start_main+0x80) [0x7f29f083ff60]
[5] ./a.out(_start+0x25) [0x55ccb5c120a5]

消除 frame-pointer 后,仍然可以用 backtrace 获取当前线程的调用堆栈信息,但是却无法通过内置函数 __builtin_return_address(2) 获取第 2 层调用栈(理论上应是 main 函数代码相关部分)信息

1
2
3
4
5
6
7
8
9
> clang++ test1.cpp -O3 -rdynamic && ./a.out l2

[0] ./a.out(_Z14dump_tracebackv+0x1e) [0x56397028f18e]
[1] ./a.out(_Z2f3ILi2EEPvv+0x9) [0x56397028f249]
[2] ./a.out(main+0xb) [0x56397028f1fb]
[3] /usr/lib64/libc.so.6(+0x3feb0) [0x7f552de3feb0]
[4] /usr/lib64/libc.so.6(__libc_start_main+0x80) [0x7f552de3ff60]
[5] ./a.out(_start+0x25) [0x56397028f0a5]
[1] 2757999 segmentation fault (core dumped) ./a.out l2

test1 分析

导出反汇编结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
> clang++ test1.cpp -O3 -rdynamic && objdump -C -r -d a.out

0000000000001080 <_start>:
1080: f3 0f 1e fa endbr64
1084: 31 ed xor %ebp,%ebp
1086: 49 89 d1 mov %rdx,%r9
1089: 5e pop %rsi
108a: 48 89 e2 mov %rsp,%rdx
108d: 48 83 e4 f0 and $0xfffffffffffffff0,%rsp
1091: 50 push %rax
1092: 54 push %rsp
1093: 45 31 c0 xor %r8d,%r8d
1096: 31 c9 xor %ecx,%ecx
1098: 48 8d 3d 51 01 00 00 lea 0x151(%rip),%rdi # 11f0 <main>
109f: ff 15 33 2f 00 00 callq *0x2f33(%rip) # 3fd8 <__libc_start_main@GLIBC_2.34>
10a5: f4 hlt

0000000000001170 <dump_traceback()>:
1170: 41 57 push %r15
1172: 41 56 push %r14
1174: 41 54 push %r12
1176: 53 push %rbx
1177: 48 81 ec 48 06 00 00 sub $0x648,%rsp
117e: 49 89 e6 mov %rsp,%r14
1181: 4c 89 f7 mov %r14,%rdi
1184: be c8 00 00 00 mov $0xc8,%esi
1189: e8 c2 fe ff ff callq 1050 <backtrace@plt>
118e: 89 c3 mov %eax,%ebx
1190: 4c 89 f7 mov %r14,%rdi
1193: 89 c6 mov %eax,%esi
1195: e8 a6 fe ff ff callq 1040 <backtrace_symbols@plt>
119a: 48 85 c0 test %rax,%rax
119d: 74 41 je 11e0 <dump_traceback()+0x70>
119f: 49 89 c7 mov %rax,%r15
11a2: 85 db test %ebx,%ebx
11a4: 7e 32 jle 11d8 <dump_traceback()+0x68>
11a6: 41 89 dc mov %ebx,%r12d
11a9: 4c 8d 35 54 0e 00 00 lea 0xe54(%rip),%r14 # 2004 <_IO_stdin_used+0x4>
11b0: 31 db xor %ebx,%ebx
11b2: 66 66 66 66 66 2e 0f data16 data16 data16 data16 nopw %cs:0x0(%rax,%rax,1)
11b9: 1f 84 00 00 00 00 00
11c0: 49 8b 14 df mov (%r15,%rbx,8),%rdx
11c4: 4c 89 f7 mov %r14,%rdi
11c7: 89 de mov %ebx,%esi
11c9: 31 c0 xor %eax,%eax
11cb: e8 90 fe ff ff callq 1060 <printf@plt>
11d0: 48 ff c3 inc %rbx
11d3: 49 39 dc cmp %rbx,%r12
11d6: 75 e8 jne 11c0 <dump_traceback()+0x50>
11d8: 4c 89 ff mov %r15,%rdi
11db: e8 50 fe ff ff callq 1030 <free@plt>
11e0: 48 81 c4 48 06 00 00 add $0x648,%rsp
11e7: 5b pop %rbx
11e8: 41 5c pop %r12
11ea: 41 5e pop %r14
11ec: 41 5f pop %r15
11ee: c3 retq
11ef: 90 nop

00000000000011f0 <main>:
11f0: 50 push %rax
11f1: 83 ff 02 cmp $0x2,%edi
11f4: 7c 09 jl 11ff <main+0xf>
11f6: e8 15 00 00 00 callq 1210 <void* f1<2>()>
11fb: 31 c0 xor %eax,%eax
11fd: 59 pop %rcx
11fe: c3 retq
11ff: e8 1c 00 00 00 callq 1220 <void* f1<0>()>
1204: 31 c0 xor %eax,%eax
1206: 59 pop %rcx
1207: c3 retq
1208: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
120f: 00

0000000000001210 <void* f1<2>()>:
1210: e9 1b 00 00 00 jmpq 1230 <void* f2<2>()>
1215: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
121c: 00 00 00
121f: 90 nop

0000000000001220 <void* f1<0>()>:
1220: e9 3b 00 00 00 jmpq 1260 <void* f2<0>()>
1225: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
122c: 00 00 00
122f: 90 nop

0000000000001230 <void* f2<2>()>:
1230: e9 0b 00 00 00 jmpq 1240 <void* f3<2>()>
1235: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
123c: 00 00 00
123f: 90 nop

0000000000001240 <void* f3<2>()>:
1240: 55 push %rbp
1241: 48 89 e5 mov %rsp,%rbp
1244: e8 27 ff ff ff callq 1170 <dump_traceback()>
1249: 48 8b 45 00 mov 0x0(%rbp),%rax
124d: 48 8b 00 mov (%rax),%rax
1250: 48 8b 40 08 mov 0x8(%rax),%rax
1254: 5d pop %rbp
1255: c3 retq
1256: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
125d: 00 00 00

0000000000001260 <void* f2<0>()>:
1260: e9 0b 00 00 00 jmpq 1270 <void* f3<0>()>
1265: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
126c: 00 00 00
126f: 90 nop

0000000000001270 <void* f3<0>()>:
1270: 50 push %rax
1271: e8 fa fe ff ff callq 1170 <dump_traceback()>
1276: 48 8b 44 24 08 mov 0x8(%rsp),%rax
127b: 59 pop %rcx
127c: c3 retq

参考 附录 解析以上代码

0000000000001270 <void* f3<0>()>:

  • 0x0(%rsp) 是返回地址
  • push %rax 后,返回地址为 0x8(%rsp)
  • mov 0x8(%rsp),%rax 获取返回地址

0000000000001240 <void* f3<2>()>:

  • 执行完 push %rbpmov %rsp,%rbp
    • %rsp 是当前栈顶地址
    • %rbp 是当前栈基地址
    • 0x0(%rbp) 是上个栈基地址,即栈基地址+0x0
    • 0x8(%rbp) 是返回地址,即栈基地址+0x8
  • mov 0x0(%rbp),%rax 获取上个栈基地址
  • mov (%rax),%rax 获取上上个栈基地址
    • 根据层数,逐步解析出栈基地址
  • mov 0x8(%rax),%rax 获取最终返回地址

在当前实验环境中,__builtin_return_address(0) 不依赖 RBP,可以正常工作;这不是跨编译器、跨架构的通用保证。

__builtin_return_address(?) 函数调用存在几个明显缺点:

  • 当解析层数大于 0 时依赖具体编译器和目标平台的栈帧布局,消除 frame-pointer 或进行尾调用、sibling-call 优化后可能无法获取准确的返回地址
  • 只能尝试获取返回地址,不能重建完整调用帧或恢复通用寄存器状态
  • 无法关联源代码

Frame Pointer 栈回溯缺点总结

  • 强依赖于 frame-pointer,如果调用栈中有栈帧没有保存 frame-pointer,则会导致解析失败
  • 独占一个寄存器存储 frame-pointer,可能导致性能下降

DWARF(Debugging With Arbitrary Record Formats)

ref: DWARF Debugging Standard

DWARF is a debugging information file format used by many compilers and debuggers to support source level debugging. It addresses the requirements of a number of procedural languages, such as C, C++, and Fortran, and is designed to be extensible to other languages. DWARF is architecture independent and applicable to any processor or operating system. It is widely used on Unix, Linux and other operating systems, as well as in stand-alone environments.

DWARF 以 CFI(Call Frame Information) 描述各指令位置上的 CFA 和寄存器恢复规则,使工具可以在不依赖 frame-pointer 的情况下回溯调用栈。用于调试的 CFI 通常位于 .debug_frame,源代码和类型等其他调试信息位于 .debug_*;运行时异常展开则通常使用由其衍生的 .eh_frame。这些段用途不同,也不保证同时存在。这种方式的优点:

  • 基于 CFI 的栈回溯不依赖 rbp,还可以按规则恢复其他寄存器数据
  • 在不执行栈展开时,通常不会增加正常路径上的动态指令;但会增加二进制体积和内存映射,真正执行栈展开时仍有运行时成本
    • .debug_frame 记录调试器可使用的栈帧信息,其他调试信息拆分在 .debug_info.debug_abbrev.debug_line.debug_str 等段中

Exception Handling Frame

LSB 5.0 发布于 2015 年,本文将其作为 Linux/ELF ABI 的历史规范参考。其 Exception Frames 章节指明:支持异常的语言(例如 C++)需要向运行时环境提供附加信息,以描述在异常处理期间必须展开的调用帧;该信息包含在特殊段 .eh_frame.eh_frame_hdr 中。

.eh_frame 基于 DWARF v2 版本的 .debug_frame,主要由 CIE(Common Information Entry)FDE(Frame Description Entry) 组成。对于函数定义,编译器在汇编中嵌入 CFI Directive 相关指令,由汇编器解析生成 .eh_frame.debug_frame。在本文的 Clang 15、Linux x86-64 实验环境中,生成行为受编译参数影响如下;其他编译器和目标平台可能不同:

编译参数 生成段
-fasynchronous-unwind-tables -fexceptions .eh_frame
-fno-asynchronous-unwind-tables -fexceptions .eh_frame
-fasynchronous-unwind-tables -fno-exceptions .eh_frame
-fno-asynchronous-unwind-tables -fno-exceptions none
-fno-asynchronous-unwind-tables -fno-exceptions -g0 none
-fno-asynchronous-unwind-tables -fno-exceptions -g .debug_frame

CFI Directive 指令

基本介绍

  • 指令通常以 .cfi_* 命名
  • .cfi_startproc.cfi_endproc 标识 FDE 区域
  • .cfi_def_cfa_offset 定义 CFA 相对于当前 CFA 寄存器的偏移量
  • .cfi_offset 定义寄存器旧值相对于 CFA 的保存位置
  • .cfi_def_cfa_* 定义 CFA 的计算规则
  • 返回地址的位置由独立的 CFI 规则描述,例如 ra = CFA - 8

test3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
> echo 'void test() {__builtin_unwind_init();}' > test3.cpp && clang++ test3.cpp -S && cat test3.s

.text
.file "test3.cpp"
.globl _Z4testv # -- Begin function _Z4testv
.p2align 4, 0x90
.type _Z4testv,@function
_Z4testv: # @_Z4testv
.cfi_startproc
# %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
pushq %r15
pushq %r14
pushq %r13
pushq %r12
pushq %rbx
.cfi_offset %rbx, -56
.cfi_offset %r12, -48
.cfi_offset %r13, -40
.cfi_offset %r14, -32
.cfi_offset %r15, -24
popq %rbx
popq %r12
popq %r13
popq %r14
popq %r15
popq %rbp
.cfi_def_cfa %rsp, 8
retq
.Lfunc_end0:
.size _Z4testv, .Lfunc_end0-_Z4testv
.cfi_endproc
# -- End function
.ident "Debian clang version 15.0.7"
.section ".note.GNU-stack","",@progbits
.addrsig

.eh_frame

.eh_frame 在 x86 平台下的内容示例如下:

  • 每个 FDE 均有关联的 CIE
  • FDE 每个条目记载特定 PC 位置的 CFA,被调用者 nonvolatile 寄存器的保存位置和返回地址(ra)
1
2
3
4
5
6
7
8
9
10
11
12
Contents of the .eh_frame section:

(FDE 偏移量) (FDE 长度) (FDE 所属的 CIE) (FDE 对应函数的起始 PC 和结束 PC)
00000030 0000000000000024 00000034 FDE cie=00000000 pc=0000000000001020..0000000000001080

(PC 位置) (上一级 (被调用者非易失性 (返回地址的位置)
调用者的 寄存器保存的位置)
栈顶地址)
LOC CFA rbx r12 r14 r15 ra
0000000000001170 rsp+8 u u u u c-8
000000000000117e rsp+1648 c-40 c-32 c-24 c-16 c-8
00000000000011ee rsp+8 c-40 c-32 c-24 c-16 c-8

test1 调用帧分析

导出并解析调用帧定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
> clang++ test1.cpp -O3 -rdynamic && readelf -wF ./a.out

Contents of the .eh_frame section:


00000000 0000000000000014 00000000 CIE "zR" cf=1 df=-8 ra=16
LOC CFA ra
0000000000000000 rsp+8 u

00000018 0000000000000014 0000001c FDE cie=00000000 pc=0000000000001080..00000000000010ab

00000030 0000000000000014 00000000 CIE "zR" cf=1 df=-8 ra=16
LOC CFA ra
0000000000000000 rsp+8 c-8

00000048 0000000000000024 0000001c FDE cie=00000030 pc=0000000000001020..0000000000001070
LOC CFA ra
0000000000001020 rsp+16 c-8
0000000000001026 rsp+24 c-8
0000000000001030 exp c-8

00000070 0000000000000014 00000044 FDE cie=00000030 pc=0000000000001070..0000000000001078

00000088 0000000000000038 0000005c FDE cie=00000030 pc=0000000000001170..00000000000011ef
LOC CFA rbx r12 r14 r15 ra
0000000000001170 rsp+8 u u u u c-8
0000000000001172 rsp+16 u u u u c-8
0000000000001174 rsp+24 u u u u c-8
0000000000001176 rsp+32 u u u u c-8
0000000000001177 rsp+40 u u u u c-8
000000000000117e rsp+1648 c-40 c-32 c-24 c-16 c-8
00000000000011e7 rsp+40 c-40 c-32 c-24 c-16 c-8
00000000000011e8 rsp+32 c-40 c-32 c-24 c-16 c-8
00000000000011ea rsp+24 c-40 c-32 c-24 c-16 c-8
00000000000011ec rsp+16 c-40 c-32 c-24 c-16 c-8
00000000000011ee rsp+8 c-40 c-32 c-24 c-16 c-8

000000c4 000000000000001c 00000098 FDE cie=00000030 pc=00000000000011f0..0000000000001208
LOC CFA ra
00000000000011f0 rsp+8 c-8
00000000000011f1 rsp+16 c-8
00000000000011fe rsp+8 c-8
00000000000011ff rsp+16 c-8
0000000000001207 rsp+8 c-8

000000e4 0000000000000010 000000b8 FDE cie=00000030 pc=0000000000001210..0000000000001215

000000f8 0000000000000010 000000cc FDE cie=00000030 pc=0000000000001220..0000000000001225

0000010c 0000000000000010 000000e0 FDE cie=00000030 pc=0000000000001230..0000000000001235

00000120 000000000000001c 000000f4 FDE cie=00000030 pc=0000000000001240..0000000000001256
LOC CFA rbp ra
0000000000001240 rsp+8 u c-8
0000000000001241 rsp+16 c-16 c-8
0000000000001244 rbp+16 c-16 c-8
0000000000001255 rsp+8 c-16 c-8

00000140 0000000000000010 00000114 FDE cie=00000030 pc=0000000000001260..0000000000001265

00000154 0000000000000018 00000128 FDE cie=00000030 pc=0000000000001270..000000000000127d
LOC CFA ra
0000000000001270 rsp+8 c-8
0000000000001271 rsp+16 c-8
000000000000127c rsp+8 c-8

00000170 0000000000000044 00000144 FDE cie=00000030 pc=0000000000001280..00000000000012dd
LOC CFA rbx rbp r12 r13 r14 r15 ra
0000000000001280 rsp+8 u u u u u u c-8
0000000000001282 rsp+16 u u u u u c-16 c-8
0000000000001287 rsp+24 u u u u c-24 c-16 c-8
000000000000128c rsp+32 u u u c-32 c-24 c-16 c-8
0000000000001291 rsp+40 u u c-40 c-32 c-24 c-16 c-8
0000000000001299 rsp+48 u c-48 c-40 c-32 c-24 c-16 c-8
00000000000012a1 rsp+56 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012a8 rsp+64 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012d2 rsp+56 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012d3 rsp+48 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012d4 rsp+40 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012d6 rsp+32 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012d8 rsp+24 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012da rsp+16 c-56 c-48 c-40 c-32 c-24 c-16 c-8
00000000000012dc rsp+8 c-56 c-48 c-40 c-32 c-24 c-16 c-8

000001b8 0000000000000010 0000018c FDE cie=00000030 pc=00000000000012e0..00000000000012e1

000001cc ZERO terminator

> readelf -wF /usr/lib64/libc.so.6

000000ac 0000000000000018 000000b0 FDE cie=00000000 pc=000000000003fe30..000000000003fedc
LOC CFA ra
000000000003fe30 rsp+8 c-8
000000000003fe31 rsp+16 c-8
000000000003fe32 rsp+8 c-8
000000000003fe39 rsp+160 c-8

000000c8 0000000000000030 000000cc FDE cie=00000000 pc=000000000003fee0..0000000000040028
LOC CFA rbx rbp r12 r13 r14 r15 ra
000000000003fee0 rsp+8 u u u u u u c-8
000000000003fee6 rsp+16 u u u u u c-16 c-8
000000000003feeb rsp+24 u u u u c-24 c-16 c-8
000000000003feed rsp+32 u u u c-32 c-24 c-16 c-8
000000000003fef2 rsp+40 u u c-40 c-32 c-24 c-16 c-8
000000000003fef6 rsp+48 u c-48 c-40 c-32 c-24 c-16 c-8
000000000003fef9 rsp+56 c-56 c-48 c-40 c-32 c-24 c-16 c-8
000000000003fefd rsp+80 c-56 c-48 c-40 c-32 c-24 c-16 c-8

000000fc 0000000000000010 00000100 FDE cie=00000000 pc=0000000000040030..000000000004004a

分析可知 backtrace() 的调用栈展开过程为:

  • dump_traceback()+0x1e
    • 118e:
      • ref 000000000000117e rsp+1648 c-40 c-32 c-24 c-16 c-8
      • 1640(%rsp) 得到返回地址 1276
  • void* f3<0>()+0x6
    • 1276:
      • ref 0000000000001271 rsp+16 c-8
      • 8(%rsp) 得到返回地址 1204
  • main()+0x14
    • 1204:
      • ref 00000000000011ff rsp+16 c-8
      • 8(%rsp) 得到 /usr/lib64/libc.so.6 中地址 3feb0
  • /usr/lib64/libc.so.6(+0x3feb0)
    • ref 000000000003fe39 rsp+160 c-8
    • 152(%rsp) 得到 /usr/lib64/libc.so.6 中地址 3ff60
  • /usr/lib64/libc.so.6(__libc_start_main+0x80)
    • ref 000000000003fefd rsp+80 c-56 c-48 c-40 c-32 c-24 c-16 c-8
    • 72(%rsp) 得到返回地址 10a5
  • _start+0x25
    • ref 0000000000000000 rsp+8 u

libunwind Stack Unwinding

libunwind 提供了可移植的 API 来确定程序调用链,支持本地(同进程)和远程(跨进程)操作。API 可以读取 cursor 所表示调用帧的保存状态;unw_resume 可以尝试从有效的 cursor 上下文恢复执行,是否支持以及具体限制取决于平台和 libunwind 实现。典型使用场景包括异常处理、debug 调试和调用链监控。

libunwind 的相关接口函数如下

  • unw_init_local 主要用于当前进程的栈展开
  • unw_init_remote 则通常作用于其他进程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
> nm -CD libunwind.so | grep 'unw_' | less

0000000000007d84 W unw_getcontext
0000000000001d80 W unw_get_fpreg
0000000000001f30 W unw_get_proc_info
0000000000002030 W unw_get_proc_name
0000000000001bc0 W unw_get_reg
0000000000001ab0 W unw_init_local
00000000000020d0 W unw_is_fpreg
00000000000021d0 W unw_is_signal_frame
0000000000002240 W unw_iterate_dwarf_unwind_cache
000000000000e0c8 D unw_local_addr_space
0000000000002150 W unw_regname
0000000000001fc0 W unw_resume
0000000000001e20 W unw_set_fpreg
0000000000001c60 W unw_set_reg
0000000000001ec0 W unw_step

test2

test2 测试基于 libunwind 进行本地调用栈回溯。以下命令显式选择 libc++ 和 libc++abi;-lunwind 最终链接到 LLVM libunwind 还是其他同名实现,取决于当前系统的库搜索路径。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// test2.cpp

#include <cstddef>
#include <cstdio>
#include <cstdlib>
#include <cxxabi.h>

#define UNW_LOCAL_ONLY
#include <libunwind.h>

#ifndef NO_INLINE
#define NO_INLINE __attribute__((__noinline__))
#endif

NO_INLINE void dump_backtrace() {
char buff[1024];
std::size_t demangle_buff_size = 0;
char *demangle_buff = nullptr;
unw_cursor_t cursor;
unw_context_t uc;

if (unw_getcontext(&uc) < 0 || unw_init_local(&cursor, &uc) < 0) {
return;
}

while (unw_step(&cursor) > 0) {
unw_word_t ip{};
if (unw_get_reg(&cursor, UNW_REG_IP, &ip) < 0) {
continue;
}

unw_word_t offset{};
if (unw_get_proc_name(&cursor, buff, sizeof(buff), &offset) < 0) {
printf("0x%016lx <unknown>\n", static_cast<unsigned long>(ip));
continue;
}

auto realname = buff;
{
if (int status = -1;
demangle_buff = abi::__cxa_demangle(buff, demangle_buff,
&demangle_buff_size, &status),
status == 0) {
realname = demangle_buff;
}
}
printf("0x%016lx <%s+0x%lx>\n", static_cast<unsigned long>(ip),
realname, static_cast<unsigned long>(offset));
}
if (demangle_buff) {
free(demangle_buff);
}
}

NO_INLINE void *f3() {
dump_backtrace();
return nullptr;
}
NO_INLINE void *f2() { return f3(); }
NO_INLINE void *f1() { return f2(); }

int main(int argc, char **argv) {
f1();
return 0;
}
1
2
3
4
5
6
7
8
9
> clang++ test2.cpp -O3 -L/usr/lib64 -lunwind -lc++ -lc++abi -stdlib=libc++ -std=gnu++20 -rdynamic && ./a.out

0x00005645e5d11306 <f3()+0x6>
0x00005645e5d11316 <f2()+0x6>
0x00005645e5d11326 <f1()+0x6>
0x00005645e5d11336 <main+0x6>
0x00007ff550c3feb0 <__libc_start_call_main+0x80>
0x00007ff550c3ff60 <__libc_start_main+0x80>
0x00005645e5d110f5 <_start+0x25>

本次构建没有对 f1()f2()f3()dump_backtrace() 执行 sibling-call 优化。符号可见性与 interposition 可能是影响因素之一;是否执行这种优化还取决于编译器版本、目标 ABI、PIC/PIE、调用约定和栈帧清理逻辑。将函数声明为 static 或放入匿名空间后,编译器通常能获得更多优化空间,但仍不保证一定执行尾调用优化。相关限制参考 GCC Tail Calls

C++ Exception Handling

C++ Exception Handling 是 Stack Unwinding 的典型应用。异常处理相关的 ABI 有多种,以 Itanium C++ ABI: Exception Handling 使用最广,其中 C++ 异常处理的 ABI 被分成 3 个级别:

  • Base ABI:语言无关的公共接口
  • C++ ABI:C++ 实现的必要接口
  • 特定运行时下的实现规范

Landing Pad 定义

  • landing-pad 是指捕获异常或在异常后执行清理流程的用户代码
  • 异常处理过程中的 Personality Routine 流程会有选择地将代码的控制权移交给 landing-pad,执行相关逻辑后,或结束异常处理并回到正常用户代码,或继续处理异常,或抛出异常

test4

test4 以简单的代码实例介绍异常从被抛出到捕获的过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// test4.cpp

volatile int v = 0x0;

struct N {
__attribute__((__noinline__)) N() { v = 0x1; }
__attribute__((__noinline__)) ~N() { v = 0x2; }
};

void test(bool x) {
N n;
try {
if (x)
throw v;
v = 0x4;
} catch (int &e) {
throw static_cast<double>(v);
} catch (double &e) {
v = 0x5;
} catch (...) {
v = 0x3;
}
}

void test_noexcept(bool) noexcept {
N n;
throw v;
}

void test2(bool x) {
N n;
try {
test(x);
} catch (float &e) {
v = 0x6;
} catch (double &e) {
v = 0x7;
}
v = 0x8;
}

需要注意,catch (int &) 中新抛出的 double 不会被同一个 try 后面的 catch (double &) 捕获。该异常会继续寻找动态外层的匹配 handler,在本例中由 test2()catch (double &) 捕获。相关语义参考 C++ Exception Handling

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
> clang++ -c test4.cpp -O3 && objdump -C -r -d ./test4.o

Disassembly of section .text:

0000000000000000 <test(bool)>:
0: 53 push %rbx
1: 48 83 ec 10 sub $0x10,%rsp
5: 89 fb mov %edi,%ebx
7: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
c: e8 00 00 00 00 callq 11 <test(bool)+0x11>
d: R_X86_64_PLT32 N::N()-0x4
# test x
11: 85 db test %ebx,%ebx
13: 75 1a jne 2f <test(bool)+0x2f>

# x is 0
# 正常流程结束函数调用
15: c7 05 00 00 00 00 04 movl $0x4,0x0(%rip) # 1f <test(bool)+0x1f>
1c: 00 00 00
17: R_X86_64_PC32 v-0x8
1f: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
24: e8 00 00 00 00 callq 29 <test(bool)+0x29>
25: R_X86_64_PLT32 N::~N()-0x4
29: 48 83 c4 10 add $0x10,%rsp
2d: 5b pop %rbx
2e: c3 retq

# x is NOT 0
# __cxa_allocate_exception 分配异常对象空间
2f: bf 04 00 00 00 mov $0x4,%edi
34: e8 00 00 00 00 callq 39 <test(bool)+0x39>
35: R_X86_64_PLT32 __cxa_allocate_exception-0x4
# 设置异常对象数据
39: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx # 3f <test(bool)+0x3f>
3b: R_X86_64_PC32 v-0x4
3f: 89 08 mov %ecx,(%rax)

# 设置异常对象类型
41: 48 8b 35 00 00 00 00 mov 0x0(%rip),%rsi # 48 <test(bool)+0x48>
44: R_X86_64_REX_GOTPCRELX typeinfo for int-0x4
48: 48 89 c7 mov %rax,%rdi
4b: 31 d2 xor %edx,%edx

# __cxa_throw 抛出异常
4d: e8 00 00 00 00 callq 52 <test(bool)+0x52>
4e: R_X86_64_PLT32 __cxa_throw-0x4

# landing pad:清理
52: eb 63 jmp b7 <test(bool)+0xb7>

# landing pad:异常捕获
54: 48 89 d3 mov %rdx,%rbx
57: 48 89 c7 mov %rax,%rdi

# 匹配 int 类型异常
5a: 83 fb 03 cmp $0x3,%ebx
5d: 74 2c je 8b <test(bool)+0x8b>
5f: e8 00 00 00 00 callq 64 <test(bool)+0x64>
60: R_X86_64_PLT32 __cxa_begin_catch-0x4

# 匹配 double 类型异常
64: 83 fb 02 cmp $0x2,%ebx
67: 75 11 jne 7a <test(bool)+0x7a>
69: c7 05 00 00 00 00 88 movl $0x5,0x0(%rip) # 73 <test(bool)+0x73>
70: 88 00 00
6b: R_X86_64_PC32 v-0x8
73: e8 00 00 00 00 callq 78 <test(bool)+0x78>
74: R_X86_64_PLT32 __cxa_end_catch-0x4
78: eb a5 jmp 1f <test(bool)+0x1f>

# 匹配默认异常
7a: c7 05 00 00 00 00 03 movl $0x3,0x0(%rip) # 84 <test(bool)+0x84>
81: 00 00 00
7c: R_X86_64_PC32 v-0x8
84: e8 00 00 00 00 callq 89 <test(bool)+0x89>
85: R_X86_64_PLT32 __cxa_end_catch-0x4
89: eb 94 jmp 1f <test(bool)+0x1f>

# 匹配 int 类型异常,新抛出 double 类型异常
8b: e8 00 00 00 00 callq 90 <test(bool)+0x90>
8c: R_X86_64_PLT32 __cxa_begin_catch-0x4
90: bf 08 00 00 00 mov $0x8,%edi
95: e8 00 00 00 00 callq 9a <test(bool)+0x9a>
96: R_X86_64_PLT32 __cxa_allocate_exception-0x4
9a: f2 0f 2a 05 00 00 00 cvtsi2sdl 0x0(%rip),%xmm0 # a2 <test(bool)+0xa2>
a1: 00
9e: R_X86_64_PC32 v-0x4
a2: f2 0f 11 00 movsd %xmm0,(%rax)
a6: 48 8b 35 00 00 00 00 mov 0x0(%rip),%rsi # ad <test(bool)+0xad>
a9: R_X86_64_REX_GOTPCRELX typeinfo for double-0x4
ad: 48 89 c7 mov %rax,%rdi
b0: 31 d2 xor %edx,%edx
b2: e8 00 00 00 00 callq b7 <test(bool)+0xb7>
b3: R_X86_64_PLT32 __cxa_throw-0x4
b7: 48 89 c3 mov %rax,%rbx
ba: eb 08 jmp c4 <test(bool)+0xc4>

# `b2:` 抛出异常对应 landing pad:清理
bc: 48 89 c3 mov %rax,%rbx
bf: e8 00 00 00 00 callq c4 <test(bool)+0xc4>
c0: R_X86_64_PLT32 __cxa_end_catch-0x4
c4: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi
c9: e8 00 00 00 00 callq ce <test(bool)+0xce>
ca: R_X86_64_PLT32 N::~N()-0x4

# 跳转回到 cleanup 阶段
ce: 48 89 df mov %rbx,%rdi
d1: e8 00 00 00 00 callq d6 <test(bool)+0xd6>
d2: R_X86_64_PLT32 _Unwind_Resume-0x4
d6: 66 2e 0f 1f 84 00 00 nopw %cs:0x0(%rax,%rax,1)
dd: 00 00 00

00000000000000e0 <test_noexcept(bool)>:
e0: 50 push %rax
e1: 48 89 e7 mov %rsp,%rdi
e4: e8 00 00 00 00 callq e9 <test_noexcept(bool)+0x9>
e5: R_X86_64_PLT32 N::N()-0x4
e9: bf 04 00 00 00 mov $0x4,%edi
ee: e8 00 00 00 00 callq f3 <test_noexcept(bool)+0x13>
ef: R_X86_64_PLT32 __cxa_allocate_exception-0x4
f3: 8b 0d 00 00 00 00 mov 0x0(%rip),%ecx # f9 <test_noexcept(bool)+0x19>
f5: R_X86_64_PC32 v-0x4
f9: 89 08 mov %ecx,(%rax)
fb: 48 8b 35 00 00 00 00 mov 0x0(%rip),%rsi # 102 <test_noexcept(bool)+0x22>
fe: R_X86_64_REX_GOTPCRELX typeinfo for int-0x4
102: 48 89 c7 mov %rax,%rdi
105: 31 d2 xor %edx,%edx
107: e8 00 00 00 00 callq 10c <test_noexcept(bool)+0x2c>
108: R_X86_64_PLT32 __cxa_throw-0x4

# landing pad:terminate 进程
10c: 48 89 c7 mov %rax,%rdi
10f: e8 00 00 00 00 callq 114 <test_noexcept(bool)+0x34>
110: R_X86_64_PLT32 __clang_call_terminate-0x4
114: 66 66 66 2e 0f 1f 84 data16 data16 nopw %cs:0x0(%rax,%rax,1)
11b: 00 00 00 00 00

0000000000000120 <test2(bool)>:
120: 55 push %rbp
121: 53 push %rbx
122: 50 push %rax
123: 89 fb mov %edi,%ebx
125: 48 89 e7 mov %rsp,%rdi
128: e8 00 00 00 00 callq 12d <test2(bool)+0xd>
129: R_X86_64_PLT32 N::N()-0x4
12d: 89 df mov %ebx,%edi
12f: e8 00 00 00 00 callq 134 <test2(bool)+0x14>
130: R_X86_64_PLT32 test(bool)-0x4
134: c7 05 00 00 00 00 08 movl $0x8,0x0(%rip) # 13e <test2(bool)+0x1e>
13b: 00 00 00
136: R_X86_64_PC32 v-0x8
13e: 48 89 e7 mov %rsp,%rdi
141: e8 00 00 00 00 callq 146 <test2(bool)+0x26>
142: R_X86_64_PLT32 N::~N()-0x4
146: 48 83 c4 08 add $0x8,%rsp
14a: 5b pop %rbx
14b: 5d pop %rbp
14c: c3 retq

# landing pad:异常捕获
14d: 48 89 c3 mov %rax,%rbx

# 匹配 float 类型异常
150: bd 06 00 00 00 mov $0x6,%ebp
155: 83 fa 02 cmp $0x2,%edx
158: 74 0a je 164 <test2(bool)+0x44>

# 匹配 double 类型异常
15a: bd 07 00 00 00 mov $0x7,%ebp
15f: 83 fa 01 cmp $0x1,%edx
162: 75 15 jne 179 <test2(bool)+0x59>
164: 48 89 df mov %rbx,%rdi
167: e8 00 00 00 00 callq 16c <test2(bool)+0x4c>
168: R_X86_64_PLT32 __cxa_begin_catch-0x4
16c: 89 2d 00 00 00 00 mov %ebp,0x0(%rip) # 172 <test2(bool)+0x52>
16e: R_X86_64_PC32 v-0x4
172: e8 00 00 00 00 callq 177 <test2(bool)+0x57>
173: R_X86_64_PLT32 __cxa_end_catch-0x4
177: eb bb jmp 134 <test2(bool)+0x14>

# 无匹配异常
# landing pad:清理并继续 unwind
179: 48 89 e7 mov %rsp,%rdi
17c: e8 00 00 00 00 callq 181 <test2(bool)+0x61>
17d: R_X86_64_PLT32 N::~N()-0x4
181: 48 89 df mov %rbx,%rdi
184: e8 00 00 00 00 callq 189 <test2(bool)+0x69>
185: R_X86_64_PLT32 _Unwind_Resume-0x4

Disassembly of section .text._ZN1NC2Ev:

0000000000000000 <N::N()>:
0: c7 05 00 00 00 00 01 movl $0x1,0x0(%rip) # a <N::N()+0xa>
7: 00 00 00
2: R_X86_64_PC32 v-0x8
a: c3 retq

Disassembly of section .text._ZN1ND2Ev:

0000000000000000 <N::~N()>:
0: c7 05 00 00 00 00 02 movl $0x2,0x0(%rip) # a <N::~N()+0xa>
7: 00 00 00
2: R_X86_64_PC32 v-0x8
a: c3 retq

Disassembly of section .text.__clang_call_terminate:

0000000000000000 <__clang_call_terminate>:
0: 50 push %rax
1: e8 00 00 00 00 callq 6 <__clang_call_terminate+0x6>
2: R_X86_64_PLT32 __cxa_begin_catch-0x4
6: e8 00 00 00 00 callq b <__clang_call_terminate+0xb>
7: R_X86_64_PLT32 std::terminate()-0x4

test4 分析

异常抛出逻辑主要步骤:

  • 异常对象构造:__cxa_allocate_exception 为异常对象分配存储空间;编译器生成的代码随后构造或复制异常对象,并将对象地址、类型信息和析构函数传给 __cxa_throw
  • 异常抛出:__cxa_throw 设置当前异常,执行 _Unwind_RaiseException(主要分为 2 个阶段 search 和 cleanup):
    • search 阶段:通过 Personality Routine 机制虚拟遍历调用链,查找 try{}catch{} 与当前异常类型匹配的模块。GCC/Clang 的 C++ 异常通常由 __gxx_personality_v0 解析 .gcc_except_table 中的语言相关数据;__gcc_personality_v0 则可用于不需要 C++ 类型匹配的清理场景。search 阶段通常不修改实际调用栈,也不执行局部对象的析构函数;如果没有找到 handler,最终进入 terminate 流程
    • cleanup 阶段:通过 Personality Routine 机制真正展开调用栈,恢复相关寄存器状态,并将控制权交给相应的 landing-pad
      • 找到需要清理变量的栈帧后,恢复寄存器状态,跳转到该帧相关的 landing-pad。该 landing-pad 最后会调用 _Unwind_Resume 跳转回到 cleanup 阶段。
      • 找到需要执行异常捕获的栈帧后,恢复寄存器状态,跳转到该帧相关的 landing-pad。如果异常匹配成功后,调用 __cxa_begin_catch,执行相关 catch 代码逻辑,最后调用 __cxa_end_catch 结束异常处理流程,回归正常代码;如果无异常匹配,则清理残留变量并通过 _Unwind_Resume 跳转回 cleanup 阶段;

__cxa_* 为 C++ 内部实现的异常处理接口,clang 下的具体行为可参考 Exception Handling in LLVM

  • __cxa_begin_catch 返回异常对象的指针
  • __cxa_end_catch 减少当前异常的 handler 计数,并在满足条件时销毁和释放异常对象
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
> nm -CD libstdc++.so

00000000000917a0 T __cxa_allocate_exception
00000000000919f0 T __cxa_begin_catch
000000000009e0b0 T __cxa_demangle
0000000000091a60 T __cxa_end_catch
0000000000092b40 T __cxa_rethrow
0000000000092af0 T __cxa_throw

> objdump -C -r -d libstdc++.so.6

0000000000092af0 <__cxa_throw@@CXXABI_1.3>:
...
92b22: e8 b9 7a ff ff callq 8a5e0 <_Unwind_RaiseException@plt>
...

异常抛出的行为依赖 unwind 库接口 _Unwind_*。gcc 自带默认 unwind 库 libgcc_s.[so.*]libgcc_eh.a,此外还有以 nongnu.org/libunwindllvm-project/libunwind 为典型代表的 libunwind 库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
> nm -CD libunwind.so | grep '_Unwind_' | less
0000000000007760 T _Unwind_Backtrace
0000000000007350 T _Unwind_DeleteException
00000000000076a0 T _Unwind_FindEnclosingFunction
00000000000078f0 T _Unwind_Find_FDE
0000000000007170 T _Unwind_ForcedUnwind
00000000000079d0 T _Unwind_GetCFA
00000000000075c0 T _Unwind_GetDataRelBase
00000000000073a0 T _Unwind_GetGR
0000000000007470 T _Unwind_GetIP
0000000000007a40 T _Unwind_GetIPInfo
0000000000007220 T _Unwind_GetLanguageSpecificData
00000000000072d0 T _Unwind_GetRegionStart
0000000000007630 T _Unwind_GetTextRelBase
0000000000006780 T _Unwind_RaiseException
0000000000006de0 T _Unwind_Resume
0000000000007530 T _Unwind_Resume_or_Rethrow
0000000000007420 T _Unwind_SetGR
00000000000074e0 T _Unwind_SetIP

通常 C++ 编译器默认允许函数传播异常;-fno-exceptions 则禁止使用相关异常语法。noexcept 表示函数不会向调用方传播异常,主要影响体现在以下方面:

  • 如果异常试图逃逸出 noexcept 函数,运行时会调用 std::terminatenoexcept 是接口契约,不能简单等价为给函数体包裹普通的 try{}catch{}
  • 调用方可以据此排除该调用产生的异常边,减少部分 landing-pad 和清理逻辑,以便于优化调用方行为。部分 C 标准库函数在 C++ 头文件中具有无异常保证,但 extern "C" 只指定语言链接,本身并不等价于 noexcept
  • 根据 C++ 标准,在异常逃逸出 noexcept 函数并调用 std::terminate 之前,栈可能完整展开、部分展开或完全不展开,具体行为由实现决定。因此不能依赖该路径一定执行局部对象的析构函数,详见 std::terminate

C++ 异常的影响

C++ 异常实现通常把主要成本放在实际抛出和捕获异常的路径。对于基本不抛出异常的场景,使用异常可以消除调用方正常路径上的显式状态检查;这是否能提升端到端性能,仍取决于被调用方实现、编译器优化和实际工作负载。

test5 中 f1() 会在出现错误时抛出异常,f2() 则是以返回值表示状态,sum_with_exception()sum_with_status() 实现近似功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// test5.cpp

#include <cstddef>
#include <cstdint>
#include <optional>

std::int64_t f1();

using N = std::optional<std::int64_t>;

N f2() noexcept;

std::int64_t sum_with_exception(std::size_t n) {
std::int64_t res = 0;
for (std::size_t i = 0; i < n; ++i) {
res += f1();
}
return res;
}

N sum_with_status(std::size_t n) noexcept {
std::int64_t res = 0;
for (std::size_t i = 0; i < n; ++i) {
auto x = f2();
if (!x) {
return x;
}
res += *x;
}
return res;
}

从反汇编结果来看,当前编译环境中的 sum_with_exception() 调用方代码更精练紧凑,使用的寄存器更少,说明异常版本的正常路径没有显式状态检查分支。极少出现错误时,整个调用链路可以节省状态检查的相关逻辑,进一步压榨性能。

在当前 Clang、标准库和 System V AMD64 ABI 组合下,f2() 的返回结果通过 RAX 和 RDX 传回调用方,sum_with_status() 中检测 RDX 并执行条件跳转。如果返回状态错误较少,则 CPU 分支预测成功率较高情况下,这种状态检查造成的开销微乎其微。如果状态检查的逻辑较为复杂时,则需要具体评估使用场景并优化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
> clang++ -c test5.cpp -O3 -std=gnu++17 && objdump -C -r -d test5.o

test5.o: file format elf64-x86-64


Disassembly of section .text:

0000000000000000 <sum_with_exception(unsigned long)>:
0: 41 56 push %r14
2: 53 push %rbx
3: 50 push %rax
4: 48 85 ff test %rdi,%rdi
7: 74 16 je 1f <sum_with_exception(unsigned long)+0x1f>
9: 48 89 fb mov %rdi,%rbx
c: 45 31 f6 xor %r14d,%r14d
f: 90 nop
10: e8 00 00 00 00 callq 15 <sum_with_exception(unsigned long)+0x15>
11: R_X86_64_PLT32 f1()-0x4
15: 49 01 c6 add %rax,%r14
18: 48 ff cb dec %rbx
1b: 75 f3 jne 10 <sum_with_exception(unsigned long)+0x10>
1d: eb 03 jmp 22 <sum_with_exception(unsigned long)+0x22>
1f: 45 31 f6 xor %r14d,%r14d
22: 4c 89 f0 mov %r14,%rax
25: 48 83 c4 08 add $0x8,%rsp
29: 5b pop %rbx
2a: 41 5e pop %r14
2c: c3 retq
2d: 0f 1f 00 nopl (%rax)

0000000000000030 <sum_with_status(unsigned long)>:
30: 55 push %rbp
31: 41 56 push %r14
33: 53 push %rbx
34: 41 b6 01 mov $0x1,%r14b
37: 48 85 ff test %rdi,%rdi
3a: 74 27 je 63 <sum_with_status(unsigned long)+0x33>
3c: 48 89 fd mov %rdi,%rbp
3f: 31 db xor %ebx,%ebx
41: 66 66 66 66 66 66 2e data16 data16 data16 data16 data16 nopw %cs:0x0(%rax,%rax,1)
48: 0f 1f 84 00 00 00 00
4f: 00
50: e8 00 00 00 00 callq 55 <sum_with_status(unsigned long)+0x25>
51: R_X86_64_PLT32 f2()-0x4
55: 84 d2 test %dl,%dl
57: 74 0e je 67 <sum_with_status(unsigned long)+0x37>
59: 48 01 c3 add %rax,%rbx
5c: 48 ff cd dec %rbp
5f: 75 ef jne 50 <sum_with_status(unsigned long)+0x20>
61: eb 0a jmp 6d <sum_with_status(unsigned long)+0x3d>
63: 31 db xor %ebx,%ebx
65: eb 06 jmp 6d <sum_with_status(unsigned long)+0x3d>
67: 45 31 f6 xor %r14d,%r14d
6a: 48 89 c3 mov %rax,%rbx
6d: 48 89 d8 mov %rbx,%rax
70: 44 89 f2 mov %r14d,%edx
73: 5b pop %rbx
74: 41 5e pop %r14
76: 5d pop %rbp
77: c3 retq

异常影响代码体积:

  • 当函数包含需要在异常路径执行的清理或捕获逻辑时,编译器可能生成 landing-pad,并在 .gcc_except_table 中保存相关描述。
  • 异常支持通常会增加二进制体积,但具体增幅取决于程序结构、编译器、链接方式和裁剪参数。这也是部分项目(例如 LLVM Coding Standards)限制使用异常时考虑的因素之一。

异常影响编译优化:

  • 是否启用异常支持会怎样影响正常路径代码,取决于函数体、优化级别、目标 ABI 和运行库;不能仅根据编译器新旧推断结果,应比较目标环境下的反汇编和端到端基准测试。
  • noexcept 为调用方提供明确的无异常契约,可能帮助编译器删除异常边并实施进一步优化;实际收益仍需测量。只有能够保证异常不会逃逸时,才应在函数的所有声明和定义中保持一致的 noexcept 说明。

C 语言中的非局部错误处理

ISO C 没有原生的 throwtrycatch 和自动栈展开机制。对于可预期的错误,纯 C 通常通过返回码、errno、输出参数以及 goto cleanup 集中释放资源。

setjmp / longjmp 提供的是非局部控制转移,而不是完整的异常机制。它们可以从深层调用直接返回预先保存的位置,但不会自动执行资源释放、解锁或其他清理逻辑。

ref: https://en.wikipedia.org/wiki/Setjmp.h

现代 GCC/Clang 后端具备异常展开能力,但不会因此为纯 C 自动提供异常语法。GCC 默认不为 C 启用 -fexceptions;该选项主要用于让 C 栈帧能够与 C++ 等语言的异常运行时协作,例如允许 C++ 异常穿过由 C 编译的调用帧。相关选项的作用有所不同:

  • -fexceptions:生成异常传播所需的支持,常用于 C/C++ 混合调用
  • -funwind-tables:只生成静态栈展开数据
  • -fasynchronous-unwind-tables:生成精确到指令边界的展开数据,主要用于调试、采样和异步栈回溯

这些选项都不会为 C 增加 throwcatch 语法。工程上更安全的做法是在 C++ 与 C 的接口边界捕获异常,并转换成 C 返回码。具体参数行为参考 GCC Code Generation Options

注意:以下纯 C 代码只展示 setjmp / longjmp 的控制转移行为。资源仍需在目标位置显式释放,不能依赖 longjmp 自动清理。为简化示例,此处使用全局 jmp_buf,因此代码仅适用于单线程、单层调用,并且不可重入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
// test6.cc

#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <pthread.h>
#include <setjmp.h>

static void first();
static void second();

/* Use a file scoped static variable for the exception stack so we can access
* it anywhere within this translation unit. */
static jmp_buf exception_env;
static int exception_type;

struct TestFlag {
TestFlag() { printf("construct %s \n", __func__); }
~TestFlag() {
// no reachable
printf("destruct %s. SHOULD NOT HAPPEN\n", __func__);
exit(-1);
}
};

int main(void) {
char *volatile mem_buffer = NULL;

if (setjmp(exception_env)) {
// if we get here there was an exception
printf("first failed, exception type: %d\n", exception_type);
} else {
// Run code that may signal failure via longjmp.
puts("calling first");
first();

mem_buffer = (char *)(malloc(300)); // allocate a resource
printf("%s\n", strcpy(mem_buffer, "first succeeded")); // not reached
}

free(mem_buffer); // NULL can be passed to free, no operation is performed

return 0;
}

static void first() {
jmp_buf my_env;

puts("entering first"); // reached

TestFlag n;

std::memcpy(my_env, exception_env, sizeof my_env);

switch (setjmp(exception_env)) {
case 3: // if we get here there was an exception.
puts("second failed, exception type: 3; remapping to type 1");
exception_type = 1;

default: // fall through
std::memcpy(exception_env, my_env,
sizeof exception_env); // restore exception stack
longjmp(exception_env, exception_type); // continue handling the exception

case 0: // normal, desired operation
puts("calling second"); // reached
second();
puts("second succeeded"); // not reached
}

std::memcpy(exception_env, my_env,
sizeof exception_env); // restore exception stack

puts("leaving first"); // never reached
}

static void second() {
puts("entering second"); // reached

exception_type = 3;
longjmp(exception_env, exception_type); // declare that the program has failed

puts("leaving second"); // not reached
}
1
2
3
4
5
6
7
8
9
> clang test6.cc -O3 -std=gnu++17 && ./a.out

calling first
entering first
construct TestFlag
calling second
entering second
second failed, exception type: 3; remapping to type 1
first failed, exception type: 1

这种方式能够实现非局部跳转,但有明显限制:

  • 不执行普通的逐层资源清理,内存、文件和锁等资源需要显式管理
  • setjmplongjmp 之间被修改的非 volatile 自动变量,跳转后的值可能不确定
  • longjmp 的目标函数必须仍处于活动状态,并且不能跨线程使用对应的 jmp_buf
  • 嵌套跳转需要使用相互独立的 jmp_buf,不能假设它可以通过 memcpy 安全复制,相关限制参考 WG14 N735
  • 需要额外维护跳转目标和错误状态,代码复杂度较高

在 C++ 中限制更加严格:如果 longjmp 跳过具有非平凡析构函数的自动对象,行为是未定义的,详见 <csetjmp> 约束。因此它只适合边界明确、不跨越 C++ 对象生命周期的 C 风格代码。

POSIX signal 和 Windows SEH 用于处理操作系统信号、硬件故障或显式触发的软件异常,例如非法内存访问、非法指令和除零错误,不应作为普通业务错误的控制流机制。

C++ 异常总结

常见的 zero-cost exception 模型把主要运行时成本放到实际抛出和展开异常的路径,使正常路径不必为每层调用显式检查错误状态;这里的 “zero-cost” 不表示没有代码体积、编译时间或运行时成本,也不保证异常方案一定更快。

  • 异常支持可能增加二进制体积和编译成本;实际抛出、展开和捕获异常通常也比执行正常路径昂贵,具体差异需要在目标程序中测量。切忌把异常用于普通的逻辑控制。
    • 无法在当前层处理、需要跨越多层调用链传播的异常失败,适合考虑使用异常,并建议在异常对象中包含诊断所需的关键信息。
    • 未找到、解析失败、重试和限流等预期结果,通常更适合使用返回状态或结果类型。
    • 是否采用异常应根据错误语义和目标程序的基准测试决定,不宜使用固定概率阈值。
  • 相较于禁用异常,现代主流 C++ 编译器在不抛出异常时通常能生成正常路径开销较低的代码;某个具体程序能否因此受益,需要通过完整的端到端基准测试确认。
    • 在低延迟场景中,应同时测量正常路径、异常路径、返回状态方案以及不同调用深度,不应仅凭调用方反汇编选择错误处理方式。
    • 如果使用返回状态,可优先选择语义明确、布局紧凑的结果类型,例如 std::optional、C++23 的 std::expected 或项目自定义的状态类型;具体传值方式取决于类型布局、目标 ABI 和编译器实现。

对于自己定义并能保证异常不会逃逸的函数,建议在函数定义和声明处均加上 noexcept,以便于编译器做出更好的调用方优化。不要为标准库函数自行添加不一致的声明。

  • 对于内联函数或定义可见的函数,编译器通常可以根据函数体推导部分无异常属性

异常机制对于 OOP 编程模式较为友好,可以增强代码的表达性和兼容性。当需要在大型项目的复杂逻辑中增加跨层错误处理路径时,使用异常通常具有较低的改造成本。

  • 构造函数失败可抛出异常至上层,否则需引入二段式构造
  • 重载运算符之类的标准接口或不易改造的历史接口
  • 快速在繁杂逻辑中新开辟错误处理路径

附录

汇编基础

  • push %reg 近似于 rsp -= 8; [rsp] = reg
  • pop %reg 近似于 reg = [rsp]; rsp += 8
  • call target 将下一条指令地址压栈,然后跳转到 target
  • ret 从栈顶取出返回地址、增加 RSP,然后跳转到该地址
  • System V AMD64 ABI 下,执行 call 前 RSP 通常按 16 字节对齐;进入被调函数时通常满足 rsp % 16 == 8

x86_64 calling conventions

本文实验参考 AMD64 ABI Draft 0.99.6;后续修订可参考持续维护的 x86-64 psABI

An Application Binary Interface (ABI) is the interface between two binary program modules that work together. An ABI is a contract between pieces of binary code defining the mechanisms by which functions are invoked and how parameters are passed between the caller and callee.


x86_64 寄存器分类

  • volatile (caller-saved) 寄存器:RAX, RCX, RDX, RDI, RSI, R8, R9, R10, R11, XMM*, YMM*

    • 通常用于存储临时数据,函数返回值,函数参数等
  • nonvolatile (callee-saved) 寄存器:RBX, RBP, RSP, R12, R13, R14, R15

    • 寄存器数据在函数调用前后必须保持一致
      • 如果函数内需要改动寄存器数据,通用的做法是在栈上保存原始数据并还原

System V Application Binary Interface AMD64 Architecture Processor Supplement: 3.2.3 Parameter Passing

函数参数传递规范:

  • ABI 先将参数划分为一个或多个 eightbyte,并分类为 INTEGER、SSE、MEMORY 等类别
  • INTEGER 类依次使用 RDI、RSI、RDX、RCX、R8、R9,SSE 类依次使用 XMM0 ~ XMM7
  • 如果一个参数所需的寄存器不足,则该参数整体改用内存传递;需要通过内存传递的参数按从右向左的顺序布置在栈参数区
  • 聚合类型、长双精度、向量和可变参数的规则更复杂,详见 ABI 文档
1
2
3
4
5
6
7
8
9
10
11
F(a, b, c, d, e, f, g, h, double x0 ... double x7, double x8)
a: %rdi
b: %rsi
c: %rdx
d: %rcx
e: %r8
f: %r9
g: 0x8(%rsp)
h: 0x10(%rsp)
x0 ~ x7: xmm0 ~ xmm7
x8: 0x18(%rsp)

函数返回值规范:

  • ABI 对一个返回对象按 eightbyte 进行分类,而不是按“返回值个数”分类
  • INTEGER 类通常使用 RAX、RDX 返回
  • SSE 类通常使用 XMM0、XMM1 返回
  • 混合结构可能同时使用通用寄存器和 SSE 寄存器
  • MEMORY 类对象由调用方提供隐藏的返回缓冲区,被调用方通常将该地址返回到 RAX

Reference