IL2CPP interne : Une visite du code généré

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
May 13, 2015|15 Min
IL2CPP interne : Une visite du code généré
Cette page a été traduite automatiquement pour faciliter votre expérience. Nous ne pouvons pas garantir l'exactitude ou la fiabilité du contenu traduit. Si vous avez des doutes quant à la qualité de cette traduction, reportez-vous à la version anglaise de la page web.

Ceci est le deuxième article de blog dans la série IL2CPP Internals. Dans ce billet, nous allons étudier le code C++ généré par il2cpp.exe. En cours de route, nous verrons comment les types gérés sont représentés dans le code natif, nous jetterons un coup d'œil aux contrôles d'exécution utilisés pour prendre en charge la machine virtuelle .NET, nous verrons comment les boucles sont générées et bien d'autres choses encore !

Nous allons entrer dans du code très spécifique à la version qui va certainement changer dans les versions ultérieures d'Unity. Toutefois, les concepts restent les mêmes.

Exemple de projet

Pour cet exemple, j'utiliserai la dernière version d'Unity disponible, 5.0.1p1. Comme dans le premier article de cette série, je commencerai par un projet vide et j'ajouterai un fichier de script. Cette fois-ci, le contenu est le suivant :

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

Je construirai ce projet pour WebGL, en utilisant l'éditeur Unity sous Windows. J'ai sélectionné l'option "Development Player" dans les paramètres de construction, afin d'obtenir des noms relativement agréables dans le code C++ généré. J'ai également réglé l'option Enable Exceptions dans les paramètres du lecteur WebGL sur Full.

Aperçu du code généré

Une fois la compilation WebGL terminée, le code C++ généré est disponible dans le répertoire Temp\StagingArea\Data\il2cppOutput de mon répertoire de projet. Une fois l'éditeur fermé, ce répertoire sera supprimé. Tant que l'éditeur est ouvert, ce répertoire reste inchangé, ce qui nous permet de l'inspecter.

L'utilitaire il2cpp.exe a généré un certain nombre de fichiers, même pour ce petit projet. Je vois 4625 fichiers d'en-tête et 89 fichiers de code source C++. Pour maîtriser tout ce code, j'aime utiliser un éditeur de texte qui fonctionne avec Exuberant CTags. CTags génère généralement rapidement un fichier de balises pour ce code, ce qui facilite la navigation.

Dans un premier temps, vous pouvez constater que de nombreux fichiers C++ générés ne proviennent pas du simple code du script, mais de la version convertie du code des bibliothèques standard, comme mscorlib.dll. Comme mentionné dans le premier article de cette série, le backend de script IL2CPP utilise le même code de bibliothèque standard que le backend de script Mono. Notez que nous convertissons le code de mscorlib.dll et d'autres assemblages de la bibliothèque standard à chaque fois que il2cpp.exe est exécuté. Cela peut sembler inutile, puisque ce code ne change pas.

Cependant, le backend de script IL2CPP utilise toujours la suppression des octets de code pour réduire la taille de l'exécutable. Ainsi, même de petites modifications dans le code du script peuvent entraîner l'utilisation ou non de nombreuses parties différentes du code de la bibliothèque standard, en fonction de la situation. Par conséquent, nous devons convertir l'assemblage mscorlib.dll à chaque fois. Nous recherchons de meilleurs moyens de réaliser des constructions incrémentales, mais nous n'avons pas encore de bonnes solutions.

Comment le code géré correspond au code C++ généré

Pour chaque type du code géré, il2cpp.exe génère un fichier d'en-tête pour la définition C++ du type et un autre fichier d'en-tête pour les déclarations de méthodes du type. Par exemple, regardons le contenu du type UnityEngine.Vector3 converti. Le fichier d'en-tête de ce type s'appelle UnityEngine_UnityEngine_Vector3.h. Le nom est créé à partir du nom de l'assemblage, UnityEngine.dll, suivi de l'espace de noms et du nom du type. Le code se présente comme suit :

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

L'utilitaire il2cpp.exe a converti chacun des trois champs d'instance et a procédé à une petite manipulation des noms pour éviter les conflits et les mots réservés. En utilisant des traits de soulignement, nous utilisons certains noms réservés en C++, mais jusqu'à présent, nous n'avons pas constaté de conflit avec le code de la bibliothèque standard C++.

Le fichier UnityEngine_UnityEngine_Vector3MethodDeclarations.h contient les déclarations de méthodes pour toutes les méthodes de Vector3. Par exemple, Vector3 surcharge la méthode Object.ToString :

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

Notez le commentaire, qui indique la méthode gérée que cette déclaration native représente. Je trouve souvent utile de rechercher dans les fichiers de sortie le nom de la méthode gérée dans ce format, en particulier pour les méthodes ayant des noms communs, comme ToString.

Remarquez quelques points intéressants concernant toutes les méthodes converties par il2cpp.exe :

- Il ne s'agit pas de fonctions membres en C++. Toutes les méthodes sont des fonctions libres, dont le premier argument est le pointeur "this". Pour les fonctions statiques dans le code géré, IL2CPP transmet toujours la valeur NULL pour ce premier argument. En déclarant toujours les méthodes avec le pointeur "this" comme premier argument, nous simplifions le code de génération de méthodes dans il2cpp.exe et nous rendons l'invocation de méthodes via d'autres méthodes (comme les délégués) plus simple pour le code généré.

- Chaque méthode a un argument supplémentaire de type MethodInfo* qui inclut les métadonnées de la méthode utilisées pour des choses telles que l'invocation de méthodes virtuelles. Le backend de script Mono utilise des trampolines spécifiques à la plateforme pour transmettre ces métadonnées. Pour l'IL2CPP, nous avons décidé d'éviter l'utilisation de trampolines pour faciliter la portabilité.

- Toutes les méthodes sont déclarées extern "C" afin que il2cpp.exe puisse parfois mentir au compilateur C++ et traiter toutes les méthodes comme si elles avaient le même type.

- Les types sont nommés avec un suffixe "_t". Les méthodes sont nommées avec un suffixe "_m". Les conflits de noms sont résolus en ajoutant un numéro unique à chaque nom. Ces chiffres changeront si le code du script utilisateur est modifié, vous ne pouvez donc pas vous y fier d'une version à l'autre.

Les deux premiers points impliquent que chaque méthode possède au moins deux paramètres, le pointeur "this" et le pointeur MethodInfo. Ces paramètres supplémentaires entraînent-ils des frais généraux inutiles ? Bien qu'ils ajoutent clairement de la charge de travail, nous n'avons rien vu jusqu'à présent qui suggère que ces arguments supplémentaires causent des problèmes de performance. Bien qu'il semble que ce soit le cas, l'établissement de profils a montré que la différence de performance n'est pas mesurable.

Nous pouvons passer à la définition de cette méthode ToString en utilisant Ctags. Il se trouve dans le fichier Bulk_UnityEngine_0.cpp. Le code de cette définition de méthode ne ressemble pas beaucoup au code C# de la méthode Vector3::ToString(). Cependant, si vous utilisez un outil comme ILSpy pour refléter le code de la méthode Vector3::ToString(), vous verrez que le code C++ généré ressemble beaucoup au code IL.

Pourquoi il2cpp.exe ne génère-t-il pas un fichier C++ distinct pour les définitions de méthodes de chaque type, comme il le fait pour les déclarations de méthodes ? Ce fichier Bulk_UnityEngine_0.cpp est assez volumineux, 20 481 lignes en fait ! Nous avons constaté que les compilateurs C++ que nous utilisions avaient des difficultés avec un grand nombre de fichiers sources. La compilation de quatre mille fichiers .cpp a pris beaucoup plus de temps que la compilation du même code source dans 80 fichiers .cpp. Ainsi, il2cpp.exe regroupe les définitions de méthodes pour les types en groupes et génère un fichier C++ par groupe.

Revenez maintenant au fichier d'en-tête des déclarations de méthodes et remarquez cette ligne près du début du fichier :

#include "codegen/il2cpp-codegen.h"

Le fichier il2cpp-codegen.h contient l'interface que le code généré utilise pour accéder aux services d'exécution de libil2cpp. Nous verrons plus loin comment le code généré utilise la durée d'exécution.

Prologues de méthodes

Examinons la définition de la méthode Vector3::ToString(). Plus précisément, il possède un prologue commun qui est émis dans toutes les méthodes par 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 première ligne de ce prologue crée une variable locale de type StackTraceSentry. Cette variable est utilisée pour suivre la pile d'appels gérée, de sorte que IL2CPP puisse la signaler dans des appels tels que Environment.StackTrace. La génération de code de cette entrée est en fait optionnelle, et est activée dans ce cas par l'option --enable-stacktrace passée à il2cpp.exe (puisque j'ai mis l'option Enable Exceptions dans les paramètres du lecteur WebGL sur Full). Pour les petites fonctions, nous avons constaté que la surcharge de cette variable a un impact négatif sur les performances. Ainsi, pour iOS et d'autres plateformes où nous pouvons utiliser des informations de suivi de pile spécifiques à la plateforme, nous n'émettons jamais cette ligne dans le code généré. Pour WebGL, nous n'avons pas de support de trace de pile spécifique à la plateforme, il est donc nécessaire de permettre aux exceptions du code géré de fonctionner correctement.

La deuxième partie du prologue initialise paresseusement les métadonnées de type pour tout tableau ou type générique utilisé dans le corps de la méthode. Ainsi, le nom ObjectU5BU5D_t4 est le nom du type System.Object[]. Cette partie du prologue n'est exécutée qu'une seule fois et ne fait souvent rien si le type a déjà été initialisé ailleurs, de sorte que nous n'avons constaté aucune incidence négative sur les performances du code généré.

Ce code est-il sûr pour les threads ? Que se passe-t-il si deux threads appellent Vector3::ToString() en même temps ? En fait, ce code ne pose pas de problème, car tout le code du runtime libil2cpp utilisé pour l'initialisation des types peut être appelé en toute sécurité à partir de plusieurs threads. Il est possible (voire probable) que la fonction il2cpp_codegen_class_from_type soit appelée plusieurs fois, mais le travail qu'elle effectue ne se produira qu'une seule fois, sur un seul thread. L'exécution de la méthode ne se poursuivra pas tant que l'initialisation ne sera pas terminée. Le prologue de cette méthode est donc sans risque pour les threads.

Contrôles en cours d'exécution

La partie suivante de la méthode crée un tableau d'objets, stocke la valeur du champ x du Vector3 dans un local, puis met en boîte le local et l'ajoute au tableau à l'index zéro. Voici le code C++ généré (avec quelques annotations) :

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

Les trois contrôles d'exécution ne sont pas présents dans le code IL, mais sont injectés par il2cpp.exe.

- Le code NullCheck lèvera une exception de type NullReferenceException si la valeur du tableau est nulle.

- Le code IL2CPP_ARRAY_BOUNDS_CHECK lèvera une exception IndexOutOfRangeException si l'index du tableau n'est pas correct.

- Le code ArrayElementTypeCheck lèvera une exception ArrayTypeMismatchException si le type de l'élément ajouté au tableau n'est pas correct.

Ces trois contrôles d'exécution sont des garanties fournies par la machine virtuelle .NET. Plutôt que d'injecter du code, le backend de script de Mono utilise un mécanisme de signalisation spécifique à la plateforme pour gérer ces mêmes contrôles d'exécution. Pour IL2CPP, nous voulions être plus agnostiques et supporter des plateformes comme WebGL, où il n'y a pas de mécanisme de signalisation spécifique à la plateforme, donc il2cpp.exe injecte ces vérifications.

Ces contrôles d'exécution entraînent-ils des problèmes de performance ? Dans la plupart des cas, nous n'avons pas constaté d'impact négatif sur les performances et ils offrent les avantages et la sécurité requis par la machine virtuelle .NET. Dans quelques cas précis, nous constatons cependant que ces contrôles entraînent une dégradation des performances, en particulier dans les boucles serrées. Nous travaillons actuellement sur un moyen de permettre au code géré d'être annoté afin de supprimer ces contrôles d'exécution lorsque il2cpp.exe génère du code C++. Restez à l'écoute.

Champs statiques

Maintenant que nous avons vu à quoi ressemblent les champs d'instance (dans le type Vector3), voyons comment les champs statiques sont convertis et accédés. Trouvez la définition de la méthode HelloWorld_Start_m3, qui se trouve dans le fichier Bulk_Assembly-CSharp_0.cpp de ma version. À partir de là, passez au type Important_t1 (dans le fichierAssemblyU2DCSharp_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;
};

Notez que il2cpp.exe a généré une structure C++ distincte pour contenir le champ statique de ce type, puisque ce champ est partagé entre toutes les instances de ce type. Ainsi, au moment de l'exécution, une instance du type Important_t1_StaticFields sera créée, et toutes les instances du type Important_t1 partageront cette instance du type de champs statiques. Dans le code généré, le champ statique est accessible comme suit :

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

Les métadonnées de type pour Important_t1 contiennent un pointeur sur l'instance unique du type Important_t1_StaticFields, et cette instance est utilisée pour obtenir la valeur du champ statique.

Exceptions

Les exceptions gérées sont converties par il2cpp.exe en exceptions C++. Nous avons choisi cette voie pour éviter à nouveau les solutions spécifiques à une plate-forme. Lorsque il2cpp.exe doit émettre du code pour lever une exception gérée, il appelle la fonction il2cpp_codegen_raise_exception.

Le code de notre méthode HelloWorld_Start_m3 pour lancer et attraper une exception gérée ressemble à ceci :

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)

Toutes les exceptions gérées sont enveloppées dans le type C++ Il2CppExceptionWrapper. Lorsque le code généré attrape une exception de ce type, il décompresse la représentation C++ de l'exception gérée (qui est de type Exception_t8). Dans ce cas, nous ne recherchons qu'une exception de type InvalidOperationException, donc si nous ne trouvons pas d'exception de ce type, une copie de l'exception C++ est à nouveau lancée. Si nous trouvons le bon type, le code passe à l'implémentation du gestionnaire de capture et écrit le message d'exception.

Goto !?!

Ce code soulève un point intéressant. Que font ces étiquettes et ces instructions "goto" là-dedans ? Ces constructions ne sont pas nécessaires dans la programmation structurée ! Cependant, l'IL ne dispose pas de concepts de programmation structurés tels que les boucles et les instructions if/then. Puisqu'il est de niveau inférieur, il2cpp.exe suit les concepts de niveau inférieur dans le code généré.

Par exemple, examinons la boucle for de la méthode 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;
}
}

Ici, la variable V_2 est l'index de la boucle. Is commence avec une valeur de 0, puis est incrémenté au bas de la boucle dans cette ligne :

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

La condition de fin de la boucle est alors vérifiée ici :

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

Tant que V_2 est inférieur à 3, l'instruction goto passe à l'étiquette IL_00af, qui est le sommet du corps de la boucle. Vous pouvez deviner que il2cpp.exe génère actuellement du code C++ directement à partir de l'IL, sans utiliser une représentation intermédiaire de l'arbre syntaxique abstrait. Si vous avez deviné, vous avez raison. Vous avez peut-être aussi remarqué, dans la section "Contrôles d'exécution" ci-dessus, qu'une partie du code généré ressemble à ceci :

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

Il est clair que la variable L_2 n'est pas nécessaire ici. La plupart des compilateurs C++ peuvent optimiser cette affectation supplémentaire, mais nous voudrions éviter de l'émettre du tout. Nous étudions actuellement la possibilité d'utiliser un AST pour mieux comprendre le code IL et générer un meilleur code C++ pour les cas impliquant des variables locales et des boucles for, entre autres.

Conclusion

Nous n'avons fait qu'effleurer la surface du code C++ généré par le backend de script IL2CPP pour un projet très simple. Si vous ne l'avez pas encore fait, je vous encourage à creuser le code généré dans votre projet. Au cours de votre exploration, gardez à l'esprit que le code C++ généré sera différent dans les prochaines versions d'Unity, car nous travaillons constamment à l'amélioration des performances de construction et d'exécution du backend de script IL2CPP.

En convertissant le code IL en C++, nous avons pu obtenir un bon équilibre entre un code portable et performant. Nous pouvons bénéficier d'un grand nombre des fonctionnalités conviviales pour les développeurs du code géré, tout en obtenant les avantages du code machine de qualité que le compilateur C++ fournit pour diverses plates-formes.

Dans les prochains articles, nous explorerons davantage le code généré, y compris les appels de méthodes, le partage des implémentations de méthodes et les wrappers pour les appels aux bibliothèques natives. Mais la prochaine fois, nous déboguerons une partie du code généré pour une version iOS 64 bits à l'aide de Xcode.