C 异常常被贴上「零成本」的标签可真正在生产里用它的人又总在担心性能。矛盾其实很好解释「零成本」指的是不抛异常时的正常路径happy path不额外付代价而一旦真的抛出代价是巨大的编译器靠一张离线算好的表LSDA来指导栈展开逐帧查找 landing pad。这篇把这两条路径分开算账讲清异常对象存在哪、noexcept里抛异常为什么会直接terminate最后回答那个工程决策问题-fno-exceptions到底什么时候该开。1. 引子一个「不抛就不付钱」的承诺先看一个反直觉的事实下面这个函数里有try/catch但它的正常路径没有异常发生时的汇编和「完全没有异常处理」的版本一模一样// 片段带异常处理的加法 —— 正常路径不该有任何额外开销intadd(inta,intb){try{returnab;}catch(...){return0;}}在 x86-64 上这个函数的正常路径编译出来就是一条lea eax, [rdirsi]加法ret没有任何额外的分支、表查询或保存现场的代码。godbolt 上gcc 13.2-O2的真实汇编只有两行add(int, int): lea eax, [rdirsi] rettry块没带来哪怕一条额外指令catch块更是直接消失了它被编译器挪进了离线表。这就是「零成本异常zero-cost exception」字面意思的由来只要不抛你一分钱都不多付。实现它的关键机制叫表驱动 unwindtable-driven unwinding由 Itanium C ABI 定义。它把「异常发生时该怎么做」的全部信息哪一帧有catch、栈上有哪些对象要析构、catch处理完后跳转到哪离线地放进一张表而不是在正常执行路径里埋桩。表查询只在真的抛异常时才发生。官方文档Itanium C ABI · Exception Handling这套表驱动机制的事实标准GCC/Clang 都遵循2. 核心机制LSDA 表与 landing pad把上面的承诺展开成一张图。编译器为每一个可能抛异常的栈帧生成一段元数据塞进.gcc_except_tableGCC 里叫LSDALanguage-Specific Data Area栈帧调用链从底向上 异常发生时怎么办 ──────────────────────────────────────────────────────────────────── main │ call level1() ▼ level1 ◄── 有 try/catch │ call level2() ┌─────────────────────────────────┐ ▼ │ LSDA离线表.gcc_except_table│ level2 │ · 这个函数在 try 块里有没有对象 │ │ call level3() │ · 哪条 PC 范围对应哪个 landing pad│ ▼ │ · catch 子句匹配什么类型 │ level3 ◄─ 这里 throw │ · 哪些局部对象要析构cleanup │ throw ... └─────────────────────────────────┘ ──────────────────────────────────────────────────────────────────── 抛出后从 throw 点开始逐帧向上查 LSDA找到能 catch 的帧 沿途把每一帧的局部对象按「构造的逆序」析构掉栈展开 最后跳进 catch 的代码入口landing pad。这张表是编译期算好、运行时只读查询的所以正常路径完全感知不到它的存在。而「快乐路径不付代价」的前提也解释了为什么异常不适用于「高频失败」的场景它的成本不是摊销在成功上的而是全部集中到失败那一次。栈展开局部对象按构造逆序析构抛异常时最直观的行为是栈展开stack unwinding从throw点往回走把每一层已经构造出来的局部对象析构掉直到找到匹配的catch。这个顺序是确定的构造的逆序也正是 RAII 能保证「异常时资源不漏」的根基。真跑一遍// unwind.cpp — 编译: g -stdc17 -Wall -O2 unwind.cpp -o unwind#includecstdio#includestdexceptstructTracer{constchar*name;explicitTracer(constchar*n):name(n){std::printf(构造 %s\n,name);}~Tracer(){std::printf(析构 %s\n,name);}};voidlevel3(){Tracer t3{t3};throwstd::runtime_error(boom);// 从这里抛出}voidlevel2(){Tracer t2{t2};level3();}voidlevel1(){Tracer t1{t1};level2();}intmain(){try{level1();}catch(conststd::exceptione){std::printf(捕获到: %s\n,e.what());}}构造 t1 构造 t2 构造 t3 析构 t3 析构 t2 析构 t1 捕获到: boom读这个输出t3先析构然后t2、t1正好是构造的逆序异常传播到main的catch之前把沿途三帧的局部对象全清干净了。这就是「异常按值抛、按引用捕」之外栈展开在替你做的那部分工作析构是被自动调用的你一个delete都不用写。官方文档throw expression · Stack unwinding代价算账两条路径分开看维度快乐路径不抛抛出路径额外指令零表驱动不埋桩逐帧查 LSDA、逐帧跑 cleanup涉及的代码无库函数__cxa_throw→ unwind 库 → landing pad复杂度O(1)O(栈深度)越深越贵局部性极好冷路径表/landing pad 大概率不在缓存里典型量级与无异常代码一致比一次return慢几个数量级结论很明确异常的「零成本」是有条件的。它适合「很少发生、发生时走非正常路径」的场景错误处理、资源申请失败不适合「高频、可预期」的失败比如解析一个可能非法的输入那种情况用错误码或std::expected更划算。3. 异常对象存哪std::current_exception把异常提升到共享存储异常对象本身不放在被抛出的那一帧的栈上那样栈一展开对象就没了。ABI 的实现是throw时先通过__cxa_allocate_exception在堆或紧急缓冲区里分配一块存储把异常对象构造进去再开始 unwind。所以异常对象是脱离栈帧独立存活的这带来两个后果它能被catch(...)之后存起来、稍后再抛。std::exception_ptr就是这块独立存储的句柄。用std::current_exception()可以把「当前正在处理的异常」捕获并保存栈帧返回后依然有效之后用std::rethrow_exception()再抛// exception_ptr.cpp — 编译: g -stdc17 -Wall -O2 exception_ptr.cpp -o eptr#includecstdio#includeexception#includestdexceptstd::exception_ptr saved;voidcapture(){try{throwstd::runtime_error(保存我);}catch(...){savedstd::current_exception();// 异常对象被提升到共享存储脱离当前栈帧}}intmain(){capture();std::printf(异常已保存capture() 的栈帧早已返回\n);try{std::rethrow_exception(saved);// 稍后再抛对象依然完整}catch(conststd::exceptione){std::printf(稍后重新抛出: %s\n,e.what());}}异常已保存capture() 的栈帧早已返回 稍后重新抛出: 保存我这个例子直接回答了「异常对象存哪」它不在capture()的栈上而是被 ABI 提升到一块引用计数的共享存储里GCC/Clang 的实现里exception_ptr内部就是对这个共享对象的引用。所以跨线程传递异常、把异常存进容器都是合法的。官方文档std::current_exception · std::exception_ptr · std::rethrow_exception4.noexcept里抛异常直接terminatenoexcept的真实语义不是「编译器帮你拦住异常」而是「一旦异常从这里逃出来直接调用std::terminate()」不做栈展开外层的catch也接不住。下面这段在 gcc 下能编过只给一条-Wterminate警告但一跑就abort// 片段noexcept 里抛异常 —— 编译只给警告运行时直接 terminate// 反例不要这么写给会抛的函数标 noexceptCore Guidelines 里「承诺了就不能反悔」#includestdexceptvoidboom()noexcept{throwstd::runtime_error(boom);// 运行时直接 std::terminate外层 catch 接不住}它在 godbolt 上gcc 13.2-O1真跑的结果是terminate called after throwing an instance of std::runtime_error what(): boom Program terminated with signal SIGABRT (6)注意三件事第一退出信号是 SIGABRT退出码 134而不是普通返回。进程被abort了第二terminate是在不做任何栈展开的情况下直接触发的所以main里就算写try/catch也接不住第三std::terminate这条路径还会把 stdio 缓冲区里没刷出去的内容一起丢掉所以上面连一行正常的 stdout 都看不到。所以noexcept是「标了就不能反悔」的硬承诺只该给真正在所有路径上都不抛的函数用移动构造、swap、析构这类。官方文档noexcept specifier · std::terminate5.-fno-exceptions什么时候用-fno-exceptions是 GCC/Clang 的一个编译开关关掉整个异常机制。它的代价很直接行为默认有异常-fno-exceptionsthrow合法运行时展开编译错误new失败抛std::bad_alloc直接abort不能抛了二进制体积带.gcc_except_tableLSDA不带这些表更小正常路径性能零成本相同本来就零成本错误处理方式异常必须全程错误码 / 返回码关键结论-fno-exceptions主要省的是「表」占的体积不是运行速度因为正常路径本来就不付代价。它真正换来的是体积不生成 LSDA 表和 landing pad 代码对资源极度受限的嵌入式设备有意义。可预测性没有「突然抛一个你接不住的异常」这种路径控制流全在明面上。禁止异常团队约定「本项目不用异常」时用编译开关把它变成编译期强制而不是靠自觉。它的典型适用场景嵌入式资源极紧、异常表都算负担、操作系统内核早期内核不支持 C 异常、游戏引擎要求每帧开销完全可预测、且异常在热路径上是毒药。但有个坑必须提醒-fno-exceptions下标准库里「依赖异常报错」的接口语义会变最典型的是operator new失败不再抛std::bad_alloc而是直接终止std::vector::at()这类带检查的访问越界也不能靠catch兜底了。所以关异常不是「删掉try/catch」这么简单而是整个错误处理策略都要改成返回码这是一次贯穿全项目的架构决策不是加个编译参数就能白嫖的优化。6. 完整示例RAII 事务 异常对象 noexcept 自检把这篇三个要点串起来一个用 RAII 保证「异常时自动回滚」的类配合异常对象保存与noexcept的编译期自检// exception_final.cpp — 编译: g -stdc17 -Wall -O2 exception_final.cpp -o ef#includecstdio#includeexception#includestdexcept#includetype_traits#includeutility#includevector// RAII 事务构造时记录旧值析构时若未提交则回滚异常安全的关键手法classRollback{public:Rollback(intslot,intold_value):slot_(slot),old_(old_value),done_(false){}~Rollback(){if(!done_){slot_old_;std::printf( 回滚到 %d\n,old_);}}voidcommit()noexcept{done_true;}Rollback(constRollback)delete;Rollbackoperator(constRollback)delete;private:intslot_;intold_;booldone_;};intmain(){// 1) noexcept 是编译期自省析构、移动、swap 默认不抛structA{~A(){}};static_assert(noexcept(std::declvalA().~A()),析构默认 noexcept);static_assert(std::is_nothrow_move_constructiblestd::vectorint::value,vector 的移动构造不抛);// 2) RAII 回滚 栈展开异常发生时未 commit 的事务自动还原intbalance100;try{Rollbackguard(balance,balance);balance999;std::printf(转账中balance 暂改为 %d\n,balance);throwstd::runtime_error(对方账户无效);// 抛出guard 析构时回滚guard.commit();// 执行不到}catch(conststd::exceptione){std::printf(捕获: %s\n,e.what());}std::printf(最终 balance %d\n,balance);// 3) 异常对象脱离栈帧独立存活std::exception_ptr saved;try{throwstd::logic_error(跨帧异常);}catch(...){savedstd::current_exception();}try{std::rethrow_exception(saved);}catch(conststd::exceptione){std::printf(保存后重新抛出: %s\n,e.what());}}转账中balance 暂改为 999 回滚到 100 捕获: 对方账户无效 最终 balance 100 保存后重新抛出: 跨帧异常注意Rollback的手写规则析构里做回滚、commit()标noexcept、拷贝禁用Rule of Five 的精神这里只保留需要的那部分拷贝无意义就删掉。balance 999之后抛异常栈展开时guard析构把balance还原成 100异常安全不是靠catch里手动恢复而是靠 RAII 让析构替你兜底这正是栈展开存在的意义。7. 延伸阅读Itanium C ABI · Exception Handling —— 表驱动 unwind 的权威定义LSDA、landing pad、__cxa_*全部在这里throw expression — cppreference ——throw的语义与栈展开的正式措辞含「异常对象存哪」的说明std::exception_ptr / current_exception — cppreference —— 跨帧、跨线程保存异常的机制noexcept specifier — cppreference —— 「抛了就 terminate」的准确规则C Core Guidelines · E.15 —— 什么时候该用异常、什么时候别用的工程判断依据本知识库内的相关篇目《C ABI 与跨库兼容为什么换编译器要重编、符号可见性》 —— C ABI 与跨库兼容同体系里最该一起看的另一篇。《std::launder、std::bit_cast 与 placement new合法绕过别名》 —— 想把一个 int 的位模式当 float 读《静态库 vs 动态库-fPIC、PLT/GOT、LD_PRELOAD、链接顺序》 —— 用 nm/readelf/ldd/size 的真实输出讲清静态库 .a 与动态库 .so 的本质差异8. 一句话总结C 异常的「零成本」只针对快乐路径表驱动的 unwind 让不抛时代码零开销代价全部集中在抛出那一次逐帧查 LSDA、逐帧析构局部对象复杂度随栈深度增长异常对象被 ABI 提升到独立的共享存储所以能用std::current_exception存起来跨帧/跨线程再抛noexcept是「抛了直接terminate」的硬承诺只给真正不抛的函数而-fno-exceptions省的主要是体积和「禁止异常」的强制力不是速度它要求你彻底改成返回码式错误处理是贯穿全项目的架构决策不是白嫖的编译开关。