GCC (GNU Compiler Collection) und Clang gehören zu den bedeutendsten C++-Compilern in der Softwareentwicklung. Beide verfolgen eine eigene Designphilosophie und Architektur, die unterschiedliche Anforderungen und Präferenzen adressiert. Dieser Artikel untersucht die grundlegenden Designunterschiede zwischen GCC und Clang und zeigt, wie sie sich auf Funktionalität, Leistung und Benutzerfreundlichkeit auswirken.
Unterschiede im Architekturdesign
- Clang: Das Design von Clang ist stark modular aufgebaut. Es besteht aus einer Reihe klar definierter Bibliotheken für Frontend, Middle-End und Backend, die unabhängig voneinander oder gemeinsam genutzt werden können. Diese Modularität erleichtert die Erweiterung und Wartung von Clang. LibClang stellt zudem eine stabile C-Schnittstelle zu den Clang-Bibliotheken bereit und vereinfacht damit die Entwicklung von Tools und IDE-Integrationen.
- GCC: GCC wurde ursprünglich als monolithischer Compiler mit eng gekoppelten Komponenten konzipiert. Zwar ist die Architektur im Laufe der Zeit modularer geworden, sie bleibt jedoch weniger modular als die von Clang. GCC unterstützt Plugins, lässt sich aufgrund seiner Architektur aber schwieriger erweitern als das flexiblere Clang.
Sehen wir uns das Design von Clang genauer an, um zu verstehen, warum seine Architektur das Hinzufügen neuer Funktionen und Plugins erleichtert.
Das Design von Clang
Wie viele andere Compiler ist auch Clang in drei Phasen gegliedert:
- Das Frontend analysiert den Quellcode, prüft ihn auf Fehler und erstellt einen sprachspezifischen Abstract Syntax Tree (AST), der den Eingabecode repräsentiert.
- Der Optimierer führt Optimierungen auf Grundlage des vom Frontend erzeugten AST durch.
- Das Backend erzeugt den endgültigen Maschinencode für die Zielarchitektur.

Was unterscheidet Clang von anderen Compilern?
Der wichtigste Unterschied im Design besteht darin, dass Clang auf LLVM basiert. Ein zentrales Konzept von LLVM ist die LLVM Intermediate Representation (IR), die sich mit dem Bytecode von Java vergleichen lässt.
LLVM IR wurde für Analysen und Transformationen auf mittlerer Ebene entwickelt, wie sie im Optimierer eines Compilers stattfinden. Zu den Designzielen gehören leichtgewichtige Laufzeitoptimierungen, funktionsübergreifende bzw. interprozedurale Optimierungen, Ganzprogrammanalysen und weitreichende Restrukturierungen. Besonders wichtig ist jedoch, dass LLVM IR selbst als vollwertige Sprache mit klar definierter Semantik spezifiziert ist.
Durch dieses Design lässt sich ein großer Teil des Compilers für andere Compiler wiederverwenden. So kann beispielsweise lediglich das Frontend ausgetauscht werden, um andere Sprachen zu verarbeiten.

I – Frontend
Clang ist modular aufgebaut, und jede Kompilierungsphase wird von einem eigenen Modul übernommen. An der Frontend-Phase sind unter anderem folgende Projekte beteiligt:

Wie bei jedem Frontend-Parser werden ein Lexer und eine semantische Analyse benötigt. Das Clang-Frontend kann mit dem Argument -cc1 ausgeführt werden und bietet mehrere Funktionen, darunter die Erzeugung des AST:
clang -cc1 -ast-dump test.c
Dieser Befehl wird von der Funktion cc1_main verarbeitet. Die folgende Sequenz zeigt einige der interessanten Methoden, die dabei ausgeführt werden:

Die Methode ExecuteAction besitzt einen Parameter vom Typ FrontEndAction; damit wird festgelegt, welche Frontend-Aktion ausgeführt werden soll. FrontEndAction ist eine abstrakte Klasse; für eine konkrete Frontend-Aktion muss davon abgeleitet werden.
Ermitteln wir mit CQLinq, welche Frontend-Aktionen Clang implementiert. Dazu suchen wir nach allen Klassen, die direkt oder indirekt davon erben.
from t in Types
let depth0 = t.DepthOfDeriveFrom(“clang.FrontendAction”)
where depth0 >= 0 orderby depth0
select new { t, depth0 }

Es stehen zahlreiche Frontend-Aktionen zur Verfügung. ASTDumpAction erzeugt beispielsweise den AST, ohne eine endgültige ausführbare Datei zu erstellen. Fast alle Frontend-Aktionen erben von ASTFrontEndAction und arbeiten somit mit dem erzeugten AST.
Das Interessante an diesem Design ist, dass sich eine eigene FrontEndAction sehr einfach einbinden lässt: Es muss lediglich eine neue Implementierung erstellt werden.
Wie können wir den AST weiterverarbeiten?
Jede ASTFrontEndAction erzeugt eine oder mehrere ASTConsumer-Instanzen. ASTConsumer ist eine abstrakte Klasse; für spezifische Anforderungen kann ein eigener AST-Consumer implementiert werden.
Die FrontEndAction ruft den AST-Consumer entsprechend dem folgenden Abhängigkeitsgraphen auf.

Suchen wir mit CQLinq:
from t in Types
let depth0 = t.DepthOfDeriveFrom(“clang.ASTConsumer”)
where depth0 == 1
select new { t, depth0 }

CodeGenerator ist ein Beispiel für einen AST-Consumer. Wie bereits erwähnt, liegt eine Stärke von LLVM in der Verwendung der IR, deren Erzeugung eine Analyse des AST voraussetzt. CodeGenerator erbt von ASTConsumer und ist für die Erzeugung der IR verantwortlich. Bemerkenswert ist, dass diese Verarbeitung in einem eigenen Projekt namens ClangCodeGen isoliert ist.
Hier sind einige der Klassen, die an der Erzeugung der LLVM IR beteiligt sind:

II – Optimierer
Diese Phase lässt sich kaum besser erklären als von Chris Lattner, dem Vater von LLVM, in diesem Beitrag:
„Um ein Gefühl dafür zu vermitteln, wie Optimierungen funktionieren, lohnt es sich, einige Beispiele durchzugehen. Es gibt viele verschiedene Arten von Compiler-Optimierungen, daher lässt sich kaum ein allgemeines Rezept für beliebige Probleme angeben. Die meisten Optimierungen folgen jedoch einer einfachen dreiteiligen Struktur:
- Nach einem Muster suchen, das transformiert werden soll.
- Prüfen, ob die Transformation für die gefundene Instanz sicher und korrekt ist.
- Die Transformation durchführen und den Code aktualisieren.
Der Optimierer liest LLVM IR ein, verarbeitet sie und gibt anschließend wieder LLVM IR aus, die im Idealfall schneller ausgeführt wird. In LLVM – wie auch in vielen anderen Compilern – ist der Optimierer als Pipeline einzelner Optimierungspässe organisiert. Jeder Pass wird auf die Eingabe angewendet und kann Veränderungen vornehmen. Typische Beispiele sind Inlining, bei dem der Funktionsrumpf an Aufrufstellen eingesetzt wird, die Neuzuordnung von Ausdrücken oder das Verschieben schleifeninvarianter Berechnungen. Je nach Optimierungsstufe werden unterschiedliche Pässe ausgeführt: Bei -O0 führt Clang keine Optimierungspässe aus, während bei -O3 eine Reihe von 67 Pässen durchlaufen wird (Stand LLVM 2.8).
Untersuchen wir die LLVMCore-Pässe, indem wir nach Klassen suchen, die von der Klasse „pass“ erben.
from t in Types
let depth0 = t.DepthOfDeriveFrom(“llvm.Pass”)
where t.ParentProject.Name==”LLVMCore” && depth0 >= 0 orderby depth0
select new { t, depth0 }

Natürlich existieren in anderen LLVM-Modulen zahlreiche weitere Pässe.
III – Backend
Wie die anderen Phasen ist auch das Backend für die Erzeugung der Ausgabe für ein bestimmtes Ziel verantwortlich. Bei Clang ist diese Phase stark modular aufgebaut. LLVMX86Target erzeugt beispielsweise Code für die x86-Zielarchitektur.
Der folgende Graph zeigt alle Module, die an der Erzeugung von Binärdateien für die x86-Zielarchitektur beteiligt sind.

An dieser Phase sind viele Module beteiligt, die jeweils eine klar abgegrenzte Aufgabe übernehmen. Das fördert Kohäsion, saubere APIs und eine klare Trennung der Verantwortlichkeiten. Für Entwickler wird das System dadurch leichter verständlich, weil sie sich auf einzelne Teile des Gesamtbildes konzentrieren können.
Fazit
LLVM/Clang ist nicht nur ein C/C++-Compiler, sondern zugleich eine Infrastruktur für die Entwicklung von Tools, deren Verhalten sich leicht erweitern lässt. Zahlreiche Werkzeuge sind bereits im LLVM/Clang-Quellcode enthalten, viele weitere sind im Web verfügbar.
Wenn Sie für die Entwicklung eines Tools einen C/C++-Parser benötigen, ist Clang eine sehr gute Wahl.
