Compiler
Compilieren und Linken bei Java
- Java kennt keine Header-Dateien, jede Java-Klasse ist selbstbeschreibend
- Verwender kann alle Meta-Informationen aus der .class-Datei beziehen
javapdient dazu, diese Informationen auszugeben- Java kennt keinen Linker, sondern nur einen Compiler
- Klassen
- werden von der Java VM dynamisch bei Bedarf geladen
- werden erst geladen, wenn sie das erste Mal benötigt werden
- werden erst bei Bedarf von der VM intern gelinkt
- Java VM sucht die Klassen auf dem Klassenpfad (classpath) (VM-Option
-cpoder-classpath)
Das Übersetzen von Programmen mit Java folgt anderen Prinzipien als das Übersetzen und Linken in klassischen Programmiersprachen.
In Java existieren keine Header-Dateien, sondern der Verwender eine Klasse kann alle Informationen, die er benötigt, aus der Klasse selbst beziehen. Java-Klassen sind somit selbstbeschreibend und enthalten sowohl die Definition als auch die Deklaration von Methoden und Variablen. Man kann sich diese Meta-Informationen mit dem Tool javap ansehen.
Bei Java existiert kein Linker, d. h. die kompilierten Klassen werden nicht in einem zusätzlichen Schritt zu einer ausführbaren Datei gebunden. An Stelle des Linkers fungiert die Java-VM, die während des Ablauf eines Programms dynamisch alle Klassen nachlädt, die von diesem benötigt werden (dynamic classloading). Natürlich müssen auch im Falle von Java die Beziehungen zwischen den Klassen aufgelöst werden und symbolische Methodenaufrufe durch echte ersetzt werden. Dieses Binden oder Linken wird aber von der VM intern zur Laufzeit durchgeführt und ist daher für den Verwender nicht sichtbar.
Damit die Java-VM die benötigten Klassen finden kann, kann man mithilfe der Kommandozeilenoption -classpath festlegen, wo sie überall nach Klassen suchen soll.
Klassische Programmiersprachen
In klassischen Programmiersprachen (C, C++, …) besteht die Programmerzeugung aus zwei Schritten
- Übersetzen (compile) – die Quelldateien werden einzeln in plattformspezifischen Maschinencode übersetzt
- Binden (link) – alle Teile des Programms werden zu einer einzigen ausführbaren Datei (Executable) zusammengebunden
- statisches Binden – das erzeugte Executable enthält alle Programmteile und alle Bibliotheken
- dynamisches Binden – nur Teile des Programms sind im Executable enthalten, andere Teile (meistens Bibliotheken) werden bei Bedarf zur Laufzeit nachgeladen
Klassische Programmiersprachen (C, C++, Modula 2, Pascal) erzeugen ausführbare Dateien, die spezifisch für die jeweilige Plattform sind, d. h. sie enthalten Maschinenbefehle für den Prozessor der Maschine. Um die Programmierung modularisieren zu können, wird die Erzeugung einer solchen Binärdatei in zwei Schritte aufgeteilt:
- Im ersten Schritt werden die vorhandenen Quelldateien vom Compiler in Maschinencode für die Plattform übersetzt. Hierbei werden allerdings die Beziehungen zwischen den Quelldateien noch nicht aufgelöst, da sie einzeln übersetzt werden. Beziehungen (meist Funktionsaufrufe), die sich von einer auf die andere Quelldatei beziehen werden nur vermerkt aber noch nicht miteinander verbunden. Das Ergebnis eines solchen Schrittes bezeichnet man als Objektdatei (object file).
- In einem zweiten Schritt, sammelt ein weiteres Werkzeug, der Linker, alle benötigten Objektdateien zusammen und löst die Querbeziehungen auf, indem er die symbolischen Referenzen durch echte Funktionsaufrufe ersetzt (binden). Als Ergebnis erhält man eine Programmdatei (executable).
Beim Linken besteht die Möglichkeit entweder alle Objektdateien statisch zu einem einzigen Executable zu binden (statisches Linken) oder aber erst zu Laufzeit einzelne Teile nachzuladen (dynamisches Linken).
Beim statischen Linken werden alle notwendigen Objektdateien zu einem großen Executable zusammengebunden. Wenn das Programm Funktionen aus Bibliotheken (z. B. der C-Standardbibliothek) verwendet, werden diese ebenfalls in das Executable kopiert.
Der Vorteil eines statisch gelinkten Programms ist, dass es keine Abhängigkeiten zum System hat, auf dem es ausgeführt wird. Es bringt alle seine Bibliotheken und Funktionen mit. Der Nachteil ist, dass das Programm deutlich größer ist und das Betriebssystem keine Möglichkeit hat, die Standardbibliotheken ressourcenschonend nur einmal zu Laden. Somit sind sowohl die Binärdatei als auch der Speicherverbrauch deutlich größer.
Für C-Programme verwendet man normalerweise kein statisches Linken. In Go ist es aber der Normalfall, weil Go das Ziel hat, das gesamte Programm in einer einzigen Datei auszuliefern.
Im Gegensatz zum statischen Linken, werden beim dynamischen Linken nur die Objektdateien des Programms selbst zusammengefügt, alle externen Bibliotheken aber nicht. Der Linker setzt an die Stellen, an denen externe Bibliotheken gerufen werden, spezielle Verweise, die erst beim Laden des Programms von einem weiteren Programm, dem Dynamischen Linker (dynamic linker), aufgelöst werden.
Beispielprogramm kompilieren
- Quelltext zu einer Objektdatei
hello_world.o(nicht ausführbar) kompilieren
$ gcc -Wall -c hello_world.c
- Objektdatei (
hello_world.o) zu einem Executable (hello_world) binden
$ gcc -o hello_world hello_world.o
- Programm ausführen
$ ./hello_world
Hello, World!
$
Man kann die Datei auch direkt in einem Schritt zu einem Executable compilieren, und zwar mit dem Aufruf: gcc -o hello_world hello_world.c. Hierbei ist die Angabe des Zieldateinamens mit -o wichtig, da andernfalls eine Datei mit dem Namen a.out für das fertige Programm erstellt wird. Der Name a.out hat historische Gründe und steht als Abkürzung für assembler output. Ursprünglich bezeichnete es ein Dateiformat in frühen Unix-Systemen, ist inzwischen aber nur noch ein Relikt aus alten Zeiten, da unter Unix die ausführbaren Dateien im ELF-Format vorliegen.
Die Option -Wall bittet den C-Compiler darum, möglichst viele Warnungen (-W) auszugeben, damit Programmierfehler schneller erkannt werden. Wie wir noch sehen werden, kann man sich in C schnell selbst ein Bein stellen und -Wall sorgt dafür, dass der Compiler, zumindest in vielen Fällen, eine Warnung hiervor ausgibt.
Mit -c weist man den Compiler an, die Datei in eine Objektdatei nur zu compilieren und noch nicht zu linken. Obwohl der Linker ein getrenntes Programm ist (ld beim gcc) ist der Compiler normalerweise so nett, ihn für uns aufzurufen. Mit -c unterbinden wir das.
Wenn man dem Compiler anstatt einer Quelldatei eine Objektdatei (Endung .o) vorlegt, dann ruft er den Linker auf und linkt die Datei für uns. Den Namen der Zieldatei geben wir mit -o an.
Wir können die Datei auch linken, ohne den Compiler zu verwenden, indem wir den Linker selbst aufrufen, dafür müssen wir dem Linker aber eine ganze Reihe von Informationen mitgeben, die der Compiler sowieso schon hat, z. B. wo sich die Bibliotheken auf dem Computer befinden.
$ ld -v -plugin /usr/lib/gcc/x86_64-linux-gnu/10/liblto_plugin.so \
-plugin-opt=/usr/lib/gcc/x86_64-linux-gnu/10/lto-wrapper \
-plugin-opt=-fresolution=/tmp/ccVVbwld.res \
-plugin-opt=-pass-through=-lgcc \
-plugin-opt=-pass-through=-lgcc_s \
-plugin-opt=-pass-through=-lc \
-plugin-opt=-pass-through=-lgcc \
-plugin-opt=-pass-through=-lgcc_s \
--build-id --eh-frame-hdr -m elf_x86_64 --hash-style=gnu --as-needed \
-dynamic-linker /lib64/ld-linux-x86-64.so.2 -pie -z now -z relro \
-o hello_world \
/usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/Scrt1.o \
/usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/crti.o \
/usr/lib/gcc/x86_64-linux-gnu/10/crtbeginS.o \
-L/usr/lib/gcc/x86_64-linux-gnu/10 \
-L/usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu \
-L/usr/lib/gcc/x86_64-linux-gnu/10/../../../../lib \
-L/lib/x86_64-linux-gnu \
-L/lib/../lib -L/usr/lib/x86_64-linux-gnu \
-L/usr/lib/../lib \
-L/usr/lib/gcc/x86_64-linux-gnu/10/../../.. hello_world.o -lgcc \
--push-state \
--as-needed -lgcc_s --pop-state -lc -lgcc --push-state \
--as-needed -lgcc_s \
--pop-state /usr/lib/gcc/x86_64-linux-gnu/10/crtendS.o \
/usr/lib/gcc/x86_64-linux-gnu/10/../../../x86_64-linux-gnu/crtn.o
Deswegen lassen wir den Compiler den Linker aufrufen und überlasen ihm die ganze Komplexität.
Beispiel: Mehrere Dateien
Ein C-Programm besteht normalerweise aus mehreren Dateien, die man einzeln compilieren kann und die dann vom Linker zu einem ausführbaren Programm gebunden werden. Im folgenden Beispiel haben wir die Begrüßung in eine eigene Datei greeter.c ausgelagert, die eine passende Funktion greeter anbietet.
#include <stdio.h>
#include "greeter.h"
void greeter(char* name) {
printf("Hello, %s!\n", name);
}
#ifndef GREETER_H
#define GREETER_H
void greeter(char* name);
#endif
#include "greeter.h"
int main(int argc, char** argv) {
greeter("Thomas");
}
$ cc -c main.c
$ cc -c greeter.c
$ cc -o hello_world main.o greeter.o
$ ./hello_world
Hello, Thomas!
$
Wenn man den Code ansieht, tauchen sofort vier Fragen auf:
- Warum gibt es neben
greeter.cauch noch eingreeter.h? - Warum inkludiert
main.cdie Dateigreeter.h? - Wieso inkludiert
greeter.cdie Dateigreeter.h, die doch nur noch einmal die Funktionsdeklaration enthält? - Was soll das
#ifndef-Zeugs?
Zur Beantwortung dieser Fragen muss man verstehen, wie der C-Compiler die Objektdateien erzeugt und was der Linker genau macht.
Jede Quelldatei wird getrennt kompiliert
Jede Quelldatei wird getrennt kompiliert, ohne Betrachtung irgend einer anderen Quelldatei. Verbindungen über Quelldateien hinweg kann erst der Linker erzeugen. Wenn der Compiler die Datei main.c compiliert, dann trifft er dort auf den Funktionsaufruf greeter("Thomas");. Diese Funktion ist in der Datei aber nicht bekannt, d. h. es käme zu einem Fehler, denn Funktionen, die es nicht gibt, können wir nicht aufrufen.
Wie sagen wir dem Compiler also, dass er chillen soll und die Funktion schon später vom Linker herangeschafft werden wird? Wir sagen es ihm, indem wir eine Deklaration der Funktion angeben, also den Kopf der Funktion ohne Rumpf zur Verfügung stellen. Dem Compiler reicht dies, denn er muss nicht wissen, was die Funktion macht, nur dass es sie später geben wird. Den Rest überlässt er dann entspannt dem Linker. Wir könnten also main.c einfach so aussehen lassen:
/* Deklaration von Greeter */
void greeter(char* name);
int main(int argc, char** argv) {
greeter("Thomas"); /* Verwendung von greeter */
}
Das wäre aber nicht besonders schlau, weil die Deklaration von greeter nicht zu main.c gehört, sondern zu greeter.c, weswegen wir sie in eine eigene Header-Datei (Dateiendung .h) auslagern und diese dann von main.c aus inkludieren.
Header-Dateien beschreiben die Schnittstelle
Jetzt kann man sich vorstellen, dass viele verschiedene Programme die tolle Funktionalität von greeter.c mit der greeter-Funktion nutzen wollen. Deswegen ist es in C üblich, dass jede .c-Datei, die Funktionen für andere zur Verfügung stellt, diese über eine korrespondierende Header-Datei (.h) beschreibt. Möchte ich die Funktionen nutzen, inkludiere ich die Header-Datei, nutze die Funktionen und der Linker macht den Rest.
Nichts anderes macht greeter.c in der ersten Zeile mit #include <stdio.h>: Hier wird ebenfalls eine Header-Datei inkludiert, nämlich eine der C-Standardbibliothek, welche die printf-Funktion zur Verfügung stellt. In Zeile 332 der Datei stdio.h findet sich dann extern int printf (...); also die Deklaration der printf-Funktion. Das extern können Sie ignorieren da es bei einer Funktions-Deklaration<sup><span title='nicht aber einer Variablen-Deklaration'>*</span></sup> redundant ist, man kann also sowohl extern int f(); als auch int f(); schreiben. Der fehlende Funktionsrumpf impliziert das extern.
Warum inkludieren wir stdio.h mit spitzen Klammern, greeter.h aber mit Anführungszeichen? Die Antwort ist, dass Header-Dateien, die vom System und seinen Bibliotheken zur Verfügung gestellt werden, mit <> inkludiert und vom Compiler automatisch an festgelegten Stellen gesucht werden. Header-Dateien, die vom User geschrieben wurde, werden mit "" inkludiert und werden im aktuellen Verzeichnis gesucht. Man kann beim Inkludieren mit "" auch relative Pfade verwenden, z. B. #include "../welcome/greeter.h".
Deklaration und Definition müssen übereinstimmen
Die Datei greeter.c enthält die Definition der Funktion greeter. Beachten Sie bitte den Unterschied:
- Deklaration: Beschreibung der Schnittstelle der Funktion (Rückgabetyp, Name, Parameter)
- Definition: Implementierung der Funktion = Deklaration + Funktionsrumpf
Eine Funktion darf beliebig oft deklariert aber nur einmal definiert werde. Diese spitzfindige Behandlung der beiden Begriffe kennen Sie aus Java nicht, weil dort – bedingt durch das Design der Sprache – Definition und Deklaration fast immer gleichzeitig passieren: Die Ausnahme sind abstrakte Methoden.
Warum inkludiert greeter.c jetzt die Header-Datei mit der Deklaration einer Funktion, die sofort danach definiert wird? Die Antwort ist: So wird verhindert, dass die Funktionen sich aus Versehen unterscheiden, denn Definition und Deklaration einer Funktion gleichen Namens müssen immer übereinstimmten. Wenn ich also aus Versehen einen anderen Rückgabetyp oder andere Parameter in greeter.c als in greeter.h benutzen würde, fiele das nicht auf, wenn ich nicht greeter.h in greeter.c inkludieren. Der Linker interessiert sich nämlich nicht für die Parameter und Rückgabetypen und linkt die Funktionen ausschließlich anhand der Namen.
Das folgende Beispiel zeigt das Problem:
#include <stdio.h>
/* Hier fehlt das #include von greeter.h */
void greeter(char* name, int age) {
printf("Hello, %s! You are %d years old!\n", name, age);
}
void greeter(char* name);
#include "greeter.h"
int main(int argc, char** argv) {
greeter("Thomas");
}
Wenn man das folgende Programm compiliert – was problemlos klappt – und ausführt, dann bekommt man einen sehr seltsamen Output:
$ cc -Wall -c greeter.c
$ cc -Wall -c main.c
$ cc -o hello_world greeter.o main.o
$ ./hello_world
Hello, Thomas! You are 2006438408 years old!
Woher diese unglaubliche Zahl kommt, können Sie einmal selbst überlegen.
Durch das Inkludieren der eigenen Header-Datei verhindert man, dass es zu einer Abweichung von Deklaration und Definition kommt:
#include <stdio.h>
#include "greeter.h"
void greeter(char* name, int age) {
printf("Hello, %s! You are %d years old!\n", name, age);
}
cc -Wall -c greeter.c
greeter.c:4:6: error: conflicting types for 'greeter'
4 | void greeter(char* name, int age) {
| ^~~~~~~
In file included from greeter.c:2:
greeter.h:3:6: note: previous declaration of 'greeter' was here
3 | void greeter(char* name);
| ^~~~~~~
Doppelte Includes vermeiden
Solange eine Header-Datei nur Deklarationen enthält ist es kein Problem, wenn sie mehrfach eingebunden wird. Meist passiert eine solche mehrfache Einbindung indirekt: A inkludiert B und H, B inkludiert H. Probleme entstehen aber, wenn Header-Dateien Dinge enthalten, die nur einmal vorkommen dürfen (z. B. #define). Um hier Konflikte zu verhindern, verwendet man die folgende Konstruktion, um sicherzustellen, dass jede Header-Datei nur einmal vorkommt:
#ifndef __NAME_DER_DATEI__
#define __NAME_DER_DATEI__
...
#endif
Der Bereich zwischen #ifndef (If Not Defined) und #endif wird nur dann ausgeführt, wenn die Variable __NAME_DER_DATEI__ nicht gesetzt ist. Danach wird die Variable sofort gesetzt, sodass beim nächsten Auftreten desselben Konstruktes, der Bereich zwischen #ifndef und #endif ignoriert wird.
Makefile
Das Ausführen der Kommandos zum Kompilieren und Linken kann komplex werden. Deshalb fasst man sie in einem Makefile zusammen
# Makefile für das Hello-World-Programm
# Achtung: Einrückung muss mit Tabs erfolgen
.PHONY: all clean
all: hello_world
clean:
rm -f main.o greeter.o hello_world
main.o: main.c greeter.h
cc -Wall -c main.c
greeter.o: greeter.c greeter.h
cc -Wall -c greeter.c
hello_world: main.o greeter.o
cc -o hello_world main.o greeter.o
C-Programme von Hand zu kompilieren ist eine Qual. Zwar könnte man dem Compiler immer wieder einfach alle C-Dateien vorwerfen (cc -o hello_world main.c greeter.c), dies würde aber unnötig Zeit verschlingen. Der Compiler würde alle Dateien immer wieder neu kompilieren, auch wenn sich nichts geändert hat.
Dieses Problem wird vom Werkzeug make adressiert, das fast so alt wie C ist und konzeptionell hervorragend dazu passt. Eine Make-Datei besteht aus:
- Targets: Dateien, die erzeugt werden sollen
- Prerequisites: Dateien, von denen das Target abhängt
- Recipes: Kommandos die ausgeführt werden, um die Targets zu erzeugen
Betrachtet man die folgenden Zeilen:
main.o: main.c greeter.h
cc -Wall -c main.c
Dann ist
main.odas Targetmain.cundgreeter.hdie Prerequisitescc -Wall -c main.cdas Recipe
Man gibt beim Aufruf von make ein Target an (tut man das nicht, wird das erste Target in der Datei – normalerweise all genannt – angesprungen). Jetzt schaut make, ob alle Prerequisites existieren. Ist dem nicht so, sucht es nach einem Target, das das Prerequisites erzeugt. Ist ein Prerequisite vorhanden, wird geprüft, ob es neuer als das Target ist. (Falls das Target nicht existiert, wird es einfach als älter als alle Prerequisites betrachtet.) Ist das Target älter als mindestens eines der Prerequisites, wird das Recipe ausgeführt, um das Target auf den neuesten Stand zu bringen.
Durch diesen Mechanismus sorgt make dafür, dass immer nur dann die Recipes ausgeführt werden, wenn sich eine Änderung an den Dateien ergeben hat, die für ein Target notwendig sind. Bei großen Projekten führt das zu einer erheblichen Beschleunigung des Entwicklungsprozesses, weil nicht immer alles compiliert werden muss.
Das erste Target (hier all) im Makefile wird automatisch angesprungen. Wenn es sich bei einem Target nicht um eine Datei, sondern ein generisches Target wie all oder clean handelt, wird es als .PHONY gekennzeichnet. Dies verhindert, dass das Target nicht mehr angesprungen wird, wenn es eine gleichnamige Datei gibt.
Man kann in Makefiles auch Variablen verwenden, um sich einige Tipparbeit zu sparen. Im folgenden ein einfaches Beispiel dazu.
CC = cc
COMPILER_OPTIONS = -Wall
.PHONY: all clean
all: hello_world
clean:
@rm -f *.o hello_world
main.o: main.c greeter.h
$(CC) $(COMPILER_OPTIONS) -c $<
greeter.o: greeter.c greeter.h
$(CC) $(COMPILER_OPTIONS) -c $<
hello_world: greeter.o main.o
$(CC) -o $@ $^
Die Variablen $(CC) und $(COMPILER_OPTIONS) sollten selbsterklärend sein. Verwirrender sind die anderen Variablen, die mit $ beginnen:
$<: Wird durch das erste Prerequisite ersetzt. Im vorliegenden Beispiel sind das die C-Dateien.$^: Wird durch alle Prerequisites ersetzt, im Beispiel die Objektdateien.$@: Wird durch das Target ersetzt.
Diese Variablen werden als automatischen Variablen bezeichnet, weil man sie nicht selbst setzen muss, sondern Sie von make automatisch mit Werten belegt werden. Ihre Verwendung hilft dabei, Wiederholungen in den Make-Files zu vermeiden.
Es gibt natürlich noch deutlich mehr Möglichkeiten, Makefiles zu verbessern und zu vereinfachen. Bei großen Projekten arbeitet man meist mit einem Haupt-Makefile und entsprechenden Unter-Makefiles für Subprojekte.
Wenn man den GNU C-Compiler verwendet, kann man sich die Dependencies zwischen den einzelnen C-Dateien automatisch vom Compiler erzeugen lassen. Dazu dient die Option -MM. Ein solches Makefile für das aktuelle Projekt sähe dann wie folgt aus:
CC = cc
COMPILER_OPTIONS = -Wall
SOURCES := $(wildcard *.c)
OBJECTS := $(patsubst %.c,%.o,$(SOURCES))
.PHONY: all clean
all: hello_world .depend
clean:
@rm -f *.o hello_world .depend
.depend: $(SOURCES)
$(CC) $(COMPILER_OPTIONS) -MM $^ > "$@"
include .depend
%.o: %.c
$(CC) $(COMPILER_OPTIONS) -c $<
hello_world: $(OBJECTS)
$(CC) $(COMPILER_OPTIONS) -o $@ $^
Wenn man das Makefile erstellt hat, kann man sehen, dass nach Änderungen nur die notwendigen Dateien neu kompiliert werden.
$ make
cc -Wall -c greeter.c
cc -Wall -c main.c
cc -o hello_world greeter.o main.o
$ touch greeter.c
$ make
cc -Wall -c greeter.c
cc -o hello_world greeter.o main.o
$ touch main.c
$ make
cc -Wall -c main.c
cc -o hello_world greeter.o main.o