Wie funktioniert der Kompilierungs- / Verknüpfungsprozess?


416

Wie funktioniert der Kompilierungs- und Verknüpfungsprozess?

(Hinweis: Dies ist als Eintrag in die C ++ - FAQ von Stack Overflow gedacht . Wenn Sie die Idee kritisieren möchten, eine FAQ in dieser Form bereitzustellen, ist die Veröffentlichung auf Meta, mit der all dies begonnen hat , der richtige Ort dafür. Antworten auf Diese Frage wird im C ++ - Chatroom überwacht, in dem die FAQ-Idee ursprünglich begann, sodass Ihre Antwort sehr wahrscheinlich von denjenigen gelesen wird, die auf die Idee gekommen sind.)

Antworten:


554

Die Kompilierung eines C ++ - Programms umfasst drei Schritte:

  1. Vorverarbeitung: Der Präprozessor nimmt eine C ++ - Quellcodedatei und behandelt die Anweisungen #includes, #defines und andere Präprozessoren. Die Ausgabe dieses Schritts ist eine "reine" C ++ - Datei ohne Vorprozessoranweisungen.

  2. Kompilierung: Der Compiler nimmt die Ausgabe des Vorprozessors und erstellt daraus eine Objektdatei.

  3. Verknüpfen: Der Linker nimmt die vom Compiler erstellten Objektdateien und erstellt entweder eine Bibliothek oder eine ausführbare Datei.

Vorverarbeitung

Der Präprozessor verarbeitet die Präprozessoranweisungen wie #includeund #define. Es ist unabhängig von der Syntax von C ++, weshalb es mit Vorsicht verwendet werden muss.

Es funktioniert auf einer C ++ Quelldatei zu einem Zeitpunkt durch Ersetzen #includeRichtlinien mit dem Inhalt der jeweiligen Dateien (die in der Regel nur Erklärungen sind), von Makros (tun Ersatz #define), und die Auswahl unterschiedliche Teile des Textes in Abhängigkeit von #if, #ifdefund #ifndefRichtlinien.

Der Präprozessor arbeitet mit einem Strom von Vorverarbeitungstoken. Makrosubstitution ist definiert als Ersetzen von Token durch andere Token (der Operator ##ermöglicht das Zusammenführen von zwei Token, wenn dies sinnvoll ist).

Nach alledem erzeugt der Präprozessor eine einzelne Ausgabe, die ein Strom von Token ist, die aus den oben beschriebenen Transformationen resultieren. Außerdem werden einige spezielle Markierungen hinzugefügt, die dem Compiler mitteilen, woher die einzelnen Zeilen stammen, damit diese sinnvolle Fehlermeldungen erzeugen können.

In dieser Phase können durch geschickte Verwendung der Direktiven #ifund einige Fehler auftreten #error.

Zusammenstellung

Der Kompilierungsschritt wird an jedem Ausgang des Präprozessors ausgeführt. Der Compiler analysiert den reinen C ++ - Quellcode (jetzt ohne Präprozessoranweisungen) und konvertiert ihn in Assemblycode. Ruft dann das zugrunde liegende Back-End (Assembler in der Toolchain) auf, das diesen Code zu Maschinencode zusammensetzt und eine tatsächliche Binärdatei in einem bestimmten Format (ELF, COFF, a.out, ...) erzeugt. Diese Objektdatei enthält den kompilierten Code (in binärer Form) der in der Eingabe definierten Symbole. Symbole in Objektdateien werden mit Namen bezeichnet.

Objektdateien können sich auf Symbole beziehen, die nicht definiert sind. Dies ist der Fall, wenn Sie eine Deklaration verwenden und keine Definition dafür angeben. Der Compiler hat nichts dagegen und wird die Objektdatei gerne erstellen, solange der Quellcode wohlgeformt ist.

Mit Compilern können Sie die Kompilierung normalerweise an dieser Stelle beenden. Dies ist sehr nützlich, da Sie damit jede Quellcodedatei separat kompilieren können. Dies bietet den Vorteil, dass Sie nicht alles neu kompilieren müssen, wenn Sie nur eine einzelne Datei ändern.

Die erstellten Objektdateien können in speziellen Archiven, so genannten statischen Bibliotheken, abgelegt werden, um sie später leichter wiederverwenden zu können.

In diesem Stadium werden "normale" Compilerfehler wie Syntaxfehler oder fehlgeschlagene Überlastungsauflösungsfehler gemeldet.

Verknüpfen

Der Linker erzeugt die endgültige Kompilierungsausgabe aus den vom Compiler erstellten Objektdateien. Diese Ausgabe kann entweder eine gemeinsam genutzte (oder dynamische) Bibliothek sein (und obwohl der Name ähnlich ist, haben sie mit den zuvor erwähnten statischen Bibliotheken nicht viel gemeinsam) oder eine ausführbare Datei.

Es verknüpft alle Objektdateien, indem die Verweise auf undefinierte Symbole durch die richtigen Adressen ersetzt werden. Jedes dieser Symbole kann in anderen Objektdateien oder in Bibliotheken definiert werden. Wenn sie in anderen Bibliotheken als der Standardbibliothek definiert sind, müssen Sie dem Linker davon erzählen.

Zu diesem Zeitpunkt sind die häufigsten Fehler fehlende Definitionen oder doppelte Definitionen. Ersteres bedeutet, dass entweder die Definitionen nicht vorhanden sind (dh nicht geschrieben sind) oder dass die Objektdateien oder Bibliotheken, in denen sie sich befinden, nicht an den Linker übergeben wurden. Letzteres ist offensichtlich: Das gleiche Symbol wurde in zwei verschiedenen Objektdateien oder Bibliotheken definiert.


39
In der Kompilierungsphase wird auch Assembler aufgerufen, bevor in eine Objektdatei konvertiert wird.
Manav Mn

3
Wo werden Optimierungen angewendet? Auf den ersten Blick scheint es so, als würde es im Kompilierungsschritt gemacht, aber andererseits kann ich mir vorstellen, dass eine ordnungsgemäße Optimierung nur nach dem Verknüpfen erfolgen kann.
Bart van Heukelom

6
@BartvanHeukelom wurde traditionell während der Kompilierung durchgeführt, aber moderne Compiler unterstützen die sogenannte "Link-Time-Optimierung", die den Vorteil hat, dass sie über mehrere Übersetzungseinheiten hinweg optimiert werden kann.
R. Martinho Fernandes

3
Hat C die gleichen Schritte?
Kevin Zhu

6
Wenn der Linker Symbole, die sich auf Klassen / Methoden in Bibliotheken beziehen, in Adressen konvertiert, bedeutet dies, dass Bibliotheksbinärdateien in Speicheradressen gespeichert werden, die das Betriebssystem konstant hält? Ich bin nur verwirrt darüber, wie der Linker die genaue Adresse beispielsweise der stdio-Binärdatei für alle Zielsysteme kennen würde. Der Dateipfad wäre immer der gleiche, aber die genaue Adresse kann sich ändern, oder?
Dan Carter

42

Dieses Thema wird unter CProgramming.com behandelt:
https://www.cprogramming.com/compilingandlinking.html

Folgendes hat der Autor dort geschrieben:

Kompilieren ist nicht ganz dasselbe wie das Erstellen einer ausführbaren Datei! Stattdessen ist das Erstellen einer ausführbaren Datei ein mehrstufiger Prozess, der in zwei Komponenten unterteilt ist: Kompilieren und Verknüpfen. Selbst wenn ein Programm "gut kompiliert" wird, funktioniert es in der Realität möglicherweise aufgrund von Fehlern während der Verknüpfungsphase nicht. Der gesamte Prozess des Wechsels von Quellcodedateien zu einer ausführbaren Datei wird möglicherweise besser als Build bezeichnet.

Zusammenstellung

Die Kompilierung bezieht sich auf die Verarbeitung von Quellcodedateien (.c, .cc oder .cpp) und die Erstellung einer 'Objekt'-Datei. Dieser Schritt erstellt nichts, was der Benutzer tatsächlich ausführen kann. Stattdessen erstellt der Compiler lediglich die Maschinensprachenanweisungen, die der kompilierten Quellcodedatei entsprechen. Wenn Sie beispielsweise drei separate Dateien kompilieren (aber nicht verknüpfen), werden drei Objektdateien als Ausgabe mit dem Namen .o oder .obj erstellt (die Erweiterung hängt von Ihrem Compiler ab). Jede dieser Dateien enthält eine Übersetzung Ihrer Quellcodedatei in eine Maschinensprachendatei - Sie können sie jedoch noch nicht ausführen! Sie müssen sie in ausführbare Dateien umwandeln, die Ihr Betriebssystem verwenden kann. Hier kommt der Linker ins Spiel.

Verknüpfen

Das Verknüpfen bezieht sich auf die Erstellung einer einzelnen ausführbaren Datei aus mehreren Objektdateien. In diesem Schritt beschwert sich der Linker häufig über undefinierte Funktionen (normalerweise main selbst). Wenn der Compiler während der Kompilierung die Definition für eine bestimmte Funktion nicht finden konnte, wird lediglich davon ausgegangen, dass die Funktion in einer anderen Datei definiert wurde. Wenn dies nicht der Fall ist, würde der Compiler es auf keinen Fall wissen - er betrachtet nicht den Inhalt von mehr als einer Datei gleichzeitig. Der Linker hingegen kann mehrere Dateien anzeigen und versuchen, Referenzen für die Funktionen zu finden, die nicht erwähnt wurden.

Möglicherweise fragen Sie sich, warum es separate Kompilierungs- und Verknüpfungsschritte gibt. Erstens ist es wahrscheinlich einfacher, Dinge auf diese Weise zu implementieren. Der Compiler macht sein Ding und der Linker macht sein Ding - indem die Funktionen getrennt bleiben, wird die Komplexität des Programms reduziert. Ein weiterer (offensichtlicherer) Vorteil besteht darin, dass so große Programme erstellt werden können, ohne dass der Kompilierungsschritt bei jeder Änderung einer Datei wiederholt werden muss. Stattdessen müssen bei Verwendung der sogenannten "bedingten Kompilierung" nur die Quelldateien kompiliert werden, die sich geändert haben. Im Übrigen sind die Objektdateien ausreichend für den Linker. Dies macht es schließlich einfach, Bibliotheken mit vorkompiliertem Code zu implementieren: Erstellen Sie einfach Objektdateien und verknüpfen Sie sie wie jede andere Objektdatei.

Um alle Vorteile der Bedingungskompilierung nutzen zu können, ist es wahrscheinlich einfacher, ein Programm zu finden, das Ihnen hilft, als sich zu merken, welche Dateien Sie seit der letzten Kompilierung geändert haben. (Sie können natürlich auch jede Datei neu kompilieren, deren Zeitstempel größer als der Zeitstempel der entsprechenden Objektdatei ist.) Wenn Sie mit einer integrierten Entwicklungsumgebung (IDE) arbeiten, wird dies möglicherweise bereits für Sie erledigt. Wenn Sie Befehlszeilentools verwenden, gibt es ein nützliches Dienstprogramm namens make, das mit den meisten * nix-Distributionen geliefert wird. Neben der bedingten Kompilierung bietet es einige weitere nützliche Funktionen zum Programmieren, z. B. das Ermöglichen verschiedener Kompilierungen Ihres Programms - beispielsweise, wenn Sie eine Version haben, die eine ausführliche Ausgabe zum Debuggen erzeugt.

Wenn Sie den Unterschied zwischen der Kompilierungsphase und der Verknüpfungsphase kennen, können Sie leichter nach Fehlern suchen. Compilerfehler sind normalerweise syntaktischer Natur - ein fehlendes Semikolon, eine zusätzliche Klammer. Verknüpfungsfehler haben normalerweise mit fehlenden oder mehreren Definitionen zu tun. Wenn Sie vom Linker die Fehlermeldung erhalten, dass eine Funktion oder Variable mehrmals definiert wurde, ist dies ein guter Hinweis darauf, dass der Fehler darin besteht, dass zwei Ihrer Quellcodedateien dieselbe Funktion oder Variable haben.


1
Was ich nicht verstehe ist, dass, wenn der Präprozessor Dinge wie #includes verwaltet, um eine Super-Datei zu erstellen, es danach sicher nichts mehr zu verknüpfen gibt?
Binarysmacker

@binarysmacer Sehen Sie, ob das, was ich unten geschrieben habe, für Sie Sinn macht. Ich habe versucht, das Problem von innen heraus zu beschreiben.
Elliptische Ansicht

3
@binarysmacker Es ist zu spät, dies zu kommentieren, aber andere finden dies möglicherweise nützlich. youtu.be/D0TazQIkc8Q Grundsätzlich schließen Sie Header-Dateien ein, und diese Header-Dateien enthalten im Allgemeinen nur die Deklarationen von Variablen / Funktionen und nicht deren Definitionen. Definitionen können in einer separaten Quelldatei vorhanden sein. Der Präprozessor enthält also nur Deklarationen und keine Definitionen Linker-Hilfen. Sie verknüpfen die Quelldatei, die die Variable / Funktion verwendet, mit der Quelldatei, die sie definiert.
Karan Joisher

24

Auf der Standardfront:

  • Eine Übersetzungseinheit ist die Kombination von Quelldateien, enthaltenen Headern und Quelldateien abzüglich aller Quellzeilen, die von der Präprozessor-Direktive für bedingte Einbeziehung übersprungen werden.

  • Der Standard definiert 9 Phasen in der Übersetzung. Die ersten vier entsprechen der Vorverarbeitung, die nächsten drei sind die Kompilierung, die nächste ist die Instanziierung von Vorlagen (die Instanziierungseinheiten erzeugen ) und die letzte ist die Verknüpfung.

In der Praxis wird die achte Phase (die Instanziierung von Vorlagen) häufig während des Kompilierungsprozesses durchgeführt, aber einige Compiler verzögern sie auf die Verknüpfungsphase und einige verteilen sie auf beide.


14
Könnten Sie alle 9 Phasen auflisten? Das wäre eine schöne Ergänzung zur Antwort, denke ich. :)
Jalf


@jalf, füge einfach die Vorlageninstanziierung kurz vor der letzten Phase der Antwort hinzu, auf die @sbi zeigt. IIRC gibt es subtile Unterschiede in der genauen Formulierung im Umgang mit breiten Zeichen, aber ich glaube nicht, dass sie in den Diagrammbeschriftungen auftauchen.
AProgrammer

2
@sbi ja, aber das soll die FAQ-Frage sein, nicht wahr? So sollten diese Informationen nicht zur Verfügung stehen hier ? ;)
Jalf

3
@AProgrammmer: Es wäre hilfreich, sie einfach nach Namen aufzulisten. Dann wissen die Leute, wonach sie suchen müssen, wenn sie mehr Details wünschen. Wie auch immer, + 1'ed Ihre Antwort auf jeden Fall :)
Jalf

14

Das Dünne ist, dass eine CPU Daten von Speicheradressen lädt, Daten in Speicheradressen speichert und Befehle nacheinander aus Speicheradressen heraus ausführt, wobei einige bedingte Sprünge in der Reihenfolge der verarbeiteten Befehle erfolgen. Jede dieser drei Kategorien von Befehlen beinhaltet das Berechnen einer Adresse an eine Speicherzelle, die in dem Maschinenbefehl verwendet werden soll. Da Maschinenanweisungen abhängig von der jeweiligen Anweisung eine variable Länge haben und wir beim Erstellen unseres Maschinencodes eine variable Länge aneinanderreihen, erfolgt die Berechnung und Erstellung von Adressen in zwei Schritten.

Zuerst legen wir die Speicherzuordnung so gut wie möglich fest, bevor wir wissen, was genau in jeder Zelle vor sich geht. Wir finden die Bytes oder Wörter oder was auch immer heraus, die die Anweisungen und Literale und alle Daten bilden. Wir beginnen einfach damit, Speicher zuzuweisen und die Werte zu erstellen, mit denen das Programm erstellt wird, und notieren uns jeden Ort, an dem wir zurückkehren und eine Adresse festlegen müssen. An dieser Stelle setzen wir einen Dummy, um nur die Position aufzufüllen, damit wir weiterhin die Speichergröße berechnen können. Zum Beispiel könnte unser erster Maschinencode eine Zelle enthalten. Der nächste Maschinencode kann 3 Zellen enthalten, darunter eine Maschinencodezelle und zwei Adresszellen. Jetzt ist unser Adresszeiger 4. Wir wissen, was in der Maschinenzelle, dem Operationscode, steht, aber wir müssen warten, um zu berechnen, was in den Adresszellen steht, bis wir wissen, wo sich diese Daten befinden werden, d. H.

Wenn es nur eine Quelldatei gäbe, könnte ein Compiler theoretisch vollständig ausführbaren Maschinencode ohne Linker erzeugen. In einem Prozess mit zwei Durchläufen könnten alle tatsächlichen Adressen für alle Datenzellen berechnet werden, auf die durch Anweisungen zum Laden oder Speichern von Maschinen verwiesen wird. Und es könnte alle absoluten Adressen berechnen, auf die durch Anweisungen für absolute Sprünge verwiesen wird. So funktionieren einfachere Compiler wie der in Forth ohne Linker.

Mit einem Linker können Codeblöcke separat kompiliert werden. Dies kann den gesamten Prozess der Codeerstellung beschleunigen und eine gewisse Flexibilität bei der späteren Verwendung der Blöcke ermöglichen. Mit anderen Worten, sie können im Speicher verschoben werden, z. B. indem jeder Adresse 1000 hinzugefügt werden, um den Block um 1000 Adresszellen zu erweitern.

Der Compiler gibt also groben Maschinencode aus, der noch nicht vollständig erstellt wurde, aber so angelegt ist, dass wir die Größe von allem kennen, mit anderen Worten, damit wir berechnen können, wo sich alle absoluten Adressen befinden. Der Compiler gibt auch eine Liste von Symbolen aus, bei denen es sich um Name / Adresse-Paare handelt. Die Symbole beziehen sich auf einen Speicheroffset im Maschinencode im Modul mit einem Namen. Der Versatz ist der absolute Abstand zum Speicherort des Symbols im Modul.

Dort kommen wir zum Linker. Der Linker schlägt zuerst alle diese Maschinencodeblöcke Ende an Ende zusammen und notiert, wo jeder beginnt. Anschließend werden die zu fixierenden Adressen berechnet, indem der relative Versatz innerhalb eines Moduls und die absolute Position des Moduls im größeren Layout addiert werden.

Offensichtlich habe ich dies zu stark vereinfacht, damit Sie versuchen können, es zu erfassen, und ich habe absichtlich nicht den Jargon von Objektdateien, Symboltabellen usw. verwendet, der für mich Teil der Verwirrung ist.


13

GCC kompiliert ein C / C ++ - Programm in 4 Schritten in eine ausführbare Datei.

Zum Beispiel gcc -o hello hello.cwird wie folgt ausgeführt:

1. Vorverarbeitung

Vorverarbeitung über den GNU C-Präprozessor ( cpp.exe), der die Header ( #include) enthält und die Makros ( #define) erweitert.

cpp hello.c > hello.i

Die resultierende Zwischendatei "hello.i" enthält den erweiterten Quellcode.

2. Zusammenstellung

Der Compiler kompiliert den vorverarbeiteten Quellcode in Assembler-Code für einen bestimmten Prozessor.

gcc -S hello.i

Die Option -S gibt an, dass Assembler-Code anstelle von Objektcode erstellt werden soll. Die resultierende Assembly-Datei lautet "hello.s".

3. Montage

Der Assembler ( as.exe) konvertiert den Assembler-Code in der Objektdatei "hello.o" in Maschinencode.

as -o hello.o hello.s

4. Linker

Schließlich ld.exeverknüpft der linker ( ) den Objektcode mit dem Bibliothekscode, um eine ausführbare Datei "Hallo" zu erzeugen.

    ld -o hallo hallo.o ... Bibliotheken ...

9

Schauen Sie sich die URL an: http://faculty.cs.niu.edu/~mcmahon/CS241/Notes/compile.html
Der vollständige Abschlussprozess von C ++ wird in dieser URL klar vorgestellt.


2
Vielen Dank für das Teilen, es ist so einfach und unkompliziert zu verstehen.
Mark

Gut, Ressource, können Sie hier eine grundlegende Erklärung des Prozesses geben, die Antwort wird vom Algorithmus als b / c von geringer Qualität gekennzeichnet, es ist kurz und nur die URL.
JasonB

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.