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

В свое время я написал немало кода, взаимодействующего с 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 типами.
[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 интегрируется со сборщиком мусора.
