Internes IL2CPP : Enveloppeurs P/Invoke

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 Min
Internes IL2CPP : Enveloppeurs P/Invoke
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.
Il s’agit du sixième article de la série IL2CPP Internals. Dans cet article, nous allons explorer comment il2cpp.exe génère des méthodes wrapper et des types utilisés pour l'interopérabilité entre le code géré et le code natif. Plus précisément, nous examinerons la différence entre les types blittables et non blittables, comprendrons le marshaling de chaînes et de tableaux et découvrirons le coût du marshaling.

J'ai écrit pas mal de code d'interopérabilité native au cours de ma vie, mais obtenir des déclarations p/invoke correctes en C# est toujours difficile, c'est le moins qu'on puisse dire. Comprendre ce que fait le runtime pour rassembler mes objets est encore plus un mystère. Étant donné qu'IL2CPP effectue la majeure partie de son marshaling dans le code C++ généré, nous pouvons voir (et même déboguer !) son comportement, offrant ainsi un bien meilleur aperçu pour le dépannage et l'analyse des performances.

Cet article n'a pas pour but de fournir des informations générales sur le marshaling et l'interopérabilité native. C'est un sujet vaste, trop vaste pour un seul article. La documentation Unity explique comment les plugins natifs interagissent avec Unity. Mono et Microsoft fournissent tous deux de nombreuses informations intéressantes sur p/invoke en général.

Comme pour tous les articles de cette série, nous allons explorer du code susceptible d’être modifié et qui, en fait, est susceptible d’être modifié dans une version plus récente d’ Unity. Cependant, les concepts devraient rester les mêmes. Veuillez considérer tout ce qui est discuté dans cette série comme des détails de mise en œuvre. Nous aimons exposer et discuter des détails comme celui-ci lorsque cela est possible !

La configuration

Pour cet article, j'utilise Unity 5.0.2p4 sur OSX. Je vais construire pour la plateforme iOS, en utilisant une valeur « Architecture » de « Universelle ». J'ai construit mon code natif pour cet exemple dans Xcode 6.3.2 en tant que bibliothèque statique pour ARMv7 et ARM64.

Le code natif ressemble à ceci :

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

}

Le code de script dans Unity se trouve à nouveau dans le fichier HelloWorld.cs. Cela ressemble à ceci :

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

Chacun des appels de méthode dans ce code est intégré au code natif présenté ci-dessus. Nous examinerons la déclaration de méthode gérée pour chaque méthode telle que nous la verrons plus tard dans l’article.

Pourquoi avons-nous besoin de marshaling ?

Étant donné qu'IL2CPP génère déjà du code C++, pourquoi avons-nous besoin d'un marshaling du code C# vers le code C++ ? Bien que le code C++ généré soit du code natif, la représentation des types en C# diffère de celle en C++ dans un certain nombre de cas. Le runtime IL2CPP doit donc être capable d'effectuer des conversions dans les deux sens à partir des représentations des deux côtés. L'utilitaire il2cpp.exe fait cela à la fois pour les types et les méthodes.

Dans le code managé, tous les types peuvent être classés comme blittablesounon blittables. Les types blittables ont la même représentation dans le code géré et natif (par exemple byte, int, float). Les types non blittables ont une représentation différente dans le code géré et natif (par exemple, les types booléen, chaîne, tableau). En tant que tels, les types blittables peuvent être transmis directement au code natif, mais les types non blittables nécessitent une certaine conversion avant de pouvoir être transmis au code natif. Souvent, cette conversion implique une nouvelle allocation de mémoire.

Afin d'indiquer au compilateur de code managé qu'une méthode donnée est implémentée en code natif, le mot-clé extern est utilisé en C#. Ce mot clé, associé à un attribut DllImport, permet à l'environnement d'exécution du code géré de trouver la définition de méthode native et de l'appeler. L'utilitaire il2cpp.exe génère une méthode C++ wrapper pour chaque méthode externe. Ce wrapper effectue quelques tâches importantes :

- Il définit un typedef pour la méthode native qui est utilisé pour appeler la méthode via un pointeur de fonction.

- Il résout la méthode native par nom, en obtenant un pointeur de fonction vers cette méthode.

- Il convertit les arguments de leur représentation gérée vers leur représentation native (si nécessaire).

- Il appelle la méthode native.

- Il convertit la valeur de retour de la méthode de sa représentation native vers sa représentation gérée (si nécessaire).

- In convertit tous les arguments out ou ref de leur représentation native vers leur représentation gérée (si nécessaire).

Nous allons examiner ensuite les méthodes wrapper générées pour certaines déclarations de méthodes externes.

Réorganisation d'un type blittable

Le type le plus simple de wrapper externe ne traite que les types blittables.

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

Tout d’abord, notez le typedef pour la signature de la fonction native :

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

Quelque chose de similaire apparaîtra dans chacune des fonctions wrapper. Cette fonction native accepte un seul int32_t et renvoie un int32_t.

Ensuite, le wrapper trouve le pointeur de fonction approprié et le stocke dans une variable statique :

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

Ici, la fonction Increment provient en fait d'une instruction externe (dans le code C++) :

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

Sur iOS, les méthodes natives sont liées statiquement dans un seul binaire (indiqué par la chaîne « __Internal » dans l'attribut DllImport), de sorte que l'environnement d'exécution IL2CPP ne fait rien pour rechercher le pointeur de fonction. Au lieu de cela, cette instruction externe informe le linker de trouver la fonction appropriée au moment du lien. Sur d’autres plates-formes, l’environnement d’exécution IL2CPP peut effectuer une recherche (si nécessaire) à l’aide d’une méthode API spécifique à la plate-forme pour obtenir ce pointeur de fonction.

Concrètement, cela signifie que sur iOS, une signature p/invoke incorrecte dans le code managé apparaîtra comme une erreur de lien dans le code généré. L'erreur ne se produira pas lors de l'exécution. Ainsi, toutes les signatures p/invoke doivent être correctes, même si elles ne sont pas utilisées lors de l'exécution.

Enfin, la méthode native est appelée via le pointeur de fonction et la valeur de retour est renvoyée. Notez que l'argument est passé à la fonction native par valeur, donc toute modification de sa valeur dans le code natif ne sera pas disponible dans le code managé, comme on pourrait s'y attendre.

Réorganisation d'un type non blittable

Les choses deviennent un peu plus excitantes avec un type non blittable, comme une chaîne. Rappelons que dans un article précédent, les chaînes dans IL2CPP sont représentées comme un tableau de caractères de deux octets codés via UTF-16, préfixés par une valeur de longueur de 4 octets. Cette représentation ne correspond ni aux représentations char* ou wchar_t* des chaînes en C sur iOS, nous devons donc effectuer une conversion. Si nous regardons la méthode StringsMatch (HelloWorld_StringsMatch_m4 dans le code généré) :

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

Nous pouvons voir que chaque argument de chaîne sera converti en char* (en raison de la directive UnmangedType.LPStr).

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

La conversion ressemble à ceci (pour le premier argument) :

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

Un nouveau tampon de caractères de la longueur appropriée est alloué et le contenu de la chaîne est copié dans le nouveau tampon. Bien sûr, une fois la méthode native appelée, nous devons nettoyer ces tampons alloués :

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

Ainsi, le marshaling d'un type non blittable comme une chaîne peut être coûteux.

Maréchalage d'un type défini par l'utilisateur

Les types simples comme int et string sont intéressants, mais qu'en est-il d'un type plus complexe défini par l'utilisateur ? Supposons que nous souhaitons regrouper la structure vectorielle ci-dessus, qui contient trois valeurs flottantes. Il s'avère qu'un type défini par l'utilisateur est blittable si et seulement si tous ses champs sont blittables. Nous pouvons donc appeler ComputeLength (HelloWorld_ComputeLength_m5 dans le code généré) sans avoir besoin de convertir l'argument :

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

Notez que l'argument est passé par valeur, comme c'était le cas pour l'exemple initial lorsque le type d'argument était int. Si nous voulons modifier l'instance de Vector et voir ces modifications dans le code managé, nous devons le transmettre par référence, comme dans la méthode 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;

Ici, l'argument Vector est passé comme pointeur vers le code natif. Le code généré passe par un peu de formalités, mais il consiste essentiellement à créer une variable locale du même type, à copier la valeur de l'argument dans la variable locale, puis à appeler la méthode native avec un pointeur vers cette variable locale. Une fois la fonction native renvoyée, la valeur de la variable locale est recopiée dans l'argument et cette valeur est alors disponible dans le code managé.

Un type défini par l'utilisateur non blittable, comme le type Boss défini ci-dessus, peut également être marshalé, mais avec un peu plus de travail. Chaque champ de ce type doit être converti en sa représentation native. De plus, le code C++ généré a besoin d’une représentation du type géré qui correspond à la représentation dans le code natif.

Jetons un œil à la déclaration externe IsBossDead :

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

Le wrapper de cette méthode s'appelle 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;
}

L'argument est passé à la fonction wrapper en tant que type Boss_t2, qui est le type généré pour la structure Boss. Notez qu'il est passé à la fonction native avec un type différent : Boss_t2_marshaled. Si nous passons à la définition de ce type, nous pouvons voir qu'elle correspond à la définition de la structure Boss dans notre code de bibliothèque statique C++ :

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

Nous avons à nouveau utilisé la directive UnmanagedType.LPStr en C# pour indiquer que le champ de chaîne doit être marshalé en tant que char*. Si vous vous trouvez en train de déboguer un problème avec un type défini par l'utilisateur non blittable, il est très utile de regarder cettestructure_marshaleddans le code généré. Si la disposition du champ ne correspond pas au côté natif, une directive de marshaling dans le code managé peut être incorrecte.

La fonction Boss_t2_marshal est une fonction générée qui marshalise chaque champ, et Boss_t2_marshal_cleanup libère toute mémoire allouée pendant ce processus de marshaling.

Maréchalage d'un type défini par l'utilisateur non blittable
Maréchaliser un tableau

Enfin, nous explorerons comment les tableaux de types blittables et non blittables sont marshalés. La méthode SumArrayElements reçoit un tableau d'entiers :

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

Ce tableau est marshalé, mais comme le type d'élément du tableau (int) est blittable, le coût de son marshaling est très faible :

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

La fonction il2cpp_codegen_marshal_array renvoie simplement un pointeur vers la mémoire du tableau géré existant, c'est tout !

Cependant, rassembler un tableau de types non blittables est beaucoup plus coûteux. La méthode SumBossHealth transmet un tableau d'instances Boss :

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

Son wrapper doit allouer un nouveau tableau, puis marshaler chaque élément individuellement :

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

Bien entendu, toutes ces allocations sont également nettoyées une fois l’appel de la méthode native terminé.

Conclusion

Le backend de script IL2CPP prend en charge les mêmes comportements de marshalling que le backend de script Mono . Étant donné qu'IL2CPP produit des wrappers générés pour les méthodes et types externes, il est possible de voir le coût des appels d'interopérabilité gérés vers les appels natifs. Pour les types blittables, ce coût n'est souvent pas trop élevé, mais les types non blittables peuvent rapidement rendre l'interopérabilité très coûteuse. Comme d'habitude, nous n'avons fait qu'effleurer la surface du marshaling dans cet article. Veuillez explorer davantage le code généré pour voir comment le marshaling est effectué pour les valeurs de retour et les paramètres de sortie, les pointeurs de fonction natifs et les délégués gérés, ainsi que les types de référence définis par l'utilisateur.

La prochaine fois, nous explorerons comment IL2CPP s’intègre au ramasse-miettes.