IL2CPP internos: Envolturas P/Invoke

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 minutos
IL2CPP internos: Envolturas P/Invoke
Para tu comodidad, tradujimos esta página mediante traducción automática. No podemos garantizar la precisión ni la confiabilidad del contenido traducido. Si tienes alguna duda sobre la precisión del contenido traducido, consulta la versión oficial en inglés de la página web.
Este es el sexto post de la serie IL2CPP Internals. En este post, exploraremos cómo il2cpp.exe genera métodos envolventes y tipos utilizados para la interoperabilidad entre código gestionado y nativo. En concreto, veremos la diferencia entre tipos blitables y no blitables, comprenderemos el marshaling de cadenas y matrices, y aprenderemos sobre el coste del marshaling.

He escrito una buena cantidad de código managed to native interop en mis días, pero conseguir que las declaraciones p/invoke sean correctas en C# sigue siendo difícil, por no decir otra cosa. Entender lo que el tiempo de ejecución está haciendo para marshal mis objetos es aún más de un misterio. Dado que IL2CPP realiza la mayor parte de su marshaling en código C++ generado, podemos ver (¡e incluso depurar!) su comportamiento, lo que proporciona una visión mucho mejor para la resolución de problemas y el análisis del rendimiento.

Este post no pretende proporcionar información general sobre marshaling e interoperabilidad nativa. Es un tema muy amplio, demasiado grande para un solo post. En la documentación de Unity se explica cómo interactúan los plugins nativos con Unity. Tanto Mono como Microsoft proporcionan abundante y excelente información sobre p/invoke en general.

Como en todas las entradas de esta serie, exploraremos código que está sujeto a cambios y que, de hecho, es probable que cambie en una versión más reciente de Unity. Sin embargo, los conceptos deben seguir siendo los mismos. Por favor, tome todo lo discutido en esta serie como detalles de implementación. Sin embargo, ¡nos gusta exponer y discutir detalles como éste cuando es posible!

La configuración

Para este post, estoy usando Unity 5.0.2p4 en OSX. Construiré para la plataforma iOS, utilizando un valor de "Arquitectura" de "Universal". He construido mi código nativo para este ejemplo en Xcode 6.3.2 como una biblioteca estática tanto para ARMv7 como para ARM64.

El código nativo tiene este aspecto:

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

}

El código de scripting en Unity se encuentra de nuevo en el archivo HelloWorld.cs. Tiene este aspecto:

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

Cada una de las llamadas a métodos de este código se realiza en el código nativo que se muestra arriba. Veremos la declaración del método gestionado para cada método como lo vemos más adelante en el post.

¿Por qué necesitamos el marshaling?

Dado que IL2CPP ya genera código C++, ¿para qué necesitamos marshaling de código C# a C++? Aunque el código C++ generado es código nativo, la representación de tipos en C# difiere de la de C++ en varios casos, por lo que el tiempo de ejecución de IL2CPP debe ser capaz de convertir de un lado a otro las representaciones de ambos lados. La utilidad il2cpp.exe lo hace tanto para los tipos como para los métodos.

En código gestionado, todos los tipos pueden clasificarse como blitables o no blitables. Los tipos blitables tienen la misma representación en código gestionado y nativo (por ejemplo, byte, int, float). Los tipos no blitables tienen una representación diferente en el código gestionado y en el nativo (por ejemplo, los tipos bool, string, array). Como tales, los tipos blitables pueden pasarse al código nativo directamente, pero los tipos no blitables requieren alguna conversión antes de poder pasarse al código nativo. A menudo, esta conversión implica una nueva asignación de memoria.

Para indicar al compilador de código gestionado que un método determinado está implementado en código nativo, en C# se utiliza la palabra clave extern. Esta palabra clave, junto con un atributo DllImport, permite que el tiempo de ejecución del código gestionado encuentre la definición del método nativo y lo llame. La utilidad il2cpp.exe genera un método C++ envolvente para cada método externo. Esta envoltura realiza algunas tareas importantes:

- Define un tippedef para el método nativo que se utiliza para invocar el método a través de un puntero de función.

- Resuelve el método nativo por su nombre, obteniendo un puntero de función a ese método.

- Convierte los argumentos de su representación gestionada a su representación nativa (si es necesario).

- Llama al método nativo.

- Convierte el valor de retorno del método de su representación nativa a su representación gestionada (si es necesario).

- In convierte cualquier argumento out o ref de su representación nativa a su representación gestionada (si es necesario).

A continuación echaremos un vistazo a los métodos envolventes generados para algunas declaraciones de métodos externos.

Marshaling de un tipo blittable

La clase más sencilla de envoltura externa sólo se ocupa de los tipos blitables.

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

En primer lugar, observe el tippedef para la firma de la función nativa:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

Algo similar aparecerá en cada una de las funciones envoltorio. Esta función nativa acepta un único int32_t y devuelve un int32_t.

A continuación, la envoltura encuentra el puntero de función adecuado y lo almacena en una variable estática:

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

Aquí la función Incrementar procede en realidad de una sentencia extern (en el código C++):

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

En iOS, los métodos nativos están enlazados estáticamente en un único binario (indicado por la cadena "__Internal" en el atributo DllImport), por lo que el tiempo de ejecución IL2CPP no hace nada para buscar el puntero de la función. En su lugar, esta sentencia extern informa al enlazador para que encuentre la función adecuada en el momento del enlace. En otras plataformas, el tiempo de ejecución de IL2CPP puede realizar una búsqueda (si es necesario) utilizando un método API específico de la plataforma para obtener este puntero de función.

En la práctica, esto significa que en iOS, una firma p/invoke incorrecta en código gestionado aparecerá como un error del enlazador en el código generado. El error no se producirá en tiempo de ejecución. Por tanto, todas las firmas p/invoke deben ser correctas, aunque no se utilicen en tiempo de ejecución.

Por último, se llama al método nativo a través del puntero de función y se devuelve el valor de retorno. Observe que el argumento se pasa a la función nativa por valor, por lo que cualquier cambio en su valor en el código nativo no estará disponible en el código gestionado, como cabría esperar.

Marshaling de un tipo no blittable

Las cosas se ponen un poco más emocionantes con un tipo no mezclable, como string. Recordemos de un post anterior que las cadenas en IL2CPP se representan como una matriz de caracteres de dos bytes codificados mediante UTF-16, prefijados por un valor de longitud de 4 bytes. Esta representación no coincide con las representaciones char* o wchar_t* de las cadenas en C en iOS, por lo que tenemos que hacer alguna conversión. Si nos fijamos en el método StringsMatch (HelloWorld_StringsMatch_m4 en el código generado):

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

Podemos ver que cada argumento de cadena se convertirá en un char* (debido a la directiva UnmangedType.LPStr).

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

La conversión tiene el siguiente aspecto (para el primer argumento):

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

Se asigna un nuevo búfer char de la longitud adecuada y el contenido de la cadena se copia en el nuevo búfer. Por supuesto, después de llamar al método nativo tenemos que limpiar esos búferes asignados:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

Por lo tanto, marshaling un tipo no blittable como string puede ser costoso.

Marshaling de un tipo definido por el usuario

Los tipos simples como int y string están bien, pero ¿qué le parece un tipo más complejo, definido por el usuario? Supongamos que queremos marshalizar la estructura Vector anterior, que contiene tres valores float. Resulta que un tipo definido por el usuario es blitable si y sólo si todos sus campos son blitables. Así que podemos llamar a ComputeLength (HelloWorld_ComputeLength_m5 en el código generado) sin necesidad de convertir el argumento:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

Observe que el argumento se pasa por valor, igual que en el ejemplo inicial cuando el tipo de argumento era int. Si queremos modificar la instancia de Vector y ver esos cambios en código gestionado, necesitamos pasarla por referencia, como en el método 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;

Aquí el argumento Vector se pasa como un puntero a código nativo. El código generado es un poco farragoso, pero básicamente consiste en crear una variable local del mismo tipo, copiar el valor del argumento en la local y, a continuación, llamar al método nativo con un puntero a esa variable local. Después de que la función nativa retorne, el valor de la variable local se copia de nuevo en el argumento, y ese valor está disponible entonces en el código gestionado.

Un tipo definido por el usuario no mezclable, como el tipo Boss definido anteriormente también se puede mezclar, pero con un poco más de trabajo. Cada campo de este tipo debe ser marshaled a su representación nativa. Además, el código C++ generado necesita una representación del tipo gestionado que coincida con la representación en el código nativo.

Echemos un vistazo a la declaración externa IsBossDead:

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

La envoltura de este método se llama 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;
}

El argumento se pasa a la función envoltorio como tipo Boss_t2, que es el tipo generado para la estructura Boss. Observe que se pasa a la función nativa con un tipo diferente: Boss_t2_marshaled. Si saltamos a la definición de este tipo, podemos ver que coincide con la definición de la estructura Boss en nuestro código de biblioteca estática C++:

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

Utilizamos de nuevo la directiva UnmanagedType.LPStr de C# para indicar que el campo de cadena debe marshalearse como un char*. Si se encuentra depurando un problema con un tipo definido por el usuario que no se puede borrar, es muy útil mirar esto _marshaled struct en el código generado. Si la disposición de los campos no coincide con la del lado nativo, entonces una directiva de marshaling en código gestionado podría ser incorrecta.

La función Boss_t2_marshal es una función generada que marshaliza cada campo, y la Boss_t2_marshal_cleanup libera cualquier memoria asignada durante ese proceso de marshalización.

Marshaling de un tipo definido por el usuario no blittable
Marshaling de un array

Por último, exploraremos cómo se marshalizan las matrices de tipos blitables y no blitables. Al método SumArrayElements se le pasa una matriz de enteros:

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

Este array se marshaliza, pero como el tipo de elemento del array (int) es blitable, el coste de marshalizarlo es muy pequeño:

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

La función il2cpp_codegen_marshal_array simplemente devuelve un puntero a la memoria de array gestionada existente, ¡eso es todo!

Sin embargo, marshalizar un array de tipos no blitables es mucho más costoso. El método SumBossHealth pasa un array de instancias de Boss:

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

Su envoltorio tiene que asignar un nuevo array, y luego marshal cada elemento individualmente:

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

Por supuesto, todas estas asignaciones también se limpian una vez finalizada la llamada al método nativo.

Conclusión

El backend de scripting IL2CPP soporta los mismos comportamientos de marshalling que el backend de scripting Mono. Dado que IL2CPP produce envolturas generadas para métodos y tipos externos, es posible ver el coste de las llamadas interoperativas de gestionado a nativo. Para los tipos blitables, este coste no suele ser demasiado grave, pero los tipos no blitables pueden encarecer rápidamente la interoperabilidad. Como de costumbre, en este post sólo hemos arañado la superficie del marshaling. Explore más el código generado para ver cómo se realiza el marshaling para valores de retorno y parámetros de salida, punteros de funciones nativas y delegados gestionados, y tipos de referencia definidos por el usuario.

La próxima vez exploraremos cómo IL2CPP se integra con el recolector de basura.