IL2CPP 内部结构:通用共享实现

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jun 16, 2015|12 Min
IL2CPP 内部结构:通用共享实现
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

这是 IL2CPP Internals 系列的第五篇文章。

在上一篇文章中,我们研究了如何在为 IL2CPP 脚本后端生成的 C++ 代码中调用方法。在这篇文章中,我们将探讨它们是如何实现的。具体来说,我们将尝试更好地理解使用 IL2CPP 生成的代码的最重要的特性之一 - 通用共享。泛型共享允许许多泛型方法共享一个通用的实现。这会显著减少 IL2CPP 脚本后端的可执行文件的大小。

请注意,通用共享并不是一个新想法, Mono和 .Net 运行时也使用通用共享。最初,IL2CPP 没有执行通用共享。最近的改进使其更加强大和有益。由于 il2cpp.exe 生成 C++ 代码,我们可以看到方法实现在哪里共享。

我们将探讨如何为引用类型和值类型共享(或不共享)泛型方法实现。我们还将研究泛型参数约束如何影响泛型共享。

请记住,本系列中讨论的所有内容都是实施细节。这里讨论的主题和代码将来 可能 会发生变化。当可能的时候,我们愿意公开并讨论这样的细节!

什么是通用共享?

假设您正在用 C# 编写 List<T> 类的实现。该实现是否取决于 T 的类型?您能对 List<string> 和 List<object> 使用相同的 Add 方法实现吗?List<DateTime> 怎么样?

实际上,泛型的强大之处在于这些 C# 实现可以共享,并且泛型类 List<T> 可以适用于任何 T。但是,当 List 从 C# 转换为可执行代码(如Mono所做的)或 C++ 代码(如 IL2CPP 所做的)时,会发生什么情况?我们还能分享 Add 方法的实现吗?

是的, 大多数 时候我们可以分享。正如我们将在本文中发现的那样,共享泛型方法实现的能力几乎完全取决于该类型 T 的大小。如果 T 是任何引用类型(如字符串或对象),那么它将始终是指针的大小。如果 T 是值类型(如 int 或 DateTime),其大小可能会有所不同,并且事情会变得更加复杂。可共享的方法实现越多,得到的可执行代码就越小。

实现泛型共享Mono 的开发人员 Mark Probst 发表了一系列关于Mono如何执行泛型共享的精彩 文章 。我们不会在这里深入讨论通用共享。相反,我们将看到 IL2CPP 如何以及何时执行通用共享。希望这些信息能够帮助您更好地分析和了解项目的可执行文件大小。

IL2CPP 共享了什么?

目前,当 T 满足以下条件时,IL2CPP 会为泛型类型 SomeGenericType<T> 共享泛型方法实现:

- 任何引用类型(例如字符串,对象或任何用户定义的类)

- 任何整数或枚举类型

当 T 是值类型时,IL2CPP 不共享泛型方法实现,因为每个值类型的大小都会有所不同(基于其字段的大小)。

实际上,这意味着添加 SomeGenericType<T> 的新用法(其中 T 是引用类型)将对可执行文件大小产生最小的影响。但是,如果 T 是值类型,则可执行文件的大小将受到影响。对于Mono和 IL2CPP 脚本后端来说,此行为是相同的。如果您想了解更多,请继续阅读,现在是时候深入了解一些实施细节了!

设置

我将在 Windows 上使用Unity 5.0.2p1,并为 WebGL 平台进行构建。我已在构建设置中启用了“开发播放器”选项,并将“启用异常”选项设置为“无”。这篇文章的脚本代码以驱动方法开始,用于创建我们将要研究的泛型类型的实例:

public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}

接下来,我们定义此方法中使用的类型:

class GenericType<T> {
public T UsesGenericParameter(T value) {
return value;
}

public void DoesNotUseGenericParameter() {}

public U UsesDifferentGenericParameter<U>(U value) {
return value;
}
}

class AnyClass {}

interface AnswerFinderInterface {
int ComputeAnswer();
}

class ExperimentWithInterface : AnswerFinderInterface {
public int ComputeAnswer() {
return 42;
}
}

class InterfaceConstrainedGenericType<T> where T : AnswerFinderInterface {
public int FindTheAnswer(T experiment) {
return experiment.ComputeAnswer();
}
}

所有代码都嵌套在从MonoBehaviour派生的 HelloWorld 类中。

如果您查看 il2cpp.exe 的命令行,请注意它不包含 --enable-generic-sharing 选项,如本系列 第一篇文章 中所述。然而,通用共享现象仍在发生。它不再是可选的,并且现在在所有情况下都会发生。

引用类型的通用共享

我们首先来看一下最常见的通用共享情况:引用类型。由于托管代码中的所有引用类型都派生自 System.Object,因此生成的 C++ 代码中的所有引用类型都派生自 Object_t 类型。然后,所有引用类型都可以在 C++ 代码中使用 Object_t* 类型作为占位符来表示。我们马上就会明白为什么这很重要。

让我们搜索一下 DemonstrateGenericSharing 方法的生成版本。In my project it is named HelloWorld_DemonstrateGenericSharing_m4.我们正在寻找 GenericType 类中四种方法的方法定义。使用 Ctags,我们可以跳转到 GenericType<string> 构造函数的方法声明,GenericType_1__ctor_m8。注意这个方法声明实际上是一个#define 语句,将该方法映射到另一个方法GenericType_1__ctor_m10447_gshared。

让我们回过头来,找到 GenericType<AnyClass> 类型的方法声明。如果我们跳到构造函数的声明GenericType_1__ctor_m9,我们可以看到它也是一个#define 语句,映射到同一个函数GenericType_1__ctor_m10447_gshared!

如果我们跳转到GenericType_1__ctor_m10447_gshared的定义,从方法定义上的代码注释中我们可以看到,这个方法对应的是托管方法名HelloWorld/GenericType`1<System.Object>::.ctor()。这是 GenericType<object> 类型的构造函数。这种类型被称为 完全共享 类型,这意味着给定一个类型 GenericType<T>,对于任何引用类型的 T,所有方法的实现都将使用此版本,其中 T 是对象。

查看生成的代码中的构造函数下方,您应该看到 UsesGenericParameter 方法的 C++ 代码:

extern "C" Object_t * GenericType_1_UsesGenericParameter_m10449_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}

在使用泛型参数 T 的两个地方(返回类型和单个托管参数的类型),生成的代码都使用 Object_t* 类型。由于所有引用类型都可以在生成的代码中用 Object_t* 表示,因此我们可以对任何引用类型的 T 调用此单一方法实现。

在本系列的 第二篇博文 (关于生成的代码)中,我们提到所有方法定义都是 C++ 中的自由函数。il2cpp.exe 实用程序不会使用 C++ 继承在 C# 中生成重写的方法。但是,il2cpp.exe 确实使用 C++ 继承类型。如果我们在生成的代码中搜索字符串“AnyClass_t”,我们可以找到 C# 类型 AnyClass 的 C++ 表示形式:

struct  AnyClass_t1  : public Object_t
{
};

由于 AnyClass_t1 源自 Object_t,我们可以将指向 AnyClass_t1 的指针作为参数传递给 GenericType_1_UsesGenericParameter_m10449_gshared 函数,而不会出现问题。

那么返回值怎么样?在需要指向派生类的指针的地方,我们不能返回指向基类的指针,对吗?看一下 GenericType<AnyClass>::UsesGenericParameter 方法的声明:

#define GenericType_1_UsesGenericParameter_m10452(__this, ___value, method) (( AnyClass_t1 * (*) (GenericType_1_t6 *, AnyClass_t1 *, MethodInfo*))GenericType_1_UsesGenericParameter_m10449_gshared)(__this, ___value, method)

生成的代码实际上是将返回值(类型 Object_t*)转换为派生类型 AnyClass_t1*。所以这里IL2CPP欺骗了C++编译器以避免C++类型系统。由于 C# 编译器已经强制要求 UsesGenericParameter 中的代码不对类型 T 做任何不合理的操作,因此 IL2CPP 可以安全地欺骗 C++ 编译器。

有限制的通用共享

假设我们想要允许在 T 类型的对象上调用一些方法?由于 System.Object 上没有很多方法,那么使用 Object_t* 不会阻止这种情况吗?是的,这是正确的。但我们首先需要使用泛型约束向 C# 编译器表达这个想法。

再次查看此帖子的脚本代码中名为 InterfaceConstrainedGenericType 的类型。这个泛型类型使用 where 子句来要求它的类型 T 从给定的接口 AnswerFinderInterface 派生。这允许调用 ComputeAnswer 方法。回想一下上一篇关于 方法调用的 博客文章,其中提到调用接口方法需要在 vtable 结构中进行查找。由于 FindTheAnswer 方法将对类型 T 的约束实例进行直接函数调用,因此 C++ 代码仍然可以使用完全共享的方法实现,其中类型 T 由 Object_t* 表示。

如果我们从HelloWorld_DemonstrateGenericSharing_m4函数的实现开始,然后跳转到InterfaceConstrainedGenericType_1__ctor_m11函数的定义,我们可以看到这个方法又是一个#define,映射到InterfaceConstrainedGenericType_1__ctor_m10456_gshared函数。如果我们查看该函数下方的 InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared 函数的实现,我们可以看到,这确实是该函数的完全共享版本,采用 Object_t* 参数。它调用 InterfaceFuncInvoker0::Invoke 函数来实际调用托管的 ComputeAnswer 方法。

extern "C" int32_t InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared (InterfaceConstrainedGenericType_1_t2160 * __this, Object_t * ___experiment, MethodInfo* method)
{
static bool s_Il2CppMethodIntialized;
if (!s_Il2CppMethodIntialized)
{
AnswerFinderInterface_t11_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&AnswerFinderInterface_t11_0_0_0);
s_Il2CppMethodIntialized = true;
}
{
int32_t L_0 = (int32_t)InterfaceFuncInvoker0<int32_t>::Invoke(0 /* System.Int32 HelloWorld/AnswerFinderInterface::ComputeAnswer() */, AnswerFinderInterface_t11_il2cpp_TypeInfo_var, (Object_t *)(*(&amp;amp;___experiment)));
return L_0;
}
}

所有这些都在生成的 C++ 代码中结合在一起,因为 IL2CPP 将所有托管接口都视为System.Object。这是一个有用的经验法则,有助于理解其他情况下 il2cpp.exe 生成的代码。

具有基类的约束

除了接口约束之外,C# 还允许约束作为基类。IL2CPP 不会将所有基类都视为 System.Object,那么泛型共享如何针对基类约束起作用?

由于基类始终是引用类型,因此 IL2CPP 对这些类型使用泛型方法的完全共享版本。任何需要在约束类型上使用字段或调用方法的代码都会在 C++ 中转换为正确的类型。再次,这里我们依赖 C# 编译器来正确执行泛型约束,并且我们对 C++ 编译器撒谎关于类型。

与值类型通用共享

现在让我们回到 HelloWorld_DemonstrateGenericSharing_m4 函数并查看 GenericType<DateTime> 的实现。DateTime 类型是值类型,因此 GenericType<DateTime> 不共享。我们可以跳转到该类型的构造函数声明,GenericType_1__ctor_m10。与其他情况一样,我们在那里看到一个#define,但是#define 映射到 GenericType_1__ctor_m10_gshared 函数,该函数特定于 GenericType<DateTime> 类,并且不被任何其他类使用。

从概念上思考通用共享

通用共享的实现可能难以理解和遵循。问题空间本身充满了病态情况(例如 奇怪的重复出现的模板模式)。思考以下几个概念可能会有所帮助:

- 泛型类型的每个方法实现都是共享的

- 某些泛型类型仅与自身共享方法实现(例如具有值类型泛型参数的泛型类型,上面的 GenericType)

- 具有引用类型泛型参数的泛型类型是完全共享的- 它们始终对所有类型参数使用 System.Object 的实现。

- 如果至少有一个类型参数是引用类型,则具有两个或更多类型参数的泛型类型可以部分共享

il2cpp.exe 实用程序始终为任何泛型类型生成完全共享的方法实现。仅当使用时它才会生成其他方法实现。

泛型方法共享

正如泛型类型上的方法实现可以共享一样,泛型方法的方法实现也可以共享。在原始脚本代码中,请注意 UsesDifferentGenericParameter 方法使用与 GenericType 类不同的类型参数。当我们查看 GenericType 类的共享方法实现时,我们没有看到 UsesDifferentGenericParameter 方法。如果我在生成的代码中搜索“UsesDifferentGenericParameter”,我会看到此方法的实现位于 GenericMethods0.cpp 文件中:

extern "C" Object_t * GenericType_1_UsesDifferentGenericParameter_TisObject_t_m15243_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}

请注意,这是方法实现的完全共享版本,接受 Object_t* 类型。尽管此方法属于泛型类型,但非泛型类型中的泛型方法的行为也相同。实际上,il2cpp.exe 总是尝试为涉及泛型参数的方法实现生成尽可能少的代码。

结论

自首次发布以来,通用共享一直是 IL2CPP 脚本后端最重要的改进之一。它使生成的 C++ 代码尽可能小,在行为没有差异的地方共享方法实现。当我们寻求继续减少二进制大小时,我们将努力利用更多机会来分享方法实现。

在下一篇文章中,我们将探讨如何生成 p/invoke 包装器,以及如何将类型从托管代码编组到本机代码。我们将能够看到编组各种类型的成本,并调试编组代码的问题。