Interner Aufbau von IL2CPP: P/Invoke-Wrapper

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 2, 2015|12 Min.
Interner Aufbau von IL2CPP: P/Invoke-Wrapper
Diese Website wurde aus praktischen Gründen für Sie maschinell übersetzt. Die Richtigkeit und Zuverlässigkeit des übersetzten Inhalts kann von uns nicht gewährleistet werden. Sollten Sie Zweifel an der Richtigkeit des übersetzten Inhalts haben, schauen Sie sich bitte die offizielle englische Version der Website an.
Dies ist der sechste Beitrag in der IL2CPP Internals-Reihe. In diesem Beitrag untersuchen wir, wie il2cpp.exe Wrappermethoden und -typen für die Interoperabilität zwischen verwaltetem und nativem Code generiert. Insbesondere werden wir uns den Unterschied zwischen blitfähigen und nicht blitfähigen Typen ansehen, das Marshalling von Zeichenfolgen und Arrays verstehen und etwas über die Kosten des Marshallings lernen.

Ich habe im Laufe meines Lebens ziemlich viel verwalteten und nativen Interop-Code geschrieben, aber es ist, gelinde gesagt, immer noch schwierig, p/invoke-Deklarationen in C# richtig hinzubekommen. Zu verstehen, was die Laufzeit zum Ordnen meiner Objekte unternimmt, ist ein noch größeres Rätsel. Da IL2CPP den Großteil seines Marshallings im generierten C++-Code durchführt, können wir sein Verhalten sehen (und sogar debuggen!), was uns viel bessere Einblicke für die Fehlerbehebung und Leistungsanalyse bietet.

Dieser Beitrag zielt nicht darauf ab, allgemeine Informationen zum Marshalling und zur nativen Interoperabilität bereitzustellen. Das ist ein weites Thema, zu umfangreich für einen Beitrag. In der Unity -Dokumentation wird erläutert, wie native Plugins mit Unity interagieren. Sowohl Mono als auch Microsoft bieten zahlreiche hervorragende Informationen zu p/invoke im Allgemeinen.

Wie in allen Beiträgen dieser Reihe untersuchen wir Code, der Änderungen unterliegt und sich in einer neueren Version von Unity wahrscheinlich auch ändern wird. Die Konzepte sollten jedoch dieselben bleiben. Bitte betrachten Sie alles, was in dieser Reihe besprochen wird, als Implementierungsdetails. Wir möchten derartige Details jedoch gerne offenlegen und diskutieren, wenn dies möglich ist!

Das Setup

Für diesen Beitrag verwende ich Unity 5.0.2p4 unter OSX. Ich werde für die iOS-Plattform bauen und dabei den „Architektur“-Wert „Universal“ verwenden. Ich habe meinen nativen Code für dieses Beispiel in Xcode 6.3.2 als statische Bibliothek für ARMv7 und ARM64 erstellt.

Der native Code sieht folgendermaßen aus:

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

}

Der Skriptcode in Unity befindet sich wiederum in der Datei HelloWorld.cs. Es sieht so aus:

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

Jeder der Methodenaufrufe in diesem Code wird in den oben gezeigten nativen Code umgesetzt. Wir werden uns die verwaltete Methodendeklaration für jede Methode ansehen, wie wir sie später im Beitrag sehen.

Warum brauchen wir Marshalling?

Da IL2CPP bereits C++-Code generiert, warum müssen wir dann überhaupt ein Marshalling von C#- nach C++-Code durchführen? Obwohl es sich bei dem generierten C++-Code um nativen Code handelt, unterscheidet sich die Darstellung der Typen in C# in einigen Fällen von C++. Daher muss die IL2CPP-Laufzeit in der Lage sein, zwischen Darstellungen auf beiden Seiten hin und her zu konvertieren. Das Dienstprogramm il2cpp.exe erledigt dies sowohl für Typen als auch für Methoden.

In verwaltetem Code können alle Typen als blitfähigodernicht blitfähigkategorisiert werden. Blittable Typen haben die gleiche Darstellung in verwaltetem und nativem Code (z. B. Byte, Int, Float). Nicht blitfähige Typen haben in verwaltetem und nativem Code eine unterschiedliche Darstellung (z. B. Bool-, String-, Array-Typen). Blitfähige Typen können daher direkt an nativen Code übergeben werden, nicht blitfähige Typen erfordern jedoch eine Konvertierung, bevor sie an nativen Code übergeben werden können. Mit dieser Konvertierung ist häufig eine neue Speicherzuweisung verbunden.

Um dem verwalteten Code-Compiler mitzuteilen, dass eine bestimmte Methode im nativen Code implementiert ist, wird in C# das Schlüsselwort extern verwendet. Dieses Schlüsselwort ermöglicht zusammen mit einem DllImport-Attribut der verwalteten Codelaufzeit, die native Methodendefinition zu finden und aufzurufen. Das Dienstprogramm il2cpp.exe generiert für jede externe Methode eine Wrapper-C++-Methode. Dieser Wrapper führt einige wichtige Aufgaben aus:

– Es definiert einen Typedef für die native Methode, der zum Aufrufen der Methode über einen Funktionszeiger verwendet wird.

– Es löst die native Methode nach Namen auf und erhält einen Funktionszeiger auf diese Methode.

– Es konvertiert die Argumente von ihrer verwalteten Darstellung in ihre native Darstellung (falls erforderlich).

– Es ruft die native Methode auf.

– Es konvertiert den Rückgabewert der Methode von seiner nativen Darstellung in seine verwaltete Darstellung (falls erforderlich).

– Konvertiert alle Out- oder Ref-Argumente von ihrer nativen Darstellung in ihre verwaltete Darstellung (falls erforderlich).

Als nächstes werden wir uns die generierten Wrapper-Methoden für einige externe Methodendeklarationen ansehen.

Marshallen eines blitfähigen Typs

Die einfachste Art von externem Wrapper befasst sich nur mit blitfähigen Typen.

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

Beachten Sie zunächst den Typdefinitionstyp für die native Funktionssignatur:

typedef int32_t (DEFAULT_CALL *PInvokeFunc) (int32_t);

Etwas Ähnliches wird in jeder der Wrapper-Funktionen auftauchen. Diese native Funktion akzeptiert ein einzelnes int32_t und gibt ein int32_t zurück.

Als nächstes findet der Wrapper den richtigen Funktionszeiger und speichert ihn in einer statischen Variable:

_il2cpp_pinvoke_func = (PInvokeFunc)Increment;

Hier stammt die Increment-Funktion tatsächlich aus einer externen Anweisung (im C++-Code):

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

Unter iOS werden native Methoden statisch in eine einzelne Binärdatei verknüpft (angezeigt durch die Zeichenfolge „__Internal“ im DllImport-Attribut), sodass die IL2CPP-Laufzeitumgebung nichts unternimmt, um den Funktionszeiger nachzuschlagen. Stattdessen weist diese externe Anweisung den Linker an, zur Linkzeit die richtige Funktion zu finden. Auf anderen Plattformen kann die IL2CPP-Laufzeitumgebung (falls erforderlich) eine Suche mit einer plattformspezifischen API Methode durchführen, um diesen Funktionszeiger zu erhalten.

In der Praxis bedeutet dies, dass unter iOS eine falsche p/invoke-Signatur im verwalteten Code als Linkerfehler im generierten Code angezeigt wird. Zur Laufzeit tritt der Fehler nicht auf. Daher müssen alle P/Invoke-Signaturen korrekt sein, auch wenn sie zur Laufzeit nicht verwendet werden.

Abschließend wird über den Funktionszeiger die native Methode aufgerufen und der Rückgabewert zurückgegeben. Beachten Sie, dass das Argument als Wert an die native Funktion übergeben wird. Änderungen an seinem Wert im nativen Code sind daher wie erwartet nicht im verwalteten Code verfügbar.

Marshallen eines nicht blitfähigen Typs

Bei einem nicht blitfähigen Typ wie String wird es etwas spannender. Erinnern Sie sich an einen früheren Beitrag, in dem es darum ging, dass Zeichenfolgen in IL2CPP als Array aus zwei Byte großen Zeichen dargestellt werden, die über UTF-16 codiert sind und denen ein 4-Byte-Längenwert vorangestellt ist. Diese Darstellung stimmt weder mit der char*- noch mit der wchar_t*-Darstellung von Zeichenfolgen in C unter iOS überein, daher müssen wir einige Konvertierungen vornehmen. Wenn wir uns die Methode StringsMatch ansehen (HelloWorld_StringsMatch_m4 im generierten Code):

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

Wir können sehen, dass jedes String-Argument in ein char* konvertiert wird (aufgrund der Direktive UnmangedType.LPStr).

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

Die Konvertierung sieht folgendermaßen aus (für das erste Argument):

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

Ein neuer Zeichenpuffer der richtigen Länge wird zugewiesen und der Inhalt der Zeichenfolge wird in den neuen Puffer kopiert. Natürlich müssen wir nach dem Aufruf der nativen Methode die zugewiesenen Puffer bereinigen:

il2cpp_codegen_marshal_free(____l_marshaled);
____l_marshaled = NULL;

Daher kann das Marshallen eines nicht blitfähigen Typs wie String kostspielig sein.

Marshallen eines benutzerdefinierten Typs

Einfache Typen wie int und string sind nett, aber wie wäre es mit einem komplexeren, benutzerdefinierten Typ? Angenommen, wir möchten die obige Vektorstruktur, die drei Float-Werte enthält, marshallen. Es stellt sich heraus, dass ein benutzerdefinierter Typ genau dann blitfähig ist, wenn alle seine Felder blitfähig sind. Daher können wir ComputeLength (HelloWorld_ComputeLength_m5 im generierten Code) aufrufen, ohne das Argument konvertieren zu müssen:

typedef float (DEFAULT_CALL *PInvokeFunc) (Vector_t1 );

// I’ve omitted the function pointer code.

float _return_value = _il2cpp_pinvoke_func(___v);
return _return_value;

Beachten Sie, dass das Argument als Wert übergeben wird, genau wie im ersten Beispiel, als der Argumenttyp int war. Wenn wir die Instanz von Vector ändern und diese Änderungen im verwalteten Code sehen möchten, müssen wir sie als Referenz übergeben, wie in der SetX-Methode (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;

Hier wird das Vektorargument als Zeiger an nativen Code übergeben. Der generierte Code durchläuft ein wenig Zirkus, aber im Grunde erstellt er eine lokale Variable desselben Typs, kopiert den Wert des Arguments in die lokale Variable und ruft dann die native Methode mit einem Zeiger auf diese lokale Variable auf. Nachdem die native Funktion zurückgegeben wurde, wird der Wert in der lokalen Variable zurück in das Argument kopiert und dieser Wert ist dann im verwalteten Code verfügbar.

Ein nicht blitfähiger benutzerdefinierter Typ, wie der oben definierte Boss-Typ, kann ebenfalls marshalliert werden, allerdings ist hierfür etwas mehr Arbeit erforderlich. Jedes Feld dieses Typs muss in seine native Darstellung umgewandelt werden. Außerdem benötigt der generierte C++-Code eine Darstellung des verwalteten Typs, die mit der Darstellung im nativen Code übereinstimmt.

Werfen wir einen Blick auf die externe Deklaration IsBossDead:

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

Der Wrapper für diese Methode heißt 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;
}

Das Argument wird als Typ Boss_t2 an die Wrapper-Funktion übergeben. Dabei handelt es sich um den generierten Typ für die Boss-Struktur. Beachten Sie, dass es mit einem anderen Typ an die native Funktion übergeben wird: Boss_t2_marshaled. Wenn wir zur Definition dieses Typs springen, können wir sehen, dass sie mit der Definition der Boss-Struktur in unserem statischen C++-Bibliothekscode übereinstimmt:

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

Wir haben erneut die Direktive UnmanagedType.LPStr in C# verwendet, um anzugeben, dass das Zeichenfolgenfeld als char* marshalliert werden soll. Wenn Sie beim Debuggen eines Problems mit einem nicht blitfähigen benutzerdefinierten Typ feststellen, ist es sehr hilfreich, sich diese_marshaled-Struktur im generierten Code anzusehen. Wenn das Feldlayout nicht mit der nativen Seite übereinstimmt, ist möglicherweise eine Marshalling-Direktive im verwalteten Code falsch.

Die Funktion Boss_t2_marshal ist eine generierte Funktion, die jedes Feld ordnet, und Boss_t2_marshal_cleanup gibt den gesamten während dieses Marshalling-Prozesses zugewiesenen Speicher frei.

Marshallen eines nicht blitfähigen benutzerdefinierten Typs
Marshallen eines Arrays

Abschließend untersuchen wir, wie Arrays von blitfähigen und nicht blitfähigen Typen marshalliert werden. Der Methode SumArrayElements wird ein Array von Ganzzahlen übergeben:

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

Dieses Array wird marshallt, aber da der Elementtyp des Arrays (int) blitfähig ist, ist der Aufwand für das Marshallen sehr gering:

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

Die Funktion il2cpp_codegen_marshal_array gibt einfach einen Zeiger auf den vorhandenen verwalteten Array-Speicher zurück, das ist alles!

Das Marshallen eines Arrays nicht blitfähiger Typen ist jedoch wesentlich aufwändiger. Die Methode SumBossHealth übergibt ein Array von Boss-Instanzen:

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

Sein Wrapper muss ein neues Array zuordnen und dann jedes Element einzeln marshallen:

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

Natürlich werden alle diese Zuweisungen auch bereinigt, nachdem der native Methodenaufruf abgeschlossen ist.

Fazit

Das IL2CPP-Skripting-Backend unterstützt dieselben Marshalling-Verhaltensweisen wie das Mono -Skripting-Backend. Da IL2CPP generierte Wrapper für externe Methoden und Typen erstellt, ist es möglich, die Kosten von verwalteten zu nativen Interop-Aufrufen zu erkennen. Bei blitfähigen Typen sind diese Kosten oft nicht so hoch, aber bei nicht blitfähigen Typen kann die Interoperabilität schnell sehr teuer werden. Wie üblich haben wir in diesem Beitrag nur an der Oberfläche des Marshallings gekratzt. Bitte untersuchen Sie den generierten Code genauer, um zu sehen, wie das Marshalling für Rückgabewerte und Ausgabeparameter, native Funktionszeiger und verwaltete Delegierte sowie benutzerdefinierte Referenztypen erfolgt.

Das nächste Mal werden wir untersuchen, wie IL2CPP in den Garbage Collector integriert wird.