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 (调用栈)由多层 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 #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 109f: ff 15 33 2f 00 00 callq *0x2f33(%rip) 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 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 %rbp 和 mov %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,可能导致性能下降
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 .p2align 4, 0x90 .type _Z4testv,@function _Z4testv: .cfi_startproc 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 .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 000000000000117 e rsp+ 1648 c-40 c-32 c-24 c-16 c-8 00000000000011 ee rsp+ 8 c-40 c-32 c-24 c-16 c-8
导出并解析调用帧定义
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 Unwindinglibunwind 提供了可移植的 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 #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 个级别:
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 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 11: 85 db test %ebx,%ebx 13: 75 1a jne 2f <test (bool)+0x2f> 15: c7 05 00 00 00 00 04 movl $0x4 ,0x0(%rip) 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 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 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 44: R_X86_64_REX_GOTPCRELX typeinfo for int-0x4 48: 48 89 c7 mov %rax,%rdi 4b: 31 d2 xor %edx,%edx 4d: e8 00 00 00 00 callq 52 <test (bool)+0x52> 4e: R_X86_64_PLT32 __cxa_throw-0x4 52: eb 63 jmp b7 <test (bool)+0xb7> 54: 48 89 d3 mov %rdx,%rbx 57: 48 89 c7 mov %rax,%rdi 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 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) 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) 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> 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 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 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> 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 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 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 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 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) 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 14d: 48 89 c3 mov %rax,%rbx 150: bd 06 00 00 00 mov $0x6 ,%ebp 155: 83 fa 02 cmp $0x2 ,%edx 158: 74 0a je 164 <test2(bool)+0x44> 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) 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> 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) 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) 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/libunwind 和 llvm-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::terminate。noexcept 是接口契约,不能简单等价为给函数体包裹普通的 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 #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 没有原生的 throw、try、catch 和自动栈展开机制。对于可预期的错误,纯 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 增加 throw 或 catch 语法。工程上更安全的做法是在 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 #include <cstdio> #include <cstdlib> #include <cstring> #include <pthread.h> #include <setjmp.h> static void first () ;static void second () ;static jmp_buf exception_env;static int exception_type;struct TestFlag { TestFlag () { printf ("construct %s \n" , __func__); } ~TestFlag () { printf ("destruct %s. SHOULD NOT HAPPEN\n" , __func__); exit (-1 ); } }; int main (void ) { char *volatile mem_buffer = NULL ; if (setjmp (exception_env)) { printf ("first failed, exception type: %d\n" , exception_type); } else { puts ("calling first" ); first (); mem_buffer = (char *)(malloc (300 )); printf ("%s\n" , strcpy (mem_buffer, "first succeeded" )); } free (mem_buffer); return 0 ; } static void first () { jmp_buf my_env; puts ("entering first" ); TestFlag n; std::memcpy (my_env, exception_env, sizeof my_env); switch (setjmp (exception_env)) { case 3 : puts ("second failed, exception type: 3; remapping to type 1" ); exception_type = 1 ; default : std::memcpy (exception_env, my_env, sizeof exception_env); longjmp (exception_env, exception_type); case 0 : puts ("calling second" ); second (); puts ("second succeeded" ); } std::memcpy (exception_env, my_env, sizeof exception_env); puts ("leaving first" ); } static void second () { puts ("entering second" ); exception_type = 3 ; longjmp (exception_env, exception_type); puts ("leaving second" ); }
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
这种方式能够实现非局部跳转,但有明显限制:
不执行普通的逐层资源清理,内存、文件和锁等资源需要显式管理
在 setjmp 和 longjmp 之间被修改的非 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