Внутреннее устройство IL2CPP: P/Invoke Wrappers

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 Мин
Внутреннее устройство IL2CPP: P/Invoke Wrappers
Эта веб-страница была переведена с помощью машинного перевода для вашего удобства. Мы не можем гарантировать точность или надежность переведенного контента. Если у вас есть вопросы о точности переведенного контента, обращайтесь к официальной английской версии веб-страницы.
Это шестой пост в серии статей IL2CPP Internals. В этом посте мы рассмотрим, как il2cpp.exe генерирует методы-обертки и типы, используемые для взаимодействия между управляемым и нативным кодом. В частности, мы рассмотрим разницу между blittable и non-blittable типами, разберемся с маршалингом строк и массивов, а также узнаем о стоимости маршалинга.

В свое время я написал немало кода, взаимодействующего с managed и native, но правильно подобрать декларации p/invoke в C# по-прежнему, мягко говоря, сложно. Понимание того, что делает среда выполнения для маршалинга моих объектов, еще более загадочно. Поскольку IL2CPP делает большую часть своего маршалинга в сгенерированном коде C++, мы можем видеть (и даже отлаживать!) его поведение, предоставляя гораздо больше информации для устранения неполадок и анализа производительности.

Эта заметка не ставит своей целью предоставить общую информацию о маршалинге и native interop. Это обширная тема, слишком большая для одного сообщения. В документации по Unity рассказывается о том, как родные плагины взаимодействуют с Unity. И Mono, и Microsoft предоставляют множество отличной информации о p/invoke в целом.

Как и во всех других статьях этой серии, мы будем изучать код, который может быть изменен и, более того, скорее всего, изменится в новой версии Unity. Однако концепции должны оставаться неизменными. Пожалуйста, воспринимайте все, что обсуждается в этой серии, как детали реализации. Нам нравится раскрывать и обсуждать такие детали, когда это возможно!

Настройка

Для этого сообщения я использую Unity 5.0.2p4 на OSX. Я буду создавать для платформы iOS, используя значение "Архитектура" "Универсальная". Я собрал свой нативный код для этого примера в Xcode 6.3.2 как статическую библиотеку для ARMv7 и ARM64.

Родной код выглядит следующим образом:

#include <cstring>
#include <cmath>

extern "C" {
int Increment(int i) {
return i + 1;
}

bool StringsMatch(const char* l, const char* r) {
return strcmp(l, r) == 0;
}

struct Vector {
float x;
float y;
float z;
};

float ComputeLength(Vector v) {
return sqrt(v.x*v.x + v.y*v.y + v.z*v.z);
}

void SetX(Vector* v, float value) {
v->x = value;
}

struct Boss {
char* name;
int health;
};

bool IsBossDead(Boss b) {
return b.health == 0;
}

int SumArrayElements(int* elements, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += elements[i];
}
return sum;
}

int SumBossHealth(Boss* bosses, int size) {
int sum = 0;
for (int i = 0; i < size; ++i) {
sum += bosses[i].health;
}
return sum;
}

}

Код сценария в Unity снова находится в файле HelloWorld.cs. Это выглядит следующим образом:

void Start () {
Debug.Log (string.Format ("Using a blittable argument: {0}", Increment (42)));
Debug.Log (string.Format ("Marshaling strings: {0}", StringsMatch ("Hello", "Goodbye")));

var vector = new Vector (1.0f, 2.0f, 3.0f);
Debug.Log (string.Format ("Marshaling a blittable struct: {0}", ComputeLength (vector)));
SetX (ref vector, 42.0f);
Debug.Log (string.Format ("Marshaling a blittable struct by reference: {0}", vector.x));

Debug.Log (string.Format ("Marshaling a non-blittable struct: {0}", IsBossDead (new Boss("Final Boss", 100))));

int[] values = {1, 2, 3, 4};
Debug.Log(string.Format("Marshaling an array: {0}", SumArrayElements(values, values.Length)));
Boss[] bosses = {new Boss("First Boss", 25), new Boss("Second Boss", 45)};
Debug.Log(string.Format("Marshaling an array by reference: {0}", SumBossHealth(bosses, bosses.Length)));
}

Каждый из вызовов методов в этом коде превращается в нативный код, показанный выше. Мы рассмотрим объявление управляемого метода для каждого метода позже в этой статье.

Зачем нам нужна маршалинг?

Поскольку IL2CPP уже генерирует код на C++, зачем нам вообще нужен маршалинг из C# в код на C++? Хотя сгенерированный код на C++ является родным кодом, представление типов в C# в ряде случаев отличается от C++, поэтому среда выполнения IL2CPP должна уметь конвертировать туда и обратно представления с обеих сторон. Утилита il2cpp.exe делает это как для типов, так и для методов.

В управляемом коде все типы могут быть классифицированы как расщепляемые или неблокируемые. Типы Blittable имеют одинаковое представление в управляемом и родном коде (например, byte, int, float). Неблокируемые типы имеют разное представление в управляемом и родном коде (например, типы bool, string, array). Таким образом, blittable типы можно передавать в родной код напрямую, а не blittable типы требуют некоторого преобразования, прежде чем их можно будет передать в родной код. Часто это преобразование связано с выделением новой памяти.

Чтобы сообщить компилятору управляемого кода, что данный метод реализован в родном коде, в C# используется ключевое слово extern. Это ключевое слово, вместе с атрибутом DllImport, позволяет времени выполнения управляемого кода найти определение родного метода и вызвать его. Утилита il2cpp.exe генерирует метод-обертку C++ для каждого внешнего метода. Эта обертка выполняет несколько важных задач:

- Он определяет типизацию родного метода, которая используется для вызова метода через указатель функции.

- Он разрешает "родной" метод по имени, получая указатель функции на этот метод.

- Он преобразует аргументы из их управляемого представления в их родное представление (если это необходимо).

- Он вызывает родной метод.

- Он преобразует возвращаемое значение метода из его родного представления в управляемое (если это необходимо).

- In преобразует любые аргументы out или ref из их собственного представления в их управляемое представление (если необходимо).

Далее мы рассмотрим сгенерированные методы-обертки для некоторых объявлений внешних методов.

Маршалинг типа blittable

Самый простой вид внешней обертки имеет дело только с blittable типами.

[DllImport("__Internal")]
private extern static int Increment(int value);



In the Bulk_Assembly-CSharp_0.cpp file, search for the string “HelloWorld_Increment_m3”. The wrapper function for the Increment method looks like this:

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
extern "C" int32_t HelloWorld_Increment_m3 (Object_t * __this /* static, unused */, int32_t ___value, const MethodInfo* method)
{
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
static PInvokeFunc _il2cpp_pinvoke_func;
if (!_il2cpp_pinvoke_func)
{
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
if (_il2cpp_pinvoke_func == NULL)
{
il2cpp_codegen_raise_exception(il2cpp_codegen_get_not_supported_exception("Unable to find method for p/invoke: 'Increment'"));
}
}

int32_t _return_value = _il2cpp_pinvoke_func(___value);

return _return_value;
}

Во-первых, обратите внимание на типовое определение сигнатуры родной функции:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

Нечто подобное будет присутствовать в каждой из функций-оберток. Эта встроенная функция принимает один int32_t и возвращает int32_t.

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

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

Здесь функция Increment фактически происходит из оператора extern (в коде C++):

extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}

На iOS нативные методы статически связаны с одним бинарным файлом (на это указывает строка "__Internal" в атрибуте DllImport), поэтому время выполнения IL2CPP ничего не делает для поиска указателя функции. Вместо этого оператор extern сообщает компоновщику о необходимости найти соответствующую функцию во время компоновки. На других платформах среда выполнения IL2CPP может выполнить поиск (если необходимо) с помощью специфического для платформы метода API, чтобы получить указатель этой функции.

На практике это означает, что на iOS неправильная сигнатура p/invoke в управляемом коде будет отображаться как ошибка компоновщика в сгенерированном коде. Ошибка не возникнет во время выполнения. Поэтому все подписи p/invoke должны быть правильными, даже если они не используются во время выполнения.

Наконец, родной метод вызывается через указатель функции, и возвращается возвращаемое значение. Обратите внимание, что аргумент передается в родную функцию по значению, поэтому любые изменения его значения в родном коде не будут доступны в управляемом коде, как мы и ожидали.

Маршаллинг не бьющегося типа

Ситуация становится немного интереснее, если Вы используете не сбиваемый тип, например, string. Вспомните из предыдущего поста, что строки в IL2CPP представлены в виде массива двухбайтовых символов, закодированных в UTF-16, с префиксом в виде 4-байтового значения длины. Это представление не совпадает ни с char*, ни с wchar_t* представлениями строк в C на iOS, поэтому нам придется выполнить некоторое преобразование. Если мы посмотрим на метод StringsMatch (HelloWorld_StringsMatch_m4 в сгенерированном коде):

DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);

Мы видим, что каждый строковый аргумент будет преобразован в char* (благодаря директиве UnmangedType.LPStr).

typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);

Преобразование выглядит следующим образом (для первого аргумента):

char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);

Выделяется новый буфер char нужной длины, и содержимое строки копируется в новый буфер. Конечно, после вызова родного метода нам нужно очистить выделенные буферы:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

Таким образом, маршалинг такого нерасщепляемого типа, как string, может оказаться дорогостоящим.

Маршалинг определенного пользователем типа

Простые типы, такие как int и string, хороши, но как насчет более сложных, определяемых пользователем типов? Предположим, мы хотим передать структуру Vector, приведенную выше, которая содержит три значения float. Оказывается, что тип, определяемый пользователем, является блиттинговым тогда и только тогда, когда все его поля являются блиттинговыми. Поэтому мы можем вызвать ComputeLength (HelloWorld_ComputeLength_m5 в сгенерированном коде) без необходимости преобразовывать аргумент:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

Обратите внимание, что аргумент передается по значению, как и в начальном примере, когда тип аргумента был int. Если мы хотим изменить экземпляр Vector и увидеть эти изменения в управляемом коде, нам нужно передать его по ссылке, как в методе SetX (HelloWorld_SetX_m6):

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 *, float);

Vector_t1 * ____v_marshaled = { 0 };
Vector_t1  ____v_marshaled_dereferenced = { 0 };
____v_marshaled_dereferenced = *___v;
____v_marshaled = &____v_marshaled_dereferenced;

float _return_value = _il2cpp_pinvoke_func(____v_marshaled, ___value);

Vector_t1  ____v_result_dereferenced = { 0 };
Vector_t1 * ____v_result = &____v_result_dereferenced;
*____v_result = *____v_marshaled;
*___v = *____v_result;

return _return_value;

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

Неразбиваемый тип, определенный пользователем, например, тип Boss, определенный выше, также может быть маршализирован, но с небольшими дополнительными усилиями. Каждое поле этого типа должно быть приведено к своему собственному представлению. Кроме того, сгенерированный код C++ должен иметь представление управляемого типа, которое совпадает с представлением в родном коде.

Давайте посмотрим на внешнее объявление IsBossDead:

[DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool IsBossDead(Boss b);

Обертка для этого метода называется HelloWorld_IsBossDead_m7:

extern "C" bool HelloWorld_IsBossDead_m7 (Object_t * __this /* static, unused */, Boss_t2  ___b, const MethodInfo* method)
{
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (Boss_t2_marshaled);

Boss_t2_marshaled ____b_marshaled = { 0 };
Boss_t2_marshal(___b, ____b_marshaled);
uint8_t _return_value = _il2cpp_pinvoke_func(____b_marshaled);
Boss_t2_marshal_cleanup(____b_marshaled);

return _return_value;
}

Аргумент передается в функцию-обертку как тип Boss_t2, который является сгенерированным типом для структуры Boss. Обратите внимание, что он передается в родную функцию с другим типом: Boss_t2_marshaled. Если мы перейдем к определению этого типа, то увидим, что оно совпадает с определением Boss struct в нашем коде статической библиотеки C++:

struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};

Мы снова использовали директиву UnmanagedType.LPStr в C#, чтобы указать, что строковое поле должно быть отображено как char*. Если Вы столкнулись с проблемой при отладке не отключаемого пользовательского типа, очень полезно посмотреть на это _marshaled struct в сгенерированном коде. Если расположение полей не соответствует "родной" стороне, то директива маршалинга в управляемом коде может быть неверной.

Функция Boss_t2_marshal - это генерируемая функция, которая маршализирует каждое поле, а функция Boss_t2_marshal_cleanup освобождает память, выделенную в процессе маршалинга.

Маршаллинг не бьющегося типа, определенного пользователем
Марширование массива

Наконец, мы изучим, как передаются массивы типов blittable и non-blittable. Методу SumArrayElements передается массив целых чисел:

[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);

Этот массив маршализируется, но поскольку тип элемента массива (int) является blittable, затраты на его маршалинг очень малы:

int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array<int32_t>((Il2CppCodeGenArray*)___elements);

Функция il2cpp_codegen_marshal_array просто возвращает указатель на существующую память управляемого массива, вот и все!

Однако передача массива типов, не являющихся битами, обходится гораздо дороже. В метод SumBossHealth передается массив экземпляров Boss:

[DllImport("__Internal")]
private extern static int SumBossHealth(Boss[] bosses, int size);

Обертка должна выделить новый массив, а затем сортировать каждый элемент по отдельности:

Boss_t2_marshaled* ____bosses_marshaled = { 0 };
size_t ____bosses_Length = 0;
if (___bosses != NULL)
{
____bosses_Length = ((Il2CppCodeGenArray*)___bosses)->max_length;
____bosses_marshaled = il2cpp_codegen_marshal_allocate_array<Boss_t2_marshaled>(____bosses_Length);
}

for (int i = 0; i < ____bosses_Length; i++)
{
Boss_t2  const& item = *reinterpret_cast<Boss_t2 *>(SZArrayLdElema((Il2CppCodeGenArray*)___bosses, i));
Boss_t2_marshal(item, (____bosses_marshaled)[i]);
}

Конечно, все эти выделения также очищаются после завершения вызова родного метода.

Заключение

Бэкэнд сценариев IL2CPP поддерживает то же поведение маршалинга, что и бэкэнд сценариев Mono. Поскольку IL2CPP создает сгенерированные обертки для внешних методов и типов, можно увидеть стоимость вызовов interop из managed в native. Для сменных типов эта стоимость часто не слишком велика, но не сменные типы могут быстро сделать взаимодействие очень дорогим. Как обычно, в этом посте мы только потрогали поверхность маршалинга. Пожалуйста, изучите сгенерированный код подробнее, чтобы увидеть, как выполняется маршалинг для возвращаемых значений и параметров out, указателей родных функций и управляемых делегатов, а также пользовательских ссылочных типов.

В следующий раз мы рассмотрим, как IL2CPP интегрируется со сборщиком мусора.