【C++精讲系列】五分钟带你彻底搞懂C++异常机制
先说结论:
C++ 异常是一种由编译期预先设计、在运行时按表执行的非局部控制流机制,用于在控制流被中断时仍然保证对象析构与资源安全。
它在正常路径上几乎没有成本,但一旦抛出就会触发昂贵且隐式的栈展开过程,因此是否使用异常,本质上是工程取舍而非语法选择。
在C++里,异常是一个被大量使用,却很少被真正理解的机制。
你可能已经知道:
- throw 会“跳出当前函数”
- catch 可以在更外层捕获异常
- 异常发生时,局部对象会被析构
但是,如果进一步思考:
- throw 之后,CPU的控制流是怎么改变的?
- 栈是什么时候,以什么顺序展开的?
- 为什么在很多地方,会显示禁用异常,只用错误码?
今天我们就来深入了解一下C++的异常处理机制。
从一个关键事实开始:异常不是普通的控制流
先看一段普通的代码:
void f() { throw std::runtime_error("error"); } void g() { f(); } int main() { try { g(); } catch (...) { } } 直觉上,我们认为:
throw 就像一种特殊的 “return”,一层层返回,直到遇到 catch
但这并不准确。
从底层的视角看,异常机制的本质特征是:
异常不是“逐层返回”,而是一次非局部跳转
也就是说:
- throw 发生时,并不会执行后续语句
- 当前函数不会“正常返回”
- 程序会进入一条完全不同的控制路径
这条路径的名字叫:栈展开 (stack unwinding)
栈展开是什么?
栈展开可以理解成一个三阶段过程:抛出 → 寻找处理者 → 回溯清理并跳转。
(1)抛出阶段:构造异常对象 + 进入运行时
当程序运行到:
throw std::runtime_error("oops"); 底层至少发生两件事:
- 构造异常对象(包含类型信息 + 错误消息,通常在“运行时”分配/管理)
- 构造异常运行时入口(可以理解为把控制权交给异常处理系统)
注意:此刻开始,控制流不再沿着我们写的函数继续往下走
(2)寻找处理者:沿着调用栈“查表”,找到能接住它的 catch
异常运行时接管后,会做一件很关键的事:
从 throw 点出发,沿着调用栈向上查找:有没有在某一层存在匹配的 catch?
编译器在编译时会为每个函数生成异常相关的元数据(可理解为 异常表 / unwind 信息),“运行时”会根据这些信息判断:
- 当前栈帧是否处于某个 try 覆盖范围内
- 是否存在 catch(T) 能匹配这个异常类型(含继承、多态匹配)
- 如果匹配不到,就继续向更外层栈帧查找
如果一直找到 main 都没有匹配的 catch,“运行时”最终会走到 std::terminate()。
这一阶段的关键点是:
- 先“找落点”:确认最终会跳到哪个 catch
- 找到之前,程序并不会开始销毁过程
(3)回溯清理:从 throw 点到 catch 落点之间,逐栈帧清理
当“运行时”确定了“最终会被哪个 catch 接住” 之后,才开始真正的栈展开:
从 throw 所在的栈帧开始,一层层弹出栈帧,执行每层需要执行的清理代码。
编译器在编译时会为栈帧生成对应的清理逻辑,运行时会按顺序执行它们。
注:正常的函数作用域结束后,会执行编译器编译的析构指令,这是正常执行流;但异常执行流是走不到正常执行流的清理部分,所以编译器为异常执行流编译了特殊的清理逻辑(一种正常执行流被打断的补偿机制)
- 先析构当前作用域内已经构造完成的局部对象(RAII资源在这里释放)
- 再离开该作用域
- 然后继续回溯到上一层调用者的栈帧
用一段代码感受:
struct A { ~A(){ std::cout << "~A\n"; } }; struct B { ~B(){ std::cout << "~B\n"; } }; void f() { A a; B b; throw std::runtime_error("x"); } void g() { A a2; f(); } int main() { try { g(); } catch (...) {} } 可以看到控制台输出:

栈展开时析构发生的顺序是:
- f() 内:先 ~B 再 ~A(同一作用域:先构造先析构,和正常执行流的逻辑一样)
- 回到 g():再析构 a2
- 最终回到 mian 的 catch
(4)跳转进入 catch:控制流回到处理点
当 throw 点到 catch 点之前的栈帧都完成清理后:
- 栈已经回到 catch 所在的那一层
- “运行时把控制流移交给对应的 catch 块”
- 若是 catch(const std::exception &e),还会绑定到异常对象
到此为止,异常处理完成一次闭环:
找落点 -> 清理路径 -> 跳到 catch
什么是“运行时”?
先说定义:
运行时(runtime)指的是:程序已经被加载到内存中成为进程,由操作系统启动之后,在程序执行过程中,为语言特性提供支持的一整套代码与机制。
实际上,我们写的 C++ 代码,编译后大致会变成三类东西:
- 我们实现的函数和逻辑
- 编译器插入的辅助代码
- 由标准库/编译器提供的支持代码
其中第二类和第三类,在程序执行时一起构成了我们常说的 C++ 运行时环境。
有关的详细内容,有兴趣的同学可以阅读《【C++精讲系列】五分钟带你彻底搞懂C++代码编译的秘密》这一文。这里就不过多赘述。
为什么栈展开要分成两个阶段?
读到这里你也许会疑惑:
为什么不从 throw 的地方开始,一边往上走,一边析构?非要先找 catch,再回头清理一次?
答案是:如果不分阶段,异常机制在语义上就会出错。
异常处理的真实流程:两阶段栈展开
在主流 C++ ABI(如 Itanium ABI)中,异常处理被明确拆分为两个阶段:
- 搜索阶段(search phase)
- 清理阶段(cleanup phase)
它们的职责是严格区分的。
第一阶段:搜索阶段(不做任何析构)
搜索阶段只做一件事:
从 throw 点开始,沿调用栈向上查找:是否存在一个能处理该异常的 catch?
这一阶段的特点非常重要:
- 不会析构任何对象
- 不会修改栈
- 只读取编译期生成的异常表
- 判断异常类型是否匹配某个 catch
为什么这一步不能析构?
因为在这个阶段,程序还不知道异常最终会落到哪里。
如果在搜索过程中就开始析构对象,一旦发现根本没有如何 catch 能处理这个异常。那么程序就会直接 std::terminate(),而这时已经提前破坏了栈和对象状态 —— 这是不可接收的。
所以结论是:
在确定“有人接得住异常”之前,绝不能做任何破坏性操作。
第二阶段:清理阶段(真正的栈展开)
只有当搜索阶段成功找到某个 catch 之后,才会进入第二阶段。
清理阶段才是真正意义上的栈展开:
- 从 throw 点开始
- 一路回溯到目标 catch 所在的栈帧
- 对中间每一层栈帧:
- 执行编译期生成的清理动作
- 调用已构造对象的析构函数
- 回到上一层栈帧
最终,控制流被转移到 catch 块中。
用一句话概括两阶段设计
搜索阶段负责“确认是否安全”,清理阶段负责“执行破坏性操作”。
为什么很多公司会禁用 C++ 异常机制?
在真正的工业代码中,你会发现一个现象:
在很多大型 C++ 项目(尤其是基础设施、服务端、游戏引擎)会明确使用 -fno-exceptions 禁用异常。
异常的代价是“不可预测的”
主要原因是:
异常的代价在时间和路径上都是不确定的
一旦 throw 发生:
- 要回溯多少层栈?
- 要析构多少对象?
- 会不会触发大量复杂析构逻辑?
这些是在编译器无法准确估计的。
异常会“隐藏控制流”,降低代码可读性
异常是非局部跳转,这意味着:
f(); // 这里可能直接跳走 从这行代码本身,你看不出来:
- 它是否会抛异常
- 会跳到哪里
- 中间会执行哪些析构逻辑
对于规模很大的项目:控制流越隐式,排错和阅读起来就越困难,很反人类。因此很多团队更倾向于显示返回错误码。
异常一旦失控,后果往往是“直接终止进程”
如果异常:
- 没有被捕获
- 在 noexcept 修饰的函数中抛出
结果只有一个 std::terminate();
在长时间运行的服务中:
一次异常 ≈ 一次进程级事故
这是极大影响了系统的稳定性。
那什么时候应该用异常?
异常适合用于:不可恢复、低频、边界层的错误处理。
比如:
- 初始化失败
- 配置错误
- 构造失败
- 明确的“程序无法继续”的状态
而不是:
- 业务分支
- 常规失败
- 高频错误路径
总结:五分钟之后,你应该如何理解 C++ 异常?
异常是一种由编译期预先设计、在运行时按表执行的非局部控制流机制。
当 throw发生时,程序并不会沿正常路径返回,而是进入异常处理路径:
- 先通过搜索阶段确认是否存在匹配的处理者
- 再在清理阶段按照编译期生成的清理信息展开调用栈,保证已构造对象被正确析构
- 最终将控制流转移到对应的
catch。
异常的价值在于为资源安全和错误传播提供语言级保证,但其代价体现在异常发生时的不可预测开销和隐式控制流,因此是否使用异常,本质上是一个工程取舍问题,而非语法选择。