跳到主要内容

· 阅读需 24 分钟

原始IL指令集是基于栈的指令集,优点是指令个数少、优雅、紧凑,非常适合表示虚拟机逻辑,但并不适合被解释器高效解释运行。因此我们需要将它转换为自定义的另一种能够高效 解释执行的指令集,然后再在我们的解释器中运行。

IL指令集的缺陷

  • IL是基于栈的指令,运行时维护执行栈是个无谓的开销
  • IL有大量单指令多功能的指令,如add指令可以用于计算int、long、float、double类型的和,导致运行时需要根据上文判断到底该执行哪种计算。不仅增加了运行时判定的开销,还增加了运行时维护执行栈数据类型的开销
  • IL指令包含一些需要运行时resolve的数据,如newobj指令第一个参数是method token。token resolve是一个开销很大的操作,每次执行都进行resolve会极大拖慢执行性能
  • IL是基于栈的指令,压栈退栈相关指令数较多。像a=b+c这样的指令需要4条指令完成,而如果采用基于寄存器的指令,完全可以一条指令完成。
  • IL不适合做其他优化操作,如我们的InitOnce JIT技术。
  • 其他

hybridclr令集设计目标

hybridclr指令集设计目标就是解决原始IL的缺陷,以及做一些更高级的优化,目前主要包含以下功能

  • 指令中包含要操作的目标地址,不再需要维护栈
  • 对于单指令多功能指针需要为每种操作数据类型维护一个对应指令,免去运行时维护类型及判定类型的开销。如如add指令,要特例化为add_int、add_long之类的指令
  • 对于涉及到token解析的指令,尽量转换指令时直接固定,省去计算或者查询的开销。如 newobj中 method token字段,直接转变成 MethodInfo* 元数据
  • 对于一些常见操作如 a = b + c,需要 ldloc b , ldloc c, add, stloc a 这4条指令,我们希望提供专用指令折叠它
  • 需要能与一些常见的优化技术配合,如函数inline、InitOnce动态jit技术
  • 需要考虑到跨平台问题,如在armv7这种要求内存对齐的硬件上,也能高效执行

hybridclr使用经典的寄存器指令集配合一些其他运行时设施实现以上目标。

运行环境

无论指令的内容如何,指令的执行结果必须产生副作用(哪怕是nop这种什么也不干的指令,也导致当前指令寄存器ip发生变化),而这些副作用,必然作用于运行环境(甚至有可能作用于指令本身,如InitOnce技术)。正因为指令执行离不开具体的执行环境,指令设计必然跟hybridclr解释器及il2cpp运行时的运行环境紧密相关。

hybridclr函数帧栈

大多数基础指令都是操作 函数参数、局部变量、执行栈顶的数据,在hybridclr中使用数据栈来存放这些数据,并以逻辑地址(类型uint16_t)来标识要操作的数据的位置。逻辑地址是局部的,每个函数的执行栈帧的逻辑地址从0开始,最大2^16-1。

逻辑地址的布局如下

method frame

函数帧根据其嵌套顺序,在数据栈上的位置从低位向高位扩展,如下图

method frame

数据栈每个slot都为一个size=8的StackObject类型对象,每个变量可能占1个或多个slot。例如int类型变量只占一个slot,但Vector3类型变量占2个slot。

localVarBase 指针为函数帧栈的基准位置。

union StackObject
{
void* ptr; // can't adjust position. will raise native_invoke init args bugs.
bool b;
int8_t i8;
uint8_t u8;
int16_t i16;
uint16_t u16;
int32_t i32;
uint32_t u32;
int64_t i64;
uint64_t u64;
float f4;
double f8;
Il2CppObject* obj;
Il2CppString* str;
Il2CppObject** ptrObj;
};

StackObject* localVarBase;

运行时相关

除了只操作函数帧栈及当前函数状态的指令外,剩下的指令都要依赖于il2cpp运行时及hybridclr解释器执行环境MachineState提供的api才能完成功能。更细化地说,又分为几类

  • metadata数据相关。如typeof指令,依赖于运行时将token转换为Il2Class*运行时metadata
  • 对象相关。如 ldsfld指令 类型静态成员访问
  • gc相关。如newobj指令依赖于gc相关机制分配对象内存
  • 多线程相关。如ThreadStatic类型的类静态成员变量
  • 其他一些特殊机制。如localloc指令依赖于 hybridclr的MachineState提供api来分配内存

指令结构

hybridclr指令结构如下

instrument

其中指令的前2字节是opcode,剩下的指令数据我们称之为指令param。param分为几种类型:

  • 数据逻辑地址。
  • 普通字面常量。比如a = b + 5,其中常量5必然要体现到指令之中
  • resolve后的数据。如 ldtoken,为了优化性能不想每次执行时计算token,那就必然要把运行时resolve好的token对应的元数据包含到指令中
  • resolve后的数据的指针。一些指令中包含不定长度的数据,如switch语句可能包含n个case项目的跳转地址。为了让指令本身大小固定,我们将这些不定长的数据存到InterpMethodInfo的resolvedDatas中,用一个uint32_t的索引指向它的位置。
  • 其他一些辅助数据
  • 为了保证param内存对齐访问而插入的padding参数

跨平台兼容性

指令直接相关的跨平台兼容性问题主要是内存对齐问题。目前arm64 CPU支持非对齐的内存访问,但armv7仍然要求内存对齐,否则一旦发生非对齐访问,要么就运行效率大幅下降,要么直接导致崩溃。 尽管可以针对64位和32位设计两套完全不同的指令,但出于方便维护考虑,hybridclr还是统一使用了一套指令集。

hybridclr指令的一些设计约束:

  • 每条指令的前2字节必须为opcode
  • 满足内存对齐。指令param的size可能是1、2、4、8。为了满足内存对齐的要求,我们在param之间插入一些uint8_t类型的无用padding数据。

padding优化

为了最大程度减少浪费的padding数据空间,我们将所有param排序,从小到大排列,同时插入padding以满足内存对齐。经过不太复杂的推理,我们可以知道,每条指令最多浪费7字节的padding空间。

指令实现

由于IL指令众多,我们无法一一介绍所有指令对应的hybridclr指令集设计,我们分为几大类详细介绍。

空指令

如nop、pop指令,直接在transform阶段就被消除,完全不产生对应的hybridclr指令。

简单数据复制指令

典型有

  • 操作函数参数的指令。如 ldarg、starg、ldarga
  • 操作函数局部变量的指令。如 ldloc、stloc、ldloca
  • 隐含操作eval stack栈顶数据的指令。如add、dup

对于操作函数帧栈的指令,一般要做以下几类处理

  • 为源数据和目标数据添加对应的逻辑地址字段
  • 对于源数据或者目标数据有多个变种的指令,统一为带逻辑地址字段的指令。如ldarg.0 - ldarg.3、ldarg、ldarg.s 都统一为一条指令。

以典型的ldarg指令为例。如果被操作函数参数的类型为int时,对应的hybridclr指令为

struct IRCommon
{
uint16_t opcode;
}

struct IRLdlocVarVar : IRCommon
{
uint16_t dst;
uint16_t src;
uint8_t __pad6;
uint8_t __pad7;
};

// 对应解释执行代码
case HiOpcodeEnum::LdlocVarVar:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint16_t __src = *(uint16_t*)(ip + 4);
(*(uint64_t*)(localVarBase + __dst)) = (*(uint64_t*)(localVarBase + __src));
ip += 8;
continue;
}

  • dst 指向当前执行栈顶的逻辑地址
  • src ldarg中要加载的变量的逻辑地址
  • __pad6 为了内存对齐而插入的
  • __pad7 同上

需要expand目标数据的指令

根据CLI规范,像byte、sbyte、short、ushort这种size小于4的primitive类型,以及underlying type为这些primitive类型的枚举,它们被加载到evaluate stack时,需要符号扩展为int32_t类型数据。我们不想执行ldarg指令时作运行时判断,因为这样会降低性能。因此为这些size小于4的操作,单独设计了对应的指令。

以byte类型为例,对应的hybridclr指令为

struct IRLdlocExpandVarVar_u1 : IRCommon
{
uint16_t dst;
uint16_t src;
uint8_t __pad6;
uint8_t __pad7;
};

// 对应解释执行代码
case HiOpcodeEnum::LdlocExpandVarVar_u1:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint16_t __src = *(uint16_t*)(ip + 4);
(*(int32_t*)(localVarBase + __dst)) = (*(uint8_t*)(localVarBase + __src));
ip += 8;
continue;
}

静态特例化的指令

有一类指令的实际执行方式跟它的参数类型有关,如add。当操作的数是int、long、float、double时,执行对应类型的数据相加操作。但实际上由于IL程序的静态性,每条指令操作的数据类型肯定是固定的,并不需要运行时维护数据类型,并且根据数据类型决定执行什么操作。我们使用一种叫静态特例化的技术,为这种指令设计了多条hybridclr指令,在transform时,根据具体的操作数据类型,生成相应的指令。

以add 对两个int32_t类型数据相加为例

struct IRBinOpVarVarVar_Add_i4 : IRCommon
{
uint16_t ret;
uint16_t op1;
uint16_t op2;
};

// 对应解释执行代码
case HiOpcodeEnum::BinOpVarVarVar_Add_i4:
{
uint16_t __ret = *(uint16_t*)(ip + 2);
uint16_t __op1 = *(uint16_t*)(ip + 4);
uint16_t __op2 = *(uint16_t*)(ip + 6);
(*(int32_t*)(localVarBase + __ret)) = (*(int32_t*)(localVarBase + __op1)) + (*(int32_t*)(localVarBase + __op2));
ip += 8;
continue;
}

直接包含常量的指令

有一些指令包含普通字面常量,如ldc指令。相应的寄存器指令只是简单地添加了相应大小的字段。

以ldc int32_t类型数据为例

struct IRLdcVarConst_4 : IRCommon
{
uint16_t dst;
uint32_t src;
};

// 对应解释执行代码
case HiOpcodeEnum::LdcVarConst_4:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint32_t __src = *(uint32_t*)(ip + 4);
(*(int32_t*)(localVarBase + __dst)) = __src;
ip += 8;
continue;
}

隐含常量的指令

有一些指令隐含了所操作的常量,如 ldnull、ldc.i4.0 - ldc.i4.8 等等。对于这类指令,如果有对应的直接包含常量的指令的实现,则简单转换为 上一节中介绍的 直接包含常量的指令。后续可能会进一步优化。

以ldnull为例

struct IRLdnullVar : IRCommon
{
uint16_t dst;
uint8_t __pad4;
uint8_t __pad5;
uint8_t __pad6;
uint8_t __pad7;
};

// 对应解释执行代码
case HiOpcodeEnum::LdnullVar:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
(*(void**)(localVarBase + __dst)) = nullptr;
ip += 8;
continue;
}

指令共享

为了减少指令数量,操作相同size常量的ldc指令会被合并为同一个。如ldloc.r4 指令就被合并到ldloc.i4指令的实现。

包含resolved后数据的指令

有一些指令包含metadata token,如sizeof、ldstr、newobj。为了避免巨大的运行时resolve开销,hybridclr在transform这些指令时就已经将包含token数据resolve为对应的runtime metadata。

更细致一些,又分为两类。

直接包含resolved后数据的指令

以sizeof为例,原始指令token为类型信息,transform时,直接计算了对应ValueType的size,甚至都不需要专门为sizeof设计对应的指令,直接使用现成的LdcVarConst_4指令。

case OpcodeValue::SIZEOF:
{
uint32_t token = (uint32_t)GetI4LittleEndian(ip + 2);
Il2CppClass* objKlass = image->GetClassFromToken(token, klassContainer, methodContainer, genericContext);
IL2CPP_ASSERT(objKlass);
int32_t typeSize = GetTypeValueSize(objKlass);
CI_ldc4(typeSize, EvalStackReduceDataType::I4);
ip += 6;
continue;
}

间接包含resolved后数据的指令

像ldstr、newobj这些指令包含的token经过resolve后,变成对应runtime metadata的指针,考虑到指针在不同平台大小不一,因此不直接将这个指针放到指令中,而是换成一个uint32_t类型的指向InterpMethodInfo::resolvedData字段的index param。执行过程中需要一次向resolvedData的查询操作,时间复杂度为O(1)。

以newobj指令为例

struct IRLdstrVar : IRCommon
{
uint16_t dst;
uint32_t str;
};

// 对应解释执行代码
case HiOpcodeEnum::LdstrVar:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint32_t __str = *(uint32_t*)(ip + 4);
(*(Il2CppString**)(localVarBase + __dst)) = ((Il2CppString*)imi->resolveDatas[__str]);
ip += 8;
continue;
}

分支跳转指令

原始IL字节码使用了相对offset的跳转目标,并且几乎为每条跳转相关指令都设计了near和far offset 两条指令,hybridclr为了简单起见,直接使用4字节的绝对跳转地址。

以br无条件跳转指令为例


struct IRBranchUncondition_4 : IRCommon
{
uint8_t __pad2;
uint8_t __pad3;
int32_t offset;
};

// 对应解释执行代码
case HiOpcodeEnum::BranchUncondition_4:
{
int32_t __offset = *(int32_t*)(ip + 4);
ip = ipBase + __offset;
continue;
}

offset为转换后的指令地址的绝对偏移。

对象成员访问指令

由于字段在对象中的偏移已经完全确定,transform时计算出字段在对象中的偏移,保存为指令的offset param, 执行时根据对象大小,使用this指针和偏移,直接访问字段数据。

以ldfld 读取int类型字段为例


struct IRLdfldVarVar_i4 : IRCommon
{
uint16_t dst;
uint16_t obj;
uint16_t offset;
};

// 对应解释执行代码
case HiOpcodeEnum::LdfldVarVar_i4:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint16_t __obj = *(uint16_t*)(ip + 4);
uint16_t __offset = *(uint16_t*)(ip + 6);
CHECK_NOT_NULL_THROW((*(Il2CppObject**)(localVarBase + __obj)));
(*(int32_t*)(localVarBase + __dst)) = *(int32_t*)((uint8_t*)(*(Il2CppObject**)(localVarBase + __obj)) + __offset);
ip += 8;
continue;
}

ThreadStatic 成员访问指令

在初始化Il2CppClass时,如果它包含ThreadStatic属性标记的静态成员变量,则为它分配一个可以放下这个类型所有ThreadStatic变量的ThreadLocalStorage的连续空间。 借助于il2cpp运行时对ThreadStatic的支持,相关指令实现相当简单直接。

以ldsfld指令为例


struct IRLdthreadlocalVarVar_i4 : IRCommon
{
uint16_t dst;
int32_t offset;
int32_t klass;
};

// 对应解释执行代码
case HiOpcodeEnum::LdthreadlocalVarVar_i4:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint32_t __klass = *(uint32_t*)(ip + 8);
int32_t __offset = *(int32_t*)(ip + 4);

Il2CppClass* _klass = (Il2CppClass*)imi->resolveDatas[__class];
Interpreter::RuntimeClassCCtorInit(_klass);
(*(int32_t*)(localVarBase + __dst)) = *(int32_t*)((byte*)il2cpp::vm::Thread::GetThreadStaticData(_klass->thread_static_fields_offset) + __offset);
ip += 16;
continue;
}

数组访问相关指令

比较常规直接,不过有个特殊点:根据规范index变量可以是i4或者native int类型。由于数组访问是非常频繁的操作,我们不想插入运行时数据类型类型及转换,因为我们根据index变量的size为每条数组相关指令设计了2条hybridclr指令。

以ldelem.i4 指令的index是i4类型的情形为例

struct IRGetArrayElementVarVar_i4_4 : IRCommon
{
uint16_t dst;
uint16_t arr;
uint16_t index;
};

// 对应解释执行代码
case HiOpcodeEnum::GetArrayElementVarVar_i4_4:
{
uint16_t __dst = *(uint16_t*)(ip + 2);
uint16_t __arr = *(uint16_t*)(ip + 4);
uint16_t __index = *(uint16_t*)(ip + 6);
Il2CppArray* arr = (*(Il2CppArray**)(localVarBase + __arr));
CHECK_NOT_NULL_AND_ARRAY_BOUNDARY(arr, (*(int32_t*)(localVarBase + __index)));
(*(int32_t*)(localVarBase + __dst)) = il2cpp_array_get(arr, int32_t, (*(int32_t*)(localVarBase + __index)));
ip += 8;
continue;
}

函数调用指令

目前调用AOT函数和调用Interpreter函数使用不同的指令,因为Interpreter函数可以直接复用已经压到栈顶的数据,可以完全优化掉 Manged2Native -> Native2Managed 这个过程,提升性能。

调用解释器函数时可以复用当前 InterpreterModule::Execute函数帧,也节省了函数调用开销,同时也避免了解释器嵌套调用过深导致native栈overflow的问题。

对于带返回值的函数,由于多了一个返回值地址参数ret,与返回void的函数分别设计了不同指令。

如果调用的是AOT函数,由于每条函数的参数不定,我们将参数信息记录到resolvedDatas,然后argIdxs中保存这个间接索引。另外还需要通过桥接函数完成解释器函数参数到native abi函数参数的转换,为了避免运行时查找的开销,也提前计算了这个桥接函数,记录到resolvedDatas中,然后在managed2NativeMethod中保存了这个间接索引。

以call指令为例,为它设计了5条指令

  • IRCallNative_void
  • IRCallNative_ret
  • IRCallNative_ret_expand
  • IRCallInterp_void
  • IRCallInterp_ret

以IRCallNative_ret的实现为例,介绍调用AOT函数的指令:


struct IRCallNative_ret : IRCommon
{
uint16_t ret;
uint32_t managed2NativeMethod;
uint32_t methodInfo;
uint32_t argIdxs;
};

// 对应解释执行代码
case HiOpcodeEnum::CallNative_ret:
{
uint32_t __managed2NativeMethod = *(uint32_t*)(ip + 4);
uint32_t __methodInfo = *(uint32_t*)(ip + 8);
uint32_t __argIdxs = *(uint32_t*)(ip + 12);
uint16_t __ret = *(uint16_t*)(ip + 2);
void* _ret = (void*)(localVarBase + __ret);
((Managed2NativeCallMethod)imi->resolveDatas[__managed2NativeMethod])(((MethodInfo*)imi->resolveDatas[__methodInfo]), ((uint16_t*)&imi->resolveDatas[__argIdxs]), localVarBase, _ret);
ip += 16;
continue;
}

如果调用Interpreter函数,由于函数参数已经按顺序压到栈上,只需要一个argBase参数指定arg0逻辑地址即可,不需要借助resolvedDatas,也不需要managed2NativeMethod桥接函数指针。 这也是解释器函数不受桥接函数影响的原因。

以IRCallInterp_ret为例,介绍调用Interpreter函数的指令:

struct IRCallInterp_ret : IRCommon
{
uint16_t argBase;
uint16_t ret;
uint8_t __pad6;
uint8_t __pad7;
uint32_t methodInfo;
};

// 对应解释执行代码
case HiOpcodeEnum::CallInterp_ret:
{
MethodInfo* __methodInfo = *(MethodInfo**)(ip + 8);
uint16_t __argBase = *(uint16_t*)(ip + 2);
uint16_t __ret = *(uint16_t*)(ip + 4);
CALL_INTERP_RET((ip + 16), __methodInfo, (StackObject*)(void*)(localVarBase + __argBase), (void*)(localVarBase + __ret));
continue;
}

异常机制相关指令

异常机制相关指令本身不复杂,但异常处理机制非常复杂。

异常这种特殊的流程控制指令,跟分支跳转指令相似,原始指令里包含了相对offset,为了简单起见,指令转换时我们改成int32_t类型的绝对offset。

以leave指令为例

struct IRLeaveEx : IRCommon
{
uint8_t __pad2;
uint8_t __pad3;
int32_t offset;
};

// 对应解释执行代码
case HiOpcodeEnum::LeaveEx:
{
int32_t __offset = *(int32_t*)(ip + 4);
LEAVE_EX(__offset);
continue;
}

一些额外的instinct 指令

对于一些特别常见的函数,为了优化性能,hybridclr直接内置了相应的指令,例如 new Vector{2,3,4},如可空变量相关操作。这些instinct指令的执行性能基本与AOT持平。

以 new Vector3() 为例


struct IRNewVector3_3 : IRCommon
{
uint16_t obj;
uint16_t x;
uint16_t y;
uint16_t z;
uint8_t __pad10;
uint8_t __pad11;
uint8_t __pad12;
uint8_t __pad13;
uint8_t __pad14;
uint8_t __pad15;
};

// 对应解释执行代码
case HiOpcodeEnum::NewVector3_3:
{
uint16_t __obj = *(uint16_t*)(ip + 2);
uint16_t __x = *(uint16_t*)(ip + 4);
uint16_t __y = *(uint16_t*)(ip + 6);
uint16_t __z = *(uint16_t*)(ip + 8);
*(HtVector3f*)(*(void**)(localVarBase + __obj)) = {(*(float*)(localVarBase + __x)), (*(float*)(localVarBase + __y)), (*(float*)(localVarBase + __z))};
ip += 16;
continue;
}

InitOnce 指令

有一些指令(如ldsfld)第一次执行的时候需要进行初始化操作,但后续再次执行时,不需要再执行初始化操作。但即使这样,免不了一个检查是否已经初始化的操作,我们希望完全优化掉这个检查行为。InitOnce动态JIT技术用于解决这个问题。

InitOnce是hybridclr的专利技术,暂未在代码中实现,这儿不详细介绍。

其他技术相关指令

限于篇幅,对于这些指令,会在单独的文章中介绍

总结

至此我们完成hybridclr指令集实现相关介绍。

· 阅读需 23 分钟

我们在上一节完成了hybridclr可行性分析。由于hybridclr内容极多,限于篇幅本篇文章主要概述性介绍hybridclr的技术实现。

CLR和il2cpp基础

给纯AOT的il2cpp运行时添加一个原生interpreter模块,最终实现hybrid mode execution,这看起来是非常复杂的事情。

其实不然,程序不外乎代码+数据。CLR运行中做的事情,综合起来主要就几种:

  1. 执行简单的内存操作或者计算或者逻辑跳转。这部分与CLI的Base指令集大致对应
  2. 执行一个依赖于元数据信息的基础操作。例如 a.x, arr[3] 这种,依赖于元数据信息才能正确工作的代码。对应部分CLI的Object Model指令集。
  3. 执行一个依赖元数据的较复杂的操作。如 typeof(object),a is string、(object)5 这种依赖于运行时提供的函数及相应元数据才正确工作的代码。对应部分CLI的Object Model指令集。
  4. 函数调用。包括且不限于被AOT函数调用及调用AOT函数,及interpreter之间的函数调用。对应CLI指令集中的 call、callvir、newobj 等Object Model指令。

如果对CLR有深入的了解和透彻的分析,为了实现hybrid mode execution,hybridclr核心要完成的就以下两件事,其他则是无碍全局的细节:

  • assembly信息能够加载和注册。 在此基础可以实现 1-3
  • 确保interpreter函数能被找到并且被调用,并且能执行出正确的结果。则可以实现 4

由于彻底理解以上内容需要较丰富的对CLR的认知以及较强的洞察力,我们不再费口舌解释,不能理解的开发者不必深究,继续看后续章节。

核心模块

从功能来看包含以下核心部分:

  • metadata初级解析
  • metadata高级元数据结构解析
  • metadata动态注册
  • 寄存器指令集设计
  • IL指令集到hybridclr寄存器指令集的转换
  • 解释执行hybridclr指令集
  • 其他如GC、多线程相关处理

从代码结构来看包含三个目录:

  • metadata 元数据相关
  • transform 指令集转换相关
  • interpreter 解释器相关

metadata 初级解析

这部分内容技术门槛不高,但比较琐碎和辛苦,忠实地按照 ECMA-335规范 的文档实现即可。对于少量有疑惑的地方,可以网上的资料或者借鉴mono的代码。

相关代码在hybridclr\metadata目录,主要在RawImage.h和RawImage.cpp中实现。如果再细分,相关实现分为以下几个部分。

PE 文件结构解析

managed dll扩展了PE文件结构,增加了CLI相关metadata部分。这环节的主要工作有:

  • 解析PE headers
  • 解析 section headers,找出CLI header,定位出cli数据段
  • 解析出所有stream。Stream是CLI中最底层的数据结构之一,CLI将元数据根据特性分为几个大类
    • #~ 流。包含所有tables定义,是最核心的元数据结构
    • #Strings 流。包括代码中非文档类型的字符串,如类型名、字段名等等
    • #GUID 流
    • #Blob 流。一些元数据类型过于复杂,以blob格式保存。还有一些数据如数组初始化数据列表,也常常保存到Blob流。
    • #- 流
    • #Pdb 流。用于调试

解析PE文件和代码在RawImage::Load,解析stream对应的代码在RawImage::LoadStreams。

tables metadata 解析

CLI中大多数metadata被为几十种类型,每个类型的数据组织成一个table。对于每个table,每行记录都是相同大小。

初级解析中不解析table中每行记录,只解析table的每行记录大小和每个字段偏移。有一大类字段为Coded Index类型,有可能是2或4字节,并不固定,需要根据其他表的Row Count来决定table中这一列的字段大小。由于table很多,这个计算过程比较琐碎易错。

对应代码在RawImage::LoadTables,截取部分代码如下

void RawImage::BuildTableRowMetas()
{
{
auto& table = _tableRowMetas[(int)TableType::MODULE];
table.push_back({ 2 });
table.push_back({ ComputStringIndexByte() });
table.push_back({ ComputGUIDIndexByte() });
table.push_back({ ComputGUIDIndexByte() });
table.push_back({ ComputGUIDIndexByte() });
}
{
auto& table = _tableRowMetas[(int)TableType::TYPEREF];
table.push_back({ ComputTableIndexByte(TableType::MODULE, TableType::MODULEREF, TableType::ASSEMBLYREF, TableType::TYPEREF, TagBits::ResoulutionScope) });
table.push_back({ ComputStringIndexByte() });
table.push_back({ ComputStringIndexByte() });
}

// ... 其他
}

table 解析

上一节已经解析出每个table的起始数据位置、row count、表中每个字段的偏移和大小,有足够的信息可以解析出每个table中任意row的数据。table中row的id从1开始。

每个table的row的解析方式根据ECMA规范实现即可。每个table的row定义在 metadata\Coff.h文件,Row解析代码在 RawImage.h。这些解析代码都非常相似,为了避免错误,使用了大量的宏,截取部分代码如下:

TABLE2(GenericParamConstraint, TableType::GENERICPARAMCONSTRAINT, owner, constraint)
TABLE3(MemberRef, TableType::MEMBERREF, classIdx, name, signature)
TABLE1(StandAloneSig, TableType::STANDALONESIG, signature)
TABLE3(MethodImpl, TableType::METHODIMPL, classIdx, methodBody, methodDeclaration)
TABLE2(FieldRVA, TableType::FIELDRVA, rva, field)
TABLE2(FieldLayout, TableType::FIELDLAYOUT, offset, field)
TABLE3(Constant, TableType::CONSTANT, type, parent, value)
TABLE2(MethodSpec, TableType::METHODSPEC, method, instantiation)
TABLE3(CustomAttribute, TableType::CUSTOMATTRIBUTE, parent, type, value)

metadata高级元数据结构解析

从tables里直接读出来的都是持久化的初始metadata,而运行时需要的不只是这些简单原始数据,经常需要进一步resolve后的数据。例如

  • Il2CppType 。即可以是简单的 int,也可以是比较复杂的List<int>,甚至是特别复杂的List<(int,int)>&
  • MethodInfo 。 即可以是简单的object.ToString,也有复杂的泛型 IEnumerator<int>.Count

CLI的泛型机制导致元数据变得极其复杂,典型的是TypeSpec,MethodSpec,MemberSpec相关元数据的运行时解析。核心实现代码在Image.cpp中实现,剩余一部分在 InterpreterImage.cpp及AOTHomologousImage.cpp中实现。后面会有专门介绍。

metadata动态注册

根据粒度从大到小,主要分为以下几类

  • Assembly 注册。即将加载的assembly注册到il2cpp的元数据管理中。
  • TypeDefinition 注册。 这一步会生成基础运行时类型 Il2CppClass。
  • VTable虚表计算。 由于il2cpp的虚表计算是个黑盒,内部相当复杂,我们费了很多功夫才研究明白它的计算机制。后面会有专门章节介绍VTable计算,这儿不再赘述。
  • 其他元数据,如CustomAttribute计算等等。

Assembly 注册

Assembly加载的关键函数在 il2cpp::vm::MetadataCache::LoadAssemblyFromBytes 。由于il2cpp是AOT运行时,原始实现只是简单地抛出异常。我们修改和完善了实现,在其中调用了hybridclr::metadata::Assembly::LoadFromBytes,完成了Assembly的创建,然后再注册到全局Assemblies列表。相关代码实现如下:

const Il2CppAssembly* il2cpp::vm::MetadataCache::LoadAssemblyFromBytes(const char* assemblyBytes, size_t length)
{
il2cpp::os::FastAutoLock lock(&il2cpp::vm::g_MetadataLock);

Il2CppAssembly* newAssembly = hybridclr::metadata::Assembly::LoadFromBytes(assemblyBytes, length, true);
if (newAssembly)
{
// avoid register placeholder assembly twicely.
for (Il2CppAssembly* ass : s_cliAssemblies)
{
if (ass == newAssembly)
{
return ass;
}
}
il2cpp::vm::Assembly::Register(newAssembly);
s_cliAssemblies.push_back(newAssembly);
return newAssembly;
}

return nullptr;
}

TypeDefinition 注册

Assembly使用了延迟初始化方式,注册后Assembly中的类型信息并未创建相应的运行时metadata Il2CppClass,只有当第一次访问到该类型时才进行初始化。

由于交叉依赖以及为了优化性能,Il2Class的创建是个分步过程

  • Il2CppClass 基础创建
  • Il2CppClass的子元数据延迟初始化
  • 运行时Class初始化

Il2CppClass基础创建

在上一节加载Assembly时已经创建好所有类型对应的定义数据Il2CppTypeDefinition,在 il2cpp::vm::GlobalMetadata::FromTypeDefinition 中完成Il2CppClass创建工作。代码如下:

Il2CppClass* il2cpp::vm::GlobalMetadata::FromTypeDefinition(TypeDefinitionIndex index)
{
/// ... 省略其他
Il2CppClass* typeInfo = (Il2CppClass*)IL2CPP_CALLOC(1, sizeof(Il2CppClass) + (sizeof(VirtualInvokeData) * typeDefinition->vtable_count));
typeInfo->klass = typeInfo;
typeInfo->image = GetImageForTypeDefinitionIndex(index);
typeInfo->name = il2cpp::vm::GlobalMetadata::GetStringFromIndex(typeDefinition->nameIndex);
typeInfo->namespaze = il2cpp::vm::GlobalMetadata::GetStringFromIndex(typeDefinition->namespaceIndex);
typeInfo->byval_arg = *il2cpp::vm::GlobalMetadata::GetIl2CppTypeFromIndex(typeDefinition->byvalTypeIndex);
typeInfo->this_arg = typeInfo->byval_arg;
typeInfo->this_arg.byref = true;
typeInfo->typeMetadataHandle = reinterpret_cast<const Il2CppMetadataTypeHandle>(typeDefinition);
typeInfo->genericContainerHandle = GetGenericContainerFromIndex(typeDefinition->genericContainerIndex);
typeInfo->instance_size = typeDefinitionSizes->instance_size;
typeInfo->actualSize = typeDefinitionSizes->instance_size; // actualySize is instance_size for compiler generated values
typeInfo->native_size = typeDefinitionSizes->native_size;
typeInfo->static_fields_size = typeDefinitionSizes->static_fields_size;
typeInfo->thread_static_fields_size = typeDefinitionSizes->thread_static_fields_size;
typeInfo->thread_static_fields_offset = -1;
typeInfo->flags = typeDefinition->flags;
typeInfo->valuetype = (typeDefinition->bitfield >> (kBitIsValueType - 1)) & 0x1;
typeInfo->enumtype = (typeDefinition->bitfield >> (kBitIsEnum - 1)) & 0x1;
typeInfo->is_generic = typeDefinition->genericContainerIndex != kGenericContainerIndexInvalid; // generic if we have a generic container
typeInfo->has_finalize = (typeDefinition->bitfield >> (kBitHasFinalizer - 1)) & 0x1;
typeInfo->has_cctor = (typeDefinition->bitfield >> (kBitHasStaticConstructor - 1)) & 0x1;
typeInfo->is_blittable = (typeDefinition->bitfield >> (kBitIsBlittable - 1)) & 0x1;
typeInfo->is_import_or_windows_runtime = (typeDefinition->bitfield >> (kBitIsImportOrWindowsRuntime - 1)) & 0x1;
typeInfo->packingSize = ConvertPackingSizeEnumToValue(static_cast<PackingSize>((typeDefinition->bitfield >> (kPackingSize - 1)) & 0xF));
typeInfo->method_count = typeDefinition->method_count;
typeInfo->property_count = typeDefinition->property_count;
typeInfo->field_count = typeDefinition->field_count;
typeInfo->event_count = typeDefinition->event_count;
typeInfo->nested_type_count = typeDefinition->nested_type_count;
typeInfo->vtable_count = typeDefinition->vtable_count;
typeInfo->interfaces_count = typeDefinition->interfaces_count;
typeInfo->interface_offsets_count = typeDefinition->interface_offsets_count;
typeInfo->token = typeDefinition->token;
typeInfo->interopData = il2cpp::vm::MetadataCache::GetInteropDataForType(&typeInfo->byval_arg);

// 省略其他

return typeInfo;
}

可以看到TypeDefinition中字段相当多,这些都是在Assembly加载环节计算好的。

Il2CppClass的子metadata延迟初始化

由于交互依赖以及为了优化性能,Il2Class的子metadata数据使用了延迟初始化策略,分步进行,在第一次使用时才初始化。以下代码截取自 Class.h 文件:

class Class
{
// ... 其他代码
static bool Init(Il2CppClass *klass);

static void SetupEvents(Il2CppClass *klass);
static void SetupFields(Il2CppClass *klass);
static void SetupMethods(Il2CppClass *klass);
static void SetupNestedTypes(Il2CppClass *klass);
static void SetupProperties(Il2CppClass *klass);
static void SetupTypeHierarchy(Il2CppClass *klass);
static void SetupInterfaces(Il2CppClass *klass);
// ... 其他代码
};

重点来了!!!函数metadata的执行指针的绑定在SetupMethods函数中完成,其中关键代码片段如下:

void SetupMethodsLocked(Il2CppClass *klass, const il2cpp::os::FastAutoLock& lock)
{
/// ... 其他忽略的代码
for (MethodIndex index = 0; index < end; ++index)
{
Il2CppMetadataMethodInfo methodInfo = MetadataCache::GetMethodInfo(klass, index);

newMethod->name = methodInfo.name;

if (klass->valuetype)
{
Il2CppMethodPointer adjustorThunk = MetadataCache::GetAdjustorThunk(klass->image, methodInfo.token);
if (adjustorThunk != NULL)
newMethod->methodPointer = adjustorThunk;
}

// We did not find an adjustor thunk, or maybe did not need to look for one. Let's get the real method pointer.
if (newMethod->methodPointer == NULL)
newMethod->methodPointer = MetadataCache::GetMethodPointer(klass->image, methodInfo.token);

newMethod->invoker_method = MetadataCache::GetMethodInvoker(klass->image, methodInfo.token);
}
/// ... 其他忽略的代码
}

函数运行时元数据结构为 MethodInfo,定义如下,

typedef struct MethodInfo
{
Il2CppMethodPointer methodPointer;
InvokerMethod invoker_method;
const char* name;
Il2CppClass *klass;
const Il2CppType *return_type;
const ParameterInfo* parameters;

// ... 省略其他
} MethodInfo;

其中我们比较关心的是methodPointer和invoker_method这两个字段。 methodPointer指向普通执行函数,invoker_method指向反射执行函数。

我们以 methodPointer为例,进一步跟踪它的设置过程, il2cpp::vm::MetadataCache::GetMethodPointer 的实现如下:

Il2CppMethodPointer il2cpp::vm::MetadataCache::GetMethodPointer(const Il2CppImage* image, uint32_t token)
{
uint32_t rid = GetTokenRowId(token);
uint32_t table = GetTokenType(token);
if (rid == 0)
return NULL;

// ==={{ hybridclr
if (hybridclr::metadata::IsInterpreterImage(image))
{
return hybridclr::metadata::MetadataModule::GetMethodPointer(image, token);
}
// ===}} hybridclr

IL2CPP_ASSERT(rid <= image->codeGenModule->methodPointerCount);

return image->codeGenModule->methodPointers[rid - 1];
}

可以看出,如果是解释器assembly,就跳转到解释器元数据模块获得对应的MethodPointer指针。 继续跟踪,相关代码如下:


Il2CppMethodPointer InterpreterImage::GetMethodPointer(uint32_t token)
{
uint32_t methodIndex = DecodeTokenRowIndex(token) - 1;
IL2CPP_ASSERT(methodIndex < (uint32_t)_methodDefines.size());
const Il2CppMethodDefinition* methodDef = &_methodDefines[methodIndex];
return hybridclr::interpreter::InterpreterModule::GetMethodPointer(methodDef);
}

Il2CppMethodPointer InterpreterModule::GetMethodPointer(const Il2CppMethodDefinition* method)
{
const NativeCallMethod* ncm = GetNativeCallMethod(method, false);
if (ncm)
{
return ncm->method;
}
//RaiseMethodNotSupportException(method, "GetMethodPointer");
return (Il2CppMethodPointer)NotSupportNative2Managed;
}

// interpreter/InterpreterModule.cpp
template<typename T>
const NativeCallMethod* GetNativeCallMethod(const T* method, bool forceStatic)
{
char sigName[1000];
ComputeSignature(method, !forceStatic, sigName, sizeof(sigName) - 1);
auto it = s_calls.find(sigName);
return (it != s_calls.end()) ? &it->second : nullptr;
}

// s_calls 定义
static std::unordered_map<const char*, NativeCallMethod, CStringHash, CStringEqualTo> s_calls;

void InterpreterModule::Initialize()
{
for (size_t i = 0; ; i++)
{
NativeCallMethod& method = g_callStub[i];
if (!method.signature)
{
break;
}
s_calls.insert({ method.signature, method });
}

for (size_t i = 0; ; i++)
{
NativeInvokeMethod& method = g_invokeStub[i];
if (!method.signature)
{
break;
}
s_invokes.insert({ method.signature, method });
}
}

这儿根据函数定义计算其签名并且返回了一个函数指针,这个函数指针是什么呢? s_calls在InterpreterModule::Initialize中使用g_callStub初始化。那g_calStub又是什么呢?它在 interpreter/MethodBridge_xxx.cpp 中定义,原来是桥接函数相关的数据结构!

为什么要返回一个这样的函数,而不是直接将methodPointer指向 InterpreterModule::Execute 函数呢? 以 int Foo::Sum(int,int) 函数为例,这个函数的实际的签名为 int32_t (int32_t, int32_t, MethodInfo*),在调用这个methodPointer函数时,调用方一定会传递这三个参数。这些参数每个函数都不一样,如果直接指向 InterpreterModule::Execute 函数,由于ABI调用无法自省(就算可以,性能也比较差),Execute函数既无法提取出普通参数,也无法提取出MethodInfo*参数,因而无法正确运行。因此需要对每个函数,适当地将ABI调用中的这些参数传递给Execute函数。

桥接函数如其名,承担了native ABI函数参数和interpreter函数之间双向的参数的转换作用。截取一段示例代码:


/// AOT 到 interpreter 的调用参数转换
static int64_t __Native2ManagedCall_i8srr8sr(void* __arg0, double __arg1, void* __arg2, const MethodInfo* method)
{
StackObject args[4] = {*(void**)&__arg0, *(void**)&__arg1, *(void**)&__arg2 };
StackObject* ret = args + 3;
Interpreter::Execute(method, args, ret);
return *(int64_t*)ret;
}

// interpreter 到 AOT 的调用参数转换
static void __Managed2NativeCall_i8srr8sr(const MethodInfo* method, uint16_t* argVarIndexs, StackObject* localVarBase, void* ret)
{
if (hybridclr::metadata::IsInstanceMethod(method) && !localVarBase[argVarIndexs[0]].obj)
{
il2cpp::vm::Exception::RaiseNullReferenceException();
}
Interpreter::RuntimeClassCCtorInit(method);
typedef int64_t (*NativeMethod)(void* __arg0, double __arg1, void* __arg2, const MethodInfo* method);
*(int64_t*)ret = ((NativeMethod)(method->methodPointer))((void*)(localVarBase+argVarIndexs[0]), *(double*)(localVarBase+argVarIndexs[1]), (void*)(localVarBase+argVarIndexs[2]), method);
}

运行时Class初始化

即程序运行过程中第一次访问类的静态字段或者函数时或者创建对象时触发的类型初始化。在il2cpp::vm::Runtime::ClassInit(klass)中完成。不是特别关键,我们后面在单独文章中介绍。

VTable虚表计算

虚表是多态的核心。CLI的虚表计算非常复杂,但不理解它的实现并不影响开发者理解hybridclr的核心运行流程,我们后面在单独文章中介绍。

其他元数据

CustomAttribute使用延迟初始化方式,计算也很复杂,我们后面单独文章介绍。

寄存器指令集设计

直接解释原始IL指令有几个问题:

  • IL是基于栈的指令,运行时维护执行栈是个无谓的开销
  • IL有大量单指令多功能的指令,如add指令可以用于计算int、long、float、double类型的和,导致运行时需要根据上文判断到底该执行哪种计算。不仅增加了运行时判定的开销,还增加了运行时维护执行栈数据类型的开销
  • IL指令包含一些需要运行时resolve的数据,如newobj指令第一个参数是method token。token resolve是一个开销很大的操作,每次执行都进行resolve会极大拖慢执行性能
  • IL是基于栈的指令,压栈退栈相关指令数较多。像a=b+c这样的指令需要4条指令完成,而如果采用基于寄存器的指令,完全可以一条指令完成。
  • IL不适合做其他优化操作,如我们的InitOnce JIT技术。
  • 其他

因此我们需要将原始IL指令转换为更高效的寄存器指令。由于指令很多,这儿不介绍寄存器指令集的详细设计。以add指令举例


// 包含type字段,即指令ID。
struct IRCommon
{
HiOpcodeEnum type;
};

// add int, int -> int 对应的寄存器指令
struct IRBinOpVarVarVar_Add_i4 : IRCommon
{
uint16_t ret; // 计算结果对应的 栈位置
uint16_t op1; // 操作数1对应的栈位置
uint16_t op2; // 操作数2对应的栈位置
};

指令集的转换

理解这节需要初步的编译原理相关知识,我们使用了非常朴素的转换算法,并且基本没有做指令优化。转换过程分为几步:

  • BasicBlock 划分。 将IL指令块切成一段段不包含任何跳转指令的代码块,称之为BasicBlock。
  • 模拟指令执行流程,同时使用广度优先遍历算法遍历所有BasicBlock,将每个BasicBlock转换为IRBasicBlock。

BasicBlock到IRBasicBlock转换采用了最朴素的一对一指令转换算法,转换相关代码在transform::HiTransform::Transform。我们以add指令为例:


case OpcodeValue::ADD:
{
IL2CPP_ASSERT(evalStackTop >= 2);
EvalStackVarInfo& op1 = evalStack[evalStackTop - 2];
EvalStackVarInfo& op2 = evalStack[evalStackTop - 1];

CreateIR(ir, BinOpVarVarVar_Add_i4);
ir->op1 = op1.locOffset;
ir->op2 = op2.locOffset;
ir->ret = op1.locOffset;

EvalStackReduceDataType resultType;
switch (op1.reduceType)
{
case EvalStackReduceDataType::I4:
{
switch (op2.reduceType)
{
case EvalStackReduceDataType::I4:
{
resultType = EvalStackReduceDataType::I4;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_i4;
break;
}
case EvalStackReduceDataType::I:
case EvalStackReduceDataType::Ref:
{
CreateAddIR(irConv, ConvertVarVar_i4_i8);
irConv->dst = irConv->src = op1.locOffset;

resultType = op2.reduceType;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_i8;
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}
break;
}
case EvalStackReduceDataType::I8:
{
switch (op2.reduceType)
{
case EvalStackReduceDataType::I8:
case EvalStackReduceDataType::I: // not support i8 + i ! but we support
{
resultType = EvalStackReduceDataType::I8;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_i8;
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}
break;
}
case EvalStackReduceDataType::I:
case EvalStackReduceDataType::Ref:
{
switch (op2.reduceType)
{
case EvalStackReduceDataType::I4:
{
CreateAddIR(irConv, ConvertVarVar_i4_i8);
irConv->dst = irConv->src = op2.locOffset;

resultType = op1.reduceType;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_i8;
break;
}
case EvalStackReduceDataType::I:
case EvalStackReduceDataType::I8:
{
resultType = op1.reduceType;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_i8;
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}
break;
}
case EvalStackReduceDataType::R4:
{
switch (op2.reduceType)
{
case EvalStackReduceDataType::R4:
{
resultType = op2.reduceType;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_f4;
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}
break;
}
case EvalStackReduceDataType::R8:
{
switch (op2.reduceType)
{
case EvalStackReduceDataType::R8:
{
resultType = op2.reduceType;
ir->type = HiOpcodeEnum::BinOpVarVarVar_Add_f8;
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}
break;
}
default:
{
IL2CPP_ASSERT(false);
break;
}
}

PopStack();
op1.reduceType = resultType;
op1.byteSize = GetSizeByReduceType(resultType);
AddInst(ir);
ip++;
continue;
}

从代码可以看出,其实转换算法非常简单,就是根据add指令的参数类型,决定转换为哪条寄存器指令,同时正确设置指令的字段值。

解释执行hybridclr指令集

解释执行在代码 interpreter::InterpreterModule::Execute 函数中完成。涉及到几部分:

  • 函数帧构建,参数、局部变量、执行栈的初始化
  • 执行普通指令
  • 调用子函数
  • 异常处理

这块内容也很多,我们会在多篇文章中详细介绍实现,这里简单摘取 BinOpVarVarVar_Add_i4 指令的实现代码:

case HiOpcodeEnum::BinOpVarVarVar_Add_i4:
{
uint16_t __ret = *(uint16_t*)(ip + 2);
uint16_t __op1 = *(uint16_t*)(ip + 4);
uint16_t __op2 = *(uint16_t*)(ip + 6);
(*(int32_t*)(localVarBase + __ret)) = (*(int32_t*)(localVarBase + __op1)) + (*(int32_t*)(localVarBase + __op2));
ip += 8;
continue;
}

相信这段代码还是比较好理解的。指令集转换和指令解释相关代码是hybridclr的核心,但复杂度却不高,这得感谢il2cpp运行时帮我们承担了绝大多数复杂的元数据相关操作的支持。

其他如GC、多线程相关处理

我们在hybridclr可行性的思维实验中分析过这两部分实现。

GC

对于对象分配,我们使用il2cpp::vm::Object::New函数分配对象即可。还有一些其他涉及到GC的部分如ldstr指令中Il2CppString对象的缓存,利用了一些其他il2cpp运行时提供的GC机制。

多线程相关处理

  • volatile 。对于指令中包含volatile前缀指令,我们简单在执行代码前后插入MemoryBarrier。
  • ThreadStatic 。 使用il2cpp内置的Class的ThreadStatic变量机制即可。
  • Thread。 我们对于每个托管线程,都创建了一个对应的解释器栈。
  • async 相关。由于异步相关只是语法糖,由编译器和标准库完成了所有内容。hybridclr只需要解决其中产生的AOT泛型实例化的问题即可。

总结

概括地说,hybridclr的实现为:

  • MetadataCache::LoadAssemblyFromBytes (c#层调用Assembly.Load时触发)时加载并注册interpreter Assembly
  • il2cpp运行过程中延迟初始化类型相关元数据,其中关键为正确设置了MethodInfo元数据中methodPointer指针
  • il2cpp运行时通过methodPointer或者methodInvoke指针,再经过桥接函数跳转,最终执行了Interpreter::Execute函数。
    • Execute函数在第一次执行某interpreter函数时触发HiTransform::Transform操作,将原始IL指令翻译为hybridclr的寄存器指令。
    • 然后执行该函数对应的hybridclr寄存器指令。

至此完成hybridclr的技术原理介绍。

· 阅读需 10 分钟

在确定目标,动手实现hybridclr前,有一个必须考虑的问题——我们如何确定hybridclr的可行性?

il2cpp虽然不是一个极其完整的运行时,但代码仍高达12w行,复杂度相当高,想要短期内深入了解它的实现是非常困难的。除了官方几个介绍il2cpp的博客外,几乎找不到其他文档, 而且Hybrid mode execution 的实现复杂度也很高。磨刀不误砍柴工,在动手前从理论上确信这套方案有极高可行性,是完全必要的。

以我们对CLR运行时的认识,要实现 hybrid mode execution 机制,至少要解决以下几个问题

  • 能够动态注册元数据,这些动态注册的元数据必须在运行时中跟AOT元数据完全等价。
  • 所有调用动态加载的assembly中函数的路径,都能定向到正确的解释器实现。包括虚函数override、delegate回调、反射调用等等。
  • 解释器中的gc,必须能够与AOT部分的gc统一处理。
  • 多线程相关能正常工作。包括且不限于创建Thread、async、volatile、ThreadStatic等等。

我们下面一一分析解决这些问题。

动态注册元数据

我们大略地分析了il2cpp元数据初始化相关代码,得出以下结论。

首先,动态修改globalmetadata.dat这个方式不可行。因为globalmetadata.dat保存了持久化的元数据,元数据之间关系大量使用id来相互引用,添加新的数据很容易引入错误,变成极难检测的bug。另外,globalmetadata里有不少数据项由于没有文档,无法分析实际用途,也不得而知如何设置正确的值。另外,运行时会动态加载新的dll,重新计算globalmetadata.dat是成本高昂的事情。而且il2cpp中元数据管理并不支持二次加载,重复加载globalmetadata.dat会产生相当大的代码改动。

一个较可行办法,修改所有元数据访问的底层函数,检查被访问的元数据的类型,如果是AOT元数据,则保持之前的调用,如果来自动态加载,则跳转到hybridclr的元数据管理模块,返回一个恰当的值。但这儿又遇到一个问题,其次globalmetadata为了优化性能,所有dll中的元数据在统一的id命名空间下。很多元数据查询操作仅仅使用一个id参数,如何根据id区别出到底是AOT还是interpreter的元数据?

我们发现实际项目生成的globalmetadata.dat中这些元数据id的值都较小,最大也不过几十万级别。思考后用一个技巧:我们将id分成两部分: 高位为image id,低位为实际上的id,将image id=0保留给AOT元数据使用。我们为每个动态加载的dll分配一个image id,这个image中解析出的所有元数据id的高位为相应的image id。

我们通过这个技巧,hook了所有底层访问元数据的方法。大约修改了几十处,基本都是如下这样的代码,尽量不修改原始逻辑,很容易保证正确性。

const char* il2cpp::vm::GlobalMetadata::GetStringFromIndex(StringIndex index)
{
// ==={{ hybridclr
if (hybridclr::metadata::IsInterpreterIndex(index))
{
return hybridclr::metadata::MetadataModule::GetStringFromEncodeIndex(index);
}
// ===}} hybridclr
IL2CPP_ASSERT(index <= s_GlobalMetadataHeader->stringSize);
const char* strings = MetadataOffset<const char*>(s_GlobalMetadata, s_GlobalMetadataHeader->stringOffset, index);
#if __ENABLE_UNITY_PLUGIN__
if (g_get_string != NULL)
{
g_get_string((char*)strings, index);
}
#endif // __ENABLE_UNITY_PLUGIN__
return strings;
}

我们在动手前检查了多个相关函数,基本没有问题。虽然不敢确定这一定是可行的,但元数据加载是hybridclr第一阶段的开发任务,万一发现问题,及时中止hybridclr开发损失不大。于是我们认为算是解决了第一个问题。

所有调用动态加载的assembly中函数的路径,都能定向到正确的解释器实现

我们分析了il2cpp中关于Method元数据的管理方式,发现MethodInfo结构中保存了运行时实际执行逻辑的函数指针。如果我们简单地设置动态加载的函数元数据的MethodInfo结构的指针为正确的解释器函数,能否保证所有流程对该函数的调用,都能正确定向到解释器函数呢?

严谨思考后的结论是肯定的。首先AOT部分不可能直接调用动态加载的dll中的函数。其次,运行时并没有其他地方保存了函数指针。意味着,如果想调用动态加载的函数,必须获得MethodInfo中的函数指针,才能正确执行到目标函数。意味着我们运行过程中所有对该函数的调用一定会调用到正确的解释器函数。

至于我们解决了第二个问题。

解释器中的gc,必须能够与AOT部分的gc统一处理

很容易观察到,通过il2cpp::vm::Object::New可以分配托管对象,通过gc模块的函数可以分配一些能够被gc自动管理的内存。但我们如何保证,使用这种方式就一定能保存正确性呢,会不会有特殊的使用规则 ,hybridclr的解释器代码无法与之配合工作呢?

考虑到AOT代码中也有很多gc相关的操作,我们检查了一些il2cpp为这些操作生成的c++代码,都是简简单单直接调用 il2cpp::vm::Object::New 之类的函数,并无特殊之处。 可以这么分析:il2cpp生成的代码是普通的c++代码,hybridclr解释器代码也是c++代码,既然生成的代码的内存使用方式能够正确工作,那么hybridclr解释器中gc相关代码,肯定也能正确工作。

至此,我们解决了第三个问题。

多线程相关代码能正常工作

与上一个问题相似。我们检查了il2cpp生成的c++代码,发现并无特殊之处也能在多线程环境下正常运行,那我们也可以非常确信,hybridclr解释器的代码只要符合常规的多线程的要求,也能在多线程环境下正常运行。

至此,我们解决了第四个问题。

总结

我们通过少量的对实际il2cpp代码的观察,以及对CLR运行时原理的了解,再配合思维实验,可以99.9%以上确定,既然il2cpp生成的代码都能在运行时正确运行,那hybridclr解释模式下执行的代码,也能正确运行。

我们在完成思维实验的那一刻,难掩内心激动的心情。作为一名物理专业的IT人,脑海里第一时间浮现出爱因斯坦在思考广义相对论时的,使用电梯思维实验得出引力使时空弯曲这一惊人结论。我们不敢比肩这种伟大的科学家,但我们确实在使用类似的思维技巧。可以说,hybridclr不是简单的经验总结,是深刻洞察力与分析能力孕育的结果。

· 阅读需 5 分钟

我们在实现hybridclr过程中,深入研究了CLI规范与il2cpp实现,积累了大量宝贵的经验。考虑到国内游戏行业对clr及il2cpp相关的资料不多,我们希望将这些知识系统性地整理出来,帮助那些渴望深入研究Unity下CLR Runtime实现的开发者们,更好了掌握相关知识。

Inspect il2cpp 目录

  • il2cpp 序章
    • il2cpp 介绍
    • il2cpp il2cpp 架构及源码结构介绍
    • il2cpp 安装、编译及调试
  • il2cpp 运行时实现
    • il2cpp Runtime 初始化流程剖析
    • il2cpp metadata (此节内容极其庞大)
      • CLI metadata 简略介绍
      • il2cpp metadata 初始化流程剖析
      • persistent metadata 即 global-metadata.dat 介绍
      • runtime metadata 介绍
    • il2cpp IL to c++ 代码的转换
      • 基础指令集
      • 对象模型相关指令 (内容极其庞大)
      • 异常机制
      • 泛型共享机制
      • PInvoke 与 MonoPInvokeCallbackAttribute相关。(一个有趣的问题:il2cpp中lua回调c#函数相比与回调普通c函数,多了哪些开销?)
      • icalls
      • delegate
      • 反射相关支持
      • 跨平台相关
    • 类型初始化 Class::Init 流程剖析
    • 泛型类实现
    • 泛型函数实现
    • 泛型共享机制
    • 异常机制
    • 反射相关实现
    • 值类型相关机制
    • box与unbox相关机制
    • object、string、Array、TypedReference等一些基础BCL类型的探究
    • icalls 实现
    • il2cpp及mono的bug介绍
    • il2cpp gc管理
    • il2cpp 多线程及内存模型处理
  • 2018-2022中il2cpp实现的演化

Inspect hybridclr 目录

  • 1 导论
    • 手游热更新技术的发展史
    • 当前主流热更新技术的缺陷
    • 下一代热更新技术探索——unity引擎下的原生c#热更新技术
  • 2 hybridclr概览
    • 1 hybridclr介绍
    • 2 关于hybridclr可行性的思维实验
    • 3 hybridclr技术原理剖析
  • 3 metadata 加载
    • 1 coff文件解析
    • 2 stream 解析
    • 3 原始tables解析
    • 4 复杂元数据解析
  • 4 metadata 注册
    • 1 assembly 注册
    • 2 TypeDefinition 注册(复杂)
    • 3 generic class
    • 4 generic method
    • 5 桥接函数
  • 5 寄存器指令集设计
    • IL指令集介绍
    • 基于栈的指令集的缺陷
    • 寄存器指令集
      • 基础转换规则
      • 指令静态特例化
      • resolve data
      • 其他特殊处理
    • 一些用于解释器JIT技术
      • InitOnce JIT优化技术
  • 6 指令集transform实现
    • 基础思路介绍
    • transform算法
      • basic block划分
      • 基于basic block的指令流遍历及转换
      • 普通指令
      • 函数调用指令
      • branch相关指令
      • 异常相关指令
    • 指令集优化
      • 指令合并
      • ValueType相关指令优化
      • 函数inline
      • instinct函数替换
    • virtual Execution System
      • Thread Interpreter Stack
      • Interpreter Frame实现与优化
      • localloc 与 Local Memory Pool
      • 桥接函数
      • 指令实现
      • instinct函数
      • reflection相关实现
      • extern函数实现
    • 跨平台兼容性处理
      • 32位与64位
      • 内存对齐访问
      • x86与arm系列区别
        • float与int之间转换
        • abi
      • 虚拟地址空间差异
      • 一些行为不定的函数
        • memcpy
    • AOT泛型 (基于补充元数据的泛型实例化技术)
    • AOT hotfix实现
  • misc
    • 解决Unity资源上挂载interpreter脚本
    • gc 处理
    • 多线程相关处理
  • test框架
    • 测试用例项目
      • bootstrap cpp测试集
      • .net c#测试集
      • 生成测试报告
    • 测试工具
      • 创建多版本多平台的测试项目
      • 运行测试用例,收集测试报告
      • 生成最终测试报告
    • 自动化测试DevOps框架