Componentes internos de IL2CPP: Un recorrido por el código generado

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
May 13, 2015|15 minutos
Componentes internos de IL2CPP: Un recorrido por el código generado
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.

Esta es la segunda publicación del blog de la serie IL2CPP Internals . En esta publicación, investigaremos el código C++ generado por il2cpp.exe. A lo largo del camino, veremos cómo se representan los tipos administrados en el código nativo, echaremos un vistazo a las comprobaciones de tiempo de ejecución utilizadas para soportar la máquina virtual .NET, veremos cómo se generan los bucles y más.

Entraremos en código muy específico de cada versión que seguramente cambiará en versiones posteriores de Unity. Aún así, los conceptos seguirán siendo los mismos.

Proyecto de ejemplo

Utilizaré la última versión de Unity disponible, 5.0.1p1, para este ejemplo. Como en la primera publicación de esta serie, comenzaré con un proyecto vacío y agregaré un archivo de script. En esta ocasión el contenido es el siguiente:

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

Construiré este proyecto para WebGL, ejecutando el editor Unity en Windows. Seleccioné la opción Development Player en la Configuración de compilación, para que podamos obtener nombres relativamente agradables en el código C++ generado. También configuré la opción Habilitar excepciones en la configuración del reproductor WebGL en Completo.

Descripción general del código generado

Una vez completada la compilación de WebGL, el código C++ generado está disponible en el directorio Temp\StagingArea\Data\il2cppOutput en mi directorio de proyecto. Una vez cerrado el editor, este directorio se eliminará. Mientras el editor esté abierto, este directorio permanecerá sin cambios, por lo que podemos inspeccionarlo.

La utilidad il2cpp.exe generó una cantidad de archivos, incluso para este pequeño proyecto. Veo 4625 archivos de encabezado y 89 archivos de código fuente C++. Para manejar todo este código, me gusta usar un editor de texto que funcione con Exuberant CTags. CTags generalmente generará rápidamente un archivo de etiquetas para este código, lo que hace que sea más fácil navegar.

Inicialmente, puedes ver que muchos de los archivos C++ generados no provienen del código de script simple, sino que son la versión convertida del código en las bibliotecas estándar, como mscorlib.dll. Como se mencionó en la primera publicación de esta serie, el backend de scripts IL2CPP utiliza el mismo código de biblioteca estándar que el backend de scripts Mono . Tenga en cuenta que convertimos el código en mscorlib.dll y otros ensamblajes de biblioteca estándar cada vez que se ejecuta il2cpp.exe. Esto podría parecer innecesario, ya que ese código no cambia.

Sin embargo, el backend de scripting IL2CPP siempre utiliza la eliminación de código de bytes para disminuir el tamaño del ejecutable. Por lo tanto, incluso pequeños cambios en el código del script pueden provocar que se utilicen o no muchas partes diferentes del código de la biblioteca estándar, según la situación. Por lo tanto, necesitamos convertir el ensamblado mscorlib.dll cada vez. Estamos investigando mejores formas de realizar compilaciones incrementales, pero aún no tenemos ninguna buena solución.

Cómo se asigna el código administrado al código C++ generado

Para cada tipo en el código administrado, il2cpp.exe generará un archivo de encabezado para la definición C++ del tipo y otro archivo de encabezado para las declaraciones de método del tipo. Por ejemplo, veamos el contenido del tipo UnityEngine.Vector3 convertido. El archivo de encabezado del tipo se llama UnityEngine_UnityEngine_Vector3.h. El nombre se crea en función del nombre del ensamblado, UnityEngine.dll seguido del espacio de nombres y el nombre del tipo. El código se ve así:

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

La utilidad il2cpp.exe ha convertido cada uno de los tres campos de instancia y ha modificado un poco los nombres para evitar conflictos y palabras reservadas. Al utilizar guiones bajos iniciales, utilizamos algunos nombres reservados en C++, pero hasta ahora no hemos visto ningún conflicto con el código de la biblioteca estándar de C++.

El archivo UnityEngine_UnityEngine_Vector3MethodDeclarations.h contiene las declaraciones de métodos para todos los métodos en Vector3. Por ejemplo, Vector3 anula el método Object.ToString:

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

Tenga en cuenta el comentario que indica el método administrado que representa esta declaración nativa. A menudo me resulta útil buscar en los archivos de salida el nombre del método administrado en este formato, especialmente para métodos con nombres comunes, como ToString.

Tenga en cuenta algunas cosas interesantes sobre todos los métodos convertidos por il2cpp.exe:

- Estas no son funciones miembro en C++. Todos los métodos son funciones libres, donde el primer argumento es el puntero "this". Para las funciones estáticas en código administrado, IL2CPP siempre pasa un valor NULL para este primer argumento. Al declarar siempre métodos con el puntero "this" como primer argumento, simplificamos el código de generación de métodos en il2cpp.exe y hacemos que la invocación de métodos a través de otros métodos (como delegados) sea más simple para el código generado.

- Cada método tiene un argumento adicional de tipo MethodInfo* que incluye los metadatos sobre el método que se utiliza para cosas como la invocación de un método virtual. El backend de scripts Mono utiliza trampolines específicos de la plataforma para pasar estos metadatos. Para IL2CPP, hemos decidido evitar el uso de trampolines para facilitar la portabilidad.

- Todos los métodos se declaran extern “C” para que il2cpp.exe a veces pueda mentirle al compilador de C++ y tratar todos los métodos como si tuvieran el mismo tipo.

- Los tipos se nombran con un sufijo “_t”. Los métodos se nombran con el sufijo “_m”. Los conflictos de nombres se resuelven agregando un número único a cada nombre. Estos números cambiarán si algo cambia en el código del script del usuario, por lo que no puede depender de ellos de una compilación a otra.

Los dos primeros puntos implican que cada método tiene al menos dos parámetros, el puntero "this" y el puntero MethodInfo. ¿Estos parámetros adicionales causan una sobrecarga innecesaria? Si bien claramente agregan sobrecarga, hasta ahora no hemos visto nada que sugiera que esos argumentos adicionales causen problemas de rendimiento. Aunque parezca que así sería, los perfiles han demostrado que la diferencia en el rendimiento no es mensurable.

Podemos saltar a la definición de este método ToString usando Ctags. Está en el archivo Bulk_UnityEngine_0.cpp. El código en esa definición de método no se parece mucho al código C# en el método Vector3::ToString(). Sin embargo, si utiliza una herramienta como ILSpy para reflejar el código del método Vector3::ToString(), verá que el código C++ generado se parece mucho al código IL.

¿Por qué il2cpp.exe no genera un archivo C++ separado para las definiciones de métodos de cada tipo, como lo hace para las declaraciones de métodos? Este archivo Bulk_UnityEngine_0.cpp es bastante grande, ¡20.481 líneas en realidad! Descubrimos que los compiladores de C++ que estábamos usando tenían problemas con una gran cantidad de archivos fuente. Compilar cuatro mil archivos .cpp tomó mucho más tiempo que compilar el mismo código fuente en 80 archivos .cpp. Entonces, il2cpp.exe agrupa las definiciones de métodos para los tipos y genera un archivo C++ por grupo.

Ahora regrese al archivo de encabezado de declaraciones de métodos y observe esta línea cerca de la parte superior del archivo:

#include "codegen/il2cpp-codegen.h"

El archivo il2cpp-codegen.h contiene la interfaz que el código generado utiliza para acceder a los servicios de tiempo de ejecución de libil2cpp. Más adelante analizaremos algunas formas en que el código generado utiliza el tiempo de ejecución.

Prólogos del método

Echemos un vistazo a la definición del método Vector3::ToString(). En concreto, tiene un prólogo común que se emite en todos los métodos mediante il2cpp.exe.

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

La primera línea de este prólogo crea una variable local de tipo StackTraceSentry. Esta variable se utiliza para rastrear la pila de llamadas administradas, de modo que IL2CPP pueda informarla en llamadas como Environment.StackTrace. La generación de código de esta entrada es realmente opcional y se habilita en este caso mediante la opción --enable-stacktrace pasada a il2cpp.exe (ya que configuré la opción Habilitar excepciones en la Configuración del reproductor WebGL en Completo). Para funciones pequeñas, descubrimos que la sobrecarga de esta variable tiene un impacto negativo en el rendimiento. Entonces, para iOS y otras plataformas donde podemos usar información de seguimiento de pila específica de la plataforma, nunca emitimos esta línea en el código generado. Para WebGL, no contamos con soporte de seguimiento de pila específico de la plataforma, por lo que es necesario permitir que las excepciones de código administrado funcionen correctamente.

La segunda parte del prólogo realiza la inicialización diferida de los metadatos de tipo para cualquier matriz o tipo genérico utilizado en el cuerpo del método. Entonces, el nombre ObjectU5BU5D_t4 es el nombre del tipo System.Object[]. Esta parte del prólogo solo se ejecuta una vez y a menudo no hace nada si el tipo ya se inicializó en otro lugar, por lo que no hemos visto ninguna implicación adversa en el rendimiento de este código generado.

¿Pero este hilo de código es seguro? ¿Qué pasa si dos hilos llaman a Vector3::ToString() al mismo tiempo? En realidad, este código no es problemático, ya que todo el código en el entorno de ejecución de libil2cpp utilizado para la inicialización de tipos se puede llamar de forma segura desde múltiples subprocesos. Es posible (quizás incluso probable) que la función il2cpp_codegen_class_from_type se llame más de una vez, pero el trabajo real que realiza solo ocurrirá una vez, en un hilo. La ejecución del método no continuará hasta que se complete dicha inicialización. Por lo tanto, este prólogo del método es seguro para subprocesos.

Comprobaciones en tiempo de ejecución

La siguiente parte del método crea una matriz de objetos, almacena el valor del campo x de Vector3 en un local, luego encajona el local y lo agrega a la matriz en el índice cero. Aquí está el código C++ generado (con algunas anotaciones):

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

Las tres comprobaciones de tiempo de ejecución no están presentes en el código IL, sino que son inyectadas por il2cpp.exe.

- El código NullCheck arrojará una NullReferenceException si el valor de la matriz es null.

- El código IL2CPP_ARRAY_BOUNDS_CHECK generará una IndexOutOfRangeException si el índice de la matriz no es correcto.

- El código ArrayElementTypeCheck arrojará una ArrayTypeMismatchException si el tipo del elemento que se agrega a la matriz no es correcto.

Estas tres comprobaciones de tiempo de ejecución son todas garantías proporcionadas por la máquina virtual .NET. En lugar de inyectar código, el backend de scripting Mono utiliza un mecanismo de señalización específico de la plataforma para manejar estas mismas comprobaciones en tiempo de ejecución. Para IL2CPP, queríamos ser más independientes de la plataforma y soportar plataformas como WebGL, donde no hay un mecanismo de señalización específico de la plataforma, por lo que il2cpp.exe inyecta estas comprobaciones.

¿Estas comprobaciones en tiempo de ejecución causan problemas de rendimiento? En la mayoría de los casos, no hemos visto ningún impacto adverso en el rendimiento y brindan los beneficios y la seguridad que requiere la máquina virtual .NET. Sin embargo, en algunos casos específicos, vemos que estas comprobaciones provocan una degradación del rendimiento, especialmente en bucles estrechos. Ahora estamos trabajando en una forma de permitir que el código administrado se anote para eliminar estas comprobaciones de tiempo de ejecución cuando il2cpp.exe genera código C++. Mantente atento a esto.

Campos estáticos

Ahora que hemos visto cómo se ven los campos de instancia (en el tipo Vector3), veamos cómo se convierten y se accede a los campos estáticos. Encuentre la definición del método HelloWorld_Start_m3, que está en el archivo Bulk_Assembly-CSharp_0.cpp en mi compilación. Desde allí, salta al tipo Important_t1 (en el archivo AssemblyU2DCSharp_HelloWorld_Important.h):

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

Tenga en cuenta que il2cpp.exe ha generado una estructura C++ separada para contener el campo estático de este tipo, ya que el campo estático se comparte entre todas las instancias de este tipo. Entonces, en tiempo de ejecución, se creará una instancia del tipo Important_t1_StaticFields, y todas las instancias del tipo Important_t1 compartirán esa instancia del tipo de campos estáticos. En el código generado, se accede al campo estático de la siguiente manera:

int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);

Los metadatos de tipo para Important_t1 contienen un puntero a la instancia única del tipo Important_t1_StaticFields, y esa instancia se utiliza para obtener el valor del campo estático.

Excepciones

Las excepciones administradas se convierten mediante il2cpp.exe en excepciones de C++. Hemos elegido este camino para evitar nuevamente soluciones específicas de la plataforma. Cuando il2cpp.exe necesita emitir código para generar una excepción administrada, llama a la función il2cpp_codegen_raise_exception.

El código de nuestro método HelloWorld_Start_m3 para lanzar y capturar una excepción administrada se ve así:

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)

Todas las excepciones administradas están envueltas en el tipo Il2CppExceptionWrapper de C++. Cuando el código generado captura una excepción de ese tipo, descomprime la representación C++ de la excepción administrada (que tiene el tipo Exception_t8). En este caso, solo buscamos una InvalidOperationException, por lo que si no encontramos una excepción de ese tipo, se lanza nuevamente una copia de la excepción de C++. Si encontramos el tipo correcto, el código salta a la implementación del controlador de captura y escribe el mensaje de excepción.

¡¿¡Ir a!?!

Este código plantea un punto interesante. ¿Qué hacen esas etiquetas y declaraciones goto allí? ¡Estas construcciones no son necesarias en la programación estructurada! Sin embargo, IL no tiene conceptos de programación estructurada como bucles y declaraciones if/then. Dado que es de nivel inferior, il2cpp.exe sigue conceptos de nivel inferior en el código generado.

Por ejemplo, veamos el bucle for en el método HelloWorld_Start_m3:

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

Aquí la variable V_2 es el índice del bucle. Comienza con un valor de 0 y luego se incrementa en la parte inferior del bucle en esta línea:

V_2 = ((int32_t)(V_2+1));

La condición final del bucle se comprueba aquí:

if ((((int32_t)V_2) < ((int32_t)3)))

Siempre que V_2 sea menor que 3, la instrucción goto salta a la etiqueta IL_00af, que es la parte superior del cuerpo del bucle. Es posible que puedas adivinar que il2cpp.exe actualmente está generando código C++ directamente desde IL, sin utilizar una representación de árbol de sintaxis abstracta intermedia. Si adivinaste esto, estás en lo correcto. Es posible que también hayas notado que en la sección de Comprobaciones en tiempo de ejecución anterior, parte del código generado se ve así:

float L_1 = (__this->___x_1);
float L_2 = L_1;

Claramente la variable L_2 no es necesaria aquí. La mayoría de los compiladores de C++ pueden optimizar esta asignación adicional, pero nos gustaría evitar emitirla por completo. Actualmente estamos investigando la posibilidad de utilizar un AST para comprender mejor el código IL y generar un mejor código C++ para casos que involucran variables locales y bucles for, entre otros.

Conclusión

Acabamos de arañar la superficie del código C++ generado por el backend de scripting IL2CPP para un proyecto muy simple. Si aún no lo has hecho, te recomiendo que analices el código generado en tu proyecto. A medida que explora, tenga en cuenta que el código C++ generado se verá diferente en futuras versiones de Unity, ya que trabajamos constantemente para mejorar el rendimiento de compilación y tiempo de ejecución del backend de scripting IL2CPP.

Al convertir el código IL a C++, pudimos obtener un buen equilibrio entre código portable y de alto rendimiento. Podemos tener muchas de las agradables características amigables para el desarrollador del código administrado, mientras aún obtenemos los beneficios del código de máquina de calidad que el compilador C++ proporciona para varias plataformas.

En futuras publicaciones, exploraremos más código generado, incluidas llamadas a métodos, intercambio de implementaciones de métodos y envoltorios para llamadas a bibliotecas nativas. Pero la próxima vez depuraremos parte del código generado para una compilación de iOS de 64 bits usando Xcode.