IL2CPP: generierte Codetour

Ursprünglicher Autor: Josh Peterson
  • Übersetzung
Hier ist der zweite Artikel in einer Reihe über IL2CPP. Dieses Mal werden wir uns mit C ++ - Code befassen, der von il2cpp.exe generiert wurde, und uns auch mit der Darstellung verwalteter Typen im Maschinencode, Laufzeitprüfungen, die zur Unterstützung der virtuellen Maschine in .NET verwendet werden, der Schleifengenerierung und vielem mehr befassen.



Dafür werden wir sehr spezifischen Code verwenden, der sich in zukünftigen Versionen von Unity wahrscheinlich ändern wird. Die Grundprinzipien bleiben jedoch unverändert.

Projektbeispiel

Für dieses Beispiel verwende ich die neueste verfügbare Version von Unity 5.0.1p1. Wie im vorherigen Artikel werde ich ein neues leeres Projekt erstellen und ein Skript mit folgendem Inhalt hinzufügen:

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


Ich werde dieses Projekt unter WebGL mit dem Unity-Editor unter Windows erstellen. Um im generierten C ++ - Code relativ gute Namen zu erhalten, habe ich die Option Development Player in den Build-Einstellungen aktiviert. Außerdem habe ich in den WebGL-Player-Einstellungen die Option Vollständig auf Ausnahmen aktivieren gesetzt.

Übersicht über den generierten Code

Nach Abschluss der Assembly befindet sich der generierte C ++ - Code im Verzeichnis Temp \ StagingArea \ Data \ il2cppOutput im Projektordner. Sobald ich den Editor schließe, wird dieses Verzeichnis gelöscht, aber solange es geöffnet ist, können Sie es sorgfältig untersuchen.

Das Dienstprogramm il2cpp.exe hat selbst für ein so kleines Projekt viele Dateien generiert: 4625 Header-Dateien und 89 C ++ - Quelldateien. Um diese Menge an Code zu testen, bevorzuge ich die Verwendung eines Texteditors mit Exuberant CTags- Unterstützung .In der Regel generiert CTags schnell eine Tag-Datei, was die Code-Navigation erheblich vereinfacht.

Möglicherweise stellen Sie fest, dass viele generierte C ++ - Dateien keinen einfachen Code aus unserem Skript, sondern konvertierten Code aus Standardbibliotheken wie mscorlib.dll enthalten. Wie bereits in einem früheren Artikel erwähnt, verwendet das IL2CPP-Skriptmodul denselben Standardbibliothekscode wie Mono. Bitte beachten Sie, dass wir den Code mscorlib.dll und andere Standardbibliotheken bei jeder Ausführung von il2cpp.exe konvertieren. Dies erscheint möglicherweise unnötig, da sich der Code nicht ändert.

Tatsache ist, dass IL2CPP immer den Bytecode löscht, um die Größe der ausführbaren Datei zu verringern. Daher können selbst kleine Änderungen im Skriptcode dazu führen, dass je nach den Umständen verschiedene Teile des Codes der Standardbibliothek verwendet werden oder nicht. Daher muss mscorlib.dll bei jedem Build konvertiert werden. Wir versuchen, den inkrementellen Montageprozess zu verbessern, aber bisher ohne großen Erfolg.

Zuordnen von verwaltetem Code zu generiertem C ++ - Code

Für jeden Typ in verwaltetem Code generiert il2cpp.exe 2 Header-Dateien: zum Ermitteln des Typs und zum Deklarieren von Methoden für diesen Typ. Betrachten wir zum Beispiel den Inhalt des konvertierten Typs UnityEngine.Vector3. Die Header-Datei für diesen Typ heißt UnityEngine_UnityEngine_Vector3.h. Der Name wird basierend auf dem Assemblynamen (UnityEngine.dll), dem Namespace und dem Typnamen erstellt. Der Code lautet wie folgt:

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


Das Dienstprogramm il2cpp.exe konvertiert jedes der drei Felder der Instanz und ändert die Namen mithilfe der anfänglichen Unterstriche geringfügig, um mögliche Konflikte mit reservierten Wörtern zu vermeiden. Wir verwenden in C ++ reservierte Namen, aber bisher haben wir noch nie einen Konflikt mit dem Standardbibliothekscode gesehen.

Die Datei UnityEngine_UnityEngine_Vector3MethodDeclarations.h enthält Deklarationen für alle Methoden in Vector3. Beispielsweise überschreibt Vector3 die Object.ToString-Methode:

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


Beachten Sie den Kommentar, der die verwaltete Methode angibt, die die ursprüngliche Anzeige darstellt. Dies kann nützlich sein, um Ausgabedateien nach dem Namen der verwalteten Methode in diesem Format zu suchen, insbesondere für Methoden mit allgemeinen Namen, wie z. B. ToString.
Die von il2cpp.exe konvertierten Methoden haben mehrere interessante Eigenschaften:

• Sie sind keine Member-Funktionen in C ++, sondern freie Funktionen mit dem this-Zeiger als erstem Argument. Für das erste Argument statischer Funktionen in verwaltetem Code übergibt IL2CPP immer NULL. Indem Sie Methoden mit diesem Zeiger als erstem Argument deklarieren, vereinfachen Sie die Codegenerierung in il2cpp.exe und rufen Methoden über andere Methoden (z. B. Delegaten) für den generierten Code auf.

• Jede Methode verfügt über ein zusätzliches Argument vom Typ MethodInfo * mit Metadaten zur Methode, mit dem beispielsweise eine virtuelle Methode aufgerufen werden kann. Mono verwendet plattformspezifische Transporte, um diese Metadaten zu übermitteln. Im Falle von IL2CPP haben wir jedoch beschlossen, diese nicht zu verwenden, um die Portabilität zu verbessern.
• Alle Methoden werden über externes „C“ deklariert, sodass il2cpp.exe den C ++ - Compiler bei Bedarf austricksen und alle Methoden so behandeln kann, als ob sie denselben Typ hätten.

• Typnamen enthalten das Suffix "_t", Methodennamen das Suffix "_m". Namenskonflikte werden durch Hinzufügen einer eindeutigen Nummer für jeden Namen behoben. Bei Änderungen am Benutzerskriptcode ändern sich auch diese Nummern, sodass Sie sich beim Wechseln zu einer neuen Assembly nicht auf sie verlassen sollten.

Die ersten beiden Punkte setzen voraus, dass jede Methode mindestens zwei Parameter hat: den this-Zeiger und den MethodInfo-Zeiger. Fügen diese Optionen zusätzliche Ressourcen hinzu? Ja, sie fügen hinzu, aber dies beeinträchtigt die Leistung nicht, wie es auf den ersten Blick scheint. Zumindest sagen das die Ergebnisse der Profilerstellung.

Fahren wir mit der Definition der ToString-Methode mit Ctags fort. Es befindet sich in der Datei Bulk_UnityEngine_0.cpp. Der Code in dieser Methodendefinition entspricht nicht dem C # -Code in der Vector3 :: ToString () -Methode. Wenn Sie jedoch ein Tool wie ILSpy verwenden, um den Code der Vector3 :: ToString () -Methode anzuzeigen, stellen Sie möglicherweise fest, dass der generierte C ++ - Code dem IL-Code sehr ähnlich ist.

Warum generiert il2cpp.exe keine separate C ++ - Datei zum Definieren von Methoden für jeden Typ, und wie deklariert es Methoden? Die Datei Bulk_UnityEngine_0.cpp ist ziemlich groß - 20.481 Zeilen! Die verwendeten C ++ - Compiler kommen mit einer großen Anzahl von Quelldateien kaum zurecht. Das Kompilieren von 4.000 CPP-Dateien dauerte länger als das Kompilieren desselben Quellcodes in 80 CPP-Dateien. Daher unterteilt il2cpp.exe die Methodendefinitionen für Typen in Gruppen und generiert für jede von ihnen eine C ++ - Datei.

Kehren wir nun zur Header-Datei der Methodendeklaration zurück und achten Sie auf die Zeile oben in der Datei:

#include "codegen/il2cpp-codegen.h"


Die Datei il2cpp-codegen.h enthält eine Schnittstelle, über die der generierte Code auf die libil2cpp-Umgebung zugreift. Später werden wir verschiedene Möglichkeiten zur Verwendung dieser Umgebung diskutieren.

Methodenprolog

Schauen wir uns die Definition der Vector3 :: ToString () -Methode an, nämlich den generischen Prolog, der von il2cpp.exe für alle Methoden erstellt wurde.

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


In der ersten Zeile des Prologs wird eine lokale Variable vom Typ StackTraceSentry erstellt. Es wird verwendet, um den Stapel verwalteter Aufrufe zu verfolgen, z. B. mithilfe von Environment.StackTrace. Tatsächlich ist die Generierung dieses Codes optional. In diesem Fall wurde sie gestartet, weil das Argument --enable-stacktrace an il2cpp.exe übergeben wurde (da ich den Wert Full in den WebGL-Player-Einstellungen auf Enable Exceptions festgelegt habe). Wir haben festgestellt, dass diese Variable bei kleinen Funktionen die Ressourcenkosten erhöht und die Leistung beeinträchtigt. Daher fügen wir diesen Code niemals für iOS und andere Plattformen hinzu, auf denen Sie ohne diesen Code Stack-Trace-Informationen abrufen können. Die WebGL-Plattform unterstützt keine Stapelverfolgung, daher müssen Sie zulassen, dass Ausnahmen für verwalteten Code ordnungsgemäß funktionieren.

Der zweite Teil des Prologs beginnt mit der verzögerten Initialisierung des Metadatentyps für alle Arrays oder universellen Typen, die im Hauptteil der Methode verwendet werden. Daher ist ObjectU5BU5D_t4 ein Name vom Typ System.Object []. Dieser Teil des Prologs wird nur einmal ausgeführt und führt nichts aus, wenn der Typ bereits initialisiert wurde. Daher wurden keine negativen Auswirkungen auf die Leistung festgestellt.

Aber was ist mit Streaming-Sicherheit? Was ist, wenn zwei Threads gleichzeitig Vector3 :: ToString () aufrufen? Es ist in Ordnung: Der gesamte Code in der libil2cpp-Umgebung, der zum Initialisieren des Typs verwendet wird, kann sicher von mehreren Threads aufgerufen werden. Höchstwahrscheinlich wird die Funktion il2cpp_codegen_class_from_type mehrmals aufgerufen, funktioniert jedoch nur einmal in einem Thread. Die Methode wird erst fortgesetzt, wenn die Initialisierung abgeschlossen ist. Daher ist dieser Methodenprolog threadsicher.

Prüfungen zur Laufzeit

Der nächste Teil der Methode erstellt ein Array von Objekten, speichert den Wert des X-Felds für Vector3 in einer lokalen Variablen, packt diese Variable und fügt sie dem Array mit einem Index von Null hinzu. Der generierte C ++ - Code (mit Kommentaren) sieht folgendermaßen aus:

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


Il2cpp.exe fügt 3 Prüfungen hinzu, die im IL-Code fehlen:

• Wenn der Wert NULL ist, löst die NullCheck-Prüfung eine NullReferenceException aus.
• Wenn der Array-Index nicht korrekt ist, löst die IL2CPP_ARRAY_BOUNDS_CHECK-Prüfung eine IndexOutOfRangeException aus.
• Wenn der zum Array hinzugefügte Elementtyp falsch ist, löst ArrayElementTypeCheck eine ArrayTypeMismatchException aus.

Diese Laufzeitprüfungen stellen sicher, dass die Daten für die virtuelle .NET-Maschine korrekt sind. Anstatt Code einzufügen, verwendet Mono die Mechanismen der Zielplattform, um diese Prüfungen zu verarbeiten. Im Falle von IL2CPP wollten wir so viele Plattformen wie möglich abdecken, einschließlich WebGL, die keinen eigenen Überprüfungsmechanismus hatten. Daher implementiert das Dienstprogramm il2cpp.exe diese Überprüfungen selbst.

Führen diese Überprüfungen zu Leistungsproblemen? In den meisten Fällen wurden keine Probleme festgestellt. Darüber hinaus bieten Validierungen zusätzliche Vorteile und Sicherheit für die virtuelle .NET-Maschine. In Einzelfällen verzeichneten wir immer noch einen Leistungsabfall, insbesondere in kontinuierlichen Zyklen. Jetzt versuchen wir, eine Möglichkeit zu finden, mit der verwalteter Code dynamische Überprüfungen entfernt, wenn il2cpp.exe C ++ - Code generiert. Bleib dran.

Statische Felder

Nachdem wir gesehen haben, wie die Instanzfelder aussehen (am Beispiel von Vector3), wollen wir sehen, wie statische Felder konvertiert werden und wie auf sie zugegriffen wird. Suchen Sie zunächst die Definition der HelloWorld_Start_m3-Methode in der Bulk_Assembly-CSharp_0.cpp-Datei in meiner Assembly und wechseln Sie dann zum Important_t1-Typ (in der AssemblyU2DCSharp_HelloWorld_Important.h-Datei):

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


Beachten Sie, dass il2cpp.exe eine separate C ++ - Struktur erstellt hat, um ein statisches Feld bereitzustellen, auf das alle Instanzen dieses Typs zugreifen können. Zur Laufzeit wird daher eine Instanz des Typs Important_t1_StaticFields erstellt, und alle Instanzen des Typs Important_t1 verwenden sie als statisches Feld. Im generierten Code lautet der Zugriff auf das statische Feld wie folgt:

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


Die Typmetadaten für Important_t1 enthalten einen Zeiger auf eine Instanz des Typs Important_t1_StaticFields sowie Informationen, die diese Instanz zum Abrufen des Werts eines statischen Felds verwendet.

Ausnahmen

Il2cpp.exe konvertiert verwaltete Ausnahmen in C ++ - Ausnahmen. Wir haben diesen Ansatz gewählt, damit er wiederum nicht von bestimmten Plattformen abhängt. Wenn il2cpp.exe Code generieren muss, um eine verwaltete Ausnahme zu erstellen, wird die Funktion il2cpp_codegen_raise_exception aufgerufen. Der Code zum Aufrufen und Abfangen verwalteter Ausnahmen in unserer HelloWorld_Start_m3-Methode sieht folgendermaßen aus:

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)


Alle verwalteten Ausnahmen werden in den Typ Il2CppExceptionWrapper eingeschlossen. Wenn der generierte Code eine Ausnahme dieses Typs abfängt, entpackt er seine C ++ - Darstellung (vom Typ Exception_t8). In diesem Fall suchen wir nur nach einer InvalidOperationException. Wenn wir also keine Ausnahme dieses Typs finden, wirft C ++ erneut eine Kopie. Wenn wir eine Ausnahme dieses Typs finden, startet der Code den Interception-Handler und zeigt eine Ausnahmemeldung an.

Springen?!

Es stellt sich eine interessante Frage: Was machen goto labels und operator hier? Diese Konstrukte werden optional in der Strukturprogrammierung verwendet. Tatsache ist, dass IL die Prinzipien der strukturellen Programmierung wie Schleifen und bedingte Anweisungen nicht anwendet. Dies ist eine Sprache auf niedriger Ebene. Daher befolgt il2cpp.exe im generierten Code Konzepte auf niedriger Ebene.

Betrachten Sie als Beispiel die for-Schleife in der HelloWorld_Start_m3-Methode:

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


Die Variable V_2 ist der Schleifenindex. Am Anfang hat es den Wert 0, dann erhöht es sich am Ende der Schleife in dieser Zeile:

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


Die Kündigungsbedingung wird hier überprüft:

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


Solange V_2 kleiner als drei ist, springt die goto-Anweisung zum Label IL_00af, das sich am oberen Rand des Schleifenkörpers befindet. Wie Sie vielleicht vermutet haben, generiert il2cpp.exe derzeit C ++ - Code direkt aus IL, ohne eine abstrakte Zwischendarstellung des Syntaxbaums zu verwenden. Möglicherweise haben Sie auch bemerkt, dass im Abschnitt "Überprüfungen zur Laufzeit" im Code solche Fragmente vorhanden sind:

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


Offensichtlich ist die Variable L_2 hier überflüssig. Trotz der Tatsache, dass es in den meisten C ++ - Compilern eliminiert ist, möchten wir das Erscheinen im Code insgesamt vermeiden. Wir überlegen jetzt, einen abstrakten Syntaxbaum zu verwenden, um IL-Code besser zu verstehen und besseren C ++ - Code für Fälle zu generieren, in denen lokale Variablen und Schleifen verwendet werden.

Fazit

Wir haben nur einen kleinen Teil des von IL2CPP generierten C ++ - Codes für ein sehr einfaches Projekt behandelt. Nun empfehle ich Ihnen, sich den generierten Code Ihres eigenen Projekts anzuschauen. Beachten Sie, dass C ++ - Code in zukünftigen Versionen von Unity anders aussehen wird, da wir die Qualität und Leistung der IL2CPP-Technologie weiter verbessern.

Durch die Konvertierung von IL-Code in C ++ konnten wir ein ausgewogenes Verhältnis zwischen Portabilität und Leistung erzielen. Wir haben eine Menge verwalteter Code-Funktionen, die für Entwickler nützlich sind, während die Vorteile des Maschinencodes, den der C ++ - Compiler für verschiedene Plattformen bietet, erhalten bleiben.

In zukünftigen Beiträgen werden wir mehr über den generierten Code sprechen: Wir werden Methodenaufrufe und die Verteilung ihrer Implementierungen und Wrapper zum Aufrufen nativer Bibliotheken betrachten. Und das nächste Mal werden wir den generierten Code für die 64-Bit-Version von iOS mit Xcode debuggen.

Jetzt auch beliebt: