IL2CPP Interna: Eine Tour durch den generierten Code

Dies ist der zweite Blog-Beitrag in der Serie IL2CPP Internals. In diesem Beitrag werden wir den von il2cpp.exe generierten C++-Code untersuchen. Auf dem Weg dorthin werden wir sehen, wie verwaltete Typen in nativem Code dargestellt werden, einen Blick auf Laufzeitprüfungen werfen, die zur Unterstützung der virtuellen Maschine von .NET verwendet werden, sehen, wie Schleifen erzeugt werden und vieles mehr!
Wir werden uns mit einigen sehr versionsspezifischen Codes befassen, die sich in späteren Versionen von Unity sicherlich noch ändern werden. Die Konzepte bleiben jedoch dieselben.
Beispielprojekt
Für dieses Beispiel verwende ich die neueste verfügbare Version von Unity, 5.0.1p1. Wie im ersten Beitrag dieser Serie beginne ich mit einem leeren Projekt und füge eine Skriptdatei hinzu. Dieses Mal hat sie folgenden Inhalt:
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 für WebGL erstellen und den Unity-Editor unter Windows ausführen. Ich habe in den Build-Einstellungen die Option Development Player ausgewählt, damit wir relativ schöne Namen im generierten C++-Code erhalten. Ich habe auch die Option Ausnahmen aktivieren in den WebGL-Player-Einstellungen auf Voll eingestellt.
Überblick über den generierten Code
Nachdem die WebGL-Erstellung abgeschlossen ist, ist der generierte C++-Code im Verzeichnis Temp\StagingArea\Data\il2cppOutput in meinem Projektverzeichnis verfügbar. Sobald der Editor geschlossen wird, wird dieses Verzeichnis gelöscht. Solange der Editor geöffnet ist, bleibt dieses Verzeichnis jedoch unverändert, so dass wir es inspizieren können.
Das Dienstprogramm il2cpp.exe erzeugte eine Reihe von Dateien, selbst für dieses kleine Projekt. Ich sehe 4625 Header-Dateien und 89 C++ Quellcode-Dateien. Um all diesen Code in den Griff zu bekommen, verwende ich gerne einen Texteditor, der mit Exuberant CTags arbeitet. CTags erzeugt in der Regel schnell eine Tag-Datei für diesen Code, was die Navigation erleichtert.
Zunächst können Sie sehen, dass viele der generierten C++-Dateien nicht aus dem einfachen Skriptcode stammen, sondern die konvertierte Version des Codes in den Standardbibliotheken sind, wie z.B. mscorlib.dll. Wie bereits im ersten Beitrag dieser Serie erwähnt, verwendet das IL2CPP-Skript-Backend denselben Code der Standardbibliothek wie das Mono-Skript-Backend. Beachten Sie, dass wir den Code in mscorlib.dll und anderen Standardbibliotheks-Assemblies jedes Mal konvertieren, wenn il2cpp.exe ausgeführt wird. Dies mag unnötig erscheinen, da sich dieser Code nicht ändert.
Das IL2CPP-Skript-Backend verwendet jedoch immer Bytecode-Stripping, um die Größe der ausführbaren Datei zu verringern. So können selbst kleine Änderungen im Skriptcode dazu führen, dass viele verschiedene Teile des Codes der Standardbibliothek je nach Situation verwendet werden oder nicht. Daher müssen wir die Assembly mscorlib.dll jedes Mal konvertieren. Wir forschen nach besseren Möglichkeiten für inkrementelle Builds, aber wir haben noch keine guten Lösungen.
Wie verwalteter Code auf generierten C++ Code abgebildet wird
Für jeden Typ im verwalteten Code erzeugt il2cpp.exe eine Header-Datei für die C++-Definition des Typs und eine weitere Header-Datei für die Methodendeklarationen für den Typ. Sehen wir uns zum Beispiel den Inhalt des konvertierten Typs UnityEngine.Vector3 an. Die Header-Datei für diesen Typ heißt UnityEngine_UnityEngine_Vector3.h. Der Name wird auf der Grundlage des Namens der Assembly, UnityEngine.dll, gefolgt vom Namensraum und dem Namen des Typs, erstellt. Der Code sieht wie folgt aus:
// 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 hat jedes der drei Instanzfelder konvertiert und die Namen ein wenig umgeschrieben, um Konflikte und reservierte Wörter zu vermeiden. Durch die Verwendung führender Unterstriche verwenden wir einige reservierte Namen in C++, aber bisher haben wir noch keine Konflikte mit dem Code der C++-Standardbibliothek gesehen.
Die Datei UnityEngine_UnityEngine_Vector3MethodDeclarations.h enthält die Methodendeklarationen für alle Methoden in Vector3. Zum Beispiel überschreibt Vector3 die Methode Object.ToString:
// System.String UnityEngine.Vector3::ToString()
extern "C" String_t* Vector3_ToString_m2315 (Vector3_t78 * __this, MethodInfo* method) IL2CPP_METHOD_ATTRBeachten Sie den Kommentar, der angibt, welche verwaltete Methode diese native Deklaration darstellt. Ich finde es oft nützlich, die Dateien in der Ausgabe nach dem Namen der verwalteten Methode in diesem Format zu durchsuchen, insbesondere bei Methoden mit allgemeinen Namen wie ToString.
Beachten Sie ein paar interessante Dinge über alle von il2cpp.exe konvertierten Methoden:
- Dies sind keine Mitgliedsfunktionen in C++. Alle Methoden sind freie Funktionen, wobei das erste Argument der Zeiger "this" ist. Bei statischen Funktionen in verwaltetem Code übergibt IL2CPP für dieses erste Argument immer den Wert NULL. Indem wir Methoden immer mit dem "this"-Zeiger als erstem Argument deklarieren, vereinfachen wir den Code für die Methodengenerierung in il2cpp.exe und wir machen den Aufruf von Methoden über andere Methoden (wie Delegaten) für den generierten Code einfacher.
- Jede Methode hat ein zusätzliches Argument vom Typ MethodInfo*, das die Metadaten über die Methode enthält, die für Dinge wie den Aufruf virtueller Methoden verwendet werden. Das Mono Scripting Backend verwendet plattformspezifische Trampoline, um diese Metadaten zu übergeben. Für IL2CPP haben wir beschlossen, auf die Verwendung von Trampolinen zu verzichten, um die Portabilität zu erleichtern.
- Alle Methoden sind extern "C" deklariert, so dass il2cpp.exe den C++-Compiler manchmal anlügen und alle Methoden so behandeln kann, als hätten sie den gleichen Typ.
- Typen werden mit dem Suffix "_t" benannt. Methoden werden mit dem Suffix "_m" benannt. Namenskonflikte werden gelöst, indem an jeden Namen eine eindeutige Nummer angehängt wird. Diese Zahlen ändern sich, wenn sich etwas im Code des Benutzerskripts ändert. Sie können sich also nicht von Build zu Build darauf verlassen.
Die ersten beiden Punkte bedeuten, dass jede Methode mindestens zwei Parameter hat, den "this"-Zeiger und den MethodInfo-Zeiger. Verursachen diese zusätzlichen Parameter unnötigen Overhead? Obwohl sie eindeutig zusätzlichen Aufwand verursachen, haben wir bisher nichts gesehen, was darauf hindeutet, dass diese zusätzlichen Argumente Leistungsprobleme verursachen. Auch wenn es den Anschein hat, dass dies der Fall ist, hat die Profilierung gezeigt, dass der Leistungsunterschied nicht messbar ist.
Wir können zur Definition dieser ToString-Methode über Ctags springen. Sie befindet sich in der Datei Bulk_UnityEngine_0.cpp. Der Code in dieser Methodendefinition sieht dem C#-Code in der Methode Vector3::ToString() nicht allzu sehr ähnlich. Wenn Sie jedoch ein Tool wie ILSpy verwenden, um den Code für die Methode Vector3::ToString() zu reflektieren, werden Sie feststellen, dass der generierte C++-Code dem IL-Code sehr ähnlich sieht.
Warum erzeugt il2cpp.exe nicht für jeden Typ eine eigene C++-Datei für die Methodendefinitionen, wie es bei den Methodendeklarationen der Fall ist? Die Datei Bulk_UnityEngine_0.cpp ist ziemlich groß, nämlich 20.481 Zeilen! Wir stellten fest, dass die C++-Compiler, die wir verwendeten, Probleme mit einer großen Anzahl von Quelldateien hatten. Das Kompilieren von viertausend .cpp-Dateien dauerte viel länger als das Kompilieren desselben Quellcodes in 80 .cpp-Dateien. Daher fasst il2cpp.exe die Methodendefinitionen für Typen in Gruppen zusammen und erzeugt eine C++-Datei pro Gruppe.
Gehen Sie nun zurück zur Header-Datei für die Methodendeklarationen und beachten Sie diese Zeile am Anfang der Datei:
#include "codegen/il2cpp-codegen.h"Die Datei il2cpp-codegen.h enthält die Schnittstelle, über die der generierte Code auf die libil2cpp-Laufzeitdienste zugreift. Wir werden später einige Möglichkeiten erörtern, wie die Laufzeit von generiertem Code genutzt wird.
Methode Prologs
Werfen wir einen Blick auf die Definition der Methode Vector3::ToString(). Genauer gesagt hat es einen gemeinsamen Prolog, der in allen Methoden von il2cpp.exe ausgegeben wird.
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;
}Die erste Zeile dieses Prologs erstellt eine lokale Variable vom Typ StackTraceSentry. Diese Variable wird verwendet, um den verwalteten Aufrufstapel zu verfolgen, so dass IL2CPP ihn in Aufrufen wie Environment.StackTrace melden kann. Die Codegenerierung dieses Eintrags ist eigentlich optional und wird in diesem Fall durch die Option --enable-stacktrace aktiviert, die an il2cpp.exe übergeben wird (da ich die Option Enable Exceptions in den WebGL Player Settings auf Full gesetzt habe). Bei kleinen Funktionen haben wir festgestellt, dass der Overhead dieser Variable einen negativen Einfluss auf die Leistung hat. Für iOS und andere Plattformen, auf denen wir plattformspezifische Stack-Trace-Informationen verwenden können, geben wir diese Zeile also nie in den generierten Code aus. Für WebGL haben wir keine plattformspezifische Stacktrace-Unterstützung, so dass es notwendig ist, Ausnahmen von verwaltetem Code zuzulassen, damit es richtig funktioniert.
Der zweite Teil des Prologs führt eine faule Initialisierung der Typ-Metadaten für alle im Methodenkörper verwendeten Array- oder generischen Typen durch. Der Name ObjectU5BU5D_t4 ist also der Name des Typs System.Object[]. Dieser Teil des Prologs wird nur einmal ausgeführt und tut oft nichts, wenn der Typ bereits an anderer Stelle initialisiert wurde. Wir haben also keine negativen Auswirkungen auf die Leistung durch diesen generierten Code festgestellt.
Ist dieser Code jedoch thread-sicher? Was passiert, wenn zwei Threads gleichzeitig Vector3::ToString() aufrufen? Eigentlich ist dieser Code nicht problematisch, da der gesamte Code in der libil2cpp-Laufzeitumgebung, der für die Typinitialisierung verwendet wird, sicher von mehreren Threads aus aufgerufen werden kann. Es ist möglich (vielleicht sogar wahrscheinlich), dass die Funktion il2cpp_codegen_class_from_type mehr als einmal aufgerufen wird, aber die eigentliche Arbeit, die sie leistet, findet nur einmal statt, und zwar auf einem Thread. Die Ausführung der Methode wird nicht fortgesetzt, bis diese Initialisierung abgeschlossen ist. Diese Methode prologue ist also thread-sicher.
Laufzeitprüfungen
Der nächste Teil der Methode erstellt ein Objekt-Array, speichert den Wert des x-Feldes von Vector3 in einem Local, verpackt dann das Local und fügt es dem Array bei Index Null hinzu. Hier ist der generierte C++-Code (mit einigen Anmerkungen):
// 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;Die drei Laufzeitprüfungen sind nicht im IL-Code enthalten, sondern werden von il2cpp.exe injiziert.
- Der NullCheck-Code löst eine NullReferenceException aus, wenn der Wert des Arrays null ist.
- Der Code IL2CPP_ARRAY_BOUNDS_CHECK löst eine IndexOutOfRangeException aus, wenn der Array-Index nicht korrekt ist.
- Der Code ArrayElementTypeCheck löst eine ArrayTypeMismatchException aus, wenn der Typ des Elements, das dem Array hinzugefügt wird, nicht korrekt ist.
Diese drei Laufzeitprüfungen sind allesamt Garantien, die von der virtuellen Maschine von .NET bereitgestellt werden. Anstatt Code zu injizieren, verwendet das Mono Scripting Backend einen plattformspezifischen Signalisierungsmechanismus, um dieselben Laufzeitprüfungen durchzuführen. Für IL2CPP wollten wir plattformunabhängiger sein und Plattformen wie WebGL unterstützen, für die es keinen plattformspezifischen Signalisierungsmechanismus gibt, daher injiziert il2cpp.exe diese Prüfungen.
Verursachen diese Laufzeitprüfungen jedoch Leistungsprobleme? In den meisten Fällen konnten wir keine nachteiligen Auswirkungen auf die Leistung feststellen und sie bieten die Vorteile und die Sicherheit, die die virtuelle Maschine von .NET benötigt. In einigen wenigen Fällen führen diese Prüfungen jedoch zu Leistungseinbußen, insbesondere in engen Schleifen. Wir arbeiten jetzt an einer Möglichkeit, verwalteten Code mit Anmerkungen zu versehen, um diese Laufzeitprüfungen zu entfernen, wenn il2cpp.exe C++-Code generiert. Bleiben Sie auf dem Laufenden.
Statische Felder
Nachdem wir nun gesehen haben, wie Instanzfelder aussehen (im Typ Vector3), sehen wir uns an, wie statische Felder konvertiert und aufgerufen werden. Suchen Sie die Definition der Methode HelloWorld_Start_m3, die sich in der Datei Bulk_Assembly-CSharp_0.cpp in meinem Build befindet. Wechseln Sie von dort zum Typ Important_t1 (in der DateiAssemblyU2DCSharp_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;
};Beachten Sie, dass il2cpp.exe eine separate C++-Struktur erzeugt hat, um das statische Feld für diesen Typ zu speichern, da das statische Feld von allen Instanzen dieses Typs gemeinsam genutzt wird. Zur Laufzeit wird also eine Instanz des Typs Important_t1_StaticFields erstellt, und alle Instanzen des Typs Important_t1 teilen sich diese Instanz des Typs Statische Felder. Im generierten Code wird auf das statische Feld wie folgt zugegriffen:
int32_t L_1 = (((Important_t1_StaticFields*)InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo)->static_fields)->___ClassIdentifier_0);Die Typ-Metadaten für Important_t1 enthalten einen Zeiger auf die einzelne Instanz des Typs Important_t1_StaticFields, und diese Instanz wird verwendet, um den Wert des statischen Feldes zu erhalten.
Ausnahmen
Verwaltete Ausnahmen werden von il2cpp.exe in C++-Ausnahmen umgewandelt. Wir haben diesen Weg gewählt, um wiederum plattformspezifische Lösungen zu vermeiden. Wenn il2cpp.exe Code ausgeben muss, um eine verwaltete Ausnahme auszulösen, ruft es die Funktion il2cpp_codegen_raise_exception auf.
Der Code in unserer Methode HelloWorld_Start_m3 zum Auslösen und Abfangen einer verwalteten Ausnahme sieht wie folgt 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 sind in den C++-Typ Il2CppExceptionWrapper verpackt. Wenn der generierte Code eine Ausnahme dieses Typs abfängt, entpackt er die C++-Darstellung der verwalteten Ausnahme (die den Typ Exception_t8 hat). In diesem Fall suchen wir nur nach einer InvalidOperationException. Wenn wir also keine Ausnahme dieses Typs finden, wird erneut eine Kopie der C++-Ausnahme ausgelöst. Wenn wir den richtigen Typ finden, springt der Code zur Implementierung des Catch-Handlers und schreibt die Ausnahmemeldung aus.
Goto!?
Dieser Code wirft einen interessanten Punkt auf. Was machen diese Labels und goto-Anweisungen da drin? Diese Konstrukte sind in der strukturierten Programmierung nicht notwendig! Allerdings verfügt IL nicht über strukturierte Programmierkonzepte wie Schleifen und if/then-Anweisungen. Da es sich um ein Programm der unteren Ebene handelt, folgt il2cpp.exe den Konzepten der unteren Ebene im generierten Code.
Sehen wir uns zum Beispiel die for-Schleife in der Methode HelloWorld_Start_m3 an:
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;
}
}Hier ist die Variable V_2 der Schleifenindex. beginnt mit einem Wert von 0 und wird dann am Ende der Schleife in dieser Zeile erhöht:
V_2 = ((int32_t)(V_2+1));Hier wird dann die Endbedingung in der Schleife überprüft:
if ((((int32_t)V_2) < ((int32_t)3)))Solange V_2 kleiner als 3 ist, springt die Anweisung goto zum Label IL_00af, das am Anfang des Schleifenkörpers steht. Sie können sich vielleicht denken, dass il2cpp.exe derzeit C++-Code direkt aus IL generiert, ohne eine zwischengeschaltete abstrakte Syntaxbaumdarstellung zu verwenden. Wenn Sie das erraten haben, sind Sie richtig. Vielleicht haben Sie im obigen Abschnitt Laufzeitprüfungen auch bemerkt, dass ein Teil des generierten Codes wie folgt aussieht:
float L_1 = (__this->___x_1);
float L_2 = L_1;Natürlich ist die Variable L_2 hier nicht notwendig. Die meisten C++-Compiler können diese zusätzliche Zuweisung wegoptimieren, aber wir möchten vermeiden, sie überhaupt auszugeben. Wir untersuchen derzeit die Möglichkeit, einen AST zu verwenden, um den IL-Code besser zu verstehen und besseren C++-Code für Fälle zu generieren, die u.a. lokale Variablen und for-Schleifen beinhalten.
Fazit
Wir haben gerade erst die Oberfläche des C++-Codes angekratzt, der vom IL2CPP-Skript-Backend für ein sehr einfaches Projekt erzeugt wird. Falls Sie das noch nicht getan haben, sollten Sie sich den generierten Code in Ihrem Projekt genauer ansehen. Bedenken Sie bei Ihrer Erkundung, dass der generierte C++-Code in zukünftigen Versionen von Unity anders aussehen wird, da wir ständig daran arbeiten, die Build- und Laufzeitleistung des IL2CPP-Skript-Backends zu verbessern.
Durch die Konvertierung von AWL-Code in C++ konnten wir eine gute Balance zwischen portierbarem und performantem Code erzielen. Wir können viele der netten, entwicklerfreundlichen Funktionen von verwaltetem Code nutzen und gleichzeitig die Vorteile von hochwertigem Maschinencode erhalten, die der C++-Compiler für verschiedene Plattformen bietet.
In zukünftigen Beiträgen werden wir mehr generierten Code untersuchen, darunter Methodenaufrufe, die gemeinsame Nutzung von Methodenimplementierungen und Wrapper für Aufrufe von nativen Bibliotheken. Aber beim nächsten Mal werden wir einige der generierten Codes für ein iOS 64-Bit-Build mit Xcode debuggen.
