Compiler

Video zum Kapitel

Link zu YouTube

Compilieren und Linken bei Java

  • Jede Java-Klasse ist selbstbeschreibend
    • Verwender kann alle Meta-Informationen aus der .class-Datei beziehen
    • javap dient dazu, diese Informationen auszugeben
  • Java kennt keinen Linker, sondern nur einen Compiler
  • Klassen werden
    • von der Java VM dynamisch bei Bedarf geladen
    • erst geladen, wenn sie das erste Mal benötigt werden
    • erst bei Bedarf von der VM intern gelinkt
  • Java VM sucht die Klassen auf dem Klassenpfad (classpath)
    (VM-Option -cp oder -classpath)

Das Übersetzen von Programmen mit Java folgt anderen Prinzipien als das Übersetzen und Linken in klassischen Programmiersprachen.

In Java existieren keine zusätzlichen 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 Ablaufs 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-Standard-Bibliothek) 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.

Linkeraufruf von Hand
$ 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.

greeter.c
#include <stdio.h>
#include "greeter.h"

void greeter(char* name) {
    printf("Hello, %s!\n", name);
}
greeter.h
#ifndef GREETER_H
#define GREETER_H
void greeter(char* name);
#endif
main.c
#include "greeter.h"

int main(int argc, char** argv) {
    greeter("Thomas");
}
Kompilieren und linken
$ 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:

  1. Warum gibt es neben greeter.c auch noch ein greeter.h?
  2. Warum inkludiert main.c die Datei greeter.h?
  3. Wieso inkludiert greeter.c die Datei greeter.h, die doch nur noch einmal die Funktionsdeklaration enthält?
  4. 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 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-Deklarationnicht aber einer Variablen-Deklaration 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 wir 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 böse erwachen kommt dann erst zur Laufzeit.

Das folgende Beispiel zeigt das Problem:

greeter.c
#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);
}
greeter.h
void greeter(char* name);
main.c
#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:

greeter.c
#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.

Exports und Imports

Wie schafft es der Linker, die verschiedenen Dateien zusammenzubringen und am Ende ein funktionierendes Executable zu erzeugen? Die Antwort liegt darin, dass jede Objektdatei entsprechende Informationen liefert.

Jede Objektdatei liefert dem Linker

  • Exports: Funktionen, die sie anderen Objektdateien zur Verfügung stellen kann
  • Imports: Funktionen, die sie von anderen Objektdateien benötigt
  • Der Überbegriff für Exports und Imports ist Symbole

Linker sammelt die Informationen und baut das fertige Executable zusammen

Wir werden später noch sehen, dass es sich bei den Exports und Imports nicht nur um Funktionen handel kann, sondern auch Variablen als Symbole exportiert und importiert werden können.

Man kann das Tool rabin2 aus dem Radare2-Werkzeugset benutzen. Mit rabin2 kann man sich Informationen zu Objektdateien und Executables ausgeben lassen. Im folgenden Beispiel mit der Option -Ei die Exports und Imports bzw. noch korrekter ausgedrückt die exportierten und importierten Symbole.

Imports und Exports von greeter.o
$ rabin2 -Ei greeter.o
[Imports]
nth vaddr      bind   type   lib name
5   ---------- GLOBAL NOTYPE     printf

[Exports]
nth paddr      vaddr      bind   type size lib name
4   0x00000040 0x08000040 GLOBAL FUNC 42       greeter
  • die Funktion printf wird importiert
  • die Funktion greeter wird exportiert

Das Beispiel zeigt, dass die Objektdatei greeter.o eine Funktion namens greeter exportiert und die Funktion printf importiert. Man sieht ebenfalls, dass die Parameter nicht Teil der Informationen sind, die man über die exportierten und importierten Funktionen bekommt. Tatsächlich interessieren diese den Linker auch nicht, da jede Funktion einen eindeutigen Namen haben muss (es gibt keine überladenen Funktionen) und die Funktionen sich selbst darum kümmern müssen, die richtigen Parameter beim Aufruf zur Verfügung zu stellen.

Import und Exports von main.o
$ rabin2 -Ei main.o
[Imports]
nth vaddr      bind   type   lib name
5   ---------- GLOBAL NOTYPE     greeter

[Exports]
nth paddr      vaddr      bind   type size lib name
4   0x00000040 0x08000040 GLOBAL FUNC 26       main
  • die Funktion greeter wird importiert
  • die Funktion main wird exportiert

main.o importiert die Funktion greeter und exportiert selbst die Funktion main. Letztere wird dann später beim Start des Programms aufgerufen.

Man kann an den Ausgaben nicht erkennen, woher die Funktionen importiert werden. Es ist Aufgabe des Linkers, alle vorhandenen Objektdateien und Bibliotheken nach den passenden Funktionen zu durchsuchen und dann die entsprechenden Verbindungen herzustellen. Die Objektdateien enthalten diese Information nicht.

Schaut man sich als Letztes noch die Exports und Imports vom fertig gelinkten Executable an, dann sieht man (gekürzt) folgendes:

Imports und Exports des fertigen Executables
$ rabin2 -Ei hello_world
[Imports]
nth vaddr      bind   type   lib name
3   0x00001030 GLOBAL FUNC       printf

[Exports]
nth paddr      vaddr      bind   type   size lib name
18  0x00001040 0x00001040 GLOBAL FUNC   38       _start
19  0x00001153 0x00001153 GLOBAL FUNC   42       greeter
21  0x00001139 0x00001139 GLOBAL FUNC   26       main

Die Funktion printf ist weiterhin ein Import des Programms, weil diese Abhängigkeit vom Linker nicht befriedigt wurde, sondern die Funktion zur Laufzeit aus der C-Standard-Bibliothek dynamisch nachgeladen wird. Hierzu wird beim Programmstart nach der Funktion gesucht und sie wird dynamisch dem Programm zur Verfügung gestellt, bevor die main-Funktion läuft. Hierfür ist eine spezielle Komponente im Betriebssystem verantwortlich, die man dynamischen Linker oder unter Linux als Program Interpreter bezeichnet.

Ein interessanter Export ist die Funktion _start. Diese kommt in den Objektdateien nicht vor, ist aber der echte Einstiegspunkt in das Programm. main ist nur für C-Programme die erste Funktion, die gestartet wird. Aber wie verhält es sich mit anderen Programmiersprachen? Tatsächlich ist es so, dass das Betriebssystem beim Start eines Programms nicht die Funktion main anspringt, sondern die Funktion _start. Es ist Aufgabe des Compilers der jeweiligen Programmiersprache, eine _start-Funktion zu generieren, die dann im Fall von C eben die main-Funktion aufruft.

Makefile

  • Kompilieren und Linken ist komplex ⇒ man fasst Kommandos 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.o das Target
  • main.c und greeter.h die Prerequisites
  • cc -Wall -c main.c das 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 Abhängigkeiten (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

Das Kommando touch verändert den Zeitstempel einer Datei auf die aktuelle Zeit, lässt sie also für Make als verändert erscheinen. Man könnte die Datei natürlich auch öffnen, ändern und wieder abspeichern. Eine weitere Funktion von touch – die hier nicht gebraucht wird – ist, dass es die Datei auch anlegt, wenn sie nicht vorhanden ist.


Copyright © 2025 Thomas Smits