LLVM 中的异常处理

简介

本文档是关于 LLVM 中异常处理的所有信息的中心存储库。它描述了 LLVM 异常处理信息所采用的格式,这对于那些有兴趣创建前端或直接处理信息的人很有用。此外,本文档还提供了 C 和 C++ 中使用的异常处理信息的具体示例。

Itanium ABI 零成本异常处理

大多数编程语言的异常处理旨在从应用程序的一般使用期间很少发生的条件中恢复。为此,异常处理不应通过执行检查点任务(例如保存当前 pc 或寄存器状态)来干扰应用程序算法的主要流程。

Itanium ABI 异常处理规范定义了一种方法,用于以异常表的形式提供外围数据,而无需在应用程序主算法的流程中内联推测性异常处理代码。因此,该规范被称为为应用程序的正常执行增加“零成本”。

有关 Itanium ABI 异常处理运行时支持的更完整描述,请访问 Itanium C++ ABI:异常处理。有关异常帧格式的描述,请访问 异常帧,有关 DWARF 4 规范的详细信息,请访问 DWARF 4 标准。有关 C++ 异常表格式的描述,请访问 异常处理表

Setjmp/Longjmp 异常处理

Setjmp/Longjmp (SJLJ) 基于异常处理使用 LLVM 内联函数 llvm.eh.sjlj.setjmpllvm.eh.sjlj.longjmp 来处理异常处理的控制流。

对于每个执行异常处理的函数(无论是 try/catch 块还是清理),该函数都会在全局帧列表中注册自身。当异常正在展开时,运行时会使用此列表来识别哪些函数需要处理。

着陆区选择被编码在函数上下文的调用站点条目中。运行时通过 llvm.eh.sjlj.longjmp 返回到函数,其中一个 switch 表根据存储在函数上下文中的索引将控制权转移到适当的着陆区。

与 DWARF 异常处理(在外部表中编码异常区域和帧信息)相比,SJLJ 异常处理在运行时构建和删除展开帧上下文。这导致更快的异常处理,但代价是在没有抛出异常时执行速度较慢。由于异常本质上是为不常见的代码路径设计的,因此 DWARF 异常处理通常优于 SJLJ。

Windows 运行时异常处理

LLVM 支持处理 Windows 运行时生成的异常,但这需要非常不同的中间表示。它不是基于 “landingpad” 指令,就像其他两个模型一样,而是在本文档后面的 使用 Windows 运行时进行异常处理 中描述。

概述

当在 LLVM 代码中抛出异常时,运行时会尽最大努力找到适合处理这种情况的处理程序。

运行时首先尝试查找与抛出异常的函数相对应的异常帧。如果编程语言支持异常处理(例如 C++),则异常帧包含对异常表的引用,该异常表描述了如何处理异常。如果语言不支持异常处理(例如 C),或者如果异常需要转发到先前的激活,则异常帧包含有关如何展开当前激活并恢复先前激活状态的信息。重复此过程,直到异常被处理。如果异常未被处理且没有剩余激活,则应用程序将终止并显示相应的错误消息。

由于不同的编程语言在处理异常时具有不同的行为,因此异常处理 ABI 提供了一种机制来提供个性。异常处理个性通过个性函数(例如 C++ 中的 __gxx_personality_v0)定义,该函数接收异常的上下文、包含异常对象类型和值的异常结构以及对当前函数的异常表的引用。当前编译单元的个性函数在通用异常帧中指定。

异常表的组织是语言相关的。对于 C++,异常表组织为一系列代码范围,定义如果在该范围内发生异常该怎么办。通常,与范围关联的信息定义了在该范围内处理哪些类型的异常对象(使用 C++ 类型信息),以及应采取的关联操作。操作通常将控制权传递给着陆区

着陆区大致对应于在 try/catch 序列的 catch 部分中找到的代码。当执行在着陆区恢复时,它会接收一个异常结构和一个选择器值,该值对应于抛出的异常的类型。然后,选择器用于确定哪个 catch 应该实际处理该异常。

LLVM 代码生成

从 C++ 开发人员的角度来看,异常是根据 throwtry/catch 语句定义的。在本节中,我们将根据 C++ 示例描述 LLVM 异常处理的实现。

抛出

支持异常处理的语言通常提供 throw 操作来启动异常过程。在内部,throw 操作分解为两个步骤。

  1. 请求为异常结构分配异常空间。此结构需要超出当前激活而存在。此结构将包含要抛出的对象的类型和值。

  2. 调用运行时以引发异常,并将异常结构作为参数传递。

在 C++ 中,异常结构的分配由 __cxa_allocate_exception 运行时函数完成。异常引发由 __cxa_throw 处理。异常的类型使用 C++ RTTI 结构表示。

Try/Catch

try 语句范围内的调用可能会引发异常。在这些情况下,LLVM C++ 前端会将调用替换为 invoke 指令。与 call 不同,invoke 有两个可能的继续点

  1. 当调用按正常方式成功时继续的位置,以及

  2. 如果调用引发异常(通过抛出或抛出的展开)时继续的位置

用于定义 invoke 在异常后继续的位置的术语称为着陆区。LLVM 着陆区在概念上是备用函数入口点,其中异常结构引用和类型信息索引作为参数传入。着陆区保存异常结构引用,然后继续选择与异常对象的类型信息相对应的 catch 块。

LLVM ‘landingpad’ 指令 用于向后端传达有关着陆区的信息。对于 C++,landingpad 指令返回一个指针和整数对,分别对应于指向异常结构的指针和选择器值

landingpad 指令在父函数的属性列表中查找要用于此 try/catch 序列的个性函数的引用。该指令包含 cleanupcatchfilter 子句的列表。异常会从第一个到最后一个依次针对子句进行测试。这些子句具有以下含义

  • catch <type> @ExcType

    • 此子句意味着,如果抛出的异常的类型为 @ExcType@ExcType 的子类型,则应进入 landingpad 块。对于 C++,@ExcType 是指向 std::type_info 对象(RTTI 对象)的指针,该对象表示 C++ 异常类型。

    • 如果 @ExcTypenull,则任何异常都匹配,因此应始终进入 landingpad。这用于 C++ catch-all 块(“catch (...)”)。

    • 当此子句匹配时,选择器值将等于 “@llvm.eh.typeid.for(i8* @ExcType)” 返回的值。这将始终是一个正值。

  • filter <type> [<type> @ExcType1, ..., <type> @ExcTypeN]

    • 此子句意味着,如果抛出的异常与列表中的任何类型都匹配(对于 C++,再次指定为 std::type_info 指针),则应进入 landingpad。

    • C++ 前端使用此功能来实现 C++ 异常规范,例如 “void foo() throw (ExcType1, ..., ExcTypeN) { ... }”。(注意:此功能在 C++11 中已弃用,并在 C++17 中已删除。)

    • 当此子句匹配时,选择器值将为负数。

    • filter 的数组参数可以为空;例如,“[0 x i8**] undef”。这意味着应始终进入 landingpad。(请注意,这样的 filter 不等同于 “catch i8* null”,因为 filtercatch 分别产生负值和正值选择器值。)

  • cleanup

    • 此子句意味着应始终进入 landingpad。

    • C++ 前端使用此功能来调用对象的析构函数。

    • 当此子句匹配时,选择器值将为零。

    • 运行时可能会以不同于 “catch <type> null” 的方式处理 “cleanup”。

      在 C++ 中,如果发生未处理的异常,语言运行时将调用 std::terminate(),但运行时是否首先展开堆栈并调用对象析构函数是实现定义的。例如,GNU C++ unwinder 在发生未处理的异常时不会调用对象析构函数。这样做的原因是为了提高可调试性:它确保从 throw 的上下文中调用 std::terminate(),以便此上下文不会因展开堆栈而丢失。运行时通常通过搜索匹配的非 cleanup 子句来实现此目的,如果在进入任何 landingpad 块之前未找到匹配的子句,则中止。

一旦着陆区具有类型信息选择器,代码就会分支到第一个 catch 的代码。然后,catch 将类型信息选择器的值与该 catch 的类型信息的索引进行比较。由于类型信息索引在后端收集所有类型信息之前是未知的,因此 catch 代码必须调用 llvm.eh.typeid.for 内联函数来确定给定类型信息的索引。如果 catch 未能匹配选择器,则控制权将传递给下一个 catch。

最后,catch 代码的入口和出口用对 __cxa_begin_catch__cxa_end_catch 的调用括起来。

  • __cxa_begin_catch 接受异常结构引用作为参数,并返回异常对象的值。

  • __cxa_end_catch 不接受任何参数。此函数

    1. 查找最近捕获的异常并递减其处理程序计数,

    2. 如果处理程序计数变为零,则从捕获堆栈中删除异常,并且

    3. 如果处理程序计数变为零并且异常未被 throw 重新抛出,则销毁异常。

    注意

    从 catch 中重新抛出可能会将此调用替换为 __cxa_rethrow

清理

清理是在展开作用域时需要运行的额外代码。C++ 析构函数是一个典型的例子,但其他语言和语言扩展提供了各种不同类型的清理。通常,着陆区可能需要在实际进入 catch 块之前运行任意数量的清理代码。为了指示清理的存在,‘landingpad’ 指令 应具有 cleanup 子句。否则,如果没有需要 unwinder 停止的 catch 或过滤器,unwinder 将不会在着陆区停止。

注意

不允许新的异常从清理的执行中传播出去。这可能会损坏 unwinder 的内部状态。不同的语言描述了这些情况的不同高级语义:例如,C++ 要求终止进程,而 Ada 取消两个异常并抛出第三个异常。

当所有清理完成后,如果异常未被当前函数处理,则通过调用 resume 指令 恢复展开,并传入原始着陆区的 landingpad 指令的结果。

抛出过滤器

在 C++17 之前,C++ 允许指定可以从函数中抛出哪些异常类型。为了表示这一点,可能存在一个顶层着陆区来过滤掉无效类型。为了在 LLVM 代码中表达这一点,‘landingpad’ 指令 将具有过滤器子句。该子句由类型信息数组组成。landingpad 将返回一个负值,如果异常与任何类型信息都不匹配。如果未找到匹配项,则应调用 __cxa_call_unexpected,否则调用 _Unwind_Resume。这些函数中的每一个都需要对异常结构的引用。请注意,landingpad 指令的最通用形式可以具有任意数量的 catch、cleanup 和 filter 子句(尽管具有多个 cleanup 是毫无意义的)。由于内联创建嵌套的异常处理作用域,LLVM C++ 前端可以生成这样的 landingpad 指令。

限制

unwinder 将是否在调用帧中停止的决定委托给该调用帧的特定于语言的个性函数。并非所有 unwinder 都保证它们会停止执行清理。例如,GNU C++ unwinder 不会这样做,除非异常实际上在堆栈更上方的位置被捕获。

为了使内联行为正确,着陆区必须准备好处理它们最初未声明的选择器结果。假设一个函数捕获类型为 A 的异常,并且它被内联到捕获类型为 B 的异常的函数中。内联器将更新内联着陆区的 landingpad 指令,以包含 B 也被捕获的事实。如果该着陆区假定它仅用于捕获 A,那么它将面临粗鲁的觉醒。因此,着陆区必须测试它们理解的选择器结果,如果没有任何条件匹配,则使用 resume 指令 恢复异常传播。

异常处理内联函数

除了 landingpadresume 指令外,LLVM 还使用多个内联函数(名称以 llvm.eh 为前缀)在生成的代码中的各个点提供异常处理信息。

llvm.eh.typeid.for

i32 @llvm.eh.typeid.for(i8* %type_info)

此内联函数返回当前函数的异常表中的类型信息索引。此值可用于与 landingpad 指令的结果进行比较。单个参数是对类型信息的引用。

此内联函数的用途由 C++ 前端生成。

llvm.eh.exceptionpointer

i8 addrspace(N)* @llvm.eh.padparam.pNi8(token %catchpad)

此内联函数检索指向给定 catchpad 捕获的异常的指针。

SJLJ 内联函数

llvm.eh.sjlj 内联函数在 LLVM 后端内部使用。它们的使用由后端的 SjLjEHPrepare 过程生成。

llvm.eh.sjlj.setjmp

i32 @llvm.eh.sjlj.setjmp(i8* %setjmp_buf)

对于基于 SJLJ 的异常处理,此内联函数强制保存当前函数的寄存器,并存储以下指令的地址,以用作 llvm.eh.sjlj.longjmp 的目标地址。缓冲区格式和此内联函数的总体功能与 GCC __builtin_setjmp 实现兼容,允许使用 clang 和 GCC 构建的代码互操作。

单个参数是指向五个字缓冲区的指针,调用上下文保存在其中。缓冲区的格式和内容特定于目标。在某些目标(ARM、PowerPC、VE、X86)上,前端将帧指针放在第一个字中,将堆栈指针放在第三个字中,而此内联函数的目标实现填写其余字。在其他目标 (SystemZ) 上,将调用上下文保存到缓冲区完全留给目标实现。

llvm.eh.sjlj.longjmp

void @llvm.eh.sjlj.longjmp(i8* %setjmp_buf)

对于基于 SJLJ 的异常处理,llvm.eh.sjlj.longjmp 内联函数用于实现 __builtin_longjmp()。单个参数是指向由 llvm.eh.sjlj.setjmp 填充的缓冲区的指针。帧指针和堆栈指针从缓冲区恢复,然后控制权转移到目标地址。

llvm.eh.sjlj.lsda

i8* @llvm.eh.sjlj.lsda()

对于基于 SJLJ 的异常处理,llvm.eh.sjlj.lsda 内联函数返回当前函数的语言特定数据区域 (LSDA) 的地址。SJLJ 前端代码将此地址存储在异常处理函数上下文中,供运行时使用。

llvm.eh.sjlj.callsite

void @llvm.eh.sjlj.callsite(i32 %call_site_num)

对于基于 SJLJ 的异常处理,llvm.eh.sjlj.callsite 内联函数标识与以下 invoke 指令关联的调用站点值。这用于确保 LSDA 中的着陆区条目以匹配的顺序生成。

汇编表格式

异常处理运行时使用两个表来确定在抛出异常时应采取哪些操作。

异常处理帧

异常处理帧 eh_frame 与 DWARF 调试信息使用的展开帧非常相似。该帧包含拆解当前帧并恢复先前帧状态所需的所有信息。每个编译单元中的每个函数都有一个异常处理帧,以及一个通用异常处理帧,该帧定义了该单元中所有函数共有的信息。

然而,此调用帧信息 (CFI) 的格式通常是平台相关的。例如,ARM 定义了自己的格式。Apple 有自己的紧凑型展开信息格式。在 Windows 上,自 32 位 x86 以来,所有架构都使用了另一种格式。LLVM 将发出目标所需的任何信息。

异常表

异常表包含有关在函数的特定代码部分中抛出异常时要采取的操作的信息。这通常称为特定于语言的数据区域 (LSDA)。LSDA 表的格式特定于个性函数,但大多数个性函数都使用 __gxx_personality_v0 使用的表的变体。每个函数都有一个异常表,叶函数和仅调用非抛出函数的函数除外。它们不需要异常表。

使用 Windows 运行时进行异常处理

Windows 异常的背景

在 Windows 上与异常交互比在 Itanium C++ ABI 平台上复杂得多。两种模型之间的根本区别在于,Itanium EH 是围绕“连续展开”的思想设计的,而 Windows EH 则不是。

在 Itanium 下,抛出异常通常涉及分配线程局部内存来保存异常,并调用 EH 运行时。运行时识别具有适当异常处理操作的帧,并连续将当前线程的寄存器上下文重置为最近活动的具有要运行操作的帧。在 LLVM 中,执行在 landingpad 指令处恢复,该指令生成由运行时提供的寄存器值。如果函数仅清理已分配的资源,则该函数负责调用 _Unwind_Resume 以在完成清理后转换到下一个最近活动的帧。最终,负责处理异常的帧调用 __cxa_end_catch 以销毁异常,释放其内存,并恢复正常控制流。

Windows EH 模型不使用这些连续的寄存器上下文重置。相反,活动异常通常由堆栈上的帧描述。在 C++ 异常的情况下,异常对象在堆栈内存中分配,其地址传递给 __CxxThrowException。通用结构化异常 (SEH) 更类似于 Linux 信号,它们由 Windows 提供的用户空间 DLL 分派。堆栈上的每个帧都分配有一个 EH 个性例程,该例程决定要采取哪些操作来处理异常。C 和 C++ 代码有几个主要的个性:C++ 个性 (__CxxFrameHandler3) 和 SEH 个性 (_except_handler3, _except_handler4, 和 __C_specific_handler)。所有这些都通过回调到父函数中包含的“funclet”来实现清理。

在这种上下文中,Funclet 是父函数的区域,可以像调用具有非常特殊调用约定的函数指针一样调用。父帧的帧指针使用标准 EBP 寄存器或作为第一个参数寄存器传递到 funclet 中,具体取决于架构。Funclet 通过帧指针访问内存中的局部变量来实现 EH 操作,并返回一些适当的值,从而继续 EH 过程。没有变量可以存活到 funclet 中或 funclet 外,并且可以在寄存器中分配。

C++ 的特性也使用功能块 (funclet) 来包含 catch 块的代码(即 catch (Type obj) { ... } 中大括号之间的所有用户代码)。运行时 (runtime) 必须为 catch 主体使用功能块,因为 C++ 异常对象是在处理异常的函数的子栈帧中分配的。如果运行时将堆栈回溯到 catch 的帧,则保存异常的内存将很快被后续的函数调用覆盖。功能块的使用还允许 __CxxFrameHandler3 实现重新抛出 (rethrow) 而无需借助 TLS。相反,运行时抛出一个特殊的异常,然后使用 SEH (__try / __except) 在子帧中恢复执行并带有新的信息。

换句话说,连续展开 (unwinding) 的方法与 Visual C++ 异常和通用的 Windows 异常处理不兼容。由于 C++ 异常对象存在于栈内存中,LLVM 无法提供使用着陆点 (landingpad) 的自定义个性化函数 (personality function)。同样,SEH 也没有提供任何机制来重新抛出异常或继续展开。因此,LLVM 必须使用本文档后面描述的 IR 构造来实现兼容的异常处理。

SEH 过滤器表达式

SEH 个性化函数也使用功能块来实现过滤器表达式,这允许执行任意用户代码来决定捕获哪些异常。过滤器表达式不应与 LLVM landingpad 指令的 filter 子句混淆。通常,过滤器表达式用于确定异常是否来自特定的 DLL 或代码区域,或者代码是否在访问特定内存地址范围时发生故障。LLVM 目前没有 IR 来表示过滤器表达式,因为很难表示它们的控制依赖关系。过滤器表达式在 EH 的第一阶段运行,在清理 (cleanup) 运行之前,这使得构建忠实的控制流图 (control flow graph) 非常困难。目前,新的 EH 指令无法表示 SEH 过滤器表达式,前端必须提前概述 (outline) 它们。父函数的局部变量可以使用 llvm.localescapellvm.localrecover 内联函数 (intrinsic) 进行转义和访问。

新的异常处理指令

新的 EH 指令的主要设计目标是在保留有关 CFG 的信息的同时支持功能块生成,以便 SSA 形式仍然有效。作为次要目标,它们被设计为在 MSVC 和安腾 (Itanium) C++ 异常之间通用。它们对个性化函数所需的数据几乎没有假设,只要它使用熟悉的 EH 核心操作:catch、cleanup 和 terminate。然而,如果不了解 EH 个性化函数的细节,就很难修改新的指令。虽然它们可以用于表示安腾 EH,但对于优化目的而言,着陆点模型 (landingpad model) 严格来说更好。

以下新的指令被认为是“异常处理 pad”,因为它们必须是基本块 (basic block) 的第一个非 phi 指令,该基本块可能是 EH 流边的展开目标 (unwind destination):catchswitchcatchpadcleanuppad。与着陆点一样,当进入 try 作用域时,如果前端遇到可能抛出异常的调用点 (call site),则应发出一个 invoke 指令,该指令展开到 catchswitch 块。类似地,在具有析构函数 (destructor) 的 C++ 对象的作用域内,invoke 指令应展开到 cleanuppad

新的指令也用于标记控制权从 catch/cleanup 处理器 (handler) 转移出去的点(这将对应于从生成的功能块的出口)。通过正常执行到达其末尾的 catch 处理器执行 catchret 指令,这是一个终结符 (terminator),指示控制权返回到函数中的哪个位置。通过正常执行到达其末尾的 cleanup 处理器执行 cleanupret 指令,这是一个终结符,指示活动异常将展开到何处。

每个新的 EH pad 指令都有一种方法来识别在此操作之后应考虑哪个操作。catchswitch 指令是一个终结符,并具有类似于 invoke 指令的展开目标操作数。cleanuppad 指令不是终结符,因此展开目标存储在 cleanupret 指令上。成功执行 catch 处理器应恢复正常的控制流,因此 catchpadcatchret 指令都不能展开。所有这些“展开边 (unwind edge)”都可以引用包含 EH pad 指令的基本块,或者它们可以展开到调用者 (caller)。展开到调用者与着陆点模型中的 resume 指令具有大致相同的语义。当通过 invoke 指令内联时,展开到调用者的指令被连接到调用点的展开目标。

将所有内容放在一起,这是一个使用所有新 IR 指令的一些假设的 C++ 代码的底层表示

struct Cleanup {
  Cleanup();
  ~Cleanup();
  int m;
};
void may_throw();
int f() noexcept {
  try {
    Cleanup obj;
    may_throw();
  } catch (int e) {
    may_throw();
    return e;
  }
  return 0;
}
define i32 @f() nounwind personality ptr @__CxxFrameHandler3 {
entry:
  %obj = alloca %struct.Cleanup, align 4
  %e = alloca i32, align 4
  %call = invoke ptr @"??0Cleanup@@QEAA@XZ"(ptr nonnull %obj)
          to label %invoke.cont unwind label %lpad.catch

invoke.cont:                                      ; preds = %entry
  invoke void @"?may_throw@@YAXXZ"()
          to label %invoke.cont.2 unwind label %lpad.cleanup

invoke.cont.2:                                    ; preds = %invoke.cont
  call void @"??_DCleanup@@QEAA@XZ"(ptr nonnull %obj) nounwind
  br label %return

return:                                           ; preds = %invoke.cont.3, %invoke.cont.2
  %retval.0 = phi i32 [ 0, %invoke.cont.2 ], [ %3, %invoke.cont.3 ]
  ret i32 %retval.0

lpad.cleanup:                                     ; preds = %invoke.cont.2
  %0 = cleanuppad within none []
  call void @"??1Cleanup@@QEAA@XZ"(ptr nonnull %obj) nounwind
  cleanupret from %0 unwind label %lpad.catch

lpad.catch:                                       ; preds = %lpad.cleanup, %entry
  %1 = catchswitch within none [label %catch.body] unwind label %lpad.terminate

catch.body:                                       ; preds = %lpad.catch
  %catch = catchpad within %1 [ptr @"??_R0H@8", i32 0, ptr %e]
  invoke void @"?may_throw@@YAXXZ"()
          to label %invoke.cont.3 unwind label %lpad.terminate

invoke.cont.3:                                    ; preds = %catch.body
  %3 = load i32, ptr %e, align 4
  catchret from %catch to label %return

lpad.terminate:                                   ; preds = %catch.body, %lpad.catch
  cleanuppad within none []
  call void @"?terminate@@YAXXZ"()
  unreachable
}

功能块父令牌

为了为使用功能块的 EH 个性化函数生成表,有必要恢复源代码中存在的嵌套关系。这种功能块父关系使用新的 “pad” 指令生成的令牌 (token) 在 IR 中进行编码。“pad” 或 “ret” 指令的令牌操作数指示它在哪个功能块中,如果它没有嵌套在另一个功能块中,则为 “none”。

catchpadcleanuppad 指令建立新的功能块,它们的令牌被其他 “pad” 指令消耗以建立成员关系。catchswitch 指令不创建功能块,但它生成一个令牌,该令牌始终被其直接后继 catchpad 指令消耗。这确保了由 catchpad 建模的每个 catch 处理器都属于恰好一个 catchswitch,这模拟了 C++ try 之后的调度点。

这是一个使用一些假设的 C++ 代码的功能块嵌套样子的示例

void f() {
  try {
    throw;
  } catch (...) {
    try {
      throw;
    } catch (...) {
    }
  }
}
define void @f() #0 personality i8* bitcast (i32 (...)* @__CxxFrameHandler3 to i8*) {
entry:
  invoke void @_CxxThrowException(i8* null, %eh.ThrowInfo* null) #1
          to label %unreachable unwind label %catch.dispatch

catch.dispatch:                                   ; preds = %entry
  %0 = catchswitch within none [label %catch] unwind to caller

catch:                                            ; preds = %catch.dispatch
  %1 = catchpad within %0 [i8* null, i32 64, i8* null]
  invoke void @_CxxThrowException(i8* null, %eh.ThrowInfo* null) #1
          to label %unreachable unwind label %catch.dispatch2

catch.dispatch2:                                  ; preds = %catch
  %2 = catchswitch within %1 [label %catch3] unwind to caller

catch3:                                           ; preds = %catch.dispatch2
  %3 = catchpad within %2 [i8* null, i32 64, i8* null]
  catchret from %3 to label %try.cont

try.cont:                                         ; preds = %catch3
  catchret from %1 to label %try.cont6

try.cont6:                                        ; preds = %try.cont
  ret void

unreachable:                                      ; preds = %catch, %entry
  unreachable
}

“内部” catchswitch 消耗由外部 catchswitch 生成的 %1

功能块转换

用于功能块的 EH 表隐式地使用功能块嵌套关系来编码展开目标,因此在它们可以表示的功能块转换集方面受到约束。相关的 LLVM IR 指令因此具有约束,以确保 EH 边在流图中的可编码性。

catchswitchcatchpadcleanuppad 执行时,据说它们被“进入 (entered)”。随后可以通过以下任何方式“退出 (exited)”

  • 当没有一个 catchswitch 的组成 catchpad 适合正在处理的异常,并且它展开到其展开目标或调用者时,catchswitch 立即退出。

  • 当执行来自 catchpadcatchret 时,catchpad 及其父 catchswitch 都退出。

  • 当执行来自 cleanuppadcleanupret 时,cleanuppad 退出。

  • 当控制展开到函数的调用者时,这些 pad 中的任何一个都会退出,无论是通过完全展开到函数调用者的 call,还是标记为 “unwinds to caller” 的嵌套 catchswitch,还是标记为 “unwinds to caller" 的嵌套 cleanuppadcleanupret

  • 当展开边 (来自 invoke、嵌套 catchswitch 或嵌套 cleanuppadcleanupret) 展开到不是给定 pad 的后代的 目标 pad 时,这些 pad 中的任何一个都会退出。

请注意,ret 指令不是退出功能块 pad 的有效方式;当 pad 已进入但未退出时执行 ret 是未定义的行为。

单个展开边可以退出任意数量的 pad(限制是来自 catchswitch 的边必须至少退出自身,并且来自 cleanupret 的边必须至少退出其 cleanuppad),然后必须恰好进入一个 pad,该 pad 必须与所有退出的 pad 不同。展开边进入的 pad 的父级必须是最近进入的尚未退出的 pad(在从展开边退出的任何 pad 退出之后),如果不存在这样的 pad,则为 “none”。这确保了运行时执行的功能块堆栈始终对应于父令牌编码的功能块 pad 树中的某些路径。

退出任何给定功能块 pad 的所有展开边(包括退出其 cleanuppadcleanupret 边和退出其 catchswitchcatchswitch 边)必须共享相同的展开目标。同样,任何可能通过展开到调用者而退出的功能块 pad 都不能被任何展开到调用者以外的任何地方的异常边退出。这确保了每个功能块作为一个整体只有一个展开目标,基于功能块的个性化函数的 EH 表可能需要这样做。请注意,任何退出 catchpad 的展开边也会退出其父 catchswitch,因此这意味着对于任何给定的 catchswitch,其展开目标也必须是退出其任何组成 catchpad 的任何展开边的展开目标。由于 catchswitch 没有 nounwind 变体,并且由于 IR 生成器要求将不会展开的调用注释为 nounwind,因此在具有非调用者展开目标的功能块 pad 中嵌套 call 或 “unwind to callercatchswitch 是合法的;对于这样的 callcatchswitch 展开是未定义的行为。

最后,功能块 pad 的展开目标不能形成循环。这确保了 EH 底层表示可以构建具有树状结构的 “try 区域”,基于功能块的个性化函数可能需要这样做。

目标平台上的异常处理支持

为了在特定目标平台上支持异常处理,需要实现一些项目。

  • CFI 指令

    首先,您必须为每个目标寄存器分配一个唯一的 DWARF 编号。然后在 TargetFrameLoweringemitPrologue 中,您必须发出 CFI 指令,以指定如何计算 CFA (规范帧地址 (Canonical Frame Address)) 以及如何从 CFA 指向的地址以偏移量恢复寄存器。汇编器 (assembler) 由 CFI 指令指示构建 .eh_frame section,解旋器 (unwinder) 使用该 section 在异常处理期间展开堆栈。

  • getExceptionPointerRegistergetExceptionSelectorRegister

    TargetLowering 必须实现这两个函数。个性化函数通过 getExceptionPointerRegistergetExceptionSelectorRegister 指定的寄存器将异常结构(指针)和选择器值(整数)传递到着陆点。在大多数平台上,它们将是 GPR (通用寄存器 (General Purpose Register)),并且将与调用约定 (calling convention) 中指定的寄存器相同。

  • EH_RETURN

    ISD 节点 (node) 表示未公开的 GCC 扩展 __builtin_eh_return (offset, handler),它通过偏移量调整堆栈,然后跳转到处理器。__builtin_eh_return 在 GCC 解旋器 (libgcc) 中使用,但不在 LLVM 解旋器 (libunwind) 中使用。如果您位于 libgcc 的顶层并且对您的目标平台有特殊要求,则必须在 TargetLowering 中处理 EH_RETURN

如果您不利用现有的运行时 (libstdc++libgcc),则必须查看 libc++libunwind,以了解必须在那里完成什么。对于 libunwind,您必须执行以下操作

  • __libunwind_config.h

    为您的目标平台定义宏 (macros)。

  • include/libunwind.h

    为目标寄存器定义枚举 (enum)。

  • src/Registers.hpp

    为您的目标平台定义 Registers 类,实现设置函数 (setter function) 和 获取函数 (getter function)。

  • src/UnwindCursor.hpp

    为您的 Registers 类定义 dwarfEncodingstepWithCompactEncoding

  • src/UnwindRegistersRestore.S

    编写一个汇编函数,以从内存中恢复所有目标寄存器。

  • src/UnwindRegistersSave.S

    编写一个汇编函数,以将所有目标寄存器保存在内存中。