[Перевод] IL2CPP: Обертки P/Invoke для типов и методов


046ae59335e845abbfec4553d3ada106.jpg

Это шестая статья из серии по IL2CPP. На этот раз мы посмотрим, как il2cpp.exe генерирует обертки для методов и типов, необходимые для взаимодействия управляемого и нативного кода. В частности, мы обсудим разницу между непреобразуемыми и преобразуемыми типами, разберемся с маршалингом строк и массивов и поговорим о расходах на маршалинг.

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

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

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

Подготовка к работе


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

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

[cpp]
#include 
#include 

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;
}

}
[/cpp]

Код скрипта в Unity по-прежнему содержится в файле HelloWorld.cs. Он выглядит так:
[csharp]
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)));
}
[/csharp]

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

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


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

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

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

  • определяет typedef для нативного метода, который используется для вызова метода через указатель функции;
  • разрешает нативный метод по имени, передавая в этот метод указатель функции;
  • преобразовывает аргументы из их управляемого представления в нативное представление (если необходимо);
  • вызывает нативный метод;
  • преобразовывает возвращаемое значение метода из его нативного представления в управляемое (если необходимо);
  • преобразовывает любой аргумент ref или out из их нативного представления в управляемое (если необходимо).

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

Маршалинг непреобразуемого типа


Самый простой тип extern-обертки имеет дело только с непреобразуемыми типами.
[csharp]
[DllImport("__Internal")]
private extern static int Increment(int value);
[/csharp]

В файле Bulk_Assembly-CSharp_0.cpp найдите строку «HelloWorld_Increment_m3». Функция-обертка для метода Increment выглядит так:
[cpp]
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;
}
[/cpp]

Сначала укажите typedef для нативной сигнатуры функции:
[cpp]
typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);
[/cpp]

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

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

[cpp]
_il2cpp_pinvoke_func = (PInvokeFunc)Increment;
[/cpp]

Здесь функция Increment поступает из оператора extern (в коде C++):
[cpp]
extern "C" {int32_t DEFAULT_CALL Increment(int32_t);}
[/cpp]

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

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

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

Маршалинг преобразуемого типа


С преобразуемым типом, таким как string, дело обстоит немного интереснее. Как было сказано в одной из предыдущих публикаций, строки в IL2CPP представлены в виде массива двухбайтных символов, зашифрованных с помощью UTF-16 и с префиксом в виде четырехбайтного значения. Это представление не совпадает ни с представлением char*, ни с wchar_t* в C на iOS, потому нам придется выполнять преобразование. Взглянем на метод StringsMatch (HelloWorld_StringsMatch_m4 в генерируемом коде):
[csharp]
DllImport("__Internal")]
[return: MarshalAs(UnmanagedType.U1)]
private extern static bool StringsMatch([MarshalAs(UnmanagedType.LPStr)]string l, [MarshalAs(UnmanagedType.LPStr)]string r);
[/csharp]

Как видно, каждый строковый аргумент будет преобразован в char* (из-за директивы UnmangedType.LPStr).
[cpp]
typedef uint8_t (DEFAULT_CALL *PInvokeFunc) (char*, char*);
[/cpp]

Преобразование выглядит так (для первого аргумента):
[cpp]
char* ____l_marshaled = { 0 };
____l_marshaled = il2cpp_codegen_marshal_string(___l);
[/cpp]

Новый буфер типа char подходящей длины размещен в памяти, и содержимое строки скопировано в новый буфер. Конечно, после вызова нативного метода нам нужно будет очистить эти размещенные буферы:
[cpp]
il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;
[/cpp]

Потому маршалинг такого преобразуемого типа, как строка, может быть затратным.

Маршалинг пользовательского типа


С простыми типами вроде int и string всё понятно, но как насчет более сложных, пользовательских типов? Допустим, мы хотим провести маршалинг структуры Vector из примера выше, содержащей три значения float. Оказывается, пользовательский тип является непреобразуемым исключительно в тех случаях, когда все его поля непреобразуемые. Потому мы можем вызвать ComputeLength (HelloWorld_ComputeLength_m5 в генерируемом коде) без необходимости преобразовывать аргумент:
[cpp]
typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;
[/cpp]

Обратите внимание, что аргумент передается по значению — точно так же, как это было в изначальном примере, когда типом аргумента был int. Если мы хотим изменить экземпляр Vector и увидеть эти значения в управляемом коде, нам нужно передать его по ссылке, как в методе SetX (HelloWorld_SetX_m6):
[cpp]
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;
[/cpp]

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

Маршалинг преобразуемого пользовательского типа


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

Рассмотрим extern-объявление IsBossDead:

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

Обертка для этого метода называется HelloWorld_IsBossDead_m7:
[cpp]
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;
}
[/cpp]

Аргумент передается в функцию-обертку в качестве типа Boss_t2, который является сгенерированным типом для структуры Boss. Обратите внимание, что он передается в нативную функцию с другим типом: Boss_t2_marshaled. Если мы перейдем к определению этого типа, мы увидим, что оно совпадает с определением структуры Boss в нашем коде статической библиотеки C++:
[cpp]
struct Boss_t2_marshaled
{
char* ___name_0;
int32_t ___health_1;
};
[/cpp]

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

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

Маршалинг массива


Наконец, рассмотрим маршалинг массивов преобразуемых и непреобразуемых типов. Методу SumArrayElements передается массив целых чисел:
[csharp]
[DllImport("__Internal")]
private extern static int SumArrayElements(int[] elements, int size);
[/csharp]

Это маршализированный массив, но поскольку тип элемента массива (int) является непреобразуемым, стоимость маршалинга будет очень незначительной:
[cpp]
int32_t* ____elements_marshaled = { 0 };
____elements_marshaled = il2cpp_codegen_marshal_array((Il2CppCodeGenArray*)___elements);
[/cpp]

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

Тем не менее, маршалинг массива преобразуемых типов требует гораздо больше ресурсов. Метод SumBossHealth передает массив экземпляров Boss:

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

Его обертка должна выделить память для нового массива, а затем выполнить маршалинг каждого элемента по отдельности:
[cpp]
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(____bosses_Length);
}

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

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

Вывод


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

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

Комментарии (2)

  • 14 марта 2017 в 18:45

    0

    Качество перевода хромает, начиная с
    > сборку для платформы iOS, применив для Universal значение Architecture
    • 14 марта 2017 в 19:33

      0

      Уточните, пожалуйста, где недочеты? Поправим.

© Habrahabr.ru