编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

    • README
    • 入门教程

    • 综合案例

    • 专栏博客

      • README
      • 进程地址空间布局
      • 对象内存布局原理
      • 引用与指针本质
      • this指针与成员函数
      • 虚函数表深度剖析
      • 多重继承内存模型
      • 内存对齐与缓存行
      • 内存分配器演进史
      • 五大值类别详解
      • 右值引用与移动语义
      • 完美转发与引用折叠
      • 类型推导三大规则
      • 类型转换与隐式构造
      • const与volatile真相
      • RTTI与dynamic_cast
      • 类型擦除技术原理
      • 模板实例化机制
      • 模板特化与偏特化
      • SFINAE与enable_if
      • 可变参数模板原理
      • constexpr编译期计算
      • Concepts深度剖析
      • 元编程模板技巧
      • Modules模块化设计
      • RAII的设计哲学
      • 对象构造与析构
      • 拷贝与移动控制
      • unique_ptr原理剖析
      • shared_ptr底层剖析
      • weak_ptr与this增强
      • 五种存储期管理
      • vector扩容真相
      • deque分段连续设计
      • list与forward_list
      • 关联容器红黑树
      • 哈希容器深度剖析
      • 迭代器五大类别
      • STL算法设计哲学
      • Allocator分配器机制
      • C++内存模型基石
      • 六大内存序详解
      • atomic原子操作原理
      • mutex与条件变量
      • thread与jthread机制
      • 异步编程future家族
      • 无锁数据结构设计
      • 协程coroutine原理
      • 翻译单元与预处理
      • 编译期符号生成
      • 链接器工作原理
      • ODR规则与陷阱
      • 动态库与符号可见性
      • C++ ABI兼容性
      • LTO与PGO优化
      • 异常机制底层原理
        • 1. 案例引入
          • 1.1 析构函数抛异常
          • 1.2 noexcept 函数抛异常——运行时的 std::terminate 不让异常逃离
          • 1.3 七个待解疑问
        • 2. 架构概览
          • 2.1 从 throw 到 catch 的四个阶段——throw / search / cleanup / catch
          • 2.2 为何这么切
          • 2.3 C++ 异常 vs C setjmp/longjmp vs Go panic/recover——三种异常模型的对比
        • 3. Itanium C++ 异常 ABI——.eh_frame 与 LSDA 的完整结构
          • 3.1 .eh_frame 段——每个函数有一条 FDE 记录
          • 3.2 CIE 与 FDE——共用信息条目与函数描述条目
          • 3.3 LSDA——语言相关的数据区:try/catch 的静态表
          • 3.4 人格函数(personality routine)——异常处理的调度中心
        • 4. 栈展开(Stack Unwinding)——反向遍历调用链、逐帧析构
          • 4.1 展开的两阶段——search 阶段 vs cleanup 阶段
          • 4.2 每一帧的处置——FDE + LSDA 决定当前帧是跳过、析构、还是捕获
          • 4.3 局部变量的析构——LSDA 中的 cleanup landing pad
          • 4.4 跨动态库的异常传播——type_info 匹配与 .so 边界的异常类型识别
        • 5. 零开销异常的设计原理——正常路径零指令
          • 5.1 正常的 try 不产生任何额外指令——编译器只登记元数据
          • 5.2 throw 才查阅注册表——元数据放在独立的不可执行段中
          • 5.3 和 C setjmp/longjmp 的对比——每次进入 try 都要调 setjmp
          • 5.4 为什么「不抛异常」时异常不任何成本——.eh_frame 不是可执行代码
        • 6. noexcept 的语义与价值
          • 6.1 noexcept 是契约——不是优化提示
          • 6.2 移动构造函数必须 noexcept——std::vector 扩容的强异常保证
          • 6.3 noexcept 对代码生成的影响——编译器可以消除异常表数据
          • 6.4 noexcept(expr) 的条件推导——noexcept(auto) 与 decltype
        • 7. 异常的性能代价——抛异常的成本分解
          • 7.1 正常路径——零开销(只有 .eh_frame 的只读内存占用)
          • 7.2 抛路径——分配异常对象 + 栈展开 + type_info 匹配
          • 7.3 catch 路径——try/catch 块中的异常代码对周围代码的影响
        • 8. 为什么很多公司禁用异常——不是性能问题
          • 8.1 隐式的控制流——异常让代码的执行路径不可预测
          • 8.2 异常安全的代码需要无处不在的 RAII——不是所有团队都能做到
          • 8.3 嵌入式系统——异常的 .eh_frame 元数据和堆栈展开器占用可观的 ROM/RAM
          • 8.4 异常 vs 错误码 vs std::expected——正确性、可读性、性能三角决策
        • 9. 常见陷阱与反模式
          • 9.1 在析构函数中抛异常——双重异常的直接死亡
          • 9.2 catch(...) 吞没一切——包括访问冲突和断言
          • 9.3 按值 catch——对象切片丢失派生类信息
          • 9.4 在异常对象构造期再抛异常——new 分配失败后的 double fault
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 一次 throw 到 catch 的完整旅程
          • 10.3 设计哲学回扣
          • 10.4 速查表合集
      • Ranges革命与管道
      • format与print体系
      • UB未定义行为图鉴
      • C++设计哲学回望
      • 写作模板
    • 开发技巧

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

  • CodeX
  • Cpp入门到精通
  • 专栏博客
杨充
2026-06-06
目录

异常机制底层原理

# 55.异常机制底层原理

# 目录介绍

  • 1. 案例引入
    • 1.1 析构函数抛异常——双重异常直接调用 std::terminate
    • 1.2 noexcept 函数抛异常——运行时的 std::terminate 不让异常逃离
    • 1.3 七个待解疑问
  • 2. 架构概览
    • 2.1 从 throw 到 catch 的四个阶段——throw / search / cleanup / catch
    • 2.2 为何这么切
    • 2.3 C++ 异常 vs C setjmp/longjmp vs Go panic/recover——三种异常模型的对比
  • 3. Itanium C++ 异常 ABI——.eh_frame 与 LSDA 的完整结构
    • 3.1 .eh_frame 段——每个函数有一条 FDE 记录
    • 3.2 CIE 与 FDE——共用信息条目与函数描述条目
    • 3.3 LSDA——语言相关的数据区:try/catch 的静态表
    • 3.4 人格函数(personality routine)——异常处理的调度中心
  • 4. 栈展开(Stack Unwinding)——反向遍历调用链、逐帧析构
    • 4.1 展开的两阶段——search 阶段 vs cleanup 阶段
    • 4.2 每一帧的处置——FDE + LSDA 决定当前帧是跳过、析构、还是捕获
    • 4.3 局部变量的析构——LSDA 中的 cleanup landing pad
    • 4.4 跨动态库的异常传播——type_info 匹配与 .so 边界的异常类型识别
  • 5. 零开销异常的设计原理——正常路径零指令
    • 5.1 正常的 try 不产生任何额外指令——编译器只登记元数据
    • 5.2 throw 才查阅注册表——元数据放在独立的不可执行段中
    • 5.3 和 C setjmp/longjmp 的对比——每次进入 try 都要调 setjmp
    • 5.4 为什么「不抛异常」时异常不任何成本——.eh_frame 不是可执行代码
  • 6. noexcept 的语义与价值
    • 6.1 noexcept 是契约——不是优化提示
    • 6.2 移动构造函数必须 noexcept——std::vector 扩容的强异常保证
    • 6.3 noexcept 对代码生成的影响——编译器可以消除异常表数据
    • 6.4 noexcept(expr) 的条件推导——noexcept(auto) 与 decltype
  • 7. 异常的性能代价——抛异常的成本分解
    • 7.1 正常路径——零开销(只有 .eh_frame 的只读内存占用)
    • 7.2 抛路径——分配异常对象 + 栈展开 + type_info 匹配
    • 7.3 catch 路径——try/catch 块中的异常代码对周围代码的影响
  • 8. 为什么很多公司禁用异常——不是性能问题
    • 8.1 隐式的控制流——异常让代码的执行路径不可预测
    • 8.2 异常安全的代码需要无处不在的 RAII——不是所有团队都能做到
    • 8.3 嵌入式系统——异常的 .eh_frame 元数据和堆栈展开器占用可观的 ROM/RAM
    • 8.4 异常 vs 错误码 vs std::expected——正确性、可读性、性能三角决策
  • 9. 常见陷阱与反模式
    • 9.1 在析构函数中抛异常——双重异常的直接死亡
    • 9.2 catch(...) 吞没一切——包括访问冲突和断言
    • 9.3 按值 catch——对象切片丢失派生类信息
    • 9.4 在异常对象构造期再抛异常——new 分配失败后的 double fault
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 一次 throw 到 catch 的完整旅程
    • 10.3 设计哲学回扣
    • 10.4 速查表合集

# 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
1
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
};
1
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 章
1
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, &amp;type_info_MyException, &amp;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() → 析构异常对象
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

# 2.2 为何这么切

核心设计:把「异常处理所需的全部信息」从正常执行流中剥离出去。

正常执行流(不抛异常):
  try { ... } catch { ... } → 和没有 try/catch 时的指令完全一样
  没有 setjmp、没有函数调用、没有条件判断、没有计数器
  → 零额外指令

异常发生时:
  运行时查表(.eh_frame + LSDA)→ 确定每个帧的处置
  → 「查表」的开销只在异常路径上支付

这就是「零开销异常」(Zero-Cost Exception)的名字来源——
不是「异常无开销」——是「不抛异常时无开销」。
1
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 的描述
  ├────────────┤
  │   ...      │
  └────────────┘
1
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) 指令:
       - 如何从当前帧恢复返回地址
       - 如何计算前一帧的栈指针
       → 展开器用这些指令「爬」到上一帧
1
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 废弃——但编译器仍保留位)
  };
1
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"——跳到上一帧

③ 整个过程是「纯查表」——不涉及分配、不涉及锁
1
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
1
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)
   → 继续循环——重复 ①
1
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 中
}
1
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 → 继续向上
1
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&amp;) —— 尝试匹配

type_info 的跨 .so 匹配:
  展开器通过 type_info::operator== 或 type_info::name() 的字符串比较
  → 不同 .so 中同一个类可能有不同的 type_info 地址
  → 不能依赖地址比较——必须依赖 name() 的字符串比较
  → 如果 name() 也失败(如 RTTI 被 -fno-rtti 关闭)→ 匹配失败 → 异常继续传播

风险:跨 .so 的异常类型匹配依赖 RTTI 一致性——Itanium ABI 要求所有 .so 使用同一套 type_info
1
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
1
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% 的额外内存是唯一的代价
1
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 元数据
// 正常执行:不调任何函数——不保存寄存器
// 异常执行:运行时查表 + 栈展开
1
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%)
1
2
3
4
5
6
7
8
9
10
11
12

# 6. noexcept 的语义与价值

# 6.1 noexcept 是契约——不是优化提示

void func() noexcept;     // 承诺:我不会抛异常
                           // 如果尝试抛异常→ std::terminate
                           // 编译器可以据此优化(消除异常表数据)

// noexcept 不是「这个函数恰好不抛异常」
// 是「如果我抛了异常——终止进程——因为我承诺过不会」
1
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 完整——以备移动抛异常时的回滚)
1
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 边界
1
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);
}
1
2
3
4
5
6
7

# 7. 异常的性能代价——抛异常的成本分解

# 7.1 正常路径——零开销(只有 .eh_frame 的只读内存占用)

不抛异常:try/catch 块对性能的影响 = 零。
唯一的静态内存占用:.eh_frame + .gcc_except_table(~3-5% 的 .text)
1
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×
1
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 块之后不可用——编译器需要确保它们被析构
  → 这可能限制了少量优化——但影响极小
1
2
3
4
5
6
7

# 8. 为什么很多公司禁用异常——不是性能问题

# 8.1 隐式的控制流——异常让代码的执行路径不可预测

void process() {
    auto data = fetch();          // ① 可能抛异常
    validate(data);               // ② 可能抛异常
    transform(data);              // ③ 假设 ①② 正常——但也可能从 ①② 跳到这里?
                                  //    异常让控制流「隐式」跳过中间代码
    save(data);                   // ④ 可能存在也可能不执行
}
// 异常让每个函数调用后面都藏着一个「隐式的 goto catch」
// 代码审计、测试覆盖、静态分析都变得困难
1
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 包装中——对存量代码库是个巨大挑战
1
2
3
4
5
6
7
8
9
10
11
12
13

# 8.3 嵌入式系统——异常的 .eh_frame 元数据和堆栈展开器占用可观的 ROM/RAM

小型嵌入式项目(ROM &lt; 128KB):
  .eh_frame:~5KB——在 128KB 中是 4%
  展开器库:~15KB——又是一个可观的比例
  → 省掉异常 = 省 20% ROM + 省展开器的运行时内存

游戏中:异常表数据在加载时占用内存——对于大型游戏(数十万函数)——.eh_frame 可达数 MB
1
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 (...) {         // 吞没所有异常——防止双重异常
            // 记录日志——但不能重新抛出
        }
    }
};
1
2
3
4
5
6
7
8
9

# 9.2 catch(...) 吞没一切——包括访问冲突和断言

try {
    do_work();
} catch (...) {
    // ❌ 吞没一切——包括 SIGSEGV 被某些平台映射的异常
    // 这个代码让崩溃变成了静默忽略——生产环境最危险的代码模式
}
1
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 ✅
}
1
2
3
4
5
6
7
8
9
10

# 9.4 在异常对象构造期再抛异常——new 分配失败后的 double fault

throw BigObject();  // BigObject 的构造函数可能抛异常
// 如果 BigObject 的构造函数抛异常——但异常对象还没完全构造
// → __cxa_throw 中的分配已成功——但构造函数失败
// → 展开器处理这种 corner case——析构已分配的内存和已构造的子对象
1
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&amp; 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&amp;)
   → search 阶段结束——进入 cleanup 阶段

⑤ cleanup:f3 → f2 → f1 → main
   每帧:检查 LSDA——如果有局部变量需要析构→执行 cleanup landing pad
   没有手动 RAII 变量的帧——直接跳过

⑥ catch:跳入 main 的 catch 块
   e 绑定到异常对象 → 执行 catch 体 → __cxa_end_catch → 异常对象析构
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

# 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::expected vs std::optional、Outcome 库、决策树——把错误处理的完整工具箱展开。

上次更新: 2026/06/10, 11:13:41
LTO与PGO优化
Ranges革命与管道

← LTO与PGO优化 Ranges革命与管道→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式