异常处理编译与栈展开

零成本异常把开销从执行路径转移到元数据:正常路径不付代价,抛出时才查表展开。本文讲解表驱动栈展开、两阶段展开、landing pad 与清理代码的生成,以及异常与析构、GC 的交互。

1. 异常处理的编译难题

一句话总结: 异常给编译器出了一道难题:控制流可以从任意调用点跳出到任意外层捕获点,而正常的优化假设「调用会返回」。

普通函数的控制流是局部的:调用返回后继续执行下一条。异常打破了这个假设——throw 可以从调用栈的任意深度跳出,跳过的每一帧都必须被正确清理(局部对象析构、锁释放、缓冲区归还)。

void f() {
    std::string s = "resource";      // 需要在异常时析构
    std::lock_guard<std::mutex> g(m); // 需要在异常时解锁
    may_throw();                      // 可能抛出
    use(s);
}

编译器必须保证:无论 may_throw() 从哪一层抛出,g 与 s 都会被析构,且顺序与正常返回一致(逆序)。这带来三个问题:

  1. 清理代码放在哪。每条可能抛出的调用后面都插一段清理代码,代码会爆炸;不插,则异常路径不正确。
  2. 如何找到捕获点。抛出时如何知道当前帧该跳到哪个外层帧?
  3. 如何与优化共存。内联、尾调用、寄存器分配都会改变栈帧结构,清理与展开信息必须随之更新。
方案正常路径开销异常路径开销代码体积
返回码检查每次调用后比较无小
setjmp / longjmp每次 setjmp 保存现场中等小
表驱动零成本零高(查表展开)大(元数据)

2. 零成本异常模型

一句话总结: 「零成本」的含义是正常执行路径不付出额外指令,代价全部转移到了编译期生成的展开元数据与异常发生时的查表开销上。

2.1 从 setjmp 到表驱动

一句话总结: setjmp 方案在每个可能捕获的作用域保存寄存器现场,正常路径也要付出代价;表驱动方案只在二进制里留下一张表,正常路径完全免费。

// setjmp 方案:进入作用域时保存现场,正常路径也要执行
jmp_buf env;
if (setjmp(env) == 0) {
    may_throw();          // 正常路径:这里付出了一次现场保存
} else {
    handle_error();
}
# 表驱动方案:正常路径只有一次调用,没有额外指令
call may_throw
# 异常发生时才去查 .eh_frame 表,找到当前 PC 对应的展开信息

这就是 C++ 的实现路线:用元数据换指令。代价是二进制里多出了 .eh_frame 与 .gcc_except_table 两个段,通常让体积增加 10%~20%。

2.2 两阶段展开

一句话总结: 展开分两趟:第一趟只做搜索找到捕获点,第二趟才真正执行清理;这样保证了「找不到捕获点时不破坏任何状态」。

阶段一(搜索):
  从抛出点向上遍历栈帧,逐帧查 .eh_frame 与 .gcc_except_table
  找到第一个能处理该异常的帧,记录其 landing pad 地址
  若遍历到栈顶仍未找到,调用 std::terminate

阶段二(清理):
  再次从抛出点向上遍历到捕获帧
  对每一帧执行其清理代码(析构局部对象)
  最后跳转到捕获帧的 landing pad,异常对象作为参数传入

为什么需要两趟?因为析构函数本身可能抛出。如果阶段一就执行清理,而清理过程中又抛出异常,就会出现「展开中又展开」的复杂状态。两阶段设计把「决策」与「动作」分开,第一阶段完全只读,即使最终没找到处理器,栈也未被破坏,可以干净地调用 std::terminate。

阶段是否修改栈是否执行用户代码目的
一(搜索)否否定位捕获点
二(清理)是是(析构)逐帧清理并跳转

3. 栈展开机制

一句话总结: 栈展开依赖编译器为每个函数生成的展开表,表中记录「在哪个 PC 范围、栈帧有多大、哪些寄存器被保存在哪里」。

3.1 展开表与 CFI

一句话总结: CFI(调用帧信息)用一组虚拟机指令描述栈帧布局,编码进 .eh_frame;展开器解释这些指令就能还原任意一帧的寄存器状态。

# 一个函数对应的 CFI 指令(示意)
    .cfi_startproc
    pushq %rbp
    .cfi_def_cfa_offset 16        # CFA = rsp + 16
    .cfi_offset %rbp, -16         # rbp 的旧值存在 CFA-16
    movq %rsp, %rbp
    .cfi_def_cfa_register %rbp    # CFA 改用 rbp 计算
    subq $32, %rsp
    ...
    .cfi_endproc

CFI 是一台小虚拟机的字节码:def_cfa 设定规范帧地址的计算方式,offset 声明某个寄存器保存在相对 CFA 的哪个位置,remember_state 与 restore_state 处理分支。展开器按 PC 定位到对应的指令序列并解释执行,就能得到「上一帧的 rsp 与 rbp 是什么」。

.eh_frame 中每个 FDE(帧描述条目)的结构:
  PC 起始地址 -> 函数起始
  PC 范围     -> 函数长度
  CFI 指令流  -> 如何从当前帧走到调用者帧

3.2 展开过程

一句话总结: 展开器从抛出点出发,逐帧执行 CFI 得到调用者帧,同时查询 LSDA 判断该帧是否有 landing pad 能处理异常。

// 展开器的简化逻辑
void unwind_search(void *throw_pc) {
    void *pc = throw_pc;
    while (pc) {
        Frame f = lookup_fde(pc);              // 查 .eh_frame
        if (!f.valid) { std::terminate(); }    // 信息缺失
        LSDA lsda = lookup_lsda(f);            // 查 .gcc_except_table
        LandingPad lp = match_handler(lsda, current_exception);
        if (lp.found) { return lp.address; }   // 找到捕获点
        pc = f.caller_pc();                    // 继续向上
    }
}
段内容谁生成谁使用
.eh_frame帧布局与 CFI 指令编译器与汇编器展开器(运行期)
.gcc_except_tableLSDA:捕获表与清理表编译器展开器(运行期)
.debug_frame供调试器使用的同类信息编译器(带 -g)调试器

4. landing pad 与清理代码

一句话总结: landing pad 是异常落入一个帧时的入口,它负责判断该帧是「捕获」还是「清理后继续向上抛」,并把清理代码与捕获代码组织成一段带分支的代码。

// 源
void f() {
    A a;                  // 需要析构
    try { g(); }
    catch (const E& e) { handle(e); }
}
; 编译器生成的 landing pad(简化)
lpad:
  %lp = landingpad { ptr, i32 } cleanup catch ptr @_ZTI1E
  ; %lp 的第一个分量是异常对象,第二个是「动作选择」
  switch i32 %lp.action, label %resume [
    i32 0, label %cleanup          ; 清理路径
    i32 1, label %catch_handler    ; 捕获路径
  ]
cleanup:
  call void @_ZN1AD1Ev(%a)          ; 析构 a
  resume { ptr, i32 } %lp           ; 继续向上抛
catch_handler:
  call void @handle(ptr %lp.exception)
  br label %exit

关键点在于 resume:清理完成后如果本帧不捕获,就调用 resume 让展开继续。这对应了两阶段展开的第二阶段——此时栈已经被这个帧修改过,resume 会通知运行期「继续从当前位置向上」。

情况landing pad 动作生成代码
本帧有匹配的 catch跳转到捕获块绑定异常对象,执行 catch 体
本帧只有清理执行析构后 resume调用析构,再 resume
本帧不关心不生成 landing pad无(展开器直接跳过)
# 观察真实的异常表与 landing pad
clang++ -S -O1 -fexceptions f.cpp -o f.s
objdump -d --section=.text f.o | grep -A10 landingpad
readelf --debug-dump=frames f.o | head -40      # 看 CFI

5. 与析构和 GC 的交互

一句话总结: 异常路径上的资源释放必须与正常路径一致,这要求编译器识别所有「需要清理的活变量」;而在有 GC 的语言里,展开时还要保证栈上的对象引用被正确扫描。

5.1 析构与清理点

一句话总结: 编译器为每个可能抛出的调用点计算「此处的活跃清理对象集合」,为每个不同的集合生成一段独立的清理路径。

void f() {
    A a;
    B b;              // b 构造后抛出:只需析构 b
    may_throw_1();
    C c;              // c 构造后抛出:需要析构 c、b、a
    may_throw_2();
}

编译器在 may_throw_1 处生成的清理只含 b,在 may_throw_2 处生成的清理含 c、b、a。这种「按活跃集合生成不同清理路径」的策略会产生代码膨胀,因此编译器常做清理路径合并:把公共后缀(如「析构 a」)提取成独立块,多条路径共享。

清理路径合并:
  pad_1 -> 析构 b -> 析构 a -> resume
  pad_2 -> 析构 c -> 析构 b -> 析构 a -> resume
  合并后:
  pad_1 -> [b] ---------------------> 公共尾 -> resume
  pad_2 -> [c] -> [b] -> 公共尾(析构 a)-> resume

5.2 与 GC 的交互

一句话总结: 在托管语言中,展开过程要保证栈上仍被引用的对象不被回收,同时展开本身可能触发 GC,两者必须协同。

托管运行时的展开要求:
  1. 每个栈帧都有「栈图」描述哪些槽位是对象引用
  2. 展开到某帧之前,该帧的引用仍算作根
  3. 清理代码本身可能分配,因此可能触发 GC
  4. 若 GC 在展开中移动对象,展开器持有的异常对象引用必须被更新

这解释了为什么 Java 与 .NET 的异常表格式与 C++ 不同:它们需要同时描述「哪些 PC 范围对应哪个 catch」与「每个 PC 处哪些寄存器或栈槽是引用」。CLR 的异常处理子句表与 JVM 的 exception_table 都带有这些信息。

6. 异常与性能

一句话总结: 零成本异常的「零成本」只对正常路径成立;频繁抛出的程序会因查表与展开而变慢几十倍,把异常当控制流用是性能反模式。

场景相对开销说明
正常路径(无异常)接近 0只有元数据体积成本
抛出并捕获(同帧)数百周期查表加跳转
抛出并跨 10 帧展开数千到数万周期逐帧查表与清理
用作循环退出极差每次迭代一次完整展开
// 反模式:用异常做正常控制流
try {
    for (int i = 0; ; i++) { if (i >= n) throw Done(); process(v[i]); }
} catch (Done&) { }

// 正确:用条件判断
for (int i = 0; i < n; i++) process(v[i]);
# 测量异常开销:对比正常路径与异常路径
perf stat -e cycles,instructions ./bench_normal
perf stat -e cycles,instructions ./bench_throw_catch
# 观察 .eh_frame 的体积占比
size -A ./app | grep -E "eh_frame|except_table"

一个值得注意的工程事实:-fno-exceptions 能显著减小体积(libstdc++ 的异常表常占数 MB),因此在嵌入式与体积敏感场景常被禁用。但禁用后必须保证没有 throw——标准库的部分实现会在内存不足时抛出,需要替换分配器。

7. 实现要点与陷阱

一句话总结: 异常编译的陷阱集中在「优化与展开信息的同步」上:任何改变栈帧或控制流的优化都必须同步更新元数据,否则展开会在运行期崩溃。

陷阱表现应对
内联后未更新清理点析构被跳过内联时合并调用者的清理范围
尾调用破坏帧信息展开器丢失一帧有清理需求时禁止尾调用
清理代码中再次抛出双重异常终止析构默认 noexcept,违反则 terminate
异常穿越 C 帧展开中断C 代码用 -fexceptions 或用 C 接口隔离
元数据被 strip无法展开保留 .eh_frame,不要全量 strip
跨模块类型不匹配catch 不到类型信息需一致,注意 RTTI 与可见性
// 陷阱:析构函数抛出会直接终止程序
struct Bad {
    ~Bad() { throw std::runtime_error("boom"); }   // 隐式 noexcept,调用 terminate
};
// 正确:析构不抛,或显式声明 noexcept(false) 并承担风险
// 陷阱:C 回调中抛出的异常无法穿越 C 帧
extern "C" void callback() {
    try { may_throw(); }        // 必须在 C 边界内捕获
    catch (...) { /* 转成错误码或记录 */ }
}
# 验证展开信息是否完整
readelf --debug-dump=frames ./app | grep -c "FDE"    # 统计 FDE 数量
# 用 _Unwind_Backtrace 自测展开能否走通

一个实用的调试技巧:如果程序在异常抛出时崩溃(而不是被捕获),几乎总是展开信息的问题。常见原因有三:编译时用了 -fno-asynchronous-unwind-tables 导致 CFI 缺失、链接时把 .eh_frame 段丢弃、或者异常跨越了没有展开信息的汇编代码。

8. 总结

环节要点
核心难题控制流可从任意调用点跳出,每帧都需正确清理
零成本模型正常路径零指令,代价转移到元数据与抛出时
两阶段展开先只读搜索定位捕获点,再执行清理与跳转
展开信息.eh_frame 存 CFI,.gcc_except_table 存 LSDA
landing pad帧内异常入口,按动作选择清理或捕获
清理生成按活跃清理集合生成路径,再做公共尾合并
与 GC 交互栈图标记引用,展开中可能触发 GC
性能特征正常路径免费,抛出代价高,不可用作控制流
元数据完整性优化与 strip 都必须保留展开信息

零成本异常是一个典型的「用空间与异常路径的复杂度换正常路径的简洁」的设计。它成立的前提是编译器能生成完整、精确的展开元数据——而这份元数据又必须与所有的优化保持同步:内联、尾调用、寄存器分配、栈帧合并,每一项都可能破坏展开的正确性。这也解释了为什么异常相关的 bug 往往难以复现:它们只在抛出路径上暴露,而抛出路径在测试中覆盖得最少。下一篇我们会从运行期回到编译期的类型系统,讨论约束求解与类型类如何在编译期完成「选择哪个实现」的决策。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. MLIR 与多层次 IR
  2. 可复现构建与确定性输出
  3. 约束求解与类型类