Mono 运行时异常处理机制深度剖析:从 CIL throw 到信号处理器与栈展开 📅 发布时间:2026/9/18 22:59:45 👁 浏览次数: Mono 运行时异常处理机制深度剖析从 CIL throw 到信号处理器与栈展开【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime导读异常处理Exception Handling是 Mono 运行时中最复杂、架构相关性最强的子系统之一。本文以 docs/design/mono/web/exception-handling.md 为核心骨架结合当前仓库中src/mono/mono/mini/与src/mono/mono/metadata/下的源码实现系统讲解 Mono 如何统一处理托管代码显式/隐式抛出的异常、运行时icall抛出的异常、同步/异步信号以及栈溢出并深入解析MonoContext、LMF 栈与架构相关 EH 函数的契约。读完本文你将理解异常从发生到被 catch/finally 捕获的完整调用链以及每个后端ARCH需要实现哪些关键函数。一、异常全景运行时需要处理的六类异常Mono 运行时需要处理的异常来源远不止throw语句。根据原文档至少包含以下六类异常来源典型示例触发位置托管代码显式抛出CIL 的throw/rethrow指令JIT 生成的代码IL 指令隐式抛出castclass抛出InvalidCastExceptionJIT 生成的代码运行时代码抛出icall 实现中的错误检测原生C代码托管代码执行中的同步信号空指针解引用触发 SIGSEGVCPU → 内核 → 进程原生代码执行中的同步信号原生库内部崩溃原生C代码异步信号线程中止 / 挂起 / 恢复其他线程主动投递这些异常虽然发生在运行时不同部分但最终都会汇入mono_handle_exception()函数见 mini-exceptions.c该函数会递增全局异常计数exceptions_thrown可通过mono_get_exception_count()查询然后委托给mono_handle_exception_internal()完成真正的托管级 catch/finally 搜索。由于异常处理高度依赖 CPU 架构架构相关的部分位于各后端的exceptions-ARCH.c文件中而架构无关的公共逻辑集中在 mini-exceptions.c 中。当前仓库中已存在的架构实现包括exceptions-amd64.cx86-64exceptions-x86.c32 位 x86exceptions-arm.c / exceptions-arm64.cexceptions-ppc.c / exceptions-riscv.c / exceptions-s390x.c二、托管代码显式抛出的异常动态生成的 throw helper当托管代码执行throw/rethrowCIL 指令时JIT 会将其翻译为对辅助函数mono_arch_throw_exception/mono_arch_rethrow_exception的调用。一个关键事实是这些 helper 函数在编译期并不存在而是由exceptions-ARCH.c中的代码在运行时动态生成trampoline。以 amd64 为例exceptions-amd64.c 中定义了mono_amd64_throw_exception负责将异常对象、MonoContext等信息装入寄存器mono_arch_get_throw_exception 与 mono_arch_get_rethrow_exception 通过get_throw_trampoline()生成对应的跳板代码trampoline并分别命名为throw_exception/rethrow_exception。这些动态生成的 helper 会执行各种栈操作魔法保存被调用者保存寄存器、构造伪栈帧等然后调用 C 代码中的throw_exception()做进一步处理最终进入mono_handle_exception()完成剩余工作。三、托管代码隐式抛出的异常EMIT_COND_SYSTEM_EXCEPTION 与异常代码外置许多 IL 指令如castclass、ldelem、算术指令在出错时需要抛出系统异常。若在每个指令处都内联异常抛出代码会污染指令缓存icache。Mono 采用的策略是JIT 遇到可能出错的指令时发出一个前向条件分支并记住该位置以及需要抛出的异常类型——这通常由mini-ARCH.c中的EMIT_COND_SYSTEM_EXCEPTION宏完成方法机器码生成完毕后JIT 调用架构相关的mono_arch_emit_exceptions()将异常抛出代码追加到方法末尾并回填之前的前向分支使它们指向这段代码这段异常抛出代码分支到动态生成的mono_arch_throw_corlib_exceptionhelper由它创建正确的异常对象、做栈操作再调用throw_exception()。这种把极少执行的异常代码与方法主体分离的做法显著提升了 icache 命中率——正常路径上只有一条分支指令的开销。四、运行时代码抛出的异常icall 与 mono_raise_exception运行时通常是 InternalCall / icall 的实现抛出异常的标准流程是用 metadata/exception.c 中的辅助函数创建合适的异常对象——该文件为运行时使用的每种异常都提供独立的分配函数例如mono_get_exception_divide_by_zero()L344mono_get_exception_thread_abort()L376mono_get_exception_null_reference()L430mono_get_exception_invalid_cast()L467mono_get_exception_index_out_of_range()L496mono_get_exception_stack_overflow()L897mono_get_exception_out_of_memory()L907调用mono_raise_exception()真正抛出异常。该函数永不返回。典型用法if (something_is_wrong) mono_raise_exception (mono_get_exception_index_out_of_range ());mono_raise_exception()通过运行时内部 API 将异常传递给 JIT 侧的 helper由mono_arch_throw_exception创建此后该异常与托管代码抛出的异常走完全相同的路径。在 mini-exceptions.c 中可以看到其内部形态mono_raise_exception_with_ctx()先调用mono_handle_exception(ctx, exc)若处理器决定继续执行如 handler 中恢复了执行再通过mono_restore_context(ctx)恢复现场。五、同步信号用 CPU 硬件替代软件检查出于性能考虑Mono 并不在软件层面执行 CLI 规范要求的全部检查而是依赖 CPU 硬件来检测错误主要有两类空指针检查JIT 代码解引用空指针时CPU 触发中断内核向进程发送 SIGSEGV算术检查除零等算术异常类似处理。运行时为 SIGSEGV 安装了信号处理器mono_sigsegv_signal_handler。与文档写作时位于mini.c不同当前仓库中处理器实现位于 mini-runtime.c另有 debug 变体 mono_sigsegv_signal_handler_debug信号注册位于 mini-posix.c通过add_signal_handler()同时注册 SIGBUS 与 SIGSEGVWindows 平台则在 mini-windows.c 通过win32_seh_set_handler(SIGSEGV, mono_sigsegv_signal_handler)接入 SEH。信号处理器创建合适的异常对象如NullReferenceException并带着内核提供的机器状态调用mono_handle_exception()。六、原生代码中的同步信号直接中止如果在原生native代码中收到 SIGSEGV 这类同步信号通常意味着发生了非常严重的问题。此时运行时无法安全地继续因此会打印托管栈 原生栈的混合栈跟踪后中止进程。这段逻辑在mono_handle_native_sigsegv()函数中。需要注意原生信号的来源有两种运行时自身内部的代码应用程序加载的原生库如 libgtk中的代码。这两种情况都无法安全恢复统一按致命错误处理。七、栈溢出检测altstack 的取舍栈溢出需要特殊处理因为它的检测存在一个经典的鸡生蛋难题线程栈溢出时内核照常发送 SIGSEGV但信号处理器默认运行在同一个已溢出的栈上会再次触发 SIGSEGV导致线程直接被终止。解决方案是 UNIX 系统提供的备选信号栈sigaltstack(2)。线程启动时运行时调用 mono_setup_altstack() 安装 altstack。收到 SIGSEGV 后信号处理器判断故障地址是否接近线程正常栈的底部若是 → 构造StackOverflowException而非NullReferenceException该异常之后按普通异常处理仅存在少量差异。从当前实现看mono_setup_altstack()受#ifdef MONO_ARCH_SIGSEGV_ON_ALTSTACK保护mini-exceptions.c且依赖MONO_ARCH_USE_SIGACTION否则编译期直接报错Cant use sigaltstack without sigaction。它通过mono_thread_info_get_stack_bounds()获取线程栈边界设置end_of_stack、stack_size并建立栈溢出保护页guard page在 macOSMojave 起主线程栈页映射存在 bug与 AIX 上还会为主线程禁用 guard page在 Valgrind 下运行则直接跳过 altstack 安装。文档明确解释了 sigaltstack默认被禁用的两个原因GC 不可见性altstack 使用的栈对 GC 不可见GC 可能漏掉其中的对象引用平台依赖性完善的 sigaltstack 支持高度依赖操作系统 / 内核 / libc 的组合因此默认关闭。八、异步信号线程中止 / 挂起 / 恢复的实现基础异步信号是运行时通知某个线程需要改变自身状态的机制目前用于实现线程的 abort / suspend / resume。正确处理异步信号的难点在于收到信号时目标线程可能处于任意状态——可能在执行托管代码、原生代码可能持有各种托管/原生锁、正在获取锁的过程中可能正在启动或关闭。而运行时使用的大多数 C API 都不是异步信号安全的async-signal safe尤其是 pthread 锁函数若信号处理器打断了正在获取锁的代码而处理器自己又去获取锁就会死锁。处理策略分两步信号处理器首先判断线程被打断时是否在执行托管代码若是 → 可以安全打断构造并抛出ThreadAbortException若否正在原生代码中→ 通常不能安全打断。运行时设置一个标志后立即返回该标志会在每次从原生代码返回托管代码时被检查届时再抛出异常同时使用平台相关机制打断线程可能正在进行的阻塞操作。文档中该异步信号处理器名为sigusr1_signal_handler位于mini.c而决定异常是否可安全抛出的逻辑在mono_thread_request_interruption()。当前仓库中该逻辑位于 metadata/threads.cmono_thread_request_interruption_internal(gboolean running_managed, MonoExceptionHandle *pexc)根据目标线程是否运行托管代码running_managed决定直接抛异常还是仅置标志并分别暴露了mono_thread_request_interruption_native()L3729对应原生路径与mono_thread_request_interruption_managed()L3735对应托管路径两个入口。九、异常处理中的栈展开MonoContext、LMF 与 DWARF9.1 MonoContext线程执行状态的载体异常处理期间线程的执行状态保存在架构相关的MonoContext结构中通常包含IP指令指针SP栈指针FP帧指针被调用者保存寄存器callee-saved registers被调用者保存寄存器是指按平台 ABIApplication Binary Interface约定任何过程在使用前必须保存、使用后必须恢复的寄存器。例如在 x86 上它们是 EBX、ESI、EDI。9.2 初始 MonoContext 的构造调用mono_handle_exception()的代码必须负责构造初始 MonoContext构造方式取决于调用者托管代码抛出的异常mono_arch_throw_exceptionhelper 保存所需寄存器值并传给throw_exception()由其存入 MonoContext信号处理器抛出的异常MonoContext 直接由内核传来的信号信息unix 上是ucontext_t初始化。9.3 展开mono_arch_find_jit_info异常处理过程中需要展开unwind栈即给定线程在某个栈帧的状态构造其调用者的状态。这是平台相关操作由mono_arch_find_jit_info()完成。需要处理两类栈帧托管帧较容易JIT 会为每个托管方法存储元信息如使用了哪些被调用者保存寄存器。基于这些信息mono_arch_find_jit_info()能在线程栈上找到寄存器值并恢复。在部分平台上运行时改用基于 DWARF 展开接口的通用展开器实现位于unwind.h/unwind.c。原生帧较困难我们对如何穿过原生帧毫无信息——部分编译器会生成展开信息部分不会且没有通用库来获取和解码这些信息。Mono 的解决方案是MonoLMFLast Managed Frame最后托管帧当托管代码需要调用原生代码时必须经过 JIT 生成的managed→native wrapper 函数该函数负责把机器状态保存到线程局部的MonoLMF结构中。这些 LMF 结构存放在线程栈上并通过其中一个字段链接成链。展开器遇到原生帧时直接从 LMF 栈弹出一个条目把帧状态恢复到控制权移交原生代码之前的那一刻——效果上所有连续的原生帧被一次性跳过。十、已知问题与未来工作10.1 从原生代码抛出异常的代价目前运行时是在自身代码中途调用mono_raise_exception()来抛异常的这带来两个问题无清理如果抛出异常的函数调用者已经获取了锁或分配了内存这些资源不会被清理。因此mono_raise_exception()只适合非常靠近托管代码的地方调用即只能在 icall 函数内部使用非零成本为了让mono_raise_exception()能穿过原生代码展开需要保存 LMF 结构——即使在没有异常抛出的正常路径上也会带来大量开销。文档提出的替代方案是JNI 风格的 set-pending-exception API运行时代码调用mono_set_pending_exception()后带错误指示返回由调用者负责清理执行回到托管代码时managed→native wrapper 检查是否存在 pending exception必要时抛出。由于运行时已经为挂起线程中断做过类似检查该方案理论上零额外开销并允许删除 LMF 保存/恢复代码的相当一部分。10.2 libunwind独立栈展开库的取舍文档还评估了 OSS 项目libunwind独立栈展开库当时由 gcc 在 ia64 上默认用于栈展开Mono 也在 ia64 上使用它。其优点包括平台无关 API同一套展开代码可用于多平台生成的展开表在每条指令处都正确可用于从异步信号中展开只要有足够的 C 编译器展开信息就能穿过 C 代码展开API 大部分异步安全实现了 gcc 的 C 异常处理 API理论上可用于实现混合语言异常处理C 异常被 mono 捕获、mono 异常被 C 捕获MIT 许可。其最大短板是平台支持不均衡ia64 支持完整且经过充分测试其他平台缺失或不完整。十一、后端 EH 函数契约每个架构必须实现的三件事本部分是给各后端移植者的接口文档。这些函数通常位于各架构的exceptions-ARCH.c文件中。11.1 mono_arch_handle_exception()gboolean mono_arch_handle_exception (void *ctx, gpointer obj);调用者信号处理器。职责接收信号处理器传入的机器状态CTXunix 上为ucontext_t与异常对象OBJ可能为 NULL。由于在信号处理器中处理异常问题重重该函数应改写 CTX使信号处理器返回后执行流继续进入另一个运行时函数去做真正的工作。传递 CTX/OBJ 的约定CTX可通过 TLS 传递而OBJ必须通过寄存器/栈传递通过修改 CTX 实现因为 TLS 存储可能不被 GC 跟踪。以 amd64 为例mono_arch_handle_exception 的实现中即可看到对栈溢出场景的处理当故障是栈溢出时直接以mini_get_stack_overflow_ex()作为异常对象传入。11.2 mono_arch_get_restore_context()gpointer mono_arch_get_restore_context (MonoTrampInfo **info, gboolean aot);返回一个签名为void restore_context (MonoContext *ctx)的 trampoline将机器状态设置为CTX中的状态然后跳转到CTX中的 PC。只需恢复状态子集即被调用者保存寄存器 / SP / FP。该 trampoline 通过mono_get_restore_context()获取并被mono_restore_context()使用见 mini-exceptions.c后者恢复现场后永不返回g_assert_not_reached()。11.3 mono_arch_get_call_filter()gpointer mono_arch_get_call_filter (MonoTrampInfo **info, gboolean aot)返回一个签名为int call_filter (MonoContext *ctx, gpointer addr)的 trampoline用于异常处理期间调用 finally 与 filter 子句。其职责建立新的栈帧把被调用者保存寄存器保存进去从CTX恢复同样的寄存器调用ADDR恢复保存的寄存器并把调用结果作为自身返回值返回。关于栈帧布局的关键约定finally 子句既需要访问方法状态也需要进行调用因此它们运行在非标准栈帧中——FP指向方法帧的原始 FP而SP是正常的位于call_filter()创建的帧之下。这意味着call_filter()需要从 CTX 加载 FP但不能加载 SP。结语Mono 的异常处理是一个典型的多条路径、一个出口设计无论是 CIL 显式 throw、IL 指令隐式抛异常、icall 主动 raise、CPU 触发的同步信号、还是用于线程中断的异步信号最终都汇聚到mono_handle_exception()通过MonoContext描述现场、LMF/DWARF 负责展开、架构 trampoline 完成恢复最终交给托管层的 catch/finally 逻辑。理解这条主链路再对照exceptions-ARCH.c中各架构的函数实现即可快速掌握整个子系统并为移植新后端或排查崩溃问题提供清晰的路线图。相关文档与源码可继续参阅 docs/design/mono/web/exception-handling.md、mini-exceptions.c 及 metadata/exception.c。【免费下载链接】runtime.NET is a cross-platform runtime for cloud, mobile, desktop, and IoT apps.项目地址: https://gitcode.com/GitHub_Trending/runtime6/runtime创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考