Перед вами вторая статья из серии по IL2CPP. В этот раз мы поговорим о коде C++, генерируемом утилитой il2cpp.exe, а также рассмотрим представление управляемых типов в машинном коде, проверки во время выполнения, которые используются для поддержки виртуальной машины .NET, генерацию циклов и многое другое.
Для этого мы будем использовать очень специфический код, который наверняка изменится в следующих версиях Unity. Но основные принципы останутся неизменными.
Пример проекта
Для этого примера я буду использовать последнюю доступную версию Unity 5.0.1p1. Как и в предыдущей статье, создам новый пустой проект и добавлю один скрипт со следующим содержанием:
using UnityEngine;
public class HelloWorld : MonoBehaviour {
private class Important {
public static int ClassIdentifier = 42;
public int InstanceIdentifier;
}
void Start () {
Debug.Log("Hello, IL2CPP!");
Debug.LogFormat("Static field: {0}", Important.ClassIdentifier);
var importantData = new [] {
new Important { InstanceIdentifier = 0 },
new Important { InstanceIdentifier = 1 } };
Debug.LogFormat("First value: {0}", importantData[0].InstanceIdentifier);
Debug.LogFormat("Second value: {0}", importantData[1].InstanceIdentifier);
try {
throw new InvalidOperationException("Don't panic");
}
catch (InvalidOperationException e) {
Debug.Log(e.Message);
}
for (var i = 0; i < 3; ++i) {
Debug.LogFormat("Loop iteration: {0}", i);
}
}
}
Я соберу этот проект под WebGL, используя редактор Unity на Windows. Чтобы получить относительно хорошие имена в генерируемом коде C++, я включил опцию Development Player в Build Settings. Кроме того, я установил значение Full для Enable Exceptions в WebGL Player Settings.
Обзор генерируемого кода
После завершения сборки сгенерированный код C++ можно найти в директории TempStagingAreaDatail2cppOutput в папке проекта. Как только я закрою редактор, эта директория будет удалена, но, пока он открыт, можно внимательно изучить ее.
Утилита il2cpp.exe сгенерировала много файлов даже для такого маленького проекта: 4625 файлов заголовков и 89 файлов исходного кода C++. Для проверки такого количества кода я предпочитаю использовать текстовый редактор с поддержкой Exuberant CTags. Обычно CTags быстро генерирует файл тегов, что значительно упрощает навигацию по коду.
Вы можете заметить, что многие сгенерированные файлы C++ содержат не простой код из нашего скрипта, а преобразованный код стандартных библиотек, таких как mscorlib.dll. Как уже говорилось в предыдущей статье, скриптовый движок IL2CPP использует тот же код стандартных библиотек, что и Mono. Обратите внимание, что мы преобразовываем код mscorlib.dll и других стандартных библиотек при каждом запуске il2cpp.exe. Это может показаться ненужным, так как код не меняется.
Дело в том, что IL2CPP всегда очищает байт-код, чтобы уменьшить размер исполняемого файла. Следовательно, даже небольшие изменения в коде скрипта могут привести к тому, что различные части кода стандартной библиотеки будут использоваться или нет, в зависимости от обстоятельств. Поэтому mscorlib.dll должен быть преобразован при каждой сборке. Мы пытаемся усовершенствовать процесс инкрементальной сборки, но пока без особых успехов.
Отображение управляемого кода в генерируемом коде C++
Для каждого типа в управляемом коде il2cpp.exe генерирует 2 файла заголовков: для определения типа и объявления методов для этого типа. Например, давайте посмотрим на содержимое преобразованного типа UnityEngine.Vector3. Файл заголовка для этого типа называется UnityEngine_UnityEngine_Vector3.h. Имя создается на основе имени сборки (UnityEngine.dll), пространства имен и имени типа. Код выглядит следующим образом:
// UnityEngine.Vector3
struct Vector3_t78
{
// System.Single UnityEngine.Vector3::x
float ___x_1;
// System.Single UnityEngine.Vector3::y
float ___y_2;
// System.Single UnityEngine.Vector3::z
float ___z_3;
};
Утилита il2cpp.exe преобразует каждое из трех полей экземпляра и немного изменяет имена, используя начальные подчеркивания во избежание возможных конфликтов с зарезервированными словами. Мы используем зарезервированные имена в C++, но пока ни разу не видели, чтобы они конфликтовали с кодом стандартных библиотек.
Файл UnityEngine_UnityEngine_Vector3MethodDeclarations.h содержит объявления для всех методов в Vector3. Например, Vector3 переопределяет метод Object.ToString:
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR
Обратите внимание на комментарий, в котором указан управляемый метод, представляющий исходное объявление. Это может пригодиться для поиска файлов на выходе по имени управляемого метода в данном формате, особенно для методов с общими именами, такими как ToString.
У методов, преобразованных il2cpp.exe, есть несколько интересных особенностей:
• Они не являются функциями-членами в C++, а представляют собой свободные функции с указателем this в качестве первого аргумента. Для первого аргумента статических функций в управляемом коде IL2CPP всегда передает значение NULL. Объявляя методы с указателем this в качестве первого аргумента, мы упрощаем генерацию кода в il2cpp.exe и вызов методов через другие методы (например делегаты) для генерируемого кода.
• Каждый метод имеет дополнительный аргумент типа MethodInfo*, содержащий метаданные о методе, которые могут использоваться, например, для вызова виртуального метода. Mono использует специфичные для платформы транспорты, чтобы передать эти метаданные. Но в случае IL2CPP мы решили не использовать их, чтобы улучшить переносимость.
• Все методы объявлены через extern «C», чтобы il2cpp.exe могла при необходимости обмануть компилятор C++ и рассматривать все методы, как если бы они имели один тип.
• Имена типов содержат суффикс «_t», имена методов — суффикс «_m». Конфликты имен решаются добавлением уникального номера для каждого имени. В случае любых изменений в коде пользовательского скрипта эти цифры тоже меняются, поэтому на них не стоит рассчитывать при переходе на новую сборку.
Первые 2 пункта подразумевают, что каждый метод имеет по крайней мере 2 параметра: указатель this и указатель MethodInfo. Добавляют ли эти параметры лишние затраты ресурсов? Да, добавляют, но это не влияет на производительность, как может показаться на первый взгляд. По крайней мере, так говорят результаты профилирования.
Перейдем к определению метода ToString с помощью Ctags. Оно находится в файле Bulk_UnityEngine_0.cpp. Код в этом определении метода не похож на код C# в методе Vector3::ToString(). Однако, если вы используете инструмент вроде ILSpy для просмотра кода метода Vector3::ToString(), вы можете заметить, что генерируемый код C++ очень похож на код IL.
Почему il2cpp.exe не генерирует отдельный файл C++ для определения методов каждого типа, как делает это для объявления методов? Файл Bulk_UnityEngine_0.cpp довольно большой – 20 481 строка! Используемые компиляторы C++ с трудом справлялись с большим количеством исходных файлов. Компиляция 4 тысяч файлов .cpp длилась дольше, чем компиляция того же исходного кода в 80 файлах .cpp. Поэтому il2cpp.exe делит определения методов для типов на группы и генерирует по одному файлу C++ для каждой из них.
Теперь вернемся к заголовочному файлу объявления методов и обратим внимание на строку в верхней части файла:
#include "codegen/il2cpp-codegen.h"
Файл il2cpp-codegen.h содержит интерфейс, с помощью которого генерируемый код получает доступ к среде libil2cpp. Позже мы обсудим несколько способов использования этой среды.
Пролог метода
Давайте посмотрим на определение метода Vector3::ToString(), а именно на общий пролог, созданный il2cpp.exe для всех методов.
StackTraceSentry _stackTraceSentry(&Vector3_ToString_m2315_MethodInfo);
static bool Vector3_ToString_m2315_init;
if (!Vector3_ToString_m2315_init)
{
ObjectU5BU5D_t4_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&ObjectU5BU5D_t4_0_0_0);
Vector3_ToString_m2315_init = true;
}
В первой строке пролога создается локальная переменная типа StackTraceSentry. Она используется для отслеживания стека управляемых вызовов, например, с помощью Environment.StackTrace. На самом деле генерация этого кода необязательна, в этом случае она запустилась из-за передачи il2cpp.exe аргумента --enable-stacktrace (так как я установил значение Full для Enable Exceptions в WebGL Player Settings). Мы обнаружили, что для маленьких функций эта переменная увеличивает затраты ресурсов и негативно влияет на производительность. Поэтому мы никогда не добавляем этот код для iOS и других платформ, где можно получить информацию трассировки стека без него. Платформа WebGL не поддерживает трассировку стека, поэтому для корректной работы необходимо разрешить исключения управляемого кода.
Вторая часть пролога запускает «ленивую» инициализацию типа метаданных для любого массива или универсальных типов, используемых в теле метода. Таким образом, ObjectU5BU5D_t4 – это имя типа System.Object[]. Эта часть пролога выполняется всего один раз и не делает ничего, если тип уже был инициализирован, поэтому никакого негативного влияния на производительность замечено не было.
А как же потоковая безопасность? Что если два потока вызывают Vector3::ToString() одновременно? Ничего страшного: весь код в среде libil2cpp, используемый для инициализации типа, безопасно вызывать из нескольких потоков. Скорее всего, функция il2cpp_codegen_class_from_type будет вызвана несколько раз, но фактически сработает только единожды, в одном потоке. Выполнение метода не возобновится до тех пор, пока не завершится инициализация. Поэтому этот пролог метода является потокобезопасным.
Проверки во время выполнения
Следующая часть метода создает массив объектов, сохраняет значение поля Х для Vector3 в локальную переменную, затем упаковывает эту переменную и добавляет ее в массив с нулевым индексом. Генерируемый код C++ (с комментариями) выглядит так:
// Create a new single-dimension, zero-based object array
ObjectU5BU5D_t4* L_0 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 3));
// Store the Vector3::x field in a local
float L_1 = (__this->___x_1);
float L_2 = L_1;
// Box the float instance, since it is a value type.
Object_t * L_3 = Box(InitializedTypeInfo(&Single_t264_il2cpp_TypeInfo), &L_2);
// Here are three important runtime checks
NullCheck(L_0);
IL2CPP_ARRAY_BOUNDS_CHECK(L_0, 0);
ArrayElementTypeCheck (L_0, L_3);
// Store the boxed value in the array at index 0
*((Object_t **)(Object_t **)SZArrayLdElema(L_0, 0)) = (Object_t *)L_3;
Il2cpp.exe добавляет 3 проверки, отсутствующие в коде IL:
• При значении массива NULL проверка NullCheck выбрасывает исключение NullReferenceException.
• При неправильном индексе массива проверка IL2CPP_ARRAY_BOUNDS_CHECK выбрасывает исключение IndexOutOfRangeException.
• При неправильном типе элемента, добавляемого в массив, ArrayElementTypeCheck выбрасывает исключение ArrayTypeMismatchException.
Эти проверки во время выполнения гарантируют правильность данных для виртуальной машины .NET. Вместо того чтобы внедрять код, для обработки этих же проверок Mono использует механизмы целевой платформы. В случае IL2CPP мы хотели охватить как можно больше платформ, включая такие, как WebGL, у которых нет своего механизма проверок. Поэтому утилита il2cpp.exe внедряет эти проверки сама.
Не создают ли эти проверки проблем с производительностью? В большинстве случаев никаких проблем замечено не было. Более того, проверки обеспечивают дополнительные преимущества и безопасность для виртуальной машины .NET. В некоторых отдельных случаях мы все-таки зафиксировали снижение производительности, особенно в сплошных циклах. Сейчас мы пытаемся найти способ, который позволит управляемому коду удалять динамические проверки, когда il2cpp.exe генерирует код C++. Следите за обновлениями.
Статические поля
Теперь, когда мы увидели, как выглядят поля экземпляра (на примере Vector3), давайте посмотрим, как преобразуются статические поля и как к ним осуществляется доступ. Сначала найдем определение метода HelloWorld_Start_m3, которое находится в файле Bulk_Assembly-CSharp_0.cpp в моей сборке, а затем перейдем к типу Important_t1 (в файле AssemblyU2DCSharp_HelloWorld_Important.h):
struct Important_t1 : public Object_t
{
// System.Int32 HelloWorld/Important::InstanceIdentifier
int32_t ___InstanceIdentifier_1;
};
struct Important_t1_StaticFields
{
// System.Int32 HelloWorld/Important::ClassIdentifier
int32_t ___ClassIdentifier_0;
};
Обратите внимание, что il2cpp.exe создала отдельную структуру С++, чтобы предоставить статическое поле, доступное всем экземплярам этого типа. Таким образом, во время выполнения будет создан один экземпляр типа Important_t1_StaticFields, и все экземпляры типа Important_t1 будут использовать его как статическое поле. В генерируемом коде доступ к статическому полю осуществляется следующим образом:
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);
Метаданные типа для Important_t1 содержат указатель на один экземпляр типа Important_t1_StaticFields, а также информацию о том, что этот экземпляр используется для получения значения статического поля.
Исключения
Il2cpp.exe преобразует управляемые исключения в исключения C++. Мы выбрали такой подход, чтобы, опять же, не зависеть от конкретных платформ. Когда il2cpp.exe нужно сгенерировать код для создания управляемого исключения, она вызывает функцию il2cpp_codegen_raise_exception. Код вызова и перехвата управляемых исключений в нашем методе HelloWorld_Start_m3 выглядит так:
try
{ // begin try (depth: 1)
InvalidOperationException_t7 * L_17 = (InvalidOperationException_t7 *)il2cpp_codegen_object_new (InitializedTypeInfo(&InvalidOperationException_t7_il2cpp_TypeInfo));
InvalidOperationException__ctor_m8(L_17, (String_t*) &_stringLiteral5, /*hidden argument*/&InvalidOperationException__ctor_m8_MethodInfo);
il2cpp_codegen_raise_exception(L_17);
// IL_0092: leave IL_00a8
goto IL_00a8;
} // end try (depth: 1)
catch(Il2CppExceptionWrapper& e)
{
__exception_local = (Exception_t8 *)e.ex;
if(il2cpp_codegen_class_is_assignable_from (&InvalidOperationException_t7_il2cpp_TypeInfo, e.ex->object.klass))
goto IL_0097;
throw e;
}
IL_0097:
{ // begin catch(System.InvalidOperationException)
V_1 = ((InvalidOperationException_t7 *)__exception_local);
NullCheck(V_1);
String_t* L_18 = (String_t*)VirtFuncInvoker0< String_t* >::Invoke(&Exception_get_Message_m9_MethodInfo, V_1);
Debug_Log_m6(NULL /*static, unused*/, L_18, /*hidden argument*/&Debug_Log_m6_MethodInfo);
// IL_00a3: leave IL_00a8
goto IL_00a8;
} // end catch (depth: 1)
Все управляемые исключения заворачиваются в тип Il2CppExceptionWrapper. Когда генерируемый код перехватывает исключение такого типа, он распаковывает его C++ представление (имееющее тип Exception_t8). В данном случае мы ищем только InvalidOperationException, поэтому, если не найдем исключение этого типа, C++ снова выбросит его копию. Если же мы найдем исключение этого типа, код запустит обработчик перехвата и выведет сообщение об исключении.
Goto?!
Возникает интересный вопрос: что здесь делают метки и операторы goto? Эти конструкции необязательно использовать в структурном программировании. Дело в том, что в языке IL не используются принципы структурного программирования, такие как циклы и условные операторы. Это низкоуровневый язык, поэтому il2cpp.exe придерживается низкоуровневых концепций в генерируемом коде.
В качестве примера рассмотрим цикл for в методе HelloWorld_Start_m3:
IL_00a8:
{
V_2 = 0;
goto IL_00cc;
}
IL_00af:
{
ObjectU5BU5D_t4* L_19 = ((ObjectU5BU5D_t4*)SZArrayNew(ObjectU5BU5D_t4_il2cpp_TypeInfo_var, 1));
int32_t L_20 = V_2;
Object_t * L_21 =
Box(InitializedTypeInfo(&Int32_t5_il2cpp_TypeInfo), &L_20);
NullCheck(L_19);
IL2CPP_ARRAY_BOUNDS_CHECK(L_19, 0);
ArrayElementTypeCheck (L_19, L_21);
*((Object_t **)(Object_t **)SZArrayLdElema(L_19, 0)) = (Object_t *)L_21;
Debug_LogFormat_m7(NULL /*static, unused*/, (String_t*) &_stringLiteral6, L_19, /*hidden argument*/&Debug_LogFormat_m7_MethodInfo);
V_2 = ((int32_t)(V_2+1));
}
IL_00cc:
{
if ((((int32_t)V_2) < ((int32_t)3)))
{
goto IL_00af;
}
}
Переменная V_2 является индексом цикла. В начале она имеет значение 0, затем увеличивается внизу цикла в этой строке:
V_2 = ((int32_t)(V_2+1));
Условие окончания цикла проверяется здесь:
if ((((int32_t)V_2) < ((int32_t)3)))
Пока V_2 меньше трех, оператор goto переходит к метке IL_00af, которая является верхней частью тела цикла. Как вы уже могли догадаться, на данный момент il2cpp.exe генерирует код C++ непосредственно из IL без использования промежуточного абстрактного представления синтаксического дерева. Возможно, вы также заметили, что в разделе «Проверки во время выполнения» в коде есть такие фрагменты:
float L_1 = (__this->___x_1);
float L_2 = L_1;
Очевидно, что переменная L_2 здесь лишняя. Несмотря на то, что в большинстве компиляторов C++ она отсеивается, нам бы хотелось вообще избежать ее появления в коде. Сейчас мы рассматриваем возможность использования абстрактного синтаксического дерева, чтобы лучше понять код IL и генерировать лучший код C++ для случаев, когда используются локальные переменные и циклы.
Вывод
Мы затронули только малую часть кода C++, генерируемого IL2CPP для очень простого проекта. Теперь я рекомендую вам взглянуть на генерируемый код вашего собственного проекта. Имейте в виду, что в будущих версиях Unity код C++ будет выглядеть по-другому, так как мы продолжаем повышать качество и производительность технологии IL2CPP.
Благодаря преобразованию кода IL в C++ нам удалось добиться хорошего баланса между его переносимостью и производительностью. Мы получили много полезных для разработчиков функций управляемого кода, сохранив преимущества машинного кода, которые компилятор С++ обеспечивает для различных платформ.
В будущих постах мы поговорим о генерируемом коде подробнее: рассмотрим вызовы методов и распределение их реализаций и оберток для вызова нативных библиотек. А в следующий раз мы займемся отладкой генерируемого кода для 64-битной версии iOS с использованием Xcode.
Автор: Plarium