Erstellen einer Programmiersprache mit LLVM. Teil 10: Fazit und andere LLVM-Goodies

Ursprünglicher Autor: Chris Lattner
  • Übersetzung
  • Tutorial
Inhaltsverzeichnis:
Teil 1: Einführung und lexikalische Analyse
Teil 2: Implementierung des Parsers und des AST
Teil 3: Generieren des LLVM-IR-Codes
Teil 4: Hinzufügen der JIT- und Optimierungsunterstützung
Teil 5: Spracherweiterung: Kontrollfluss
Teil 6: Spracherweiterung: Benutzerdefinierte Operatoren
Teil 7: Erweiterung der Sprache: Variable Variablen
Teil 8: Kompilieren in Objektcode
Teil 9: Hinzufügen von Debugging-Informationen
Teil 10: Fazit und andere Goodies LLVM



9.1. Fazit


Willkommen zum letzten Teil des Tutorials „Erstellen einer Programmiersprache mit LLVM“. In diesem Tutorial haben wir unsere kleine Kaleidoskopsprache von einem nutzlosen Spielzeug zu einem ziemlich interessanten (obwohl vielleicht immer noch nutzlosen) Spielzeug ausgebaut.

Es ist interessant zu sehen, wie weit wir gekommen sind und wie wenig Code dies erfordert hat. Wir haben einen vollständigen lexikalischen Analysator, Parser, AST, Codegenerator, interaktive Ausführung (mit JIT!) Und die Erzeugung von Debugging-Informationen in einer eigenständigen ausführbaren Datei erstellt - dies alles sind weniger als 1000 Zeilen WAD (ohne Leerzeilen und Kommentare).

Unsere kleine Sprache unterstützt einige interessante Funktionen: Sie unterstützt benutzerdefinierte binäre und unäre Operatoren, verwendet die JIT-Kompilierung zur sofortigen Ausführung und unterstützt einige Flusssteuerungskonstruktionen durch Generieren von Code in einem SSA-Formular.

Teil der Idee in diesem Handbuch war es, Ihnen zu zeigen, wie einfach es ist, die Sprache zu definieren, zu erstellen und mit ihr zu spielen. Das Erstellen eines Compilers muss kein beängstigender oder mystischer Prozess sein! Nachdem Sie die Grundlagen kennengelernt haben, empfehle ich dringend, den Code zu verwenden und damit umzugehen. Versuchen Sie beispielsweise, Folgendes hinzuzufügen:

Globale Variablen - Obwohl der Wert globaler Variablen in der modernen Softwareentwicklung fraglich ist, werden sie häufig für kleine schnelle Hacks verwendet, wie z. B. den Kaleidoscope-Compiler selbst. Glücklicherweise ist das Hinzufügen globaler Variablen zu unserem Programm sehr einfach: Wir überprüfen einfach für jede Variable, ob sie in der globalen Symboltabelle enthalten ist. Dafür. Erstellen Sie eine Instanz der LLVM GlobalVariable-Klasse, um eine neue globale Variable zu erstellen.
typisierte Variablen - jetzt unterstützt Kaleidoscope nur doppelte Variablen. Dies macht die Sprache sehr elegant, da die Unterstützung für nur einen Typ bedeutet, dass Sie keine Variablentypen angeben müssen. Verschiedene Sprachen haben verschiedene Möglichkeiten, um dieses Problem zu lösen. Am einfachsten ist es, den Benutzer aufzufordern, einen Typ für jede Variablendefinition anzugeben und die Variablentypen zusammen mit ihrem Wert * in die Symboltabelle zu schreiben.
Arrays, Strukturen, Vektoren usw. Wenn Sie Typen eingeben, können Sie das Typensystem in verschiedene interessante Richtungen erweitern. Einfache Arrays können für verschiedene Arten von Anwendungen sehr einfach und nützlich gestaltet werden. Fügen Sie sie als Übung hinzu, um zu erfahren, wie die LLVM-Anweisung getelementptr funktioniert: Sie ist so elegant und ungewöhnlich, dass sie eine eigene FAQ hat!
Standardlaufzeit - In der aktuellen Form bietet die Sprache dem Benutzer die Möglichkeit, auf beliebige externe Funktionen zuzugreifen, und wir verwenden diese für Dinge wie "printd" und "putchard". Sie können die Sprache erweitern, um übergeordnete Konstrukte hinzuzufügen. Oft ist es sinnvoll, solche Konstrukte in Laufzeitfunktionen einzubinden, anstatt sie in Form von Inline-Befehlssequenzen zu erstellen.
Speicherverwaltung - jetzt gibt es in der Kaleidoscope-Sprache nur noch Zugriff auf den Stack. Es ist auch nützlich, Speicher auf dem Heap zuzuweisen, indem Sie entweder die Standardschnittstellen libc malloc / free aufrufen oder den Garbage Collector verwenden. Wenn Sie den Garbage Collector bevorzugen, wird die genaue Garbage Collection von LLVM vollständig unterstützt, einschließlich Algorithmen zum Verschieben von Objekten und die zum Scannen / Aktualisieren des Stapels erforderlich sind.
Ausnahmenunterstützung - LLVM unterstützt die Generierung von Ausnahmen ohne Kosten und mit der Fähigkeit, mit in anderen Sprachen kompiliertem Code zu interagieren. Sie können auch Code generieren, der impliziert, dass jede Funktion einen Fehlerwert zurückgibt und dies überprüft. Sie können Ausnahmen auch explizit mit setjmp / longjmp implementieren. Im Allgemeinen gibt es viele verschiedene Möglichkeiten.
OOP, verallgemeinerte Typen, Zugriff auf Datenbanken, komplexe Zahlen, geometrische Programmierung ... in der Tat gibt es kein Ende für die verrückten Dinge, die der Sprache hinzugefügt werden können.
ungewöhnliche Anwendungen - Wir sprachen über die Verwendung von LLVM in einem Bereich, an dem viele interessiert sind: dem Erstellen eines Compilers für eine bestimmte Sprache. Es gibt jedoch viele andere Bereiche, in denen der Einsatz des Compilers auf den ersten Blick nicht berücksichtigt wird. Zum Beispiel wird LLVM verwendet, um OpenGL-Grafiken zu beschleunigen, C ++ - Code in ActionScript zu übersetzen und viele andere interessante Dinge. Vielleicht sind Sie der Erste, der einen JIT-Compiler mit LLVM in nativen Code für reguläre Ausdrücke umwandelt?
Vergnügen - versuche etwas Verrücktes und Ungewöhnliches zu tun. Eine Sprache so zu machen wie alle anderen, ist nicht so lustig wie etwas Verrücktes zu machen. Wenn Sie darüber sprechen möchten, schreiben Sie bitte an die Mailingliste llvm-dev: Es gibt viele Leute, die sich für Sprachen interessieren und oft helfen möchten.

Bevor wir das Tutorial beenden, möchte ich einige Tipps zum Generieren von LLVM-IR geben. Es gibt einige Feinheiten, die möglicherweise nicht auf der Hand liegen, aber sehr nützlich sind, wenn Sie die Funktionen von LLVM nutzen möchten.

10.2. LLVM IR-Eigenschaften


Es gibt einige Fragen zum LLVM-IR-Code, die wir uns jetzt ansehen.

10.2.1. Zielplattform Unabhängigkeit


Ein Kaleidoskop ist ein Beispiel für eine "tragbare Sprache": Jedes auf einem Kaleidoskop geschriebene Programm funktioniert auf jeder Zielplattform, auf der es gestartet wird, genauso. Viele andere Sprachen haben dieselbe Eigenschaft, z. B. Lisp, Java, Haskell, Javascript, Python usw. (Beachten Sie, dass diese Sprachen zwar portierbar sind, jedoch nicht alle Bibliotheken portierbar sind.)

Ein guter Aspekt von LLVM ist die Unabhängigkeit von der Zielplattform auf IR-Ebene: Sie können die LLVM-IR für das von Kaleidoscope kompilierte Programm verwenden und auf jeder von LLVM unterstützten Zielplattform ausführen, sogar C-Code generieren und auf den Zielplattformen kompilieren, die Kaleidoscope nicht unterstützt unterstützt nativ. Wir können sagen, dass der Kaleidoscope-Compiler plattformunabhängigen Code generiert, da er beim Generieren des Codes keine Informationen über die Plattform anfordert.

Die Tatsache, dass LLVM eine kompakte, plattformunabhängige Codedarstellung bietet, ist sehr attraktiv. Leider denken die Leute oft nur an das Kompilieren von C- oder C-ähnlichen Sprachen, wenn sie nach der Portabilität von Sprachen fragen. Ich habe "leider" gesagt, weil es in der Tat (im allgemeinen Fall) unmöglich ist, den C-Code portierbar zu machen, weil der C-Quellcode im allgemeinen Fall natürlich nicht portierbar ist, selbst im Falle einer Portierung Anwendungen von 32 bis 64 Bit.

Das Problem mit C (im Allgemeinen) ist, dass es stark von plattformspezifischen Annahmen abhängt. Als einfaches Beispiel macht ein Präprozessor die Codeplattform abhängig, wenn er den folgenden Text verarbeitet:
#ifdef __i386__
  int X = 1;
#else
  int X = 42;
#endif

Obwohl es möglich ist, dieses Problem auf verschiedene komplexe Arten zu lösen, kann es nicht allgemein gelöst werden.

Eine Teilmenge von C kann jedoch portabel gemacht werden. Wenn Sie primitive Typen mit einer festen Größe erstellen (z. B. int = 32 Bit, long = 64 Bit), sich keine Gedanken über die ABI-Kompatibilität mit vorhandenen Binärdateien machen und auf einige andere Funktionen verzichten, können Sie portablen Code erhalten. Dies ist in einigen Sonderfällen sinnvoll.

10.2.2. Sicherheitsgarantien


Viele der genannten Sprachen sind auch "sicher": Es ist unmöglich, dass ein in Java geschriebenes Programm den Adressraum verderbt und den Prozess löscht (vorausgesetzt, die JVM hat keine Fehler). Sicherheit ist eine interessante Funktion, die eine Kombination aus Sprachdesign, Laufzeitunterstützung und häufig auch Betriebssystemunterstützung erfordert.

Es ist definitiv möglich, eine sichere Sprache in LLVM zu implementieren, aber LLVM IR allein garantiert keine Sicherheit. LLVM IR ermöglicht unsichere Zeigerkonvertierungen, Speichernutzung nach der Freigabe, Pufferüberläufe und verschiedene andere Probleme. Sicherheit sollte auf einer höheren Ebene als LLVM implementiert werden, und glücklicherweise haben mehrere Gruppen dieses Problem untersucht. Fragen Sie auf der Mailingliste von llvm-dev nach, ob Sie an den Details interessiert sind.

10.2.3. Sprachspezifische Optimierungen


Es gibt eine Sache in LLVM, die viele nicht mögen: Es löst nicht alle Probleme der Welt in einem System (sorry, hungernde Kinder, jemand anderes sollte Ihr Problem lösen, nicht heute). Eine von LLVM vorgebrachte Beschwerde ist, dass es nicht in der Lage ist, eine sprachspezifische Optimierung auf hoher Ebene durchzuführen: LLVM "verliert zu viele Informationen."

Leider gibt es keinen Ort, an dem Sie eine vollständige und universelle Version der "Theorie des Compiler-Designs" schreiben können. Stattdessen möchte ich einige Bemerkungen machen:

Das erste ist wahr, LLVM verliert Informationen. Beispielsweise kann auf LLVM-IR-Ebene nicht unterschieden werden, ob der SSA-Wert vom Typ C "int" oder "long" auf dem ILP32-Computer generiert wurde (mit Ausnahme von Debuginformationen). Beide werden zu einem Wert vom Typ "i32" kompiliert, und Informationen zum Quelltyp gehen verloren. Ein allgemeineres Problem besteht darin, dass das LLVM-Typsystem Typen mit derselben Struktur und nicht mit demselben Namensäquivalent berücksichtigt. Dies ist eine andere Sache, die Leute überrascht, wenn Sie zwei Typen in einer Hochsprache haben, die dieselbe Struktur haben (zum Beispiel zwei verschiedene Strukturen mit einem int-Feld): Diese Typen werden in einen LLVM-Typ kompiliert und es wird unmöglich sein Sagen Sie, zu welcher Anfangsstruktur die Variablen gehörten.
Zweitens, obwohl LLVM Informationen verliert, hat es keine feste Zielplattform: Wir erweitern und verbessern diese weiterhin in verschiedene Richtungen. Wir fügen neue Funktionen hinzu (LLVM unterstützte nicht immer Ausnahmen oder Debugging-Informationen), erweitern IR, um wichtige Informationen für die Optimierung zu erfassen (unabhängig davon, ob das Argument mit Nullen oder einem vorzeichenbehafteten Bit erweitert wurde, Informationen zum Pointer-Aliasing usw.). Viele Verbesserungen werden von den Benutzern initiiert: Die Benutzer möchten, dass LLVM bestimmte Funktionen aufweist, und wir gehen auf sie zu.
Drittens können leicht sprachspezifische Optimierungen hinzugefügt werden, und es gibt eine Reihe von Möglichkeiten, dies zu tun. Ein einfaches Beispiel ist das Hinzufügen eines Optimierungspasses, der verschiedene Dinge über den Quellcode "kennt". Bei C-ähnlichen Sprachen „kennt" dieser Optimierungspass die Funktionen der Standardbibliothek C. Wenn Sie in main () die Funktion „exit (0)" aufrufen, weiß er, dass der Aufruf sicher in „return 0" konvertiert werden kann, weil Standard C beschreibt, was die Exit-Funktion tun soll.

Zusätzlich zu einfachen Kenntnissen der Bibliothek ist es möglich, verschiedene andere sprachspezifische Informationen in LLVM IR einzubetten. Wenn Sie spezielle Anforderungen haben, schreiben Sie bitte an die Mailingliste llvm-dev. Im schlimmsten Fall können Sie LLVM als „dummen Code-Generator“ betrachten und die von Ihnen gewünschten allgemeinen Optimierungen in Ihrem Frontend in Ihrem sprachspezifischen AST implementieren.

10.3. Tricks und Tricks


Es gibt verschiedene nützliche Tricks und Tricks, zu denen Sie kommen, nachdem Sie mit / an LLVM gearbeitet haben und die auf den ersten Blick nicht offensichtlich sind. Damit nicht jeder sie wieder entdeckt, ist dieser Abschnitt einigen von ihnen gewidmet.

10.3.1. Implementieren von portablem Offsetof / Sizeof


Eine interessante Sache ist, dass Sie, wenn Sie versuchen, den von Ihrem Compiler generierten Code „plattformunabhängig“ zu halten, die Größe der LLVM-Typen und den Versatz der spezifischen Unteransichten in den Strukturen kennen müssen. Beispielsweise können Sie die Schriftgröße an eine Funktion übergeben, die Speicher zuweist.
Leider kann die Größe der Typen je nach Plattform sehr unterschiedlich sein: Die Größe des Zeigers ist das einfachste Beispiel. Eine clevere Möglichkeit, solche Probleme zu lösen, ist die Verwendung der Anweisung getelementptr .

10.3.2. Garbage-Collector-Stack-Frames


Einige Sprachen möchten Stapelrahmen explizit steuern, häufig aufgrund des Vorhandenseins eines Garbage Collectors oder um die Implementierung von Closures zu vereinfachen. Oft gibt es bessere Möglichkeiten, diese Funktionen zu implementieren, als die explizite Stapelrahmenverwaltung. LLVM unterstützt dies jedoch, wenn Sie dies möchten. Dazu muss Ihr Frontend den Code in Continuation Passing Style konvertieren und Tail Calls verwenden (von LLVM ebenfalls unterstützt).

Jetzt auch beliebt: