记得好久之前看过一篇异常相关的文章,讲的主要是编译器插入的桩(怎么做的RAII等)
今天看CPython的时候看到了他的异常处理机制,和cpp不太一样,就延伸问了问。
直跳方案与表驱动异常:例子与定义
- 直跳(direct jumps + 就地清理)示例:
- C 语言:没有语言级异常,结构化控制流编译成条件/无条件跳转;资源清理靠就地代码或“goto cleanup”惯用法。
- 部分字节码 VM(如 Lua 系列):通过显式指令(如 close/upvalue 关闭、return)配合跳转完成离开作用域的清理;错误传播多依赖
setjmp/longjmp
风格的保护调用,未以显式“块栈”建模异常路径。 - 简化教学语言/小型解释器:常在每个可能退出路径手写清理序列与跳转目标,换取实现简单,但字节码/控制流易膨胀。
- 表驱动异常(table‑driven exceptions)示例:
- JVM/Java:每个方法携带异常处理表,记录“字节码区间→处理器入口(含类型约束)”,抛出异常时查表决定落点。
- CLR/.NET:方法元数据中包含 try/catch/finally 的处理表,运行时据此展开与进入 landing pad。
- C++ “零开销异常”(Itanium ABI / DWARF CFI、Windows SEH):正常路径无额外成本,异常时根据编译器生成的元数据表逐帧展开并跳入相应 landing pad 执行析构与处理逻辑。
- 什么是“表驱动异常”
- 定义:不在正常指令流中内联所有异常路径与清理序列,而是为代码段生成一张“异常处理表/展开表”(映射:指令地址区间 → 处理器或清理 landing pad + 类型匹配/栈修复信息)。抛出异常时,运行时(或硬件/ABI 指定的展开器)查表决定如何沿调用栈逐帧展开、何处执行清理、哪条处理器可接住异常。
- 典型特征:
- 正常路径零/低成本(无额外分支),异常路径付费;
- 需要编译期生成并携带元数据表,运行时具备查表与展开器;
- 对调试/回溯有更稳定的落点(landing pad),但实现复杂度较高。
- 与 block stack 的对比:block stack 把控制流边界显式建模在解释器层,用同一套结构处理异常与结构化跳转;表驱动异常把结构信息前移到“元数据表 + 展开器”,正常路径更干净,但对解释器/编译器协作要求更高。
CPython是block stack,就是把异常块作为一个类似函数的东西进行调用,回溯的时候跟着block stack回溯即可,执行起来比较方便。
CPP这种主要是依赖编译器做异常处理:
在 C++ 中,从 throw
到进入异常处理逻辑,其背后有一套比较复杂的 异常处理模型,主要依赖于编译器生成的元信息和运行时库的支持。整体过程可以分为以下几个阶段:
1. 抛出异常 (throw
)
当执行 throw
表达式时,编译器会做两件事:
- 构造一个异常对象(通常存放在运行时系统管理的内存区域,比如异常对象栈)。
- 调用运行时库的异常分发函数(如 GCC 的
__cxa_throw
或 MSVC 的_CxxThrowException
)。
这些运行时函数会保存异常对象的信息,包括:
- 异常类型(通过 RTTI:运行时类型信息)。
- 指向异常对象的指针。
- 辅助的清理函数(销毁异常对象时调用)。
2. 栈展开 (Stack Unwinding)
此时运行时库会触发 栈展开(stack unwinding),即从抛出点往调用栈上层逐帧回溯。栈展开的过程主要依赖编译器在编译时生成的 异常处理表(EH tables),这些表描述了:
- 每个函数中可能的
try-catch
块。 - 哪些位置需要运行清理逻辑(如局部对象析构函数)。
栈展开时会做两件事:
- 执行需要清理的析构函数,保证局部对象正确析构(RAII 的基础)。
- 检查当前栈帧是否有能匹配该异常类型的
catch
块。
这一过程在不同平台上实现不同:
- GCC/Clang 使用 DWARF EH 表 和
libunwind
。 - MSVC 使用 SEH(结构化异常处理)表。
3. 匹配 catch
块
运行时在找到某个函数的 EH 表里有匹配 catch
的类型时:
- 停止继续展开栈。
- 把异常对象指针传给对应的
catch
块。
比如:
try {
throw std::runtime_error("err");
} catch (const std::exception& e) {
// e 就是传入的异常对象引用
}
如果没有任何 catch
匹配,栈会一直展开到最外层。如果依然无人捕获,就会调用 std::terminate()
,程序结束。
4. 进入异常处理逻辑
一旦找到匹配的 catch
:
- 运行时将异常对象转换成
catch
参数所需的类型。 - 跳转到
catch
块执行。
到此,程序就进入了异常处理逻辑。
✅ 小结
throw
调用运行时分发函数保存异常对象。- 运行时通过 异常表 + 栈展开 来寻找匹配的处理器。
- 在展开过程中会执行析构函数,保证资源释放。
- 找到
catch
后,跳转执行异常处理逻辑。
C++ 异常展开不是 CPU 的原生特性,而是编译器 + 运行时库 + ABI 约定共同实现的协议。
1. CPU 层面
- CPU 根本不知道 C++ 异常这回事。
- 对 CPU 来说,
throw
就是执行了一次普通的函数调用(比如 GCC 下调用__cxa_throw
,MSVC 下调用_CxxThrowException
)。 - CPU 提供的只是最基础的:
- 调用栈回溯的可能性(通过栈帧布局、寄存器保存约定)。
- 间接跳转机制(运行时库可以修改返回地址/IP,跳转到新的代码块)。
- 所以并没有“CPU 指令支持 C++ 异常”这种东西。
2. ABI / 平台协议
真正让 unwind 能工作的是 ABI 协议,比如:
- Itanium C++ ABI(被 GCC/Clang、libc++、libstdc++ 在 Linux/Unix 系统上采用)。
- 定义了异常对象如何存放,如何在
.eh_frame
/.gcc_except_table
中描述 call-site 对应的清理/捕获逻辑。 - 定义了 personality function 接口(比如
__gxx_personality_v0
),unwinder 调用它来问“这帧该怎么处理异常?”
- 定义了异常对象如何存放,如何在
- Windows x64 ABI(MSVC/Win64 用的 SEH +
__CxxFrameHandler3
协议)。- 用
.pdata
/.xdata
来描述函数的异常和清理范围。 - 内核和
RtlUnwindEx
负责驱动展开,调用编译器生成的 funclet 完成析构。
- 用
这些 ABI 是“软件协议”,编译器和运行时都必须遵守。
3. Unwind 的实际跳转机制
当异常真正发生时:
throw
→ 调用运行时抛出函数,运行时启动 unwinder(比如libunwind
或 Windows 的RtlUnwindEx
)。- Unwinder 读到当前栈帧的返回地址(PC),根据 EH 表 找到“如果这里有异常,应该跳到哪个 landing pad/funclet”。
- 修改栈帧/寄存器状态,把程序计数器(IP/RIP)设置到 landing pad 地址。
- 继续执行,就好像“跳转”过去了一样。
这一步并不是 CPU 内建的“异常指令”,而是运行时库通过栈帧和寄存器操作(软件实现的 longjmp/restore context)完成的。
4. 类比
可以把它想象成:
- setjmp/longjmp 的高级版本:CPU 不管,运行时保存/恢复上下文,程序流“跳到”另一个地方。
- 只不过 C++ 异常展开会在跳之前,遍历栈帧、查 EH 表、调用清理函数,比单纯的
longjmp
复杂很多。
✅ 结论
C++ 异常展开的“协议”不是 CPU 硬件提供的,而是 ABI 定义的软件协议。CPU 只负责最底层的指令执行(比如能让运行时改写 PC 和栈指针)。真正的 “unwind 跳转” 是运行时库依靠编译器生成的 EH 表来决定目标地址,再通过软件方式修改寄存器/栈帧,让控制流转到对应 landing pad。
在底层的实现中,就是一个跳转,抛异常的时候跳转到特殊的处理区域,做资源的回收,以及stack unwinding。
稍微上层一点,throw调用的是libunwinder
,unwinder再去查编译器生成的跳转表,跳转到异常处理逻辑中。
文章评论