英文原文:
https://www.jacksondunstan.com/articles/3916
我们使用 C# 编写代码,但这只是一个起点。我们的 C# 代码被编译为 DLL,然后转换为 C++,然后再次编译为机器代码。好消息是,这不是一个黑匣子!我最近一直在阅读 IL2CPP 输出的 C++ 代码并学到了很多东西。今天的文章是关于我遇到的一些惊喜以及如何更改 C# 代码以避免一些讨厌的陷阱。
静态变量
假设我们编写了一个使用静态变量的静态函数。这是基本圆几何:
static class CircleFunctions
{
private static readonly float Pi = 3.14f;
public static float Area(float radius)
{
return Pi * radius * radius;
}
}
请注意,Pi是一个静态变量,而不是一个常数。通常你会把它变成一个常数,但在这个例子中我们将使用一个静态变量。有很多时候你都不能使用常数。
现在我们来看看IL2CPP为Area生成的C++代码。我在其中加入了内联注释和一些间距,解释了正在发生的事情。
extern "C" float CircleFunctions_Area_m4188038 (Il2CppObject * __this /* static, unused */, float ___radius0, const MethodInfo* method)
{
// 仅限于此函数的静态变量
// 这用于对方法进行一次性初始化
static bool s_Il2CppMethodInitialized;
// 每次调用该函数时都会触发此If
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_method (CircleFunctions_Area_m4188038_MetadataUsageId);
s_Il2CppMethodInitialized = true;
}
{
// This macro expands to this:
// do {
// if((klass)->has_cctor && !(klass)->cctor_finished)
// il2cpp::vm::Runtime::ClassInit ((klass));
// } while (0)
// 每次调用函数时都会发生这种情况
IL2CPP_RUNTIME_CLASS_INIT(CircleFunctions_t532702825_il2cpp_TypeInfo_var);
// 访问 Pi
float L_0 = ((CircleFunctions_t532702825_StaticFields*)CircleFunctions_t532702825_il2cpp_TypeInfo_var->static_fields)->get_Pi_0();
// 实际的工作
float L_1 = ___radius0;
float L_2 = ___radius0;
return ((float)((float)((float)((float)L_0*(float)L_1))*(float)L_2));
}
}
这应该是一个简单的功能,但变成了相当复杂的东西。 IL2CPP 增加了很多开销。现在让我们看一个调用它的函数:
static void TestStaticFunctionUsingStaticVariable()
{
float area = CircleFunctions.Area(3.0f);
}
这是来自 IL2CPP 的 C++:
extern "C" void TestScript_TestStaticFunctionUsingStaticVariable_m953893846 (Il2CppObject * __this /* static, unused */, const MethodInfo* method)
{
// 用于一次性初始化的更多静态布尔值
static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_method (TestScript_TestStaticFunctionUsingStaticVariable_m953893846_MetadataUsageId);
s_Il2CppMethodInitialized = true;
}
float V_0 = 0.0f;
{
// 更多类初始化 (same macro as above)
IL2CPP_RUNTIME_CLASS_INIT(CircleFunctions_t532702825_il2cpp_TypeInfo_var);
// 正真的工作:
float L_0 = CircleFunctions_Area_m4188038(NULL /*static, unused*/, (3.0f), /*hidden argument*/NULL);
V_0 = L_0;
return;
}
}
所以即使是这个函数的调用者也需要为静态变量付出代价。哎哟。
现在让我们看看如果你避免使用静态变量会发生什么。在这种情况下,很容易只使用 const:
static class CircleFunctionsConst
{
private const float Pi = 3.14f;
public static float Area(float radius)
{
return Pi * radius * radius;
}
}
这是 C++:
extern "C" float CircleFunctionsConst_Area_m2838794717 (Il2CppObject * __this /* static, unused */, float ___radius0, const MethodInfo* method)
{
{
float L_0 = ___radius0;
float L_1 = ___radius0;
return ((float)((float)((float)((float)(3.14f)*(float)L_0))*(float)L_1));
}
}
开销没了!只剩下实际的工作。那么调用者呢?
static void TestStaticFunctionNotUsingStaticVariable()
{
float area = CircleFunctionsConst.Area(3.0f);
}
extern "C" void TestScript_TestStaticFunctionNotUsingStaticVariable_m2848480467 (Il2CppObject * __this /* static, unused */, const MethodInfo* method)
{
float V_0 = 0.0f;
{
float L_0 = CircleFunctionsConst_Area_m2838794717(NULL /*static, unused*/, (3.0f), /*hidden argument*/NULL);
V_0 = L_0;
return;
}
}
所有令人讨厌的开销都完全消失了。
建议:考虑使用常量和参数而不是静态变量。
结构初始化
现在让我们做一个简单的struct:
struct MyVector3
{
public float X;
public float Y;
public float Z;
public MyVector3(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
}
让我们用默认构造函数初始化它,然后设置它的字段:
static void TestDefaultStructConstructor()
{
MyVector3 vec = new MyVector3();
vec.X = 1;
vec.Y = 2;
vec.Z = 3;
}
这是在 C++ 中的样子:
extern "C" void TestScript_TestDefaultStructConstructor_m1260349596 (Il2CppObject * __this /* static, unused */, const MethodInfo* method)
{
// The static variable overhead is back!
static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_method (TestScript_TestDefaultStructConstructor_m1260349596_MetadataUsageId);
s_Il2CppMethodInitialized = true;
}
MyVector3_t770449606 V_0;
// 该struct 默认为全零
memset(&V_0, 0, sizeof(V_0));
{
// Initobj 看起来像这样:
// inline void Initobj(Il2CppClass* type, void* data)
// {
// if (type->valuetype)
// memset(data, 0, type->instance_size - sizeof(Il2CppObject));
// else
// *static_cast<Il2CppObject**>(data) = NULL;
// }
// 尽管我们知道这是一个struct,但还有一个“If”的开销
// 然后struct被再次清零
Initobj (MyVector3_t770449606_il2cpp_TypeInfo_var, (&V_0));
// 设置字段。 这些“set_*”函数是微不足道的传递。
(&V_0)->set_X_0((1.0f));
(&V_0)->set_Y_1((2.0f));
(&V_0)->set_Z_2((3.0f));
return;
}
}
为什么那个静态开销又回来了?我们能摆脱它吗?让我们尝试一个对象初始化器语法:
static void TestStructInitializer()
{
MyVector3 vec = new MyVector3 { X = 1, Y = 2, Z = 3 };
}
extern "C" void TestScript_TestStructInitializer_m3484430381 (Il2CppObject * __this /* static, unused */, const MethodInfo* method)
{
// 相同的静态变量开销
static bool s_Il2CppMethodInitialized;
if (!s_Il2CppMethodInitialized)
{
il2cpp_codegen_initialize_method (TestScript_TestStructInitializer_m3484430381_MetadataUsageId);
s_Il2CppMethodInitialized = true;
}
// 相同的静态变量开销
MyVector3_t770449606 V_0;
memset(&V_0, 0, sizeof(V_0));
// 声明另一个结构?为什么?
// 同时,将其清除为零。
MyVector3_t770449606 V_1;
memset(&V_1, 0, sizeof(V_1));
{
// _再次_将其中一个结构清零
Initobj (MyVector3_t770449606_il2cpp_TypeInfo_var, (&V_1));
// 设置所有字段
(&V_1)->set_X_0((1.0f));
(&V_1)->set_Y_1((2.0f));
(&V_1)->set_Z_2((3.0f));
// 将一个结构复制到另一个结构
MyVector3_t770449606 L_0 = V_1;
V_0 = L_0;
return;
}
}
字段初始化器增加了更多开销!现在在静态变量初始化代码之上有两个结构、一个副本和三个不必要的清零。
好的,让我们尝试一个自定义构造函数:
static void TestCustomStructConstructor()
{
MyVector3 vec = new MyVector3(1, 2, 3);
}
extern "C" void TestScript_TestCustomStructConstructor_m3485483736 (Il2CppObject * __this /* static, unused */, const MethodInfo* method)
{
MyVector3_t770449606 V_0;
memset(&V_0, 0, sizeof(V_0));
{
MyVector3__ctor_m3460461338((&V_0), (1.0f), (2.0f), (3.0f), /*hidden argument*/NULL);
return;
}
}
extern "C" void MyVector3__ctor_m3460461338 (MyVector3_t770449606 * __this, float ___x0, float ___y1, float ___z2, const MethodInfo* method)
{
{
float L_0 = ___x0;
__this->set_X_0(L_0);
float L_1 = ___y1;
__this->set_Y_1(L_1);
float L_2 = ___z2;
__this->set_Z_2(L_2);
return;
}
}
自定义构造函数摆脱了所有静态变量开销和额外的结构。它全部替换为设置字段的函数(构造函数)。不幸的是,即使 C# 语言要求构造函数设置所有字段并且在设置它们之前不访问它们,IL2CPP 仍然在调用构造函数以将结构清零之前生成了 memset 调用。这是我们将获得的最低开销。
建议:考虑使用自定义构造函数而不是默认构造函数和对象初始值设定项。
类的开销
最后,让我们制作上述结构的类版本:
class MyVector3Class
{
public float X;
public float Y;
public float Z;
public MyVector3Class(float x, float y, float z)
{
X = x;
Y = y;
Z = z;
}
}
以下是它在 C++ IL2CPP 生成中的样子:
struct MyVector3Class_t1350799278 : public Il2CppObject
{
public:
// System.Single MyVector3Class::X
float ___X_0;
// System.Single MyVector3Class::Y
float ___Y_1;
// System.Single MyVector3Class::Z
float ___Z_2;
public:
inline static int32_t get_offset_of_X_0() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___X_0)); }
inline float get_X_0() const { return ___X_0; }
inline float* get_address_of_X_0() { return &___X_0; }
inline void set_X_0(float value)
{
___X_0 = value;
}
inline static int32_t get_offset_of_Y_1() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___Y_1)); }
inline float get_Y_1() const { return ___Y_1; }
inline float* get_address_of_Y_1() { return &___Y_1; }
inline void set_Y_1(float value)
{
___Y_1 = value;
}
inline static int32_t get_offset_of_Z_2() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___Z_2)); }
inline float get_Z_2() const { return ___Z_2; }
inline float* get_address_of_Z_2() { return &___Z_2; }
inline void set_Z_2(float value)
{
___Z_2 = value;
}
};
有很多样板的“get”和“set”函数,但是这个类大多是我们所期望的。它具有三个浮点字段,并且它派生自 System.Object(又名对象),这是您未显式声明基类时的默认设置。这就是众所周知的 Il2CppObject,所以让我们看一下:
struct Il2CppObject
{
Il2CppClass *klass;
MonitorData *monitor;
};
这意味着我们类实例的大小不仅仅是三个浮点变量,还有两个指针的大小。在 64 位平台上,需要额外的 16 字节存储空间。因此,我们的向量实例实际上需要 40 个字节,而不是需要 24 个字节,增加了 66%。这是一个固定的开销,因此对于较大的类来说并不重要,但对于您有很多的较小的类,绝对要注意一些事情。
为了比较,让我们看一下 struct 版本的 C++:
struct MyVector3_t770449606
{
public:
// System.Single MyVector3::X
float ___X_0;
// System.Single MyVector3::Y
float ___Y_1;
// System.Single MyVector3::Z
float ___Z_2;
public:
inline static int32_t get_offset_of_X_0() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___X_0)); }
inline float get_X_0() const { return ___X_0; }
inline float* get_address_of_X_0() { return &___X_0; }
inline void set_X_0(float value)
{
___X_0 = value;
}
inline static int32_t get_offset_of_Y_1() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___Y_1)); }
inline float get_Y_1() const { return ___Y_1; }
inline float* get_address_of_Y_1() { return &___Y_1; }
inline void set_Y_1(float value)
{
___Y_1 = value;
}
inline static int32_t get_offset_of_Z_2() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___Z_2)); }
inline float get_Z_2() const { return ___Z_2; }
inline float* get_address_of_Z_2() { return &___Z_2; }
inline void set_Z_2(float value)
{
___Z_2 = value;
}
};
这个版本几乎相同,只是它没有这两个指针的开销。
建议:当您需要节省每个实例的内存时,考虑使用结构而不是类。
总结
IL2CPP 生成的 C++ 代码充满了惊喜。花一些时间检查一下游戏中的已知热点。您可以简单地搜索“MyClass::MyFunction”并轻松找到与您的 C# 代码等效的 C++。您可能会惊讶于您的发现!