异常与垃圾回收解释

前言

在前四篇文章中,我们依次分析了 HybridCLR 解释器的总体架构(第 26 篇)、指令分发机制(第 27 篇)、栈帧管理(第 28 篇)以及方法调用与虚方法分发(第 29 篇)。现在,我们来到解释器模块的最后一个核心主题——异常处理垃圾回收(GC) 在解释器中的实现。

异常处理和 GC 是任何托管运行时中两个最复杂的基础设施。在 HybridCLR 的解释器中,它们面临独特的挑战:

  • 异常处理——IL 层面的异常模型(throwtry/catch/finallyfilter)需要在 IR 指令层面重新实现。解释器必须能够正确地展开帧栈(stack unwind)、执行 finally 块、匹配 catch 子句,并且所有这些操作都不能依赖 C++ 的原生异常机制
  • 垃圾回收——解释器必须与 IL2CPP 的 GC(基于 Boehm GC 或及后续版本)正确集成。这意味着:在正确的时机检查 GC 安全点、为 GC 提供准确的栈扫描信息、在引用类型写入时触发写屏障

本文从源码层面深入分析这两个机制在解释器中的实现。


一、异常的解释执行

1.1 .NET 异常模型回顾

在深入解释器的异常处理实现之前,先回顾 .NET 的异常模型核心概念。一个完整的 try/catch/finally 结构在 IL 层面如下:

// IL 层面的 try/catch/finally 结构
.try
{
    // 受保护的代码区域
    IL_0000:  call  void SomeMethod()
    IL_0005:  leave  IL_0020      // 正常离开 try 块
} // end .try
catch [mscorlib]System.Exception
{
    // 异常处理代码
    IL_0010:  call  void HandleException()
    IL_0015:  leave  IL_0020      // 离开 catch 块
} // end catch
finally
{
    // 始终执行的清理代码
    IL_0018:  call  void Cleanup()
    IL_0019:  endfinally           // 结束 finally 块
} // end finally

IL 异常模型的关键特征:

  1. 结构化异常——异常必须被精确地匹配到 try 块的 catch 子句,不匹配则向调用方传播
  2. leave 指令——离开受保护区域必须使用 leave 而非 br,确保 finally 被执行
  3. finally 块的确定性执行——无论正常离开还是异常离开 try 块,finally 块始终执行
  4. 异常子句——每个方法在元数据中携带 ExceptionHandler 表,定义 try/catch/filter/finally 的范围

在 HybridCLR 中,编译器(transform 层)将 IL 的这些异常结构编译为 IR 指令,解释器在执行时通过 IR 指令模拟完整的异常处理语义。

1.2 异常子句的 IR 表示

编译器将 IL 元数据中的异常处理子句转换为 IrBody 中的 exceptionClauses 数组:

// 来源:hybridclr/metadata/ 相关定义(简化)
struct InterpExceptionClause
{
    uint32_t flags;                    // 标志:catch/filter/finally/fault
    uint32_t tryStartOffset;           // try 块在 IR 指令中的起始偏移
    uint32_t tryEndOffset;             // try 块在 IR 指令中的结束偏移
    uint32_t handlerStartOffset;       // handler 块在 IR 指令中的起始偏移
    uint32_t handlerEndOffset;         // handler 块在 IR 指令中的结束偏移
    uint32_t classTokenOrFilterOffset; // catch 类型的 token 或 filter 偏移
    uint32_t ilOffset;                 // 原始 IL 偏移(调试用)
};

// 每个方法编译后包含异常子句信息
struct IrBody
{
    uint8_t* instructions;                    // IR 指令序列
    ResolveData* resolveDatas;                // 解析数据
    InterpExceptionClause* exceptionClauses;  // 异常子句数组
    uint32_t exClauseCount;                   // 异常子句数量
    uint32_t localVarCount;                   // 局部变量数量
    // ...
};

每个异常子句定义了一个受保护区域及其对应的 handler。编译器在将 IL 编译为 IR 时,将 IL 偏移量(tryStartOffset 等)映射为 IR 指令偏移量。exceptionClauses 数组在 ExecuteMain 主循环中被解释器使用——当异常抛出时,解释器遍历当前方法的 exceptionClauses,查找匹配的 catch 或 finally。

异常子句的 flags 字段取值如下:

标志值 类型 含义
COR_ILEXCEPTION_CLAUSE_EXCEPTION (0x01) catch 捕获指定类型的异常
COR_ILEXCEPTION_CLAUSE_FILTER (0x02) filter 通过 filter 函数判断是否处理
COR_ILEXCEPTION_CLAUSE_FINALLY (0x04) finally 无论是否异常都执行
COR_ILEXCEPTION_CLAUSE_FAULT (0x08) fault 仅在发生异常时执行

1.3 throw 指令的解释

IL 层面的 throw 指令在 IR 层面被编译为 ThrowEx 指令。解释器处理 ThrowEx 的逻辑是异常处理的起点:

// 来源:hybridclr/interpreter/Interpreter_Execute.cpp(简化)
case HiOpcodeEnum::ThrowEx:
{
    auto* throwInst = (IRThrowEx*)ip;

    // 1. 获取异常对象(从 localVarBase 中读取)
    Il2CppException* exception = (Il2CppException*)localVarBase[throwInst->src].ptr;

    if (exception == nullptr)
    {
        // 2. 抛 null 引用——运行时自动创建 NullReferenceException
        exception = CreateNullReferenceException();
    }

    // 3. 在当前方法中查找能处理此异常的 handler
    if (!TryFindAndExecuteHandler(frame, exception))
    {
        // 4. 当前方法没有 handler——向调用方传播
        //    将异常状态保存到 MachineState,然后返回
        state.exception = exception;
        state.exceptionHandling = true;

        // 跳出主循环——由调用方的 ExecuteMain 处理
        goto HANDLE_EXCEPTION;
    }

    ip += 8;
    continue;
}

ThrowEx 的处理流程分三步:

  1. 获取异常对象——从 localVarBase 指令的源操作数位置读取异常对象引用。如果引用为 null(C# 中 throw null 的情况),运行时会自动创建一个 NullReferenceException
  2. 匹配 handler——在当前方法的异常子句表中查找能处理此异常的 catch 子句或需要执行的 finally 子句
  3. 向上传播——如果当前方法没有匹配的 handler,解释器将异常状态保存到 MachineState,然后通过特殊的控制流退出当前 ExecuteMain,让调用方(调用此方法的帧)继续异常处理

1.4 TryFindAndExecuteHandler 的匹配逻辑

TryFindAndExecuteHandler 是异常处理的核心函数,它遍历当前方法的异常子句表,查找匹配的 handler:

// TryFindAndExecuteHandler 的匹配逻辑(简化)
bool TryFindAndExecuteHandler(InterpFrame* frame, Il2CppException* exception)
{
    MachineState& state = *frame->machine;
    IrBody* irBody = frame->method->irBody;

    // 获取当前 IR 指令偏移(异常发生的位置)
    uint32_t ipOffset = (uint32_t)(state.ip - irBody->instructions);

    // 遍历异常子句表
    for (uint32_t i = 0; i < irBody->exClauseCount; i++)
    {
        InterpExceptionClause& clause = irBody->exceptionClauses[i];

        // 检查异常发生位置是否在 try 块范围内
        if (ipOffset >= clause.tryStartOffset && ipOffset < clause.tryEndOffset)
        {
            switch (clause.flags)
            {
                case COR_ILEXCEPTION_CLAUSE_EXCEPTION:
                {
                    // catch 子句——检查异常类型是否匹配
                    Il2CppClass* catchType = ResolveClassFromToken(clause.classTokenOrFilterOffset);
                    if (IsExceptionTypeMatch(exception, catchType))
                    {
                        // 匹配成功——跳转到 catch 块
                        ExecuteCatchHandler(frame, i, exception);
                        return true;
                    }
                    break;
                }

                case COR_ILEXCEPTION_CLAUSE_FINALLY:
                {
                    // finally 子句——压入异常流栈
                    PushExceptionFlowInfo(frame, EXCEPTION_FLOW_FINALLY, i, exception);
                    return true;  // 找到 finally 块,执行路径由异常流管理
                }

                case COR_ILEXCEPTION_CLAUSE_FILTER:
                {
                    // filter 子句——执行 filter 函数判断
                    if (ExecuteFilterClause(frame, clause, exception))
                    {
                        ExecuteCatchHandler(frame, i, exception);
                        return true;
                    }
                    break;
                }

                case COR_ILEXCEPTION_CLAUSE_FAULT:
                {
                    // fault 子句——仅在异常时执行
                    PushExceptionFlowInfo(frame, EXCEPTION_FLOW_FAULT, i, exception);
                    return true;
                }
            }
        }
    }

    return false;  // 没有匹配的 handler——向上传播
}

关键逻辑解析:

  • 偏移量比较——ipOffset 是异常抛出时 IR 指令的位置偏移。解释器遍历异常子句表,检查抛出的位置是否在 tryStartOffset ~ tryEndOffset 范围内
  • 类型匹配——对于 catch 子句,通过 IsExceptionTypeMatch 检查异常对象的运行时类型是否与 catch 声明的类型兼容(同一类型或子类)。这个检查需要遍历 IL2CPP 的类型继承层次
  • finally 的特殊处理——finally 不需要类型匹配——只要退出 try 块的方式(无论是正常 leave 还是异常抛出)经过 finally 块的保护范围,finally 就会被执行
  • filter 的预检查——filter 子句在 catch 之前执行 filter 函数,返回 true 时才进入对应的 catch 块

1.5 catch 块的执行

当异常类型匹配成功时,解释器跳转到 catch 块的 IR 指令:

// catch 块的执行
void ExecuteCatchHandler(InterpFrame* frame, uint32_t clauseIndex, Il2CppException* exception)
{
    MachineState& state = *frame->machine;
    InterpExceptionClause& clause = frame->method->irBody->exceptionClauses[clauseIndex];

    // 1. 将异常对象存入局部变量(编译器指定的槽位)
    //    C# 的 catch(Exception ex) 中,ex 被映射到局部变量
    uint32_t exceptionLocalSlot = clause.classTokenOrFilterOffset; // 编译器重用了此字段
    StackObject* localVarBase = (StackObject*)frame->localVarBase;
    localVarBase[exceptionLocalSlot].ptr = exception;

    // 2. 记录当前异常子句索引
    frame->exClauseIndex = (int32_t)clauseIndex;

    // 3. 将 IP 设置为 catch 处理代码的起始位置
    state.ip = frame->method->irBody->instructions + clause.handlerStartOffset;
}

catch 块执行的关键点:

  • 异常对象的传递——异常对象被存储到 localVarBase 的指定槽位。编译器在编译时已经为 catch 语句的异常变量分配了一个局部变量索引,exceptionLocalSlot 指明了这个位置
  • exClauseIndex 记录——InterpFrame::exClauseIndex 被设置为当前处理的异常子句索引。这个值在多个嵌套 try/catch 块的情况下非常关键——它告诉解释器当前正在执行哪个 catch 块,以便在 catch 块内部再次抛出异常时正确处理
  • IP 重定向——state.ip 被设置为 catch handler 的起始 IR 指令偏移。下一次 ExecuteMain 循环将从 catch 块的第一条 IR 指令开始执行

1.6 finally 块的保证执行

finally 块的执行是 .NET 异常模型中最复杂但最重要的语义保证之一。在 HybridCLR 解释器中,finally 块的执行通过异常流栈(Exception Flow Stack)和 IR 层面的 LeaveExEndFinallyEx 指令配合实现。

当方法中出现 leave 指令(从 try 块正常退出)时:

// leave 指令的解释——触发 finally 链
case HiOpcodeEnum::LeaveEx:
{
    auto* leaveInst = (IRLeaveEx*)ip;

    // 1. 计算目标偏移
    uint32_t targetOffset = (uint32_t)(leaveInst->targetOffset);

    // 2. 从当前位置到目标位置之间,查找需要执行的 finally 块
    uint32_t currentOffset = (uint32_t)(state.ip - (uint8_t*)frame->method->irBody->instructions);

    // 3. 遍历异常子句,找到介于当前位置和目标位置之间的 finally
    for (uint32_t i = 0; i < irBody->exClauseCount; i++)
    {
        InterpExceptionClause& clause = irBody->exceptionClauses[i];
        if (clause.flags == COR_ILEXCEPTION_CLAUSE_FINALLY)
        {
            // 检查 leave 路径是否穿过此 finally 的保护范围
            if (IsLeavePassingThroughFinally(currentOffset, targetOffset, clause))
            {
                // 4. 记录异常流信息——执行完 finally 后继续 leave
                PushExceptionFlowInfo(frame, EXCEPTION_FLOW_LEAVE, i, targetOffset);
                break;
            }
        }
    }

    // ... 继续循环(ip 已在上面隐含移动)
    continue;
}

对于异常路径触发 finally 的执行(而非正常 leave 路径),逻辑是:

// 异常路径下 finally 的执行触发
// 在 TryFindAndExecuteHandler 中,当找到 finally 子句时:
PushExceptionFlowInfo(frame, EXCEPTION_FLOW_FINALLY, i, exception);

无论是正常 leave 路径还是异常路径,最终都通过 PushExceptionFlowInfo 向异常流栈压入一个条目,指示需要执行的 finally 块。然后解释器跳转到 finally 块的 IR 指令执行。

1.7 ExceptionFlowInfo 与异常流栈管理

ExceptionFlowInfo 是异常流栈的条目结构:

// 来源:hybridclr/interpreter/InterpreterDefs.h(简化)
struct ExceptionFlowInfo
{
    ExceptionFlowType type;         // 流类型:FINALLY / LEAVE / CATCH / FAULT
    uint32_t clauseIndex;           // 异常子句索引
    uint32_t returnTargetOffset;    // 完成后的返回目标(leave 指令的目标)
    Il2CppException* exception;     // 关联的异常对象(异常路径下非空)
};

enum ExceptionFlowType
{
    EXCEPTION_FLOW_NONE     = 0,
    EXCEPTION_FLOW_FINALLY  = 1,  // 异常路径触发的 finally
    EXCEPTION_FLOW_LEAVE    = 2,  // leave 指令触发的 finally
    EXCEPTION_FLOW_CATCH    = 3,  // catch 块执行中
    EXCEPTION_FLOW_FAULT    = 4,  // fault 块执行中
};

异常流栈(MachineState.exFlowStack)是一个独立的栈结构:

// 异常流栈的压入和弹出
void PushExceptionFlowInfo(InterpFrame* frame, ExceptionFlowType type,
                           uint32_t clauseIndex, uint32_t returnTarget)
{
    MachineState& state = *frame->machine;

    if (state.exFlowStackSize >= MAX_EX_FLOW_STACK_SIZE)
    {
        // 异常流栈溢出——检查是否递归异常
        RaiseException(exFlowStackOverflowException);
        return;
    }

    ExceptionFlowInfo* info = state.exFlowStackTop;
    info->type = type;
    info->clauseIndex = clauseIndex;
    info->returnTargetOffset = returnTarget;
    info->exception = nullptr;

    state.exFlowStackTop++;
    state.exFlowStackSize++;
}

void PopExceptionFlowInfo(MachineState& state)
{
    if (state.exFlowStackSize > 0)
    {
        state.exFlowStackTop--;
        state.exFlowStackSize--;
    }
}

异常流栈最多支持 64 个嵌套条目(MAX_EX_FLOW_STACK_SIZE),这个限制通常足够——64 层嵌套的异常处理在现实中极为罕见。

当 finally 块执行完毕时,EndFinallyEx 指令负责恢复异常流栈:

// finally 块结束
case HiOpcodeEnum::EndFinallyEx:
{
    // 查看异常流栈顶端
    if (state.exFlowStackSize > 0)
    {
        ExceptionFlowInfo* info = state.exFlowStackTop - 1;

        switch (info->type)
        {
            case EXCEPTION_FLOW_LEAVE:
            {
                // leave 路径:弹出流栈,跳转到 leave 目标
                PopExceptionFlowInfo(state);
                state.ip = frame->method->irBody->instructions + info->returnTargetOffset;
                break;
            }

            case EXCEPTION_FLOW_FINALLY:
            {
                // 异常路径:弹出流栈,继续搜索 catch 或继续传播
                PopExceptionFlowInfo(state);

                // 继续在当前方法中查找能处理原始异常的 handler
                Il2CppException* originalException = info->exception;
                if (!TryFindAndExecuteHandler(frame, originalException))
                {
                    // 当前方法无 handler——向上传播
                    state.exception = originalException;
                    goto HANDLE_EXCEPTION;
                }
                break;
            }

            case EXCEPTION_FLOW_FAULT:
            {
                // fault 路径:执行完后继续传播异常
                PopExceptionFlowInfo(state);
                state.exception = info->exception;
                goto HANDLE_EXCEPTION;
            }

            default:
                break;
        }
    }
    else
    {
        // 异常流栈为空——endfinally 不匹配任何 finally 开始
        // 这是 IR 生成错误,但在发布模式下忽略
        ip += 8;
        continue;
    }
    break;
}

EndFinallyEx 是异常流栈的"弹出点"。它根据栈顶的 ExceptionFlowInfo.type 决定接下来做什么:

  • 如果是 EXCEPTION_FLOW_LEAVE——表明这是正常 leave 路径触发的 finally,执行完后跳转到 leave 指令的目标位置继续正常执行
  • 如果是 EXCEPTION_FLOW_FINALLY——表明这是异常路径触发的 finally,执行完后继续搜索 catch 子句或向调用方传播
  • 如果是 EXCEPTION_FLOW_FAULT——fault 块执行完后,异常继续传播

二、栈展开的解释实现

2.1 异常传播时的帧遍历

当异常在当前方法中没有被任何 catch 子句捕获时,它必须向调用方传播。在 HybridCLR 解释器中,异常传播通过帧栈遍历特殊的控制流退出实现:

// 异常传播——向上遍历帧栈
// 在 ExecuteMain 主循环中,当异常未被处理时的出口
HANDLE_EXCEPTION:
{
    // state.exception 已经被设置为未处理的异常

    // 1. 执行当前帧中所有处于待执行状态的 finally 块
    // (已经被压入异常流栈的 finally 条目)
    if (!ExecutePendingFinallyBlocks(frame))
    {
        // 2. 离开当前帧(不执行 LeaveFrame——异常路径不经过正常返回)
        // 清理但不能销毁帧——异常信息需要传播
        state.currentFrame = frame->previous;
        state.frameStackSize--;

        // 3. 如果上一帧是解释器帧——递归返回
        if (state.currentFrame != nullptr)
        {
            // 返回到上一帧的 ExecuteMain,由上一帧继续处理异常
            return nullptr;  // 返回值为空——异常路径不产生正常返回值
        }
        else
        {
            // 没有上一帧——异常逃逸到 .NET 运行时边界
            // 由 IL2CPP 运行时负责最终的未处理异常处理
            return nullptr;
        }
    }
    // 如果 ExecutePendingFinallyBlocks 返回 true,说明有 finally 需要执行
    // 此时继续循环——finally 块的 IR 指令将在当前帧中执行
    continue;
}

异常传播的关键机制:

  1. 不经过 LeaveFrame——异常路径上,方法不执行标准的 LeaveFrame 帧出栈流程。直接修改 state.currentFrame = frame->previous 跳过 LeaveFrame

  2. 递归返回——HANDLE_EXCEPTION 通过 return nullptr 退出当前 ExecuteMain 递归调用。调用方的 ExecuteMain 会在 CallInterp_xxx 指令之后收到 nullptr 返回值,检测到异常状态后继续传播

  3. finally 的延迟执行——在离开当前帧之前,必须执行所有已压入异常流栈但尚未执行的 finally 块。这是通过 ExecutePendingFinallyBlocks 完成的

2.2 嵌套异常的处理

嵌套异常(在 catch 或 finally 块中再次抛出异常)是异常处理中最复杂的场景。HybridCLR 通过 exClauseIndex 和异常流栈的嵌套管理来处理:

// 嵌套异常的处理
// 场景:catch 块内部重新抛出异常
case HiOpcodeEnum::RethrowEx:
{
    // Rethrow 从当前异常流获取原始异常
    Il2CppException* rethrownEx = state.exception;

    // 从当前 catch 块的上一层重新开始搜索 handler
    // 使用 exClauseIndex 跳过已经处理过的 catch
    frame->exClauseIndex = -1;

    if (!TryFindAndExecuteHandler(frame, rethrownEx))
    {
        // 仍无 handler——向上传播
        state.exception = rethrownEx;
        goto HANDLE_EXCEPTION;
    }

    ip += 8;
    continue;
}

嵌套异常处理的一个常见陷阱是:在 catch 块内重新抛出时,搜索 handler 应该从外层 try 块开始,而非当前 catch 块内部。HybridCLR 通过将 frame->exClauseIndex 重置为 -1,并确保 TryFindAndExecuteHandler 在搜索时跳过 "异常抛出点位于当前 handler 范围内" 的异常子句,来实现正确的嵌套异常语义。

2.3 混合帧的异常传播

当解释器帧和 AOT 原生帧混合存在时,异常传播需要在两种帧类型之间切换:

异常抛出点:
  [解释器帧] InterpFoo() ← 异常在此抛出
  [解释器帧] InterpBar() ← 通过 previous 指针遍历
  [AOT 帧]   AOTBaz()    ← 通过 IL2CPP 运行时栈遍历
  [AOT 帧]   AOTQuux()   ← 通过 IL2CPP 运行时栈遍历
  [解释器帧] InterpRoot() ← 从 AOT 帧重新进入解释器的入口

从解释器传播到 AOT 时,HANDLE_EXCEPTION 路径通过 return nullptr 退出当前 ExecuteMain。调用方的 InterpreterModule::Execute(由 IL2CPP 运行时调用)检测到返回值异常并负责在 IL2CPP 一侧继续传播。

从 AOT 传播回解释器时,IL2CPP 运行时会检测到该异常需要在解释器帧中处理,并将异常注入到解释器的异常流栈中,从对应的 ExecuteMain 调用点继续异常处理。


三、GC 的解释支持

3.1 GC 安全点检查

在解释器中,GC 不能在任意时刻触发——解释器必须保证在执行到安全点(safe point)时,线程的状态对于 GC 是可见的(即线程持有的所有对象引用在栈上是可枚举的)。HybridCLR 在以下位置插入安全点检查:

// GC 安全点检查(在合适的位置调用)
void CheckGCSafePoint()
{
    // IL2CPP 运行时的 GC 安全点检查函数
    // 如果 GC 正在请求线程暂停,此函数会阻塞直到 GC 完成
    il2cpp_gc_check_safe_point();
}

安全点检查的插入策略是性能与安全性的权衡:

插入位置 检查频率 性能影响 GC 响应延迟
每条 IR 指令 极高 严重 极低
仅方法调用/对象分配处 中等 中等
循环回边处 较低 很低

HybridCLR 采用方法调用和对象分配处检查的策略,这也是大多数 .NET 运行时的标准做法:

// 关键 GC 安全点——方法调用处
case HiOpcodeEnum::CallInterp_void:
{
    // 1. GC 安全点检查
    CheckGCSafePoint();

    // 2. 调用分派
    // ... (创建帧、执行、返回)

    // 3. 再次检查(调用返回后可能发生 GC)
    CheckGCSafePoint();

    ip += 8;
    continue;
}

// 关键 GC 安全点——对象分配处
case HiOpcodeEnum::NewClassVar:
{
    // 1. GC 安全点检查(分配可能触发 GC)
    CheckGCSafePoint();

    auto* newInst = (IRNewClassVar*)ip;
    Il2CppClass* klass = resolveDatas[newInst->typeIdx].type->klass;

    // 2. 从托管堆分配对象
    Il2CppObject* obj = il2cpp_object_new(klass);

    // 3. 将对象地址写入 localVarBase
    localVarBase[newInst->dst].ptr = obj;

    ip += 8;
    continue;
}

安全点检查的成本分析:

  • 在大多数情况下,il2cpp_gc_check_safe_point() 是一个极轻量的操作——检查一个全局标志位,如果 GC 不在请求暂停,立即返回
  • 当 GC 正在请求暂停时,线程在此处阻塞直到 GC 完成(通常几毫秒)
  • 对于每个方法调用都检查 2 次(调用前和返回后),开销约为每次检查 2-3 个 CPU 周期,在方法调用的总开销中占比很小

3.2 对象分配的解释器实现

对象分配在解释器中通过 NewClassVarNewArrVar(数组)、NewStringVar(字符串)等 IR 指令处理:

// 对象分配的示例——NewClassVar
case HiOpcodeEnum::NewClassVar:
{
    auto* newInst = (IRNewClassVar*)ip;

    // 1. GC 安全点检查
    CheckGCSafePoint();

    // 2. 获取类型信息
    const Il2CppClass* klass = resolveDatas[newInst->typeIdx].type->klass;

    // 3. 调用 IL2CPP 的对象分配函数
    //    此函数在托管堆上分配对象内存,并调用构造函数
    Il2CppObject* obj = il2cpp_object_new(const_cast<Il2CppClass*>(klass));

    // 4. 如果类型有运行时初始化(如静态构造函数),确保已执行
    if (klass->has_cctor && !klass->cctor_finished)
    {
        il2cpp_runtime_class_init(const_cast<Il2CppClass*>(klass));
    }

    // 5. 将对象引用写入目标槽
    localVarBase[newInst->dst].ptr = obj;

    ip += 8;
    continue;
}

// 数组分配的示例——NewArrVar
case HiOpcodeEnum::NewArrVar:
{
    auto* arrInst = (IRNewArrVar*)ip;

    CheckGCSafePoint();

    // 获取数组元素类型和长度
    const Il2CppClass* elementClass = resolveDatas[arrInst->typeIdx].type->klass;
    int32_t length = localVarBase[arrInst->len].s4;

    // 调用 IL2CPP 的数组分配函数
    Il2CppArray* arr = il2cpp_array_new_specific(
        Il2CppClass::FromIl2CppType(elementClass), length);

    localVarBase[arrInst->dst].ptr = arr;

    ip += 8;
    continue;
}

对象分配在解释器中和在原生 IL2CPP 代码中的区别:

方面 原生 IL2CPP HybridCLR 解释器
分配函数 直接 C++ 调用 通过 IL2CPP 运行时函数
GC 安全点 分配函数内置 显式 CheckGCSafePoint
性能 最快 稍慢(多一层调用)
兼容性 完全一致 完全一致(共享 IL2CPP 堆)

关键洞察:解释器和原生 IL2CPP 代码共享同一个托管堆。两者都通过 il2cpp_object_new 分配对象——这意味着解释器分配的对象和原生代码分配的对象在 GC 视角下完全等价,没有区别。

3.3 写屏障与引用类型写入

当解释器执行引用类型字段的写入操作时,必须在写入后触发 GC 写屏障(Write Barrier)。写屏障的作用是通知 GC 某个对象引用被修改了,以便 GC 在后续的标记阶段能正确追踪对象引用图:

// 引用类型字段写入——写屏障
case HiOpcodeEnum::StfldRefVarVar:
{
    auto* fieldInst = (IRStfldRefVarVar*)ip;

    // 1. 获取目标对象
    Il2CppObject* obj = (Il2CppObject*)localVarBase[fieldInst->obj].ptr;
    if (obj == nullptr)
    {
        RaiseNullReferenceException();
        ip += 8;
        continue;
    }

    // 2. 计算字段地址(对象地址 + 字段偏移)
    FieldInfo* field = resolveDatas[fieldInst->fieldIdx].field;
    int32_t fieldOffset = field->offset;
    void** fieldAddr = (void**)((uint8_t*)obj + fieldOffset);

    // 3. 获取要写入的值
    void* value = localVarBase[fieldInst->val].ptr;

    // 4. 写入引用
    *fieldAddr = value;

    // 5. 写屏障——通知 GC 此对象的引用字段被修改
    il2cpp_gc_wbarrier_set_field(obj, fieldAddr, value);

    ip += 8;
    continue;
}

写屏障的实现因 GC 类型而异:

  • Boehm GC——写屏障是一个空操作(Boehm 是保守式 GC,不需要写屏障)
  • 后续 GC——il2cpp_gc_wbarrier_set_field 是一个有实际操作的函数,它标记对象所在的 card table 或记录到 remembered set 中,供分代式 GC 使用

对于非引用类型的字段写入(如 StfldI4VarVar ——写入 int 类型字段),不需要写屏障,因为整数值不包含对象引用,GC 不需要追踪它们。

3.4 解释器帧的 GC 栈扫描

GC 在标记阶段需要找到所有"活"的对象引用。对于原生 AOT 代码,IL2CPP 运行时通过 GC 描述符(GC Descriptor)来告知 GC 栈帧中哪些位置包含对象引用。

解释器帧的 GC 栈扫描面临一个独特的问题:解释器的 localVarBase 数组中混合了多种类型的值(整数、浮点数、对象引用),GC 需要知道哪些槽位是对象引用。HybridCLR 通过以下方式解决:

// 解释器帧的 GC 栈扫描(简化)
void ScanInterpreterFrameForGC(InterpFrame* frame, GCScanCallback callback)
{
    const MethodInfo* method = frame->method;

    // 1. 获取方法的 GC 描述信息
    // 编译器在编译方法时生成了每个局部变量的类型信息
    IrBody* irBody = method->irBody;
    if (irBody->gcTrackTypes == nullptr)
        return;

    // 2. 遍历所有局部变量
    StackObject* localVarBase = (StackObject*)frame->localVarBase;
    for (uint32_t i = 0; i < frame->localCount; i++)
    {
        // 3. 检查此槽位的类型是否为对象引用
        // gcTrackTypes 是一个位图,每个位表示对应槽位是否包含引用
        if (irBody->gcTrackTypes[i / 32] & (1 << (i % 32)))
        {
            // 4. 此槽位是对象引用——通知 GC
            void* ref = localVarBase[i].ptr;
            if (ref != nullptr)
            {
                callback(ref);  // GC 标记此对象
            }
        }
    }
}

GC 栈扫描的重要细节:

  • GC 描述符由编译器生成——在 IL → IR 的编译过程中,编译器分析了每个局部变量的类型,生成一个位图(bitmap)来标记哪些 IR 指令的 localVarBase 槽位包含对象引用
  • 保守式 vs 精确式扫描——HybridCLR 的扫描是精确的(precise),因为编译器静态知道每个槽位的类型。这与 IL2CPP 的 AOT 编译模式一致,避免了保守式扫描可能误将整数值当作指针的问题
  • 栈扫描的时机——ScanInterpreterFrameForGC 在 GC 的标记阶段被调用。IL2CPP 运行时会遍历所有线程的栈帧,遇到解释器帧时调用此函数

3.5 根帧和跨模块 GC

解释器帧的 GC 栈扫描需要从线程的 MachineState 开始:

// 遍历所有解释器线程,扫描它们的帧栈
void ScanAllInterpreterThreads(GCScanCallback callback)
{
    // 每个线程有独立的 MachineState
    for (auto& threadState : GetAllThreadMachineStates())
    {
        InterpFrame* frame = threadState.currentFrame;
        while (frame != nullptr)
        {
            // 扫描当前帧的 localVarBase
            ScanInterpreterFrameForGC(frame, callback);

            // 向前一帧(调用方)
            frame = frame->previous;
        }
    }
}

这里的"根"包括:

  1. 局部变量和参数——通过 localVarBase 中的引用类型槽位扫描
  2. 评估栈——由于评估栈是 localVarBase 的一部分,它已经被覆盖(编译器在编译期已将评估栈位置映射为 localVarBase 的索引)
  3. 静态字段——静态字段由 IL2CPP 运行时统一管理,不通过解释器的帧扫描

四、性能与正确性的平衡

4.1 异常处理的性能开销

异常处理在解释器中比在 AOT 原生代码中更昂贵。以下是主要开销来源:

操作 AOT 原生 HybridCLR 解释器 差异倍数
throw CPU 异常处理 TryFindAndExecuteHandler 遍历 5-10x
catch 匹配 类型匹配 类型匹配 + 帧遍历 ~2x
finally 执行 原生函数调用 异常流栈管理 + IR 执行 ~3-5x
正常路径无异常 零开销 leave 指令执行时检查 finally ~1x(近似)

分析这些开销来源:

  • throw 操作的额外开销——AOT 原生中的 throw 通过操作系统或运行时异常处理机制实现。而解释器的 ThrowEx 需要遍历 exceptionClauses 数组(线性搜索),并可能多次帧遍历
  • finally 的确定执行——每个 leave 指令都需要扫描异常子句表,查找需要执行的 finally 块。这个扫描是 O(N) 的(N = 异常子句数量)
  • 正常路径的零开销原则——异常处理的一个基本原则是:正常执行路径不应因异常处理而变慢。HybridCLR 的解释器遵循这一原则——leave 指令只在执行时检查 finally,而在 try 块内部正常执行的 IR 指令不携带任何异常处理开销

异常路径在解释器中的完整开销分解:

throw 指令执行:
  ThrowEx case 分发        ~3 周期
  异常对象读取              ~1 周期
  TryFindAndExecuteHandler:
    遍历 exClauseCount 个子句  ~N × 10 周期(N = 异常子句数)
    类型匹配(IsExceptionTypeMatch)
      继承链遍历              ~深度 × 5 周期
  ───────────────────
  总计:约 50-200 周期
  
finally 块执行(额外):
  PushExceptionFlowInfo     ~5 周期
  EndFinallyEx case 分发     ~3 周期
  PopExceptionFlowInfo       ~3 周期
  IP 重定向                   ~1 周期
  ───────────────────
  总计:约 15-20 周期(不包括 finally 块本身的 IR 指令执行)

异常路径的总开销(50-200 周期)相对于 I/O 操作或 Unity API 调用来说非常小。在正常执行中,异常不应该频繁触发——这个性能特征是合理的。

4.2 GC 对指令分发的影响

GC 安全点检查对指令分发循环的影响可以量化分析:

// GC 安全点检查的宏定义
// 在发布模式下的安全点检查
#define CHECK_GC_SAFE_POINT() \
    do { \
        if (UNLIKELY(g_gcWorldStopped)) \
        { \
            il2cpp_gc_wait_for_gc_done(); \
        } \
    } while(0)

在非 GC 触发期间,安全点检查的开销非常低:

  • 检查一个原子标志位(g_gcWorldStopped)——约 1-2 个 CPU 周期
  • 当 GC 不活跃时(99.9% 的时间),这是全部开销
  • 在 GC 活跃时,线程在此阻塞,但这是 GC 正常工作所需的行为

只有以下 IR 指令会触发安全点检查:

IR 指令类别 安全点检查 检查频率
CallInterp_xxx 2 次(调用前 + 返回后) 方法调用频率
CallNative_xxx 2 次(调用前 + 返回后) 方法调用频率
NewClassVar 1 次(分配前) 对象分配频率
NewArrVar 1 次(分配前) 数组分配频率
LoadStrVar 1 次(字符串分配可能触发 GC) 字符串加载频率
BoxVar 1 次(装箱可能触发 GC) 装箱操作频率

这与原生 IL2CPP 的处理一致——原生代码也是在方法调用、对象分配、装箱等位置检查安全点。因此解释器在 GC 安全方面的行为与原生代码保持语义一致。

4.3 写屏障的开销

写屏障对指令分发的影响仅体现在引用类型写入的 IR 指令上(StfldRefVarVarStelemRefVarVar 等):

// 写屏障的成本分析——Intel x86_64 平台
// il2cpp_gc_wbarrier_set_field 的典型实现
// 对于分代式 GC(如 SGen 或 Unity 的增量式 GC):
// 1. 计算对象所在 card 的索引
// 2. 在 card table 中标记该 card
// 此操作大约需要 10-20 个 CPU 周期

但在典型的 C# 程序中:

  • 字段写入中,引用类型的写入占比通常在 20-40%(其余为 int、float、bool 等值类型写入)
  • 一次引用写入的操作自身(对象地址计算、内存写入)已经需要 10 个周期以上
  • 写屏障额外增加 10-20 周期,总开销增加约 50-100%

这个开销是可以接受的,因为写屏障是 GC 正确性的必要条件——如果省略写屏障,分代式 GC 可能错误地回收还被引用的对象。

4.4 调试模式下的额外检查

在调试模式下,解释器会增加额外的检查来捕获异常处理和 GC 相关的问题:

#ifdef HYBRIDCLR_DEBUG

// 调试模式下的异常流栈完整性检查
void DebugCheckExFlowStack(MachineState& state)
{
    // 检查是否有孤立的异常流条目
    if (state.exFlowStackSize > 0)
    {
        // 打印当前异常流状态
        LogWarning("异常流栈非空:size=%d", state.exFlowStackSize);
        for (int32_t i = 0; i < state.exFlowStackSize; i++)
        {
            ExceptionFlowInfo* info = &state.exFlowStackBase[i];
            LogWarning("  [%d] type=%d clauseIdx=%d", i, info->type, info->clauseIndex);
        }
    }
}

// 调试模式下的局部变量类型验证
void DebugCheckLocalVarType(InterpFrame* frame, uint32_t slot, StackObjectType expected)
{
    // 检查特定槽位的类型是否与预期一致
    // 在发布模式下此函数为空
}

// 调试模式下的 LeaveFrame 完整性检查
void DebugCheckLeaveFrame(InterpFrame* frame, MachineState& state)
{
    // 确保 LeaveFrame 时异常流栈已被正确清理
    if (state.exFlowStackSize > 0)
    {
        LogError("LeaveFrame 时异常流栈非空!method=%s", frame->method->name);
    }
}

#endif // HYBRIDCLR_DEBUG

这些调试检查在测试阶段捕获了大量难以追踪的错误,如:

  • finally 块未正确执行——异常流栈在方法返回时非空,表明存在异步异常或 IR 生成错误
  • 类型混淆——引用类型槽位被错误地写入了非指针值,可能导致 GC 崩溃
  • 帧栈损坏——EnterFrame/LeaveFrame 调用次数不匹配

所有这些调试检查在发布版本中被条件编译完全移除(零运行时开销)。

4.5 GC 安全性与性能的综合权衡

HybridCLR 在解释器中的 GC 支持设计遵循以下原则:

原则 具体措施 影响
共享托管堆 解释器使用 IL2CPP 的分配函数 同一 GC 管理所有对象,无额外 GC
精确式栈扫描 编译器生成 GC 描述符 避免保守式扫描的误报
选择性安全点 仅在调用/分配处检查 安全性与性能的平衡
标准写屏障 引用写入后触发写屏障 分代式 GC 兼容性
调试检查 条件编译的完整性验证 调试期捕获错误,发布期零开销

总结

本文深入分析了 HybridCLR 解释器中的异常处理和垃圾回收机制。核心要点:

  1. 异常子句的 IR 表示——IL 层面的异常处理结构被编译器转换为 IrBody::exceptionClauses 数组,包含标志(catch/filter/finally/fault)和 try/handler 块的 IR 偏移范围

  2. ThrowEx 指令的三步处理——获取异常对象、TryFindAndExecuteHandler 匹配当前方法的 handler、不匹配则通过 HANDLE_EXCEPTION 路径向调用方传播。如果 throw null,运行时自动创建 NullReferenceException

  3. 异常子句匹配的线性搜索——TryFindAndExecuteHandler 遍历 exceptionClauses 数组,比较异常抛出点的 IR 偏移与 try 块的偏移范围。catch 需要类型匹配(通过 IsExceptionTypeMatch 遍历继承链),finally 直接执行

  4. catch 块的 IP 重定向——匹配成功后执行 ExecuteCatchHandler,将异常对象写入 localVarBase 的指定槽位,设置 frame->exClauseIndex,然后重定向 state.ip 到 handler 的 IR 起始位置

  5. 异常流栈(Exception Flow Stack)——独立于评估栈和帧栈的第三条栈,通过 ExceptionFlowInfo 条目跟踪异常处理状态(FINALLY/LEAVE/CATCH/FAULT)。LeaveEx 和异常路径都可能压入 finally 条目,EndFinallyEx 负责弹出并恢复执行

  6. 异常传播的帧链遍历——HANDLE_EXCEPTION 出口路径不经过 LeaveFrame,直接修改 state.currentFrame = frame->previous,通过递归 return 向上传播。finally 块通过 ExecutePendingFinallyBlocks 在当前帧中先执行完毕

  7. GC 安全点的选择性检查——仅在方法调用(CallInterp_xxx/CallNative_xxx 前后)、对象分配(NewClassVar/NewArrVar)、字符串加载和装箱操作处检查。安全点检查在非 GC 期间仅消耗 1-2 个 CPU 周期

  8. 解释器帧的精确式 GC 栈扫描——编译器在 IR 编译阶段生成 GC 描述符位图(gcTrackTypes),标记每个 localVarBase 槽位是否为对象引用。GC 标记阶段遍历 InterpFrame 链表,根据位图精确扫描每个帧中的引用

  9. 写屏障的引用写入集成——引用类型字段写入(StfldRefVarVar)在内存写入后调用 il2cpp_gc_wbarrier_set_field,确保分代式 GC 能正确追踪引用关系。值类型写入无需写屏障

  10. 性能与正确性的平衡——异常处理在正常路径上遵循零开销原则(try 块内无额外检查),异常路径开销 50-200 周期。调试模式下的额外检查(异常流栈完整性、局部变量类型验证、LeaveFrame 一致性)在发布版本中完全移除


参考资源

  • hybridclr/interpreter/Interpreter_Execute.cpp — ThrowEx、RethrowEx、LeaveEx、EndFinallyEx 等异常相关 IR 指令的处理
  • hybridclr/interpreter/InterpreterDefs.h — ExceptionFlowInfo、ExceptionFlowType 定义,异常流栈常量
  • hybridclr/interpreter/Engine.h/.cpp — EnterFrame/LeaveFrame,以及异常流栈的 Push/Pop 实现
  • hybridclr/interpreter/InterpreterModule.h/.cpp — ExecuteMain 主循环中的 HANDLE_EXCEPTION 出口路径
  • hybridclr/metadata/ — InterpExceptionClause 结构体定义,异常子句的元数据表示
  • hybridclr/transform/ — 编译器端的异常子句转换(IL exception clause → IR exceptionClauses)
  • hybridclr/interpreter/InterpreterFramePool.cpp — 帧池实现中的异常处理支持
  • 第 25 篇(编译器模块:异常与垃圾回收集成)— 编译器端的异常子句 IR 生成
  • 第 26 篇(解释器总览)— MachineState 三条运行时栈(包括异常流栈)
  • 第 28 篇(栈帧管理)— InterpFrame 的帧链表、EnterFrame/LeaveFrame、栈溢出检测
  • 第 29 篇(方法调用与虚方法分发)— CallInterp 递归执行的异常传播路径
  • ECMA-335 Partition I §12.4 — .NET 异常处理规范
  • ECMA-335 Partition II §22.11 — Exception Handler 元数据格式
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐