Искать на сайте Unity

IL2CPP: экскурсия по генерируемому коду

13 мая 2015 г. через Engine & platform | 15 мин. читать
Охваченные темы
Поделиться

Is this article helpful for you?

Thank you for your feedback!

Это второй пост из серии по IL2CPP. В этой статье мы будем исследовать C++ код, сгенерированный il2cpp.exe. Мы увидим, как представлены управляемые типы в машинном коде, посмотрим на проверки во время выполнения, используемые для поддержки виртуальной машины .NET, как генерируются циклы и многое другое!

Мы получим очень версия-зависимый код, который, безусловно, будет меняться в более поздних версиях Unity. Тем не менее, концепции останутся такими же.

Пример проекта

Для этого примера я буду использовать последнюю доступную версию Unity, 5.0.1p1. Как и в первом посте этой серии, я начну с пустого проекта и добавлю один скрипт со следующим содержанием:

[csharp]
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);
}
}
}
[/csharp]

Я соберу этот проект для WebGL в редакторе Unity на Windows. Я выбрал опцию "Development Player" в Build Settings, так что мы можем получить относительно хорошие имена в сгенерированном коде C++. Я также установил опцию "Enable Exceptions" для WebGL в Player Settings в значение "Full".

Обзор сгенерированного кода

После завершения сборки под WebGL, сгенерированный C++ код доступен в директории Temp\StagingArea\Data\il2cppOutput в папке моего проекта. После закрытия редактора, эта директория будет удалена. Пока редактор открыт, этот каталог будет существовать, так что мы можем проверить его.

Утилита il2cpp.exe генерирует несколько файлов, даже для этого небольшого проекта. Я вижу 4625 файлов заголовков и 89 файлов исходного кода C++. Чтобы рассмотреть этот код, я хотел бы использовать текстовый редактор, который работает с Exuberant CTags. CTags обычно для такого кода быстро генерирует файл тегов, что позволяет легче в нём ориентироваться.

Вы можете увидеть, что многие из файлов C++ созданы не из простого кода скрипта, а содержат преобразованный код стандартных библиотек, таких как mscorlib.dll. Как уже упоминалось в первом посте в этой серии, скриптовый движок IL2CPP использует тот же код стандартных библиотек, что и Mono. Обратите внимание, что мы преобразовываем код mscorlib.dll и других стандартных библиотек при каждом запуске il2cpp.exe. Это может показаться ненужным, так как код не изменяется.

Тем не менее, IL2CPP всегда использует зачистку байт-кода, чтобы уменьшить размер исполняемого файла. Как следствие, даже небольшие изменения в коде скрипта могут вызвать много различных частей кода стандартной библиотеки, которые будут использоваться или нет, в зависимости от ситуации. Поэтому мы должны преобразовывать mscorlib.dll каждый раз. Мы ищем лучший способ сделать инкрементную сборку, но пока нам этого не удалось.

Как управляемый код отображается в сгенерированном C++ коде

Для каждого типа в управляемом коде il2cpp.exe будет генерировать один файл заголовка C++ для определения типа и другой файл заголовка для объявления метода для этого типа. Например, давайте посмотрим на сгенерированный код для типа UnityEngine.Vector3. Файл заголовка для этого типа имеет название UnityEngine_UnityEngine_Vector3.h. Имя создается на основе имени сборки, UnityEngine.dll, затем берется пространство имен и в конце имя типа. Код выглядит следующим образом:

[cpp]
// 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;
};
[/cpp]

Утилита il2cpp.exe преобразует каждое из трех полей экземпляра, и делает небольшое изменение имени, используя добавление подчеркиваний в начале, чтобы избежать конфликтов с зарезервированными словами. Мы используем некоторые зарезервированные имена в C++, но пока не видели каких-либо конфликтов с кодом стандартных библиотек C++.

Файл UnityEngine_UnityEngine_Vector3MethodDeclarations.h содержит объявления для всех методов в Vector3. Например, Vector3 переопределяет метод Object.ToString:

[cpp]
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTR
[/cpp]

Обратите внимание на комментарий, в котором указан управляемый метод, представляющий это объявление. Это часто полезно для поиска файлов на выходе по имени управляемого метода в этом формате, особенно для методов с общими именами, такими как ToString.

Вот несколько интересных особенностей методов, созданных il2cpp.exe:

  • Это не функции-члены в C++. Все методы — свободные функции, где первый аргумент — указатель "this". Для статических функций в управляемом коде, IL2CPP всегда передаёт значение NULL для первого аргумента. Объявляя методы с указателем "this" в качестве первого аргумента, мы упрощаем способ генерации кода в il2cpp.exe и вызов методов через другие методы (например, делегаты) для сгенерированного кода.
  • Каждый метод имеет дополнительный аргумент типа MethodInfo*, который включает в себя метаданные о методе, которые используются для таких вещей, как вызов виртуального метода. Mono использует специфичные для платформы транспорты, чтобы передать эти метаданные. Для IL2CPP, мы решили избежать использования транспортов, чтобы улучшить переносимость
  • Все методы объявлены внешне, чтобы il2cpp.exe мог иногда обмануть компилятор C++ и рассматривать все методы, как если бы они имели такой же тип.
  • Имена типов содержат суффикс "_t". Имена методов — суффикс "_m". Конфликты имен решаются добавлением уникального номера для каждого имени. Эти цифры будут меняться, если что-либо изменить в коде пользовательского скрипта, так что вы не можете рассчитывать на них во время сборки.

Первые два пункта говорят, что каждый метод имеет по крайней мере два параметра, указатель "this" и указатель MethodInfo. Но добавляют ли эти параметры ненужные расходы? Мы не обнаружили того, чтобы эти дополнительные аргументы могли привести к снижению производительности. Хотя может показаться, что они будут, профилирование показало, что разница в производительности не поддается измерению.

Мы можем перейти к определению метода ToString, используя Ctags. Оно находится в файле Bulk_UnityEngine_0.cpp. Код в этом определении метода не выглядит похожим на C# код в методе Vector3::ToString(). Однако, если вы используете инструмент, такой как ILSpy, чтобы посмотреть код для метода Vector3::ToString(), вы увидите, что cгенерированный код C++ очень похож на IL код.

Почему il2cpp.exe не генерирует отдельный файл C++ для определения методов для каждого типа, как делает это для объявления методов? Bulk_UnityEngine_0.cpp файл на самом деле довольно большой, 20481 строка! Компиляторы C++, которые мы использовали, с трудом работали с большим количеством исходных файлов. Компиляция четырех тысяч .cpp файлов длилась дольше, чем компиляция того же кода в 80 .cpp файлах. Поэтому il2cpp.exe делит определения методов для типов на группы и генерирует один файл C++ для каждой группы.

Теперь вернемся к заголовочному файлу объявления методов и обратим внимание на строку в верхней части файла:

[cpp]
#include "codegen/il2cpp-codegen.h"
[/cpp]

Файл il2cpp-codegen.h содержит интерфейс, который использует сгенерированный код для доступа к libil2cpp во время выполнения. Мы обсудим несколько способов, которые во время выполнения используются сгенерированным кодом, позже.

Пролог метода

Давайте взглянем на определение метода Vector3::ToString(). В частности, он имеет общий пролог, который создается il2cpp.exe во всех методах.

[cpp]
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;
}
[/cpp]

В первой строке этого пролога создается локальная переменная типа StackTraceSentry. Эта переменная используется для отслеживания управляемых вызовов стека, так что IL2CPP можете сообщить об этом в вызовах, таких как Environment.StackTrace. Генерация этого кода на самом деле необязательна, и сработала в этом случае из-за передачи il2cpp.exe флага --enable-StackTrace (так как я установил для WebGL параметр "Enable Exceptions" в Player Settings в значение "Full"). Мы обнаружили, что для маленьких функций эта переменная добавляет расходы и оказывает негативное влияние на производительность. Таким образом, для iOS и других платформ, где можно получить информацию трассировки стека без этого кода, мы никогда не добавляем эту строку в генерируемый код. Для WebGL, мы не имеем поддержки трассировки стека, поэтому для правильной работы необходимо перехватывать исключения управляемого кода.

Вторая часть пролога делает «ленивую» инициализацию типа метаданных для любого массива или сгенерированных типов, используемых в теле метода. Так ObjectU5BU5D_t4 — это имя типа System.Object[]. Эта часть пролога выполняется только один раз, и часто не делает ничего, если тип был уже инициализирован, таким образом, мы не видели каких-либо неблагоприятных последствий для производительности от этого сгенерированного кода.

Этот код потокобезопасный? Что делать, если два потока вызывают Vector3::ToString () одновременно? На самом деле, этот код не создает проблем, так как весь код в libil2cpp используется для инициализации типа, безопасного для вызова из нескольких потоков. Возможно (даже вероятно), что функция il2cpp_codegen_class_from_type будет вызвана более чем один раз, но реально она сработает только один раз, в одном потоке. Выполнение метода не будет продолжаться, пока инициализация не завершена. Поэтому этот пролог метода является потокобезопасным.

Проверки во время выполнения

Следующая часть метода создает массив объектов, сохраняет значение поля Х для Vector3 в локальную переменную, затем создает локальную переменную “ящика” и добавляет его в массив с индексом ноль. Вот сгенерированный код C++ (с некоторыми аннотациями):

[cpp]
// 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;
[/cpp]

Три проверки отсутствуют в коде IL, но добавляются il2cpp.exe.

  • Проверка NullCheck бросит исключение NullReferenceException, если значение массива равно null.
  • 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 (в файле theAssemblyU2DCSharp_HelloWorld_Important.h):

[cpp]
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;
};
[/cpp]

Обратите внимание, что il2cpp.exe создала отдельную С++ структуру, чтобы предоставить статическое поле для этого типа, так как оно должно быть доступно всем экземплярам этого типа. Таким образом, во время выполнения, будет создан один экземпляр типа Important_t1_StaticFields, и все экземпляры типа Important_t1 будут использовать этот экземпляр как статическое поле. В сгенерированном коде доступ к статическому полю происходит следующим образом:

[cpp]
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);
[/cpp]

Метаданные типа для Important_t1 содержит указатель на один экземпляр типа Important_t1_StaticFields, и информацию о том, что этот экземпляр используется для получения значения статического поля.

Исключения

Управляемые исключения преобразуются il2cpp.exe в C++ исключения. Мы выбрали такой подход, чтобы избежать зависимости от платформы. Когда il2cpp.exe нужно создавать код, способный бросить управляемое исключение, он вызывает функцию il2cpp_codegen_raise_exception.

Блок try...catch для управляемых исключений в нашем методе HelloWorld_Start_m3 выглядит так:

[cpp]
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)
[/cpp]

Все управляемые исключения заворачиваются в Il2CppExceptionWrapper. Когда сгенерированный код ловит исключение этого типа, он распаковывает C++ представление управляемого исключения (которое имеет тип Exception_t8). В данном случае, мы ищем только InvalidOperationException, поэтому если мы не найдем исключение этого типа, C++  снова бросит это исключение. Если же мы встречаем исключение правильного типа, то код переходит к реализации обработчика, и выводит сообщение исключения.

Goto!?!

Этот код вызывает интересный вопрос. Что эти ярлыки и goto там делают? Эти конструкции не являются необходимыми в структурном программировании! Тем не менее, IL не имеет концепции структурированного программирования, таких как циклы, конструкции if/then. Так как этот код низкоуровневый, il2cpp.exe придерживается концепции низкоуровневого программирования в сгенерированном коде.

Для примера, давайте посмотрим на цикл в методе HelloWorld_Start_m3:

[cpp]
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;
}
}
[/cpp]

Здесь переменная V_2 является индексом цикла. В начале она имеет значение 0, и увеличивается в нижней части цикла в этой строке:

[cpp]
V_2 = ((int32_t)(V_2+1));
[/cpp]

Условие окончания цикла проверяется здесь:

[cpp]
if ((((int32_t)V_2) < ((int32_t)3)))
[/cpp]

Пока V_2 меньше 3-х, управление переходит на метку IL_00af, которая является верхней частью тела цикла. Вы могли догадаться, что на данный момент il2cpp.exe генерирует C++ код непосредственно из IL, без использования промежуточного абстрактного представления синтаксического дерева. И это действительно так. Возможно, вы также заметили выше в разделе динамических проверок, что некоторые части из сгенерированного кода выглядят следующим образом:

[cpp]
float L_1 = (__this->___x_1);
float L_2 = L_1;
[/cpp]

Очевидно, что здесь не требуется переменная L_2. Большинство компиляторов C++ может оптимизировать эту задачу, но мы хотели бы вообще избежать ее создания. В настоящее время мы исследуем возможности использования AST, чтобы лучше понять код IL и генерировать лучший C++ код для случаев, использующих локальные переменные и циклов.

Вывод

Мы рассмотрели только малую часть C++ кода, сгенерированного IL2CPP для очень простого проекта. Если вы этого не сделали, я призываю вас погрузиться в сгенерированный код вашего проекта. Изучая его, имейте в виду, что генерируемый C++ код будет выглядеть по-другому в будущих версиях Unity, так как мы постоянно работаем над улучшением качества и производительности IL2CPP.

Путем преобразования IL кода в C++, мы добились хорошего баланса между портативностью и производительностью кода. Мы смогли получить много полезных для разработчиков функций управляемого кода, и в то же время по-прежнему пользуемся преимуществами машинного кода, которые обеспечивает компилятор С++ для различных платформ.

В будущих постах, мы будем изучать больше сгенерированного кода, в том числе вызовы методов, распределения реализаций методов и обертки для вызова нативных библиотек. Но в следующий раз мы будем отлаживать некоторую часть из сгенерированного кода для 64-битной сборки iOS, используя Xcode.

13 мая 2015 г. через Engine & platform | 15 мин. читать

Is this article helpful for you?

Thank you for your feedback!

Охваченные темы
Связанные публикации
Unity, логотипы Unity и другие торговые знаки Unity являются зарегистрированными торговыми знаками компании Unity Technologies или ее партнеров в США и других странах (подробнее здесь). Остальные наименования и бренды являются торговыми знаками соответствующих владельцев.