异常机制底层原理
# 55.异常机制底层原理
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. Itanium C++ 异常 ABI——.eh_frame 与 LSDA 的完整结构
- 4. 栈展开(Stack Unwinding)——反向遍历调用链、逐帧析构
- 5. 零开销异常的设计原理——正常路径零指令
- 6. noexcept 的语义与价值
- 7. 异常的性能代价——抛异常的成本分解
- 8. 为什么很多公司禁用异常——不是性能问题
- 9. 常见陷阱与反模式
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 析构函数抛异常
某数据库连接池在一次网络闪断后的恢复逻辑中——进程直接 SIGABRT:
// ====== 事故代码 V1:析构中抛异常 → 双重异常 ======
class Connection {
public:
~Connection() {
if (is_active_) {
disconnect(); // ① 可能抛异常——网络操作不可靠
is_active_ = false;
}
}
void disconnect() {
if (send_fin()) { // ② 网络断开可能失败→抛 NetworkError
throw NetworkError("failed to send FIN");
}
}
};
void handle_transaction() {
Connection conn; // ③ 栈上的 Connection
// ... 事务处理——可能抛 TransactionError ...
} // ④ 事务异常发生时——栈展开——调用 ~Connection()
// → ~Connection() 调用 disconnect() → 抛 NetworkError
// → 此时 TransactionError 还在展开过程中——这是第二个异常!
// → C++ 运行时检测到双重异常 → std::terminate() → SIGABRT
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
根因:C++ 标准规定——在栈展开期间抛出的异常会导致 std::terminate()。 析构函数是栈展开的「清理阶段」——此时已经在处理第一个异常了——再抛一个异常 = 双重异常 = 进程死亡。
# 1.2 noexcept 函数抛异常——运行时的 std::terminate 不让异常逃离
同一个系统的日志模块标记为 noexcept——在某次磁盘满时未能写入——进程同样 SIGABRT:
// ====== 事故代码 V2:noexcept 承诺未兑现 ======
class FileLogger {
void log(const std::string& msg) noexcept { // ① 承诺不抛异常
file_ << msg << '\n'; // ② 磁盘满→operator<< 抛 std::ios_base::failure
} // ③ 异常触发了 noexcept 边界 → std::terminate() → SIGABRT
};
2
3
4
5
6
根因:noexcept 是编译器可优化的契约——不是「不会抛异常」的弱期望——是「如果抛异常就终止进程」的硬承诺。当异常试图穿越 noexcept 边界时——编译器不会让异常通过——而是直接调 std::terminate()。
# 1.3 七个待解疑问
① throw 之后——展开器怎么知道哪些帧有 catch?.eh_frame 里有什么信息? → 第 3 章
② 栈展开是什么?局部变量怎么被析构?展开器怎么遍历调用栈的? → 第 4 章
③ 为什么说 C++ 异常是「零开销」的?不抛异常时真的没有任何性能代价吗? → 第 5 章
④ noexcept 到底做了什么?为什么 vector 扩容依赖移动构造的 noexcept? → 第 6 章
⑤ 抛一次异常的性能代价有多大?和错误码 return 差多少? → 第 7 章
⑥ 为什么 Google 和许多游戏公司禁用异常?不是因为性能吗? → 第 8 章
⑦ 析构函数能抛异常吗?catch(...) 是万能的吗? → 第 9 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 从 throw 到 catch 的四个阶段——throw / search / cleanup / catch
throw MyException("oops");
═══════ 阶段 1: throw — 创建异常对象 ═══════
① __cxa_allocate_exception(sizeof(MyException))
→ 在特殊的异常内存区分配空间(不是普通的堆分配)
② 调用 MyException 的构造函数——异常对象诞生
③ __cxa_throw(exc_ptr, &type_info_MyException, &dtor_MyException)
→ 进入运行时——从此刻开始异常被「抛出」
═══════ 阶段 2: search — 寻找 catcher ═══════
栈展开器(unwinder)从 throw 点开始逆向遍历调用栈:
对每一帧:
读 .eh_frame → 获取该函数的 FDE(函数描述条目)
读 LSDA → 获取 try/catch 表
检查:异常类型是否匹配当前帧的任何 catch 块?
→ 如果找到──进入阶段 3(先清理找到 catch 的帧之前的帧)
→ 如果没找到──继续上一帧
═══════ 阶段 3: cleanup — 清理中间帧 ═══════
从 throw 点到 catcher 之间——所有帧的局部变量必须被析构:
对每一帧(throw → catcher 之间):
读 LSDA → 获取「清理代码」(cleanup landing pad)
跳转到清理代码 → 析构所有局部变量 → 跳到上一帧
→ 继续……
═══════ 阶段 4: catch — 进入 catch 块 ═══════
catcher 帧的 catch 块被跳入:
① 异常对象的引用被绑定到 catch 参数
② 执行 catch 块代码
③ catch 结束时——__cxa_end_catch() → 析构异常对象
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
# 2.2 为何这么切
核心设计:把「异常处理所需的全部信息」从正常执行流中剥离出去。
正常执行流(不抛异常):
try { ... } catch { ... } → 和没有 try/catch 时的指令完全一样
没有 setjmp、没有函数调用、没有条件判断、没有计数器
→ 零额外指令
异常发生时:
运行时查表(.eh_frame + LSDA)→ 确定每个帧的处置
→ 「查表」的开销只在异常路径上支付
这就是「零开销异常」(Zero-Cost Exception)的名字来源——
不是「异常无开销」——是「不抛异常时无开销」。
2
3
4
5
6
7
8
9
10
11
12
13
# 2.3 C++ 异常 vs C setjmp/longjmp vs Go panic/recover——三种异常模型的对比
| 维度 | C++ 异常 | C setjmp/longjmp | Go panic/recover |
|---|---|---|---|
| 正常路径开销 | 零(查表——无 setjmp) | setjmp 每次保存寄存器(~10ns) | defer 有轻微开销 |
| 抛异常时 | 查表+栈展开(~μs) | longjmp 直接跳(~ns——但不析构) | 栈展开+defer 执行(~μs) |
| 局部变量析构 | ✅ 自动(通过 LSDA) | ❌ 不析构 | ✅ defer 执行 |
| 类型匹配 | ✅ RTTI type_info | ❌ 只有 longjmp 的整数 | ✅ 类型匹配 |
| 元数据表 | .eh_frame ~1-5% .text | 无 | 无 |
# 3. Itanium C++ 异常 ABI——.eh_frame 与 LSDA 的完整结构
# 3.1 .eh_frame 段——每个函数有一条 FDE 记录
.eh_frame = 异常的「导航地图」——运行时展开器用它来遍历栈帧
每个 .o 文件中——每个函数(可能抛异常或捕获异常的)有一条 FDE 记录
结构:
┌────────────┐
│ CIE │ ← Common Information Entry(共用信息——每条 .o 一条)
│ - 人格函数是谁(__gxx_personality_v0) │
│ - 数据对齐方式 │
│ - 代码编码(DWARF CFI 指令) │
├────────────┤
│ FDE₁ │ ← 函数 func1 的描述
│ - func1 的地址范围 [start_pc, end_pc) │
│ - 指向 LSDA 的指针 │
│ - DWARF CFI 指令——如何从当前帧恢复上一帧的寄存器 │
├────────────┤
│ FDE₂ │ ← 函数 func2 的描述
├────────────┤
│ ... │
└────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 3.2 CIE 与 FDE——共用信息条目与函数描述条目
CIE (Common Information Entry):
每条 .o 文件有一个 CIE——描述该 .o 中所有函数的共用属性
最重要:personality routine 的地址(__gxx_personality_v0)
→ 展开器调用人格函数来查询「这个帧的 catch 匹配吗?需要清理吗?」
FDE (Frame Description Entry):
每个(需要异常处理的)函数有一条 FDE
内容:
① 函数的 PC 范围(起始地址 + 长度)
② LSDA 的地址(如果函数有 try/catch)
③ DWARF CFI (Call Frame Information) 指令:
- 如何从当前帧恢复返回地址
- 如何计算前一帧的栈指针
→ 展开器用这些指令「爬」到上一帧
2
3
4
5
6
7
8
9
10
11
12
13
14
# 3.3 LSDA——语言相关的数据区:try/catch 的静态表
LSDA = Language-Specific Data Area
每个有 try/catch 的函数有一段 LSDA——编译器生成的静态表
内容(简化):
struct LSDA {
// ① try 区域表(landing pad table)
// 每个条目:{try_start_pc, try_end_pc, landing_pad_pc}
// → 如果当前 IP 在 try_start...try_end——跳转到 landing_pad_pc
// ② catch 表(action table)
// 每个条目:{type_info*, landing_pad_pc}
// → 如果异常类型匹配 type_info——跳转到这个 landing pad
// ③ cleanup landing pad
// → 没有 catch——但需要析构局部变量的地址
// ④ 异常规范(C++17 废弃——但编译器仍保留位)
};
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
关键:LSDA 是只读数据——在 .gcc_except_table 段中——不是在可执行代码中。正常路径不访问 LSDA——只在异常路径上被读。
# 3.4 人格函数(personality routine)——异常处理的调度中心
__gxx_personality_v0 在每个帧上的工作流程:
① 展开器调用 personality(version, actions, exc_class, exc_obj, context)
→ 传入当前帧的上下文(IP/栈指针/寄存器)
② 人格函数读 LSDA:
a. 检查当前 IP 是否在某个 try 块中?
→ 如果是——进入 catch 匹配逻辑
b. 检查是否有 catch 块匹配异常类型?
→ 如果有——返回 "found catch"(阶段 2 终止——进入 cleanup 阶段)
c. 如果当前帧在栈展开的 cleanup 阶段:
→ 返回 "cleanup needed"——告诉展开器该帧的局部变量需要析构
d. 如果没有 try 也没有需要清理的变量:
→ 返回 "continue unwind"——跳到上一帧
③ 整个过程是「纯查表」——不涉及分配、不涉及锁
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 4. 栈展开(Stack Unwinding)——反向遍历调用链、逐帧析构
# 4.1 展开的两阶段——search 阶段 vs cleanup 阶段
阶段一:search(寻找 catcher)
展开器从 throw 点逆向遍历调用栈
对每一帧——调用人格函数 → 检查是否有匹配的 catch
→ 如果找到——停止 search——进入 cleanup 阶段
→ 如果在 main() 之外还没找到(没有帧了)→ std::terminate
阶段二:cleanup(清理中间帧 + 跳入 catch)
从 throw 点到 catcher 之间——每一帧的局部变量必须析构
对每一帧——调用人格函数 → 获取 cleanup landing pad
→ 跳转到 cleanup landing pad → 析构所有局部变量
→ 跳到上一帧——继续
→ 到达 catcher 帧——跳入 catch 块的 landing pad
2
3
4
5
6
7
8
9
10
11
12
# 4.2 每一帧的处置——FDE + LSDA 决定当前帧是跳过、析构、还是捕获
对于调用栈上的每一帧,展开器执行:
① 读当前帧的 FDE → 获取 LSDA(如果有)→ 获取 DWARF CFI 指令
② 调用人格函数:
personality(..., _UA_SEARCH_PHASE, exc_type, exc_obj, context)
→ 人格函数读 LSDA
→ 判断:当前 IP 在 try 块内?catch 类型匹配 exception type?
→ 返回值:_URC_HANDLER_FOUND 或 _URC_CONTINUE_UNWIND
③ 如果 _URC_HANDLER_FOUND:
→ 切换到 cleanup 阶段——从 throw 帧开始逐帧清理——直到当前帧
④ 如果 _URC_CONTINUE_UNWIND:
→ 用 DWARF CFI 指令「爬」到上一帧(恢复上一帧的 RIP + RSP)
→ 继续循环——重复 ①
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 4.3 局部变量的析构——LSDA 中的 cleanup landing pad
void func() {
std::string s = "hello"; // ① 局部变量——需要析构
FileHandle f = open("a"); // ② 局部变量——需要析构
risky_operation(); // ③ 抛异常
// s 和 f 的析构——被编译器登记在 LSDA 的 cleanup landing pad 中
}
2
3
4
5
6
编译器在 LSDA 中为 func() 生成的条目:
LSDA for func():
try 区域: [func+0x00, func+0x80) → landing_pad: func+0x84
cleanup landing pad: func+0x84:
→ f.~FileHandle() // 按声明反序析构
→ s.~string() // 按声明反序析构
→ resume unwind // 返回展开器——继续上一帧
当 risky_operation 抛异常时:
展开器查看 func() → LSDA 说「cleanup 在 func+0x84」
→ 展开器将 RIP 设为 func+0x84 → 执行析构
→ 析构后——resume unwind → 继续向上
2
3
4
5
6
7
8
9
10
11
# 4.4 跨动态库的异常传播——type_info 匹配与 .so 边界的异常类型识别
跨 .so 异常传播——第 53 篇已有提及——这里展开 type_info 的匹配机制:
libA.so 中定义异常类型 LibAError——抛出
libB.so 中 catch(const LibAError&) —— 尝试匹配
type_info 的跨 .so 匹配:
展开器通过 type_info::operator== 或 type_info::name() 的字符串比较
→ 不同 .so 中同一个类可能有不同的 type_info 地址
→ 不能依赖地址比较——必须依赖 name() 的字符串比较
→ 如果 name() 也失败(如 RTTI 被 -fno-rtti 关闭)→ 匹配失败 → 异常继续传播
风险:跨 .so 的异常类型匹配依赖 RTTI 一致性——Itanium ABI 要求所有 .so 使用同一套 type_info
2
3
4
5
6
7
8
9
10
11
12
# 5. 零开销异常的设计原理——正常路径零指令
# 5.1 正常的 try 不产生任何额外指令——编译器只登记元数据
// 这两个函数产生的汇编完全一样——除了 .eh_frame 段中的元数据
void without_try() {
std::string s = "hello";
do_work(s);
} // 编译器生成:正常函数返回 + s 在每个 return 点析构
void with_try() {
std::string s = "hello";
try {
do_work(s);
} catch (const std::exception& e) {
handle_error(e);
}
} // 编译器生成:和前一个函数完全相同的汇编!
// 唯一的区别:.eh_frame 中多了一条 FDE——标记了 try 区域 + catch landing pad
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
objdump 对比——两个函数的指令完全相同——只有调试符号和 .gcc_except_table 的内容不同。
# 5.2 throw 才查阅注册表——元数据放在独立的不可执行段中
.eh_frame 在 ELF 中的位置:
→ 不可执行的 .eh_frame 段(不是 .text——不会被 CPU 当作代码 fetch)
正常执行:CPU 在 .text 段中跑——完全不访问 .eh_frame
异常发生:展开器(运行时库)读取 .eh_frame——CPU 仍在执行展开器的代码——但数据来自 .eh_frame
元数据的唯一成本:
① 内存占用——每个 .o 有一份 .eh_frame(~1-5% 的 .text 段大小)
② 加载时的 mmap——.eh_frame 需要被映射到虚拟内存
③ 如果异常永不发生——这 1-5% 的额外内存是唯一的代价
2
3
4
5
6
7
8
9
10
# 5.3 和 C setjmp/longjmp 的对比——每次进入 try 都要调 setjmp
// C 的 setjmp/longjmp 模型——每次进入 try 都有开销
jmp_buf buf;
if (setjmp(buf) == 0) { // ← 每次都要调 setjmp——保存所有寄存器 (~10ns)
// try 块——正常执行
do_work();
} else {
// catch 块——异常路径
handle_error();
}
// setjmp 的开销在正常路径上每次都被支付
// C++ 异常模型——等价于:
// 编译器静态分析 try/catch——生成 LSDA 元数据
// 正常执行:不调任何函数——不保存寄存器
// 异常执行:运行时查表 + 栈展开
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 5.4 为什么「不抛异常」时异常不任何成本——.eh_frame 不是可执行代码
「零开销」的三条证据:
① 指令数:try/catch 块不增加任何 run-time 指令
→ objdump -d 比较——有 try 和没有 try 的函数——.text 段完全相同
② 寄存器使用:不保存额外寄存器——不需要 setjmp
③ CPU 缓存:.eh_frame 在独立段——不被 CPU 预取
→ 正常执行时——.eh_frame 可以不被加载到 iCache
④ 唯一代价:.eh_frame 占用只读内存(mmap 但不一定被访问)
实测:1 MB 的 .text → ~30 KB 的 .eh_frame(~3%)
2
3
4
5
6
7
8
9
10
11
12
# 6. noexcept 的语义与价值
# 6.1 noexcept 是契约——不是优化提示
void func() noexcept; // 承诺:我不会抛异常
// 如果尝试抛异常→ std::terminate
// 编译器可以据此优化(消除异常表数据)
// noexcept 不是「这个函数恰好不抛异常」
// 是「如果我抛了异常——终止进程——因为我承诺过不会」
2
3
4
5
6
# 6.2 移动构造函数必须 noexcept——std::vector 扩容的强异常保证
class Widget {
Widget(Widget&& other) noexcept : data_(std::move(other.data_)) {}
// ↑ 必须有 noexcept——否则 vector 扩容时退化到拷贝
std::vector<int> data_;
};
// vector 扩容逻辑:
// if (noexcept(Widget::move_ctor)) → 移动元素(快——~3ns per)
// else → 拷贝元素(慢——还需要保持旧 vector 完整——以备移动抛异常时的回滚)
2
3
4
5
6
7
8
9
10
noexcept 移动构造让 vector 扩容走移动而非拷贝——这是 noexcept 最重要的性能影响。
# 6.3 noexcept 对代码生成的影响——编译器可以消除异常表数据
标记为 noexcept 的函数:
→ 编译器不生成 .gcc_except_table(LSDA 元数据)
→ 函数不需要 FDE 异常处理条目
→ 减少了 .eh_frame 的体积
→ 但这不是 noexcept 的主要目的——契约才是主要目的
标记为 noexcept 的函数中调非 noexcept 函数:
→ 编译器可能生成一个「终止处理」(catch-all + std::terminate)
→ 确保异常不会穿越 noexcept 边界
2
3
4
5
6
7
8
9
# 6.4 noexcept(expr) 的条件推导——noexcept(auto) 与 decltype
template <typename T>
void swap(T& a, T& b) noexcept(noexcept(T(std::declval<T&&>()))) {
// noexcept 声明为「如果 T 的移动构造是 noexcept的——我也是 noexcept」
T tmp = std::move(a);
a = std::move(b);
b = std::move(tmp);
}
2
3
4
5
6
7
# 7. 异常的性能代价——抛异常的成本分解
# 7.1 正常路径——零开销(只有 .eh_frame 的只读内存占用)
不抛异常:try/catch 块对性能的影响 = 零。
唯一的静态内存占用:.eh_frame + .gcc_except_table(~3-5% 的 .text)
2
# 7.2 抛路径——分配异常对象 + 栈展开 + type_info 匹配
一次 throw → catch 的典型开销(AMD 7950X, GCC 13.2 -O2):
① __cxa_allocate_exception:~50ns(异常存储区分配——不是常规堆分配)
② 异常对象构造:取决于类型复杂度——对于 std::runtime_error(~100ns)
③ 栈展开 + catch 搜索:
- 每帧查 LSDA:~200ns/frame
- 5 层调用栈:~1μs
- 50 层调用栈:~10μs
→ 栈深度主导开销
④ 局部变量析构:
- 每个 RAII 变量:~10-100ns(取决于析构函数复杂度)
- 5 个 RAII 变量 × 5 层 → ~2.5μs
⑤ catch 类型匹配:
- type_info 比较:~50ns/catch 块
总开销:5 层栈 + 5 个 RAII 变量 → ~5μs
对比:return 错误码 → ~3ns(仅一次函数返回)
差距:~1500×
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 7.3 catch 路径——try/catch 块中的异常代码对周围代码的影响
catch 块的代码(landing pad)通常在函数末尾——远离 try 块的主体代码
→ 可能不在同一 iTLB 页——但 catch 只在异常路径上执行——所以不常访问
唯一对正常路径的影响:
编译器不能把 try 块中的变量跨 catch 边界优化
→ try 块中的变量在 catch 块之后不可用——编译器需要确保它们被析构
→ 这可能限制了少量优化——但影响极小
2
3
4
5
6
7
# 8. 为什么很多公司禁用异常——不是性能问题
# 8.1 隐式的控制流——异常让代码的执行路径不可预测
void process() {
auto data = fetch(); // ① 可能抛异常
validate(data); // ② 可能抛异常
transform(data); // ③ 假设 ①② 正常——但也可能从 ①② 跳到这里?
// 异常让控制流「隐式」跳过中间代码
save(data); // ④ 可能存在也可能不执行
}
// 异常让每个函数调用后面都藏着一个「隐式的 goto catch」
// 代码审计、测试覆盖、静态分析都变得困难
2
3
4
5
6
7
8
9
# 8.2 异常安全的代码需要无处不在的 RAII——不是所有团队都能做到
// ❌ 非 RAII——异常不安全
void bad() {
auto* p = new Widget();
risky_operation(); // 如果抛异常——p 泄露
delete p;
}
// ✅ RAII——异常安全
void good() {
auto p = std::make_unique<Widget>();
risky_operation(); // 如果抛异常——unique_ptr 析构——自动 delete
}
// RAII 需要每个资源包裹在 RAII 包装中——对存量代码库是个巨大挑战
2
3
4
5
6
7
8
9
10
11
12
13
# 8.3 嵌入式系统——异常的 .eh_frame 元数据和堆栈展开器占用可观的 ROM/RAM
小型嵌入式项目(ROM < 128KB):
.eh_frame:~5KB——在 128KB 中是 4%
展开器库:~15KB——又是一个可观的比例
→ 省掉异常 = 省 20% ROM + 省展开器的运行时内存
游戏中:异常表数据在加载时占用内存——对于大型游戏(数十万函数)——.eh_frame 可达数 MB
2
3
4
5
6
# 8.4 异常 vs 错误码 vs std::expected——正确性、可读性、性能三角决策
| 方案 | 正常路径开销 | 错误路径开销 | 类型安全 | 隐式控制流 | 适用场景 |
|---|---|---|---|---|---|
| 异常 | 零 | ~5μs | ✅ | ❌ | 构造失败、深层调用 |
| 错误码 | 每次返回 1 个寄存器 | ~3ns | ❌ | ✅ | C API、嵌入式 |
std::expected | 每次返回一个 variant | ~3ns + 分支 | ✅ | ✅ | C++23 推荐 |
# 9. 常见陷阱与反模式
# 9.1 在析构函数中抛异常——双重异常的直接死亡
案例 1.1 的完整解释——析构函数中的所有代码必须在 noexcept 的保护下:
class Connection {
~Connection() noexcept { // 析构函数隐式 noexcept
try {
if (is_active_) disconnect();
} catch (...) { // 吞没所有异常——防止双重异常
// 记录日志——但不能重新抛出
}
}
};
2
3
4
5
6
7
8
9
# 9.2 catch(...) 吞没一切——包括访问冲突和断言
try {
do_work();
} catch (...) {
// ❌ 吞没一切——包括 SIGSEGV 被某些平台映射的异常
// 这个代码让崩溃变成了静默忽略——生产环境最危险的代码模式
}
2
3
4
5
6
# 9.3 按值 catch——对象切片丢失派生类信息
try {
throw std::runtime_error("failed");
} catch (std::exception e) { // ❌ 按值——切片!丢失 runtime_error 的信息
e.what(); // 调用的是 std::exception::what——不是 runtime_error::what
}
// ✅ 按 const 引用(永远)
catch (const std::exception& e) {
e.what(); // 调用 runtime_error::what ✅
}
2
3
4
5
6
7
8
9
10
# 9.4 在异常对象构造期再抛异常——new 分配失败后的 double fault
throw BigObject(); // BigObject 的构造函数可能抛异常
// 如果 BigObject 的构造函数抛异常——但异常对象还没完全构造
// → __cxa_throw 中的分配已成功——但构造函数失败
// → 展开器处理这种 corner case——析构已分配的内存和已构造的子对象
2
3
4
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | throw→catch 四阶段? | 第 2.1:throw 创建对象→search 找 catcher→cleanup 清理中间帧→catch 执行 |
| ② | .eh_frame 是什么? | 第 3 章:每个函数的 FDE + LSDA——静态表——展开器的导航地图 |
| ③ | 零开销原理? | 第 5 章:正常路径零指令——.eh_frame 是不可执行数据段 |
| ④ | noexcept 价值? | 第 6 章:移动构造必须 noexcept(vector 扩容)+ 编译器可消除异常表 |
| ⑤ | 抛异常性能代价? | 第 7 章:正常路径零开销、抛路径 ~5μs vs 错误码 ~3ns |
| ⑥ | 禁用异常原因? | 第 8 章:隐式控制流 + RAII 要求 + 嵌入式 ROM/RAM 限制 |
| ⑦ | 析构抛异常? | 第 9.1:双重异常 → std::terminate——析构必须 noexcept |
案例①修复——双重异常:析构函数标记 noexcept——内部 try/catch 吞没异常。
案例②修复——noexcept 违规:移除 noexcept 标记——或用 try/catch 在函数内部处理异常。
# 10.2 一次 throw 到 catch 的完整旅程
源码:
f1() { f2(); }
f2() { f3(); }
f3() { throw MyError(); }
main() { try { f1(); } catch(MyError& e) { ... } }
═══════ 正常执行 ═══════
main → f1 → f2 → f3
→ f3 中执行 throw MyError()
→ 正常执行中断——进入异常路径
═══════ 异常路径 ═══════
① f3 帧:
__cxa_allocate_exception → __cxa_throw
展开器:读 f3 的 FDE → LSDA——没有 catch → continue unwind
→ 爬栈到 f2
② f2 帧:
展开器:读 f2 的 FDE → LSDA——没有 catch → continue unwind
→ 爬栈到 f1
③ f1 帧:
展开器:读 f1 的 FDE → LSDA——没有 catch → continue unwind
→ 爬栈到 main
④ main 帧:
展开器:读 main 的 FDE → LSDA——找到 catch(MyError&)
→ search 阶段结束——进入 cleanup 阶段
⑤ cleanup:f3 → f2 → f1 → main
每帧:检查 LSDA——如果有局部变量需要析构→执行 cleanup landing pad
没有手动 RAII 变量的帧——直接跳过
⑥ catch:跳入 main 的 catch 块
e 绑定到异常对象 → 执行 catch 体 → __cxa_end_catch → 异常对象析构
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
# 10.3 设计哲学回扣
哲学 1:零开销原则——不为「可能发生」的异常路径在正常路径上支付任何代价
C++ 异常的零开销设计是「pay-for-what-you-use」哲学的极致体现。正常路径上——有 try/catch 的代码和无 try/catch 的代码生成完全相同的指令。所有元数据(.eh_frame + LSDA)在独立的只读段——CPU 不会在执行正常代码时访问它们。这是编译器工程最美的部分:用纯数据(查表)替代指令(setjmp)作为异常的基础设施。
哲学 2:异常是 C++ 中唯一的「非局部控制流」——所有的函数调用都可能是 goto catch
f() + g() 的执行路径并不总是在 f() 返回后进入 g()。如果 f() 抛异常——g() 永远不会被执行。异常让每个函数调用带有隐式的「可能不回来」标签。 这是异常为什么在代码审计中难以处理——也是为什么 Google 风格指南建议在关键基础设施中禁用它们。
哲学 3:RAII 是异常安全的基石——如果不用 RAII,异常就是定时炸弹
异常依赖 RAII 做清理——如果代码管理裸指针、裸文件描述符、裸锁——异常发生时的资源泄露是不可避免的。换句话说——禁用异常的最大理由不是「异常不好」——是「团队没有在 100% 使用 RAII」。 如果一个代码库 100% 使用 RAII——异常是安全的;如果不是——禁用异常是更安全的选择。
哲学 4:noexcept 是契约到了「进程死亡」的级别——不像 const 那样可以被 cast 掉
const 可以用 const_cast 绕开。noexcept 不能——当异常试图穿越 noexcept 边界时——运行时强制终止进程。这是 C++ 中少数的「零容忍」契约——因为异常穿越 noexcept 承诺意味着程序的正确性假设已经崩溃——继续运行比终止更危险。
# 10.4 速查表合集
异常 ABI 组件速查:
| 组件 | 位置 | 内容 | 访问时机 |
|---|---|---|---|
.eh_frame | ELF 不可执行段 | CIE + FDE(函数 PC 范围 + CFI) | 异常时展开器读取 |
.gcc_except_table | ELF 只读段 | LSDA(try 区域 + catch 类型 + cleanup) | 异常时人格函数读取 |
__gxx_personality_v0 | libstdc++ | 人格函数——执行 catch 匹配和 cleanup 调度 | 异常时每帧调用 |
__cxa_throw | libstdc++ | 进入异常路径——分配异常对象→启动展开器 | throw 时 |
noexcept 决策表:
| 场景 | 该用 noexcept? | 原因 |
|---|---|---|
| 移动构造函数 | ✅ 必须 | vector 扩容依赖 |
| 析构函数 | ✅ 隐式 | 双重异常=terminate |
| swap 函数 | ✅ 推荐 | 配合移动构造 |
| 简单 getter/setter | ✅ | 永远不会抛异常 |
| 可能抛异常的函数 | ❌ | 标记为 noexcept 会 terminate |
异常 vs 错误码决策矩阵:
| 决策因素 | 选异常 | 选错误码/expected |
|---|---|---|
| 正常路径性能首要 | ✅ 零开销 | ❌ 每次返回值有开销 |
| 错误发生频率 | 极低(~0.001%) | 常态化错误 |
| 控制流可见性 | ❌ 隐式控制流 | ✅ 显式 |
| RAII 覆盖率 | 需要 100% | 低 |
| ROM/RAM 限制 | ❌ .eh_frame 占用 | ✅ |
本篇小结:C++ 异常机制通过
.eh_frame+ LSDA 的静态元数据表实现零开销——正常路径不需要任何额外指令。栈展开器利用这些表逆向遍历调用栈——在每一帧查表决定是跳过、清理、还是捕获。noexcept是零容忍的进程级契约——违反直接terminate。异常的性能代价不在正常路径——在抛路径上 ~5μs 的查表+展开开销——和错误码的 ~3ns 差距 1500×。禁用异常的原因不是性能——是隐式控制流和 RAII 依赖带来的可维护性挑战。
下一篇:异常机制是 C++ 的错误处理之一。下一篇进入 56.错误处理多元方案——异常 vs 错误码 vs
std::expectedvsstd::optional、Outcome 库、决策树——把错误处理的完整工具箱展开。