next up previous contents index
Next: Integrierte Entwicklungsumgebungen Up: C++-Entwicklung mit Linux Previous: Editoren für die Programmierung   Contents   Index

Subsections

Werkzeuge für die Softwareentwicklung

Bisher haben wir uns ja nur mit der Programmiersprache an sich und den Editoren beschäftigt. Das einzige Entwicklungswerkzeug, das wir benutzt haben, war der Compiler. Bei wachsender Größe eines Projekts reicht dieser allerdings nicht mehr aus. Zum Glück gibt es unter Linux eine Reihe von sehr mächtigen zusätzlichen Werkzeugen, die Sie bei der Softwareentwicklung unterstützen wollen. Die wichtigsten will ich Ihnen in diesem Kapitel vorstellen:


Steuerung der Übersetzung mit Make-Dateien

Bisher waren unsere Programme im Allgemeinen so klein, dass eine Datei für sie ausreichte. Schon bald aber werden Sie sicher größere Programme schreiben, bei denen Sie -- gemäß meiner Tipps (?) -- für jede Klasse eine eigene Datei anlegen. Wenn Sie dann an einer davon etwas ändern, ist der Aufwand nicht so groß, alle neu zu übersetzen.

Nehmen wir an, Sie wollen ein Programm erstellen, das Sie an die Geburtstage Ihrer Freunde erinnert, sobald Sie sich einloggen. Die Daten verwalten Sie in einer Liste; außerdem gibt es noch ein Hauptprogramm. Zum Kompilieren geben Sie dann an:

% g++ birthlist.cc birthday.cc -o birthcontrol

Eines Tages finden Sie das Hantieren mit dem Datum als einzelne Zahlen endgültig unpraktisch, weil Sie auch wissen wollen, wer morgen oder nächste Woche Geburtstag hat, und schreiben eine Klasse Date, die das Datum universell handhaben kann. Damit erhalten Sie natürlich neue Programmdateien date.cc und date.h, also schon drei Dateien im Projekt. Wenn Sie nur eine ein wenig ändern, müssen Sie immer alle drei neu übersetzen.

Sicher könnten Sie nur die betroffene Datei kompilieren und dann lediglich den Linker aufrufen. Wie geht das aber bei geänderten Header-Dateien? Haben Sie immer im Kopf, welche Implementationsdatei welche Header-Datei eigentlich einbindet und daher bei deren Änderung neu übersetzt werden muss?

Diese Probleme bekommen Sie mit dem Werkzeug make endlich in den Griff (hier ist die unter Linux gebräuchliche GNU-Variante gemeint, siehe org.gnu.de/software/make/make.html). Es verwaltet die Kompilierung und das Linken aller Dateien Ihres Projekts. Dazu vergleicht es das Datum der Quelldateien mit dem der Objektdateien; ist die Quelle neuer, sorgt es für die Kompilierung; Ähnliches gilt für das Linken. In einem so genannten Makefile legen Sie die Abhängigkeiten zwischen den Dateien und die Regeln für die Erzeugung einer Datei aus einer anderen nieder. Mit diesem wollen wir uns als erstes beschäftigen.

Obwohl folgende Erklärungen für einfachere Projekte ausreichen sollten, können sie bei weitem nicht alle Details zu make abdecken. Wenn Sie mehr erfahren wollen, können Sie entweder die Online-Hilfe (in Form von man- und info-Seiten) zu Rate ziehen oder in [ORAM 1991] alles Wissenswerte nachschlagen.

Aufbau von Makefiles

Ein Makefile ist eine Textdatei, die Sie daher mit jedem Editor bearbeiten können. (Natürlich hat der Emacs auch dafür einen besonderen Modus ..., siehe Seite [*]). Im Allgemeinen hat sie auch den Namen makefile (oder Makefile) und befindet sich im gleichen Verzeichnis wie Ihre übrigen Projektdateien. Wenn Sie einen anderen Namen wählen, müssen Sie diesen bei jedem Aufruf von make ausdrücklich angeben -- dazu haben Sie sicher bald keine Lust mehr. Auch andere, die einen Blick auf Ihr Projekt werfen, wissen am besten sofort, was gemeint ist, wenn sie eine Datei Makefile vorfinden. Wenn ihr Name zudem noch mit einem Großbuchstaben beginnt und damit vielleicht am Anfang aufgelistet wird, fällt sie besonders ins Auge.

Bei sehr großen Projekten (wie vielen Linux-Anwendungen) sind die Quelldateien auf mehrere Verzeichnisse aufgeteilt. Da make nicht nur einen Compiler oder Linker, sondern jedes beliebige Programm einschließlich sich selbst aufrufen kann, gibt es dabei mehrere Make-Dateien für die verschiedenen Projektteile. Deren Zusammenspiel wird von einem zentralen Makefile gesteuert, das dann sehr komplex werden kann. Es wird nicht nur zum Übersetzen, sondern auch für Konfiguration, Installation und Ähnliches genutzt. Wenn Sie schon einmal Ihren Kernel übersetzt haben, haben Sie vielleicht davon etwas gespürt.

Besondere Zeichen

Eine Make-Datei besteht aus Regeln, wie die einzelnen Dateien, die aus Ihrem Projekt hervorgehen, zu erzeugen sind, also auch welche Optionen Compiler und Linker verwenden sollen oder welche Objektdateien für die ausführbare Datei nötig sind. Pro Zeile steht immer nur eine Regel, Definition oder Befehl. Reicht Ihnen die Zeile nicht aus, weil Sie beispielsweise eine große Zahl von Compileroptionen übergeben, können Sie an das Ende einen Backslash \ anhängen. Das ist ein spezielles Fortsetzungszeichen, welches make anweist, die nachfolgende Zeile noch mit der aktuellen zusammenzufassen.

Zeilen mit ausführbaren Befehlen müssen Sie mit einem Tabulatorzeichen einrücken, damit diese als solche erkannt werden. (Der Emacs weist Sie beispielsweise durch eine Rotfärbung ausdrücklich darauf hin.) Das ist zwar am Anfang verwirrend; wenn aber etwas mit der Erzeugung nicht funktioniert, liegt es meistens am fehlenden Tab -- denn Leerzeichen sind hier nicht erlaubt!

Auch in Makefiles können Sie natürlich Kommentare einfügen. Diese beginnen (wie in Shell-Skripten) mit einem Doppelkreuz #, welches Sie wie die Doppelstriche in C++ verwenden, das heißt, damit kommentieren Sie alles bis zum Ende der aktuellen Zeile aus -- auch eventuelle Backslashes am Zeilenende.


Abhängigkeiten

In den Regeln können Sie explizit angeben, welche Datei(en) von welchen anderen abhängig sind. Meist ist damit eine Beziehung zwischen einer Quelldatei und der daraus erzeugten gemeint. In unserem Beispiel hatten wir die Dateien birthday.cc, birthlist.cc, birthlist.h, date.cc und date.h. Daraus ergeben sich folgende Abhängigkeiten:

birthcontrol: birthday.o birthlist.o date.o

 

birthday.o: birthday.cc birthlist.h

 

birthlist.o: birthlist.cc birthlist.h date.h

 

date.o: date.cc date.h

Wie Sie sehen, steht vor dem Doppelpunkt das Ziel und danach die Quelle(n). (Zwischen Doppelpunkt und Quelle muss übrigens mindestens ein Leerzeichen oder Tabulator eingesetzt werden.) Die erste Zeile bedeutet, dass birthcontrol neu erzeugt werden muss, wenn sich eine der drei Dateien birthday.o, birthlist.o oder date.o verändert hat, sprich: neuer als das Ziel ist. Und aus der zweiten Zeile ergibt sich, dass birthday.o zu übersetzen ist, wenn sich entweder birthday.cc oder birthlist.h ändern. Auf diese Weise kann es mehrere Ebenen der Abhängigkeiten geben.

Wenn Sie mehrere Zieldateien gleichzeitig erzeugen wollen, dürfen Sie bei deren Angabe auch Wildcards wie * oder ? verwenden. Auf der anderen Seite ist es sogar erlaubt, Zieldateien ohne Quellen einzusetzen. Allerdings dürfen Sie eine Datei nur einmal als Ziel innerhalb eines Makefiles angeben.

Wie weiß make dann eigentlich, wann es fertig ist; was ist das endgültige Ziel? Auf diese Frage gibt es im Grunde drei Antworten: Erstens: durch Angabe des Ziels in der Kommandozeile, aber dazu kommen wir noch. Zweitens: Das als Erstes im Makefile angegebene Ziel gilt als das zu erzeugende. Wenn dieses Ziel von anderen abhängt, werden natürlich auch diese berücksichtigt. Drittens: Sie können das endgültige Ziel auch ausdrücklich als Zieldatei all angeben, zum Beispiel

all: birthcontrol

Das eignet sich besonders, wenn Sie mehrere Dateien auf einmal erzeugen wollen, beispielsweise neben der ausführbaren Datei noch eine Bibliothek oder eine man-Seite.

Somit gilt also das Datum der letzten Änderung einer Datei als Kriterium (Sie wissen vielleicht, dass das nur einer von mehreren Zeitpunkten ist, die unter Linux mit einer Datei gespeichert werden). Manchmal möchte man aber auch das Neuübersetzen eines Programms erzwingen, ohne dass man gleich alles löscht. Dazu können Sie das Hilfsprogramm touch verwenden. Es erwartet als Argument den Namen einer oder mehrerer Dateien (analog zu anderen Kommandos wie ls oder rm) und setzt deren Änderungsdatum auf die aktuelle Systemzeit. Damit sind die betreffenden Dateien auf alle Fälle neuer als ihre Ziele. Ein Beispiel:

% touch *.cc

setzt alle C++-Quelldateien auf das aktuelle Datum. Gehen Sie aber vorsichtig mit diesem Kommando um. Da Sie damit auch das Datum und die Zeit ändern, die beim Auflisten des Verzeichnisses auftaucht, können Sie anschließend manchmal selbst nicht mehr nachvollziehen, wann Sie was geändert haben -- es sei denn, Sie verwenden konsequent ein Versionsverwaltungssystem. Als eine weitere Anwendung von touch können Sie es mit dem Namen einer bislang nicht existierenden Datei aufrufen; dann wird eine solche, völlig leere erzeugt.


Regeln

Wenn Sie dann eine Abhängigkeit formuliert haben, sollten Sie noch angeben, durch welchen Befehl denn das Ziel aus der Quelle erzeugt werden kann. Dieses Kommando geben Sie genauso ein, wie Sie es auch in der Shell tun würden. Achten Sie aber darauf, die Befehlszeilen immer mit einem Tabulatorzeichen zu beginnen! Für unser Beispielprojekt lautet das vollständige Makefile etwa:

birthcontrol: birthlist.o birthday.o date.o

        g++ birthlist.o birthday.o date.o $\backslash$

        -o birthcontrol

 

birthday.o: birthday.cc birthlist.h

        g++ -c birthday.cc 

 

birthlist.o: birthlist.cc birthlist.h date.h

        g++ -c birthlist.cc 

 

date.o: date.cc date.h

        g++ -c date.cc  

Für jedes Ziel können Sie eine oder auch mehrere Zeilen angeben; dabei darf aber immer nur ein Befehl pro Zeile stehen.

Besonders bei größeren Projekten ist es lästig, für jede Datei eine eigene Regel anzugeben, die sich ja nur im Namen voneinander unterscheiden. Hier bietet make die Möglichkeit, die Erstellung durch so genannte implizite Regeln wesentlich zu vereinfachen. Für unser Beispiel könnten wir folgende Regel anwenden:

.cc.o:

        g++ -c $<

Dabei ist $< ein Makro, das den Namen der Quelldatei bezeichnet. Derartige Makros gibt es bei make noch einige weitere; wir werden gleich noch darauf zurückkommen. Allgemein geben Sie also bei einer impliziten Regel zuerst die Dateiendung der Quelldatei und dann die der Zieldatei an. Beide beginnen jeweils mit einem Punkt, so dass ein zusätzliches Trennzeichen nicht nötig ist. Auch bei impliziten Regeln müssen Sie darauf achten, die Befehlsliste mit einem Tabulatorzeichen einzurücken.

Der Nachteil an impliziten Listen ist, dass damit auch die Abhängigkeiten implizit gesetzt werden, das heißt, es gilt dann nur die Abhängigkeit zwischen Quell- und Zieldatei. Eventuelle weitere Abhängigkeiten, etwa von Header-Dateien, bleiben unberücksichtigt. Aber auch dazu bietet Linux ein geeignetes Werkzeug -- nur Geduld!

Eine implizite Regel findet immer dann Anwendung, wenn es für das gerade zu erstellende Ziel keine explizite Regel gibt. Wenn Sie also für einzelne Dateien besondere Optionen angeben wollen, formulieren Sie für diese explizite Regeln, denn diese haben Vorrang.

Mehrere Ziele

Natürlich hat Ihr Projekt im Allgemeinen nur eine Zieldatei, nämlich die ausführbare Datei oder eine Bibliothek. Dieses Standardziel legen Sie mit all fest. In vielen Fällen ist es aber sinnvoll und wünschenswert, noch weitere Ziele zu haben. In vielen Projekten sind beispielsweise die in Tabelle [*] aufgeführten Ziele anzutreffen.


Table: Es gibt eine Reihe weit verbreiteter Ziele, die die Benutzer oft erwarten.
Ziel Beschreibung
install installiert das Programm auf dem Rechner
clean löscht das Programm und alle abhängig generierten Dateien
dist erzeugt ein Quelltextpaket, um es weiterzugeben
check führt Tests durch, um die ordnungsgemäße Erzeugung des Programms zu überprüfen
depend berechnet die Abhängigkeiten unter den einzelnen Dateien neu und speichert diese (meist im Makefile)

Für unser Beispielprojekt könnte das Ziel clean etwa lauten:

clean:

        -rm -f birthcontrol *.o

Abhängigkeiten gibt es hier keine; es sollen lediglich die ausführbare Datei und alle Objektdateien gelöscht werden. Im Unterschied zu den bisherigen Regeln findet sich vor dem Befehl hier noch ein Minuszeichen. Normalerweise meldet make einen Fehler, wenn das aufgerufene Programm mit einem Fehler endet. Setzt man allerdings das Minus davor, wird der Rückgabewert ignoriert. Das bedeutet, dass make clean auch dann fehlerfrei funktioniert, wenn die Ziele überhaupt nicht vorhanden sind.

Arbeiten mit make

Normalerweise rufen Sie make völlig ohne Argumente auf. Dann sucht das Programm nach einer Datei namens makefile oder Makefile und erzeugt entweder das erste oder das all-Ziel.

Möchten Sie ein anderes Ziel erzeugen, geben Sie dies als Argument beim Aufruf an, zum Beispiel:

make clean

Kommandozeilenoptionen

Darüber hinaus kennt make eine ganze Reihe von Kommandozeilenoptionen. Die wichtigsten sind:

-k
Normalerweise bricht make ab, wenn ein aufgerufenes Programm mit einem Fehler endet. Mit der Option -k veranlassen Sie, trotzdem mit der Abarbeitung fortzufahren.
-n
Wollen Sie zunächst einmal Ihr Makefile testen, starten Sie make mit -n. Dann werden alle Befehle nur ausgedruckt, aber nicht ausgeführt -- eine Trockenübung also.
-f
Wollen Sie eine Makefile verwenden, das einen anderen Namen trägt, geben Sie diesen hinter der Option -f an, also etwa make -f myprog.mak.
-p
Möchten Sie mehr über die intern definierten Makros und Regeln erfahren (siehe unten), können Sie sich mit -p alle ausgeben lassen. Aber Vorsicht: Da die Liste lang ist, sollten Sie sie lieber in eine Pipe schicken, hinter der sich more oder less verbergen.

Fehler

Wie gesagt, startet make beim Aufruf zunächst die Überprüfung, welche Dateien neuer als ihre Ziele sind. Trifft das für eines in der Kette der Abhängigkeiten des endgültigen Ziels zu, wird die zugehörige Regel ausgeführt, also ein anderes Programm (etwa ein Compiler) aufgerufen.

Bricht dieses Programm mit einem Fehler ab (zum Beispiel weil sich in Ihrer Quelldatei Syntaxfehler befinden), beendet auch make seine Arbeit:

make: *** [birthday.o] Error 1

Ein anderer Fehler wird gemeldet, wenn Sie in Ihrem Makefile falsche Angaben gemacht haben. Hat sich zum Beispiel ein Tippfehler bei den Abhängigkeiten eingeschlichen, wird das mit der Meldung quittiert:

make: *** No rule to make target `brthlist.o',

needed by `birthcontrol'.  Stop.

Versuchen Sie in diesem Fall also immer, zunächst die Schreibweise im Makefile und die logischen Zusammenhänge der Abhängigkeiten zu überprüfen. Das kann auch bedeuten, dass Sie eine Datei im Makefile angegeben haben, die sich gar nicht in Ihrem aktuellen Verzeichnis befindet.

Eine eher harmlose Meldung erscheint, wenn Sie make aufrufen, obwohl sich keine Dateien gegenüber der letzten Erzeugung verändert haben:

make: `birthcontrol' is up to date.


Makros

Die Syntax für die Zuweisung eines Makros entspricht der in Bourne-Shell-Skripten, also

MAKRONAME = Inhalt

Im Namen dürfen Sie (natürlich) nur alphanumerische Zeichen verwenden. Der Name muss zudem in der ersten Spalte einer Zeile beginnen. Als Inhalt gilt dann nicht nur ein Wort, sondern alle Zeichen bis zum Ende der Zeile. Somit können Sie eine beliebige Folge von Zahlen, Buchstaben und Sonderzeichen als Makro definieren. Um den Inhalt eines Makros zu erhalten, müssen Sie den Namen in Klammern mit vorangestelltem $-Zeichen angeben, also etwa $(MAKRONAME).

Eine häufige Anwendung von Makros ist die Definition von Compileroptionen, Compilernamen und so weiter. Auf diese Weise ist es nämlich möglich, an einer zentralen Stelle alle Optionen für die Übersetzung festzulegen; sollen diese einmal geändert werden (etwa, um von einer Debug- auf eine Release-Version zu schalten), muss nur diese Zeile geändert werden. Ähnlich verhält es sich, wenn ein anderer Compiler ausgewählt werden soll, zum Beispiel wenn Sie das Programm auf einer anderen Plattform übersetzen wollen, wo kein GCC vorhanden ist (gibt es das?!). Auch die nötigen Bibliotheken lassen Sie so übersichtlich auflisten.

all: birthcontrol

 

# Name des Compilers

CXX=g++

 

# Pfad für zusätzliche Header-Dateien

INCLUDE=.

 

# Compilerschalter

CCFLAGS=-g -Wall

 

# zusaetzliche Bibliotheken

LIBS= 

 

birthcontrol: birthlist.o birthday.o date.o

        $(CXX) birthlist.o birthday.o date.o $\backslash$

        -o $@ $(LIBS)

 

.cc.o: 

        $(CXX) -I$(INCLUDE) $(CCFLAGS) -c $<  

In Tabelle [*] finden Sie einige weitere vordefinierte Makros (auch automatische Variablen genannt), die oft ganz praktisch sein können.


Table: Die vordefinierten Makros in Makefiles können die Arbeit erleichtern.
Makro         Bedeutung
$@         Dateiname des Ziels, einschließlich Endung
$*         Name des Ziels, aber ohne Dateierweiterung
$<         Name der ersten abhängigen Datei
$?         Namen aller abhängigen Dateien, die neuer als das Ziel sind, getrennt durch Leerzeichen
$+         Namen aller abhängigen Dateien, getrennt durch Leerzeichen
$^         Namen aller abhängigen Dateien, getrennt durch Leerzeichen; doppelt vorkommende werden dabei weggelassen

Außerdem gibt es noch eine Reihe von weiteren internen Makros, Makromodifizierern und Kontrollanweisungen (wie !if oder !ifdef), die Sie aber erst bei recht komplexen Makefiles benötigen werden.

Natürlich haben Sie in einem Makefile auch Zugriff auf alle Umgebungsvariablen, die Sie in der Shell definiert haben.

Eingebaute Regeln

Viele Regeln sind immer wieder sehr ähnlich. Aus diesem Grund sind einige bereits in make eingebaut. Es verwundert Sie sicher auch nicht zu hören, dass diese sich unter Linux ausschließlich auf den GCC beziehen. Praktisch bedeutet das, dass Sie nur noch die Abhängigkeiten angeben müssen; die Regel zum Übersetzen ist bereits bekannt.

Ganz bequem können Sie es sich machen, wenn Ihr Programm nur aus einer Quelldatei besteht. Dann ist nicht einmal ein Makefile nötig, denn die eine Zeile darin wird ja bereits durch die eingebaute Regel überflüssig. Nehmen wir beispielsweise unsere Datei lotto.cc aus dem letzten Kapitel, so genügt es, wenn Sie in der Shell aufrufen:

make lotto

Daraufhin wird der Compiler mit den nötigen Optionen gestartet. Auf dem Bildschirm erscheint:

g++     lotto.cc   -o lotto

Bei einem Projekt aus mehreren Dateien genügt es daher oft, die Abhängigkeiten und die Linker-Regel für die ausführbare Datei zu definieren. Alles andere können Sie mit Hilfe der eingebauten Regeln erzeugen lassen.

Diese setzen sich selbst wieder aus Makros zusammen. Tabelle [*] zeigt Ihnen eine Liste wichtiger vordefinierter Makros. Diese können Sie sich auch durch den Aufruf von make -p ausgeben lassen.


Table: In make sind bereits einige Makros vordefiniert.
Makro definiert als
CC cc
CXX g++
OPTION_OUTPUT -o $@
COMPILE.c $(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c
COMPILE.cc $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c
LINK.c $(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH)
LINK.cc $(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH)
AR ar
ARFLAGS rv

Dabei sind die Makros $(CFLAGS), $(CPPFLAGS) und $(TARGET_ARCH) sowie $(CXXFLAGS) und $(LDFLAGS) nicht vordefiniert. Dass sie dennoch in den eingebauten Regeln verwendet werden, bedeutet für Sie, dass Sie durch das Setzen dieser Definitionen eigene Einstellungen in diese Regeln einbringen können. Dazu gibt es - wie immer - mehrere Möglichkeiten:

In Tabelle [*] stehen sowohl die Definitionen der Makros für die Übersetzung von C-Dateien als auch die für C++-Dateien. Den Typ der Datei erkennt make an ihrer Endung. Als C++-Quelltext werden beispielsweise die Dateien mit den Endungen .cc, .cpp oder .C erkannt. Als eingebaute Regeln kommen dann folgende zum Einsatz:

%.o: %.cc

       $(COMPILE.cc) $< $(OUTPUT_OPTION)

 

%: %.cc

       $(LINK.cc) $^ $(LOADLIBS) $(LDLIBS) -o $@

Hier sehen Sie übrigens auch eine alternative Form, mit der Sie implizite Regeln ausdrücken können. Anstelle des Sternchens, das etwa in der Shell verwendet wird, steht hier das Prozentzeichen %.

Für kleinere Projekte sind die eingebauten Regeln sicher ganz praktisch. Wenn Sie allerdings an einem größeren Projekt arbeiten, rate ich Ihnen von deren Verwendung ab. Zum einen wird durch die ausdrückliche Angabe der Regeln in der Make-Datei viel eher deutlich, welche Einstellungen in Ihrem Projekt gelten. Zum anderen sind eingebaute Regeln im Allgemeinen abhängig von der aktuellen Plattform; auf einem anderen Unix können sie deutlich anders lauten. Indem Sie sich auf eingebaute Regeln verlassen, schränken Sie also die Portablität Ihres Projekts ein.

make für Fortgeschrittene

In diesem Abschnitt will ich Sie noch auf ein paar Spezialitäten hinweisen, die den Umgang mit make für Sie einfacher machen könnten. Hier zeigt sich übrigens, dass make nicht gleich make ist. Denn GNU-make, das Sie unter Linux nutzen, hat eine Reihe von Features, über die die entsprechenden Tools der kommerziellen Unix-Varianten nicht verfügen.

Beschränkung der Aktivität

Das Übersetzen aller Dateien eines größeren Projekts kann des Öfteren selbst auf schnellen Maschinen einige Zeit in Anspruch nehmen. Obwohl Multitasking unter Linux ja kein Problem ist, können die Aktivitäten von make bei der Arbeit an der Konsole doch als störend empfunden werden.

Eine Möglichkeit der Abhilfe ist da die Option -l. Mit ihr können Sie angeben, dass make nur dann neue Befehle starten soll, wenn die Systemauslastung (load average) unterhalb des angegebenen Wertes gefallen ist. Den Wert geben Sie dabei als Dezimalbruch an (entsprechend dem Prozentualwert der Auslastung). Wenn Sie als Schranke 25% setzen möchten, lautet der Aufruf also:

make -l 0.25

Damit verhindern Sie, dass make Ihren Rechner gerade dann belastet, wenn Sie selbst umfangreichere Aktivitäten laufen haben. Auf der anderen Seite geben Sie grünes Licht weiterzuarbeiten, wenn Sie selbst außer ein paar Mausklicks oder Tastatureingaben nichts weiter tun.

Mehrere Jobs gleichzeitig

Als Alternative für große Projekte bietet es sich an, möglichst viel gleichzeitig erledigen zu lassen. Denn oftmals hängen viele Zwischenziele (etwa Objektdateien) nicht voneinander ab und könnten daher parallel übersetzt werden. Auch dazu stellt make eine Option bereit. Mit -j können Sie die Anzahl der Befehle angeben, die maximal gleichzeitig gestartet werden. Für ein mittelgroßes Projekt ist ein Wert zwischen drei und fünf ein guter Ausgangspunkt. Sie können ja die Ausführung beobachten (zum Beispiel durch top in einem anderen Fenster) und dabei erkennen, ob noch mehr Prozesse sinnvoll wären oder bereits die aktuelle Anzahl zu hoch ist. Wollen Sie etwa 5 als Maximalwert setzen, lautet der Aufruf:

make -j 5

(Das Leerzeichen vor der 5 ist dabei optional.) Wenn Sie nun denken, dass eine solche Parallelverarbeitung nur auf einem Mehrprozessorcomputer wirklich Verbesserungen bringt, haben Sie zwar nicht ganz unrecht. Sie müssen sich allerdings vor Augen halten, dass auch das Kompilieren nicht ein starrer Prozess ist, der immer gleich viel CPU-Leistung erfordert, sondern auch sich in Unterprozesse wie Präprozessor, Optimierer und so weiter aufspaltet, die jeweils für sich Vor- und Nachbereitungen zu erledigen haben. Das führt dazu, dass bei der Übersetzung einer Datei der Rechner längst nicht die ganze Zeit ausgelastet ist, sondern durchaus weitere Prozesse vertragen könnte -- sofern nicht andere Benutzer auch Rechenzeit für sich beanspruchen wollen.

An \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}}  dieser Stelle noch eine Warnung: Wenn Sie hinter -j keine Zahl angeben, ist die Anzahl der gleichzeitig zu startenden Kommandos nicht limitiert. Das kann bei größeren Projekten zur Folge haben, dass mehrere Dutzend Compileraufrufe auf einmal abgesetzt werden. Selbst ein gut ausgebautes System kann da vollständig lahmgelegt werden, so dass nicht einmal mehr Benutzerinteraktionen möglich werden! Gewöhnen Sie sich daher besser an, diese Option immer mit einer Beschränkung zu verwenden.


Automatische Erzeugung von Abhängigkeiten

Ein sehr nützliches Zusatzwerkzeug zu make ist makedepend. Es geht Ihren gesamten Quelltext durch und fügt alle impliziten Abhängigkeiten (etwa von Header-Dateien) an Ihr Makefile an. Jedes Mal, wenn Sie Dateien hinzufügen oder #include-Anweisungen ändern, sollten Sie es daher aufrufen.

Dieses Tool ist im Rahmen des X11-Projektes entstanden und wird daher im Normalfall mit jeder Distribution ausgeliefert, die eine X11-Unterstützung enthält (und das dürften alle sein). Sie sollten es auch im gleichen Verzeichnis wie die X11-Server oder andere X-Anwendungen finden können, also /usr/X11/bin. Wenn nicht, können Sie es von ftp.x11.org oder einem Mirror (zum Beispiel www.leo.org) beziehen.

So nützlich makedepend auch ist und so sehr ich Ihnen dieses Werkzeug bei allen von Ihnen selbst verwalteten Make-Dateien ans Herz legen möchte -- auf ein Problem muss ich Sie noch hinweisen. Sie können makedepend auf zweierlei Arten verwenden, nämlich mit oder ohne Berücksichtigung der System-Header (wie cstdio oder iostream). Die Vorgabe ist, dass Sie diese Dateien berücksichtigen wollen. Leider passt sich makedepend nicht dem aktuellen Compiler an, sondern sucht die System-Header in Verzeichnissen, die bei seiner eigenen Erzeugung eingestellt waren. Das hat zur Folge, dass Sie immer wieder auf Fehlermeldungen treffen wie:

makedepend: warning:  birthlist.cc, line 3: 

cannot find include file "iostream"

     not in ./iostream

     not in /usr/local/lib/gcc-include/iostream

     not in /usr/include/iostream

     not in /usr/lib/gcc-lib/i486-linux/2.7.2.3/include/iostream

Dem können Sie natürlich abhelfen, indem Sie makedepend mit der -I-Option, die Sie vom Compiler kennen, den Pfad zu Ihren Systemdateien mitteilen, also etwa:

MKDEPINC=-I$(GCC_DIR)/include/g++-3 \

  -I$(GCC_DIR)/lib/gcc-lib/i686-pc-linux-gnu/2.95/include

Aber ist das wirklich das was Sie wollen? Im Grunde ändern sich doch die System-Header nicht so oft, dass sie als Abhängigkeiten in Ihr Makefile aufgenommen werden müssten. Leider scheint es keine Möglichkeit (außer einer Änderung im Quelltext ...) zu geben, um diese Warnung abzuschalten. Da Sie aber das Tool nicht allzu oft aufrufen werden und dann auch wissen, wo das Problem liegt, können Sie die Warnungen genauso gut auch ignorieren.

Bereits bei kleineren Projekten leistet makedepend nützliche Dienste, da Sie oft nicht alle Querverbindungen zwischen Ihren Dateien im Kopf haben (was auch gar nicht nötig sein sollte!). Am besten legen Sie sich in Ihrem Makefile ein Ziel namens depend an, das dann makedepend startet. (Wenn Sie übrigens einen anderen Namen als makefile oder Makefile verwenden, müssen Sie diesen auch bei makedepend mit der Option -f ausdrücklich angeben.) Als Parameter erwartet dieses Tool übrigens nur eine Liste mit Ihren Implementierungsdateien. Falls Sie eigene Pfade für Ihre Header-Dateien mit -I beim Compileraufruf angeben, müssen Sie diese Option natürlich auch bei makedepend verwenden, da es sonst diese Header-Dateien nicht finden kann. Empfehlenswert ist daher ein Makro $INCLUDE, das Sie dann in beiden Fällen verwenden, gleichzeitig aber zentral pflegen können.

Für unser Beispiel birthcontrol lässt sich die beschriebene Technik etwa wie folgt realisieren:

INCLUDE=-I.

CXXFLAGS=-Wall -g $(INCLUDE)

OBJECTS=birthlist.o birthday.o date.o

SOURCES=birthlist.cc birthday.cc date.cc

 

all: birthcontrol

 

birthcontrol: $(OBJECTS)

        g++ $(OBJECTS) -o birthcontrol

 

depend:

        makedepend $(INCLUDE) $(SOURCES)

In diesem Fall wird makedepend wie oben beschrieben vermutlich die System-Header nicht finden und Ihnen einige Warnungen ausgeben. Trotzdem ist anschließend eine Liste wie folgende an das Makefile angehängt:

# DO NOT DELETE

 

birthlist.o: birthlist.h date.h

birthday.o: date.h birthlist.h

date.o: date.h

Hier sind die gegenseitigen Abhängigkeiten zwar noch recht übersichtlich, bei größeren Projekten werden Sie das Werkzeug aber sicher als hilfreich empfinden -- sofern Sie nicht generell nur Makefiles verwenden, die automatisch von Ihrer Entwicklungsumgebung verwaltet werden.

Übrigens ist im GCC eine ähnliche Funktion zur Bestimmung von Abhängigkeiten eingebaut, wenn auch mit etwas geringerer Leistungsfähigkeit. Sie müssen dazu die Option -MM verwenden, also etwa

g++ -MM birthlist.cc

Probieren Sie es doch einfach mal aus!

Zusammenfassung

In diesem Abschnitt haben Sie das Werkzeug make kennen gelernt, das für die Entwicklung von Programmen, die aus mehr als zwei Quelldateien bestehen, eigentlich unerlässlich ist. Auch wenn Ihnen Makefiles auf den ersten Blick etwas unverständlich vorkommen mögen -- im Grunde sind sie nicht besonders schwer zu lesen (wenn ihr Autor nicht gerade versucht hat, das Letzte an Funktionalität herauszuholen). Folgende Eigenschaften sollten Sie sich merken:

Darüber hinaus gibt es gerade für make noch eine ganze Reihe von zusätzlichen Werkzeugen. So haben beispielsweise Programme wie mkmf oder imake den Zweck, aus einem generischen Makefile ein für den jeweiligen Rechner angepasstes Makefile zu erzeugen. Aber solche Tools werden Sie nur bei sehr großen Projekten benötigen, die für mehrere Plattformen entwickelt sind. Bei den moderneren GPL-Projekten wie GNU-Tools, GNOME oder KDE kommt dafür außerdem autoconf zum Einsatz, das ungefähr denselben Zweck erfüllt (siehe org.gnu.de/software/autoconf).

Apropos: Unter org.gnu.de/software/make finden Sie bei den Codierungskonventionen für GNU-Projekte auch einen Abschnitt, wie Makefiles dabei aufgebaut sein sollten. Und mit dem Source-Code aller GNU-Programme werden auch immer Makefiles verteilt, aus denen Sie sicher noch einiges lernen können.


Fehlersuche mit dem Debugger

Jeder Mensch macht Fehler; und da auch Programmierer Menschen sind, enthalten auch deren Programme Fehler. Der Richtwert eines durchschnittlichen Programmierers liegt bei etwa einem Fehler auf je 100 Zeilen Quelltext (was natürlich von der Komplexität des Programms sowie vielen anderen Faktoren abhängt). Ein mittelgroßes Projekt mit ca. 50.000 - 100.000 Zeilen Code enthält also im Durchschnitt 500 bis 1000 Fehler. Wenn Sie Maßnahmen zur Qualitätssicherung ergreifen (etwa die ab Seite [*] vorgeschlagenen Konventionen einhalten), können Sie damit viele Fehler schon von vornherein vermeiden.

In diesem Abschnitt soll es also um Fehler gehen, um ihre Gefahrenquellen und um Werkzeuge, mit denen sie sich aufspüren lassen:


Theoretische Fehlerquellen

Wo kommen also die Fehler her, die man im Englischen so plastisch als bugs, also Käfer, bezeichnet? Zum einen können Ihnen beim Programmieren immer wieder syntaktische Fehler unterlaufen, also Vertippen bei Schlüsselwörtern oder Variablennamen, falsche Typen bei Methodenargumenten und so weiter. Diese Fehler findet aber bereits der Compiler. Das ist zwar zuweilen etwas lästig, aber im Allgemeinen recht vollständig.

Schwerwiegender sind die semantischen Fehler. Dabei will ich diesen Begriff gar nicht darauf ausdehnen, dass Ihr Programm nicht die gestellten Anforderungen erfüllt. Ich meine hier nur die zur Laufzeit auftretenden Fehler, die entweder sofort schwerwiegende Folgen wie Segmentation Faults haben, oder aber die das Ergebnis des Programms nachhaltig verändern und verzerren können. Leider sind gerade diese Fehler oft sehr schlecht reproduzierbar, da sie von vielen Einflüssen innerhalb und außerhalb der Software abhängen können. Ist es in der Theorie des Software Engineering noch so, dass Entwickler und Tester eines Programms verschiedene Personen sein sollten, so sieht die Praxis doch häufig anders aus. Und Entwickler neigen nun leider einmal dazu, instinktiv die Schwachstellen ihres Programms beim Testen zu umgehen.

Doch zunächst wollen wir uns einige repräsentative Arten von Fehlern näher ansehen.

Designfehler

Zunächst sind da die Designfehler. Ich meine damit nicht so sehr Mängel bei der grundlegenden Architektur der Software, sondern einfach, wenn der Code zu einem anderen Verhalten führt, als der Programmierer es beabsichtigte.

Ein typisches Beispiel dafür sind if-Anweisungen, bei denen die umschließende Klammer fehlt, etwa

  if (result == 0)

    text = ``Hier ist ein Fehler!'';

    cerr << text << endl;

Sicher sollte hier die Ausgabe auch noch von dem Wert von result abhängen. Auf Seite [*] hatte ich Sie ja schon auf diese Probleme hingewiesen.

In die gleiche Kategorie fallen übrigens auch vergessene breaks bei switch-Anweisungen oder falsch gesetzte Strichpunkte, beispielsweise bei for-Schleifen:

  for(k=0; k<n; k++); // Ein Semikolon zuviel!

    x += k;

Haben Sie das Problem erst einmal eingekreist, können Sie es oft durch bloßen Augenschein, sicher aber mit Hilfe der Verfolgung des Ablaufs im Debugger aufspüren.

Datenveränderungen

Diese, aber leider auch andere Ursachen können zu Fehlern führen, die Daten verändern oder zerstören. In seltenen Fällen ist dieser Effekt durch Wechselwirkungen mit anderen Programmen bedingt. Hier haben Sie unter Linux aber weniger zu befürchten als auf anderen Plattformen, da die Prozesse streng voneinander abgetrennt sind und das Betriebssystem bereits verhindert, dass sich zwei Anwendungen ins Gehege kommen. Natürlich kann aber auch Ihr Programm selbst daran schuld sein; die häufigsten Ursachen sind Überschreitungen von Feldgrenzen, Überlauf von Variablen und fehlerhafte Weitergabe von Zeigern.


Bedingte Kompilierung

Ein ganz anderes Problemfeld ist das Ein- und Ausschließen von bestimmten Programmteilen durch bedingte Kompilierung. Oftmals baut man Testausgaben und -aufrufe ein, die dann in der endgültigen Version nicht mehr enthalten sein sollen, etwa in der Form:

#ifdef DEBUG

  cout << ``Ergebnis: `` << res << endl;

#endif

Wenn Sie beispielsweise das abschließende #endif vergessen, später aber ein weiteres stehen haben, kann der Präprozessor sehr viel größere Teile aus Ihrem Programm entfernen, als Sie das eigentlich wollten. Und bei verschachtelten Anweisungen dieser Art ist noch größere Vorsicht geboten. Das fängt bereits damit an, dass natürlich immer das korrekte Makro definiert sein muss.

Rechen- und Rundungsfehler

Mit Rechen- und Rundungsfehlern hat man sich auseinander gesetzt, solange es Computer gibt. Nur allzu schnell vergessen viele Entwickler nämlich, dass sämtliche Zahlen in einem Rechner nur eine endliche Genauigkeit haben können. Die Numerik kennt dafür umfangreiche theoretische Untersuchungen, die eigentlich allen bekannt und bewusst sein sollten, die Rechenoperationen programmieren (siehe etwa [STOER 1989]). Ein Beispiel sind die Auslöschungen, die bei Verknüpfungen von sehr großen und sehr kleinen Zahlen miteinander auftreten. Dabei kann oft schon eine Vertauschung von Variablen innerhalb eines an sich assoziativen Ausdruckes zu völlig anderen Ergebnissen führen.

In die gleiche Kategorie fallen Überläufe. Ganzzahlige Datentypen haben nur einen begrenzten Wertebereich. Führt eine Operation darüber hinaus, hat die Variable anschließend einen völlig anderen, eventuell auch negativen Wert. Auch mit dem Mischen von Werten mit und ohne Vorzeichen (signed und unsigned) oder bei Zuweisungen beziehungsweise Parameterübergaben, die int auf short abbilden, muss man sehr vorsichtig sein. Solche Probleme kann man zwar relativ rasch aufspüren. Noch besser ist es aber, sie gleich zu vermeiden.


Abstürze und Core-Dateien

Abstürze sind unter Linux längst nicht so häufig wie unter Windows. Und wenn ein Programm abstürzt, bleibt das übrige System davon im Allgemeinen unbeeindruckt. Vorkommen können sie aber dennoch. Die häufigsten Ursachen für Totalabstürze (segmentation fault oder bus error) sind Fehler beim Umgang mit Zeigern oder der dynamischen Speicherverwaltung, also delete auf bereits freigegebenen Speicher, Zuweisungen an Nullzeiger und so fort. Der große Vorteil bei Linux ist dabei, dass Sie nicht mühsam versuchen müssen, das Problem zu reproduzieren, sondern dass Ihnen oftmals gleichzeitig mit dem Absturz ein Speicherauszug (core) erstellt wird. Diesen können Sie dann in den Debugger laden, der Ihnen daraufhin die genaue Stelle des Absturzes anzeigen kann. Die eigentliche Ursache müssen Sie dann aber schon selbst finden.

So praktisch ein solcher Speicherauszug auch ist -- selbstverständlich ist seine Erzeugung nicht. Da solche Core-Dateien oftmals immensen Speicher auf dem Datenträger verbrauchen, ist die Voreinstellung auf den meisten Linux-Systemen, dass überhaupt keine Speicherauszüge erzeugt werden; die maximale Größe für vom Benutzer generierte Core-Dateien ist dann 0. Um dies zu ändern, können Sie beispielsweise in der Bash mit dem Befehl

ulimit -c unlimited

die Größe auf unbegrenzt setzen. Dann sollte bei jedem schweren Speicherfehler eine Core-Datei erstellt werden.

Typische Fehlerquellen

Als größte Fehlerquellen mit dem schlechtesten Einfluss auf die Stabilität des Programms haben wir also folgende identifiziert:

Wie Sie sehen, ist es besonders die systemnahe Programmierung im Stil von C, die Probleme mit sich bringt. Wenn Sie so weit es geht darauf verzichten und nur sichere Container (wie string oder list aus der C++-Standardbibliothek) verwenden, haben Sie das Auftreten einiger gravierender Fehler bereits ausgeschlossen.

Statusausgaben im Code

Es ist eine typische Situation, die jeder Programmierer kennt: Ein Programm wurde gerade fertig, es lässt sich ohne Fehler übersetzen. Dann startet man es und -- rums! Ein Absturz mit segmentation fault. In dieser Situation sollten Sie zunächst überprüfen, ob der Fehler reproduzierbar ist, das heißt, ob das Programm wieder abstürzt, wenn Sie dieselben Eingaben vornehmen.

Dann müssen Sie das Gebiet im Code eingrenzen, in dem der Absturz passiert. Dazu haben Sie drei Möglichkeiten: Entweder Sie (ohne auch ein Kollege!) lesen den Quelltext aufmerksam durch, um mögliche Fehler zu entdecken. Diese Vorgehensweise ist zwar immer ratsam, führt aber leider nicht immer zum Ziel.

Die zweite Möglichkeit besteht darin, Ihren Quelltext mit Zwischenausgaben zu versehen. Mit diesen können Sie nicht nur erkennen, welche Anweisungen bereits abgearbeitet sind, sondern auch Werte einzelner Variablen ausgeben. Um diese Ausgaben wirklich nur bei der Fehlersuche im Programm zu haben, sollten Sie sie in einen Block einschließen, der nur mittels des Präprozessors übersetzt wird, wenn Sie eine bestimmte Definition angegeben haben (siehe Seite [*]).

Beachten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie, dass Textausgaben nicht ständig auf den Bildschirm geschrieben werden, sondern erst wenn der interne Puffer voll ist. Bei der Fehlersuche kommt es daher häufiger vor, dass zwischen Ihrer cout-Anweisung und der eigentlichen Ausgabe gerade der Programmabsturz stattfindet. Dann wissen Sie nicht mehr, ob der Schritt, den Sie ausgeben wollten, bereits vollzogen war oder nicht. Ich empfehle Ihnen daher, bei Debug-Ausgaben grundsätzlich eine Leerung des Puffers mittels flush ans Ende zu setzen, zum Beispiel:

  cout << ``Schritt 1 fertig!'' << endl << flush;

Neben der Steuerung der Ausgaben über den Präprozessor können Sie (alternativ oder zusätzlich) noch globale Variablen beziehungsweise Objekte einbauen, über die sich dann die Ausgaben sehr selektiv steuern lassen, beispielsweise nach Teilsystemen getrennt oder nach Ausführlichkeit abgestuft.

Ein fehlerhaftes Programm

Als Beispiel wollen wir ein Programm betrachten, das die Determinante einer Matrix nach der Laplace-Entwicklung berechnet. Für alle Nichtmathematiker eine kurze Erklärung: Die Determinate einer reellen, symmetrischen Matrix $A$ ist eine reelle Zahl, die unter anderem darüber Auskunft gibt, ob die Matrix invertierbar ist ( $\vert A\vert:=\det(A)\neq 0$) oder nicht ($\det(A)=0$). Das Berechnungsverfahren besagt, dass man Untermatrizen zu bilden hat, indem man eine Zeile und eine Spalte weglässt. Die Determinante ist dann eine Summe aus den Determinanten der Untermatrizen, multipliziert mit dem Wert in der weggelassenen Zeile und Spalte, wobei in der Summe stets das Vorzeichen wechselt. Alles klar? Sehen wir uns das für eine Matrix mit vier Zeilen und Spalten einfach an.


\begin{displaymath}\begin{array}{rcrr}\left\vert \begin{array}{rrrr} 1 & 4 & 3 &...
...3 & 0 & -2 -1 & 3 & 1 \end{array}\right\vert  \end{array}\end{displaymath}

Auf die Untermatrizen wird die Rechenregel wieder angewendet (also rekursiv); als Ergebnis erhält man schließlich 143.

Ein einfaches Beispiel sind zyklische Matrizen, zum Beispiel:

\begin{displaymath}D_3 = \left( \begin{array}{rrr} 1 & 2 & 3  3 & 1 & 2 2 & 3 & 1 \end{array}\right)\end{displaymath}

Deren Determinante kann man allgemein über folgende Formel berechnen:

\begin{displaymath}\det(D_n) = (-1)^{\frac{n(n-1)}{2}} \frac{n+1}{2} n^{n-1}\end{displaymath}


Die Schnittstelle der Matrix-Klasse

Wir definieren eine Klasse SymMatrix mit folgender Schnittstelle:


1:  // Datei: matrix.h 


2:  #ifndef _MATRIX_H_ 


3:  #define _MATRIX_H_ 


4:   


5:  #include <string> 


6:   


7:  class SymMatrix 


8:  { 


9:  public: 


10:    SymMatrix(); 


11:    SymMatrix(unsigned int _n); 


12:    SymMatrix(const SymMatrix& _mat); 


13:    ~SymMatrix(); 


14:     


15:    bool enter(); 


16:    bool read(const string& _fname); 


17:    double at(unsigned int _i, 


18:              unsigned int _j) const; 


19:    double& at(unsigned int _i, 


20:               unsigned int _j); 


21:    unsigned int getSize() const; 


22:    SymMatrix subMatrix(


23:      unsigned int _k) const; 


24:     


25:    friend double determinant(


26:      const SymMatrix& _a); 


27:     


28:  private: 


29:    double** dat; 


30:    unsigned int size; 


31:     


32:    void initMemory(unsigned int _n); 


33:    void freeMemory(); 


34:    void print(); 


35:  }; 


36:  #endif 


Der Konstruktor mit einer Ganzzahl soll dazu dienen, eine Matrix mit gegebener Anzahl von Zeilen und Spalten zu initialisieren. Kopierkonstruktor und Destruktor sollten Ihnen noch aus den Abschnitten [*] und [*] geläufig sein. (Wenn nicht, schlagen Sie gleich auf Seite [*] nach!)

Die Methode enter() erlaubt eine Matrixeingabe von Hand, während read() die Elemente aus einer Datei liest. Die beiden Methoden at() erlauben den Zugriff auf einzelne Elemente und zwar in einer konstanten Version für Lese- und einer anderen für Schreibzugriffe. Die Bildung der Untermatrix ist ebenfalls als Methode der Klasse selbst (subMatrix()) vorgesehen, wogegen die Determinantenberechnung in einer getrennten Funktion erfolgt, die allerdings mit der Klasse befreundet ist (zu friend siehe Seite [*]).

Die main()-Funktion

Gehen wir zunächst einmal davon aus, dass alles korrekt programmiert ist, und betrachten die Funktion main().


1:  // Datei main.cc 


2:  #include <iostream> 


3:  #include <iomanip> 


4:  #include "matrix.h" 


5:   


6:  int main() 


7:  { 


8:    string filename; 


9:    cout << "Datei mit Matrix: "; 


10:    cin >> filename; 


11:       


12:    SymMatrix a; 


13:    if (a.read(filename)) 


14:      return -1; 


15:       


16:    double d=determinant(a); 


17:    cout << "Determinante ist "  


18:         << setprecision(6) << d << endl; 


19:     


20:    return 0; 


21:  } 


Dort wird lediglich ein Dateiname vom Benutzer erfragt, die Datei eingelesen und die Determinante nach ihrer Berechnung ausgegeben. Die Methode read() ist so definiert, dass sie true im Erfolgsfall und false im Fehlerfall zurückliefert. Wenn wir nun das Programm starten und einen Dateinamen eingeben, passiert anschließend überhaupt nichts mehr. Insbesondere erhalten wir keinerlei Ausgabe. Bei einem Lesefehler müsste die read()-Methode eine Fehlermeldung ausgeben. Was ist also los? Wenn Sie dem Code nicht ansehen, wo der Fehler liegt, müssen Sie den Debugger starten.


Fehlersuche mit gdb

Der GNU Debugger gdb ist der verbreitetste Debugger unter Linux. Da er sich an die unter anderen Unix-Dialekten vorherrschenden Konventionen hält, können sehr viele darauf aufbauende Werkzeuge auch unter Linux eingesetzt werden. Denn der gdb ist zwar sehr leistungsfähig, aber recht schwer zu bedienen. Er hat nur eine Kommandozeile, auf der man alle Befehle an ihn eingeben muss. Obwohl Sie später sicherlich mehr mit so genannten grafischen Frontends zum gdb arbeiten werden, ist es ratsam, die grundlegenden Befehle schon einmal gesehen zu haben, um den Leistungsumfang beurteilen und gegebenfalls einzelne Anweisungen von Hand absetzen zu können.

Vorbereiten des Programms

Um ein Programm überwacht ablaufen zu lassen, müssen besondere Informationen eingefügt werden. Normalerweise sind Programme in reiner Maschinensprache, die keinen Bezug mehr zu Elementen der Programmiersprache wie Variablen oder Funktionen aufweist. Wir wollen aber beispielsweise Zeile für Zeile uns vorwärts durchs Programm bewegen und Inhalte von Variablen ansehen.

Die Option für den GCC zum Hinzufügen von Debug-Informationen ist -g (siehe auch Seite [*]). Dabei sollten Sie immer alle Quelldateien eines Programms damit übersetzen -- und nicht nur die, in der Sie den Fehler vermuten. Es ist zwar prinzipell möglich, Objektdateien mit und ohne Debug-Informationen zu mischen und zusammen zu linken, aber in der Praxis meist wenig hilfreich. Müssen Sie etwa einen Methodenaufruf verfolgen, landen Sie schnell in einer anderen Datei, in der Sie dann nicht mehr sehen können, was vor sich geht.

Darüber hinaus ist es ratsam, auch beim Linker diese Option anzugeben. Dadurch werden andere Systembibliotheken verwendet, so dass Sie der Debugger auch bei Aufrufen zur C-Standardbibliothek unterstützen kann.

Start des gdb

Wenn wir unser Programm also mit der nötigen Option übersetzt haben, können wir den Debugger starten. Dazu geben Sie einfach gdb ein, gefolgt vom Namen der ausführbaren Datei, etwa

gdb DebugTest

Das Programm meldet sich mit einer Copyright-Notiz und wartet dann auf weitere Eingaben.

(gdb)

Obwohl gdb über eine ausführliche Online-Hilfe verfügt (erreichbar mit dem Kommando help) empfehle ich Ihnen die info-Seiten dazu (siehe Seite [*]), die nicht nur deutlich umfangreicher sind, sondern auch den Vorteil haben, dass sie in einem anderen Fenster laufen und man in ihnen einfacher navigieren kann.

Wenn Ihr Programm beim Absturz einen Abzug des Speichers erzeugt (eine so genannte Core-Datei, siehe auch Seite [*]), können Sie diesen als zweites Argument beim Aufruf angeben. Der Debugger kann diesen Speicherauszug laden und damit sofort die Stelle anzeigen, an der der Absturz passierte.

gdb meinprog core

Start eines Programms

Nachdem der Debugger also läuft, wollen wir auch unser Programm starten. Dazu geben wir das Kommando run ein. Der gdb kennt auch eine Reihe von Kürzeln für besonders häufig benutzte Kommandos. Anstelle von run reicht daher auch r.

(gdb) r

Wenn Ihr Programm Kommandozeilenargumente erwartet, können Sie diese direkt hinter run angeben. Bei einem erneuten Start müssen Sie die Argumente nicht wieder eingeben; sie werden intern gespeichert. Ein anderer Weg, Kommandozeilenargumente zu übergeben, ist das Kommando set args. Unmittelbar dahinter setzen Sie die gewünschten Parameter. Diese Einstellung gilt so lange, bis Sie etwas anderes festlegen.

Ähnlich wie die Shell oder der Emacs verfügt der gdb auch über eine automatische Vervollständigungsfunktion für Kommandos. Wenn Sie nur einen Teil eines Kommandos eingeben und dann TAB drücken, wird bei Eindeutigkeit die Anweisung vervollständigt, ansonsten eine Liste mit möglichen Fortsetzungen angezeigt. Natürlich hat der GNU Debugger auch eine Liste der zurückliegenden Befehle, so dass Sie mit den Cursortasten blättern und auch editieren können.

Für unser Beispiel erhalten wir folgende Ausgabe:

(gdb) r

Starting program: /usr/thomas/kap05/DebugTest

 

Datei mit Matrix: cyclic.dat

 

Program exited with code 0377.

Current language:  auto; currently c

(gdb)    

Während des Laufs eines Programms im gdb erscheinen dort auch alle Ausgaben auf den Standardkanälen. Ebenso nehmen Sie die Eingaben darin vor.

Wir sehen, dass auch hier das Programm auf den ersten Blick ordnungsgemäß durchläuft, ohne aber weitere Ausgaben zu produzieren. Beachten sollten Sie, dass der Rückgabewert des Programms nicht 0 wie bei ordentlicher Beendigung ist, sondern 0377. Das deutet darauf hin, dass das Programm bereits mit Zeile 14 beendet wurde. Warum hat aber die Einlesemethode read() keine Fehlermeldung ausgegeben? Deren Code (den Sie unter anderem auf beiliegender CD-ROM finden) macht eigentlich einen ganz korrekten Eindruck. Woran liegt es dann? Das können wir nur erfahren, wenn wir den Ablauf des Programms Schritt für Schritt verfolgen.

Haltepunkte

Bei einem Haltepunkt (breakpoint) hält der Debugger das Programm an und wartet auf Ihre Eingaben. Sie können sich nun die Werte einzelner Variablen oder Objekte ansehen, die Argumente der jeweiligen Methode untersuchen, in einzelnen Schritten weiterlaufen oder das Programm fortsetzen.

Der einfachste Fall ist der unbedingte Stopp. Oft ist es aber interessant, das Programm nur dann an einer Stelle anzuhalten, wenn eine bestimmte Bedingung erfüllt ist, zum Beispiel, wenn ein Zähler i eine gegebene Grenze überschritten hat, sagen wir 12. Denn leider geht bei komplexeren Programmen meist nicht sofort etwas schief, sondern erst nach einiger Zeit ...

Das Kommando zum Setzen eines Haltepunkts ist break oder einfach b. Da der Debugger schon beim Start die Zusatzinformationen über das Programm lädt, kann er zwei ganz verschiedene Argumente anbieten: die Nummer der Zeile, bei der das Programm stehen bleiben soll, oder den Namen einer Funktion beziehungsweise Methode, zu deren Beginn gestoppt wird.

Wenn Sie bei vielen Haltepunkten schon den Überblick verloren haben, geben Sie ein:

(gdb) info breakpoints

und Sie sehen eine Liste mit allen gerade gesetzten Haltepunkten.

Wie wird man einen Haltepunkt nun wieder los? Das Gegenstück zu break ist clear. Dieses Kommando versteht dieselben Argumente, zum Beispiel

(gdb) clear matrix.cc:101

Deleted breakpoint 2

Eine andere Möglichkeit ist delete, das jedoch als Argument die Nummer des Haltepunktes erwartet.

Wenn Sie nicht ganz auf den Haltepunkt verzichten wollen, können Sie ihn auch vorübergehend deaktivieren. Das geschieht durch das Kommando disable, das wieder die Nummer erwartet. Mit enable schaltet man ihn dann wieder ein. Eine Bestätigung gibt es bei diesem Kommando nicht; den Zustand können Sie aber beispielsweise aus der mit info breakpoints erhältlichen Liste entnehmen.

Wenn wir jetzt run eingeben, haben wir unser Ziel erreicht: das Programm steht. Auf dem Bildschirm lesen wir:

(gdb) r

Starting program: /usr/thomas/kap05/DebugTest

 

Datei mit Matrix: cyclic.dat

 

Breakpoint 1, main () at main.cc:13

13        if (a.read(filename))

(gdb)           

Wie Sie sehen, erscheinen Ausgaben des Programms und des Debuggers bunt gemischt. Als Service erhalten wir noch den Programmcode, der in Zeile 13 steht.

Ausgabe von Programmcode

Wenn wir an einer Stelle anhalten, möchten wir natürlich auch den Kontext wissen, in dem wir uns gerade befinden. Am einfachsten geht das mit dem Kommando list, kurz l. Damit erhalten Sie den Code einige Zeilen vor und nach der aktuellen Position:

(gdb) list

8         string filename;

9         cout << "Datei mit Matrix: ";

10        cin >> filename;

11

12        SymMatrix a;

13        if (a.read(filename))

14          return -1;

15

16        double d=determinant(a);

17        cout << "Determinante ist "

Der list-Befehl versteht eine ganze Reihe von Argumenten (ähnlich wie break): eine Zeilennummer oder ein Funktionsname (beides auch mit Dateiname davor) sowie -, um die Zeilen vor, und +, um die Zeilen nach dem letzten Ausschnitt zu zeigen. Die Größe des Ausschnitts können Sie mit set listsize <zahl> verändern.


Einzelschritte und Fortsetzung

Wie geht es nach einem Haltepunkt weiter? Die typische Vorgehensweise bei der Fehlersuche ist, einen Haltepunkt an den Anfang des interessierenden Bereiches zu setzen und dann in einzelnen Schritten das Programm beim weiteren Ablauf zu beobachten. Dabei untersucht man auch die Inhalte der lokalen Variablen und Funktionsparameter, ob sie die erwarteten Werte haben. Anschließend kann man die Ausführung fortsetzen, entweder bis zum nächsten Haltepunkt, bis zum Programmende -- oder bis zum Absturz.

Zur Ausführung in Einzelschritten gibt es im Wesentlichen vier Kommandos, deren Bedeutung und Unterschiede Sie sich für die Arbeit mit allen Arten von Debuggern einprägen sollten: next geht zur nächsten Codezeile, führt aber Funktionsaufrufe sofort aus; step geht immer zur nächsten Codezeile, auch wenn diese in einer aufgerufenen Funktion liegt; until geht zur nächsten Codezeile, auch wenn diese außerhab der gerade durchlaufenen Schleife liegt; finish führt alle Befehle bis zum Ende der aktuellen Funktion aus und kehrt dann zum Aufrufer zurück. Diese Befehle wollen wir uns noch etwas genauer ansehen:

Am letzten Beispiel sehen wir, dass read() den Wert true zurückliefert; also hat das Einlesen funktioniert. Warum bleibt unser Programm aber dennoch stehen? Ein genauer Blick zeigt, dass unser Programm einen lästigen, aber typischen Tippfehler hatte:

  if (a.read(filename))

    return -1;

Das bedeutet: Wenn die Rückgabe wahr ist, beende das Programm. Das ist aber genau das Gegenteil von dem, was wir eigentlich wollten. Korrekt muss es heißen:

  if (!a.read(filename))

    return -1;

Damit hätten wir schon den (ersten) Fehler gefunden.


Verfolgung der Aufrufkette

Als Beispiel nehme ich nun eine zyklische Matrix vom Grad 3 (siehe Seite [*]). Deren Determinante ist -18. Wenn wir unser Programm neu übersetzen, starten und diese Matrix einlesen, beendet es sich mit einem Segmentation fault. Wie in früheren Abschnitten (Seite [*] oder Seite [*]) beschrieben, ist eine häufige Ursache dafür ein falscher Speicherzugriff -- entweder eine Bereichsüberschreitung eines Feldes beziehungsweise das Schreiben in nicht reservierten Speicher oder Freigeben von vorher nicht reserviertem Speicher.

Wenn wir das Programm aus der Shell starten, erhalten wir jedoch außer der Fehlermeldung keinerlei Hinweis, wo das Problem liegen könnte. Aber dazu haben wir ja den Debugger. Hier erfahren wir:

Program received signal SIGSEGV, 

Segmentation fault.

0x804b1c3 in SymMatrix::subMatrix 

(this=0xbffff5a8, _k=0) at matrix.cc:177

177               u.at(i, uj++) = dat[i+1][j];

Sehen wir uns die Methode SymMatrix::subMatrix() etwas genauer an:


168:  SymMatrix SymMatrix::subMatrix(


168:    unsigned int _k) const 


169:  { 


170:    // Untermatrix eine Dimension kleiner 


171:    SymMatrix u(size-1); 


172:     


173:    // Untermatrix nach der ersten Zeile 


174:    for(unsigned int i=0; i<size; i++) 


175:      for(unsigned int j=0, uj=0; j<size; j++)


175:        // Betrachte nur Spalten ungleich _k


176:        if(j != _k) 


177:          u.at(i, uj++) = dat[i+1][j]; 


178:       


179:    return u; 


180:  } 


Hier wird die Untermatrix nach der ersten Zeile gebildet. Von wo aus wurde diese Methode aber aufgerufen? Das erfahren wir durch das Kommando backtrace, kurz bt. An dieser Stelle ist die Ausgabe:

(gdb) backtrace

#0  0x804b1c3 in SymMatrix::subMatrix 

     (this=0xbffff5a8, _k=0) at matrix.cc:177

#1  0x804b424 in determinant (_a=@0xbffff5a8) 

     at matrix.cc:209

#2  0x804a77e in main () at main.cc:16

Gerade bei Abstürzen empfiehlt es sich, die Aufrufe, über die das Programm bis zum Fehler gelangt ist, zu verfolgen. Hilfreich ist das Kommando ebenso bei Funktionen, die von verschiedenen Stellen aus angesprungen werden, oder bei polymorphen Aufrufen.


Untersuchung von Variablen

Nun wissen wir zwar, dass _k den Wert 0 hat. Von den anderen Variablen ist uns jedoch nichts bekannt. Diese können wir mit dem Kommando print, kurz p, untersuchen. Interessant sind ja insbesondere die Schleifenvariablen i und j.

(gdb) p i

$1 = 2

(gdb) p j

$2 = 1

(gdb) p uj

$3 = 1

Die Anweisung war also

  u.at(2, 1) = dat[3][1];

Haben Sie das Problem schon erkannt? Wenn nein, lassen Sie uns noch ein wenig suchen.

Der erste Teil der Ausgabe ist eine interne Referenznummer. Über die können Sie den Wert selbst weiterverwenden, ohne sich ihn merken zu müssen. Noch kürzer geht es mit $, das die letzte Ausgabe, und $$, welches die vorletzte Ausgabe darstellt.

(gdb) p u.dat[$][$$]

$4 = 2

Auch ganze Objekte lassen sich mit print ausgeben (natürlich erscheinen dabei nur die Attribute):

(gdb) p u

$5 = {dat = 0x8063400, size = 2}

(gdb) p *this

$6 = {dat = 0x8063420, size = 3}

Gerade bei Feldern will man aber meist den Inhalt des ganzen Feldes wissen, und nicht nur einzelne Werte. Dazu können Sie hinter ein Feldelement das Zeichen @ setzen, gefolgt von einer Zahl (oder wieder Variablen). Damit weisen Sie den gdb an, außer dem Element selbst noch so viele weitere Speicherstellen als Elemente zu interpretieren und auszugeben, wie nach @ angegeben ist. Die erste Zeile unserer Matrix erhalten wir beispielsweise mit:

(gdb) p dat[0][0]@size

$7 = {1, 2, 3}

Eine andere Art der Ausgabe erfolgt durch das Kommando display. Damit können Sie erreichen, dass der gdb die angegebenen Variablen jedes Mal ausgibt, wenn das Programm stoppt. Dazu muss der Debugger aber gerade in einem Programmkontext sein, in dem diese Variablen bekannt sind. Ein display-Kommando in einer anderen Funktion oder vorsichtshalber vor dem Programmstart funktioniert daher nicht.

Setzen wir zum Beispiel in der Methode SymMatrix::subMatrix() einen Haltepunkt und lassen das Programm bis dahin laufen. Dann können wir auch die Beobachtung der Schleifenvariablen aktivieren:

Breakpoint 1, SymMatrix::subMatrix 

(this=0xbffff5a8, _k=0) at matrix.cc:171

171       SymMatrix u(size-1);

Current language:  auto; currently c++

(gdb) display i

No symbol "i" in current context.

(gdb) n

174    for(unsigned int i=0; i<size; i++)

(gdb) n

175      for(unsigned int j=0, uj=0; j<size; j++)

(gdb) display i

1: i = 0

(gdb) n

177        if(j != _k) 

1: i = 0

(gdb) display j

2: j = 0

Wenn wir dieses Spiel noch etwas fortsetzen (oder gleich continue eingeben), landen wir schließlich in folgender Situation:

Program received signal SIGSEGV, Segmentation 

fault.

0x804b1c3 in SymMatrix::subMatrix 

(this=0xbffff5a8, _k=0) at matrix.cc:177

177               u.at(i, uj++) = dat[i+1][j];

2: j = 1

1: i = 2     

Wir sind also in der Tat über den reservierten Speicher hinausgeschossen: dat ist eine Matrix mit drei Zeilen und drei Spalten. Da die Zählung in C++ immer bei 0 beginnt, sind also Indizes von 0 bis 2 zulässig. Hier haben wir aber i+1=3 als Zeilenindex für dat verwendet, mit den bekannten Folgen. Das Problem ist damit erkannt: Die Schleife über i läuft zu weit. Da wir eine Untermatrix bilden wollen, die eine Zeile und eine Spalte weniger hat als das Original, müssen wir natürlich auf die Bedingung i<size-1 achten anstatt auf i<size. Wir müssen unser Programm daher an folgender Stelle korrigieren:

174:  for(unsigned int i=0; i<size-1; i++)

Beenden der Debug-Sitzung

Nachdem wir das Problem ausfindig gemacht haben, brauchen wir die Dienste des Debuggers vorerst nicht mehr. Er lässt sich mit dem Kommando quit, kurz q, beenden.

Wenn jedoch gerade ein Programm läuft und etwa an einem Haltepunkt steht, fragt der gdb erst noch einmal nach, ob es Ihnen mit dem Verlassen ernst ist:

The program is running.  Exit anyway? (y or n) 

Nehmen wir mal an, Sie wollten ordentlich sein und alles aufräumen, bevor Sie das Werkzeug verlassen. Dann ist es das Beste, das Programm mit continue bis zu seinem Ende weiterlaufen zu lassen, in unserem Fall bis zur Meldung:

Program terminated with signal SIGSEGV, Segmentation fault. The program no longer exists.  

Dann können Sie den gdb guten Gewissens beenden.

Wenn wir nun den Fehler korrieren, das Programm übersetzen und starten, erhalten wir endlich die erwartete Ausgabe:

Determinante ist 18

Weitere Fähigkeiten

Mit dieser Beschreibung sind die Fähigkeiten des gdb nicht einmal annähernd vollständig aufgeführt. Es lohnt sich daher auf alle Fälle, die Info-Seiten zu studieren; Sie werden dort noch einige sehr interessante Features finden, die hier aus Platzgründen weggelassen werden mussten:

Sein großer Leistungsumfang, seine Zuverlässigkeit und Flexibilität haben den gdb zum Standard-Debugger unter Linux gemacht. Fast alle anderen Debugging-Werkzeuge sind lediglich Frontends, das heißt gekapselte Benutzerschnittstellen, zum gdb. Er ist jedoch nicht auf Linux beschränkt, sondern auf nahezu sämtlichen Unix-Plattformen ebenso verfügbar.


Der grafische Debugger DDD

So leistungsfähig der gdb auch ist -- seine Bedienung über die Kommandozeile setzt jedoch genaue Kenntnisse über die Befehle und ihre Argumente voraus. Vielen PC-Umsteigern mutet eine solche Arbeitsweise zudem sehr spartanisch an. Für all jene ist der grafische Data Display Debugger DDD zu empfehlen.

Er ist entstanden aus Diplom- und Doktorarbeiten von Dorothea Lütkehaus und Andreas Zeller an der Technischen Universität Braunschweig. Obwohl die Autoren diese Hochschule mittlerweile verlassen haben, wird er dort immer noch gepflegt und archiviert. Da er ebenfalls unter der GNU General public license (GPL, siehe Seite [*]) steht, sind viele Entwickler weltweit an seiner Weiterentwicklung beteiligt.

Sie können stets die neueste Version über die Web-Seite www.cs.tu-bs.de/softech/ddd (oder www.gnu.org/software/ddd) beziehen. Aber auch bei den meisten Linux-Distributionen ist der DDD mittlerweile enthalten.

Der Data Display Debugger ist keineswegs die einzige grafische Benutzerschnittstelle zum gdb; neben den eigenständigen Frontends xxgdb und tgdb bringen auch die meisten integrierten Entwicklungsumgebungen eigene Aufsätze auf den gdb mit, beispielsweise Kdbg in KDevelop (siehe Seite [*]) oder der SNiFF-Debugger in SNiFF+ (Seite [*]). Aufgabe jedes dieser Programme ist es, dem Benutzer die Eingabe der gdb-Befehle auf der Kommandozeile zu ersparen und die Bedienung vorwiegend auf Mausklicks zu beschränken; dabei legt man natürlich besonderen Wert auf die gebräuchlichsten Befehle und lässt seltenere außen vor.

Das Besondere am DDD, dem er auch seine Popularität verdankt, ist seine Fähigkeit, komplexe Datenstrukturen als Graphen zu visualiseren. Durch einfache Mausklicks kann der Benutzer Zeiger dereferenzieren oder die Inhalte von Objekten darstellen lassen. Besonders bei verschachtelten Objekten lassen sich die Zusammenhänge sehr gut durch automatisch verwaltete Bäume veranschaulichen. Auf Wunsch dringt die Anzeige dabei immer tiefer in die Verschachtelungen vor.

In diesem Abschnitt setze ich voraus, dass Sie die Grundbegriffe des Debuggens und des Einsatzes des gdb kennen, wie ich sie im letzten Abschnitt ab Seite [*] beschrieben habe. Zunächst wollen wir uns ansehen, wie die gdb-Kommandos über den DDD erreichbar sind. Anschließend werden wir einen Blick auf die Visualisierung von Datenstrukturen werfen.

Elementare Bedienung

Die Art des Aufrufes ist genau dieselbe wie beim gdb; Sie müssen lediglich die ersten zwei Buchstaben austauschen. Als Argument können Sie den Namen des Programms und eventuell eine Speicherauszugsdatei oder eine Prozessnummer angeben. Für unser Beispiel heißt das:

% ddd DebugTest

Figure: Die Benutzeroberfläche des DDD bietet einfachen Zugriff auf alle häufig benötigten Funktionen.



\resizebox*{1\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd1.eps}}

Gleich anschließend sehen Sie das DDD-Fenster vor sich (Abbildung [*]). Die Aufteilung des Fensters dürfte klar sein: Menüleiste, Werkzeugleiste, Quelltextfenster, Debugger-Konsole und Statusleiste. Je nach Situation können noch weitere Teile hinzukommen, zum Beispiel das grafische Datenfenster. Wenn Sie möchten, stellt Ihnen der DDD auch alle Teile in separaten Fenstern dar (siehe EDIT | PREFERENCES | STARTUP | WINDOW LAYOUT).

Besonders praktisch ist das separate Werkzeugfenster für die wichtigsten Kommandos (in Abbildung [*] über der rechten Hälfte des Quelltextfensters). Darüber können Sie die Einzelschrittausführung bequem steuern. Im Folgenden wollen wir dieses Fenster kurz als Kommandoleiste bezeichnen. (Über EDIT | PREFERENCES | SOURCE | TOOL BUTTONS LOCATION lassen sich die enthaltenen Schaltflächen aber auch im Quelltextfenster platzieren. Überhaupt können Sie mit dem PREFERENCES-Dialog den DDD sehr weitreichend an Ihre persönlichen Vorlieben anpassen.)

Am einfachsten starten Sie Ihr Programm über die Schaltfläche RUN der Kommandoleiste oder mit der Taste F2. Wenn Sie Kommandozeilenargumente brauchen, können Sie diese über den Dialog festlegen, den Sie mittels des Menüpunktes PROGRAM | RUN erhalten. Der DDD speichert dort sogar mehrere Folgen von Argumenten, aus denen Sie sich eine aussuchen können.

Auch für die Festlegung von Haltepunkten gibt es mehrere Möglichkeiten. Am einfachsten ist der Doppelklick am Anfang der Codezeile. Zur Kennzeichnung des Haltepunktes erscheint ein Stoppschild. Alternativ gibt es den Punkt SET BREAKPOINT aus dem Kontextmenü (öffnet sich durch Klick auf die rechte Maustaste) oder dem Stopp-Symbol aus der Werkzeugleiste. Wenn Sie Haltepunkte über Funktionsnamen festlegen wollen oder sonstige komplexere Vorgaben beabsichtigen, ist der Menüpunkt SOURCE | EDIT BREAKPOINTS das Richtige. Schließlich steht es Ihnen auch frei, das Kommando break in die gdb-Konsole einzugeben.

Standardmäßig lädt DDD die Quelltextdatei, die die main()-Funktion enthält. Alle weiteren beteiligten Dateien können Sie über FILE | OPEN SOURCE auswählen und laden.

Ein Programm in einzelnen Schritten unter Beobachtung ablaufen zu lassen, ist beim DDD überaus einfach. Starten Sie das Programm über RUN auf der Kommandoleiste und lassen Sie es bis zu einem eingestellten Haltepunkt laufen. Dann stehen wieder die Befehle step, next, until und finish zur Verfügung (siehe Seite [*]) -- alles über die gleichnamigen Schaltflächen auf der Kommandoleiste. Auch der Befehl cont zum Fortsetzen ist darüber erreichbar.

Wenn Sie innerhalb einer Funktion zum Halten kommen, können Sie sich alle Funktionen ausgeben lassen, über die das Programm hierher gelangt ist. Was beim gdb noch backtrace heißt, findet sich hier unter dem Menüpunkt STATUS | BACKTRACE. Die Darstellung erfolgt über ein Dialogfenster (Abbildung [*]), das noch eine weitere Hilfestellung in sich birgt. Mittels der Schaltflächen UP und DOWN können Sie auch im Quelltextfenster zu den jeweiligen Programmstellen springen, an denen der Aufruf steht. Dort können Sie dann nach Wunsch weitere Untersuchungen anstellen.

Figure: Mit dem BACKTRACE-Dialog können Sie alle Aufrufebenen zurückverfolgen.



\resizebox*{0.7\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd2.eps}}

Dabei wollen Sie sicher auch die Werte der verschiedenen Variablen überprüfen. Bei Standardtypen geht das am einfachsten, indem Sie mit dem Mauszeiger darauf deuten und einen Augenblick warten. Sofort erscheint ein kleines Hilfefenster, das den Wert der jeweiligen Variablen (oder auch Konstanten) enthält. Diese Information wird zudem gleichzeitig in der Statuszeile ausgegeben.

Wollen Sie alle lokalen Variablen verfolgen, genügt die Auswahl des Menüpunktes DATA | DISPLAY LOCAL VARIABLES. In einem Kästchen im Datenfenster werden Sie dann ständig über die aktuellen Werte informiert. Für Grafik-Freunde ist sogar eine Ausgabe von einer oder mehrerer Variablen als Plot über die gleichnamige Schaltfläche der Werkzeugleiste möglich (sofern Sie gnuplot installiert haben). Sie müssen lediglich die zu untersuchende Variable im Datenfenster oder im Quelltextfenster markiert haben.

Natürlich findet sich auch im DDD die dauerhafte Verfolgung von Variablenwerten wieder, die wir beim gdb als display-Kommando kennen gelernt haben. Eine Möglichkeit bietet das Kontextmenü. Klicken Sie über der interessierenden Variablen im Quelltextfenster die rechte Maustaste; hier können Sie nun sowohl eine Anzeige des Wertes selbst als auch, bei Zeigern, des dereferenzierten Inhalts aktivieren. Die Ausgabe erfolgt in einem Kästchen des Datenfensters.

Figure: Für Felder legen Sie am besten ein eigenes Display-Format fest.



\resizebox*{0.4\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd4.eps}}



Die Anzeige von Feldern geht nicht ganz so automatisch. Hier ist wieder Ihr gdb-Know-how gefragt. Auf Seite [*] hatten wir festgestellt, dass man mehrere Einträge eines Feldes zusammen ausgeben kann, indem man das @-Zeichen und die Anzahl der Werte hinter die Angabe des ersten Wertes setzt. Diese Möglichkeit wählen wir auch hier: Klicken Sie auf die Schaltfläche DISPLAY der Werkzeugleiste und halten Sie sie für einen Moment gedrückt. Aus dem sich öffnenden Menü wählen wir OTHER. Dadurch gelangen wir zu einem Dialog (Abbildung [*]), in dem wir unsere Eingabe, beispielsweise

this->dat[0][0]@size

vornehmen können. Anschließend wird uns das Feld im Datenfenster gezeigt (Abbildung [*]). In großen Feldern, in denen derselbe Wert mehr als zehn Mal hintereinander vorkommt, wird dieser nur einmal angezeigt und seine Häufigkeit durch ein nachgestelltes <Nx> deutlich gemacht.

Figure: Die gewünschten Feldelemente erscheinen im Datenfenster.



\resizebox*{1\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd3.eps}}



Wenn Sie beim Debuggen Ihren Fehler entdeckt haben, können Sie natürlich zu Ihrer Entwicklungsumgebung zurückkehren, die Codestelle korrieren und das Programm übersetzen. Gehören Sie jedoch zu den Kommandozeilenprogrammierern, die alle Arbeit an ihren Programmen von der Shell aus erledigen, können Sie sich diesen Umweg sparen. Über EDIT aus der Kommandoleiste können Sie ein Fenster öffnen, das Ihnen das Editieren der aktuellen Datei mittels des vi-Editors (siehe Seite [*]) erlaubt. Änderungen erscheinen sofort nach Schließen des Editors im Quelltextfenster. Wenn Sie nun noch auf MAKE klicken, wird die geänderte Datei (bei einem korrekten Makefile) sofort übersetzt und das Programm aktualisiert. Der DDD merkt übrigens beim Starten eines Programms, ob es in einem anderen Prozess neu gebaut wurde und liest die Symboltabelle gegebenenfalls nochmals ein.

Und wenn Sie mal gar nicht mehr weiter wissen, gibt Ihnen HELP | WHAT NOW? immer einen freundlichen Hinweis, was Sie als Nächstes tun könnten. Um mehr über die Arbeit mit dem DDD zu erfahren, finden Sie unter HELP | DDD REFERENCE ein ausführliches Handbuch (das zudem der Installation auch im PostScript-Format beiliegt). Bei jedem Start begrüßt Sie der DDD außerdem mit einem Tip of the day; anhand dieser Tipps können Sie auch viel über die effiziente Bedienung dieses Werkzeugs lernen.

Die grafische Datenanzeige

Insgesamt erkennen Sie, dass der DDD alle Fähigkeiten des gdb in bequemerer Form zugänglich macht, gleichzeitig aber einige eigene Funktionalität mitbringt. Dieser wollen wir uns nun widmen.

Als Beispiel verwende ich diesmal nicht die Determinantenberechnung aus dem letzten Abschnitt; für diese Funktionen brauchen wir etwas komplexere Datenstrukturen. Wir sehen uns daher das Programm birthcontrol aus Abschnitt [*] (ab Seite [*]) an, das Sie an Geburtstage Ihrer Familie und Freunde erinnern soll. Die zentrale Rolle dabei spielt die Klasse BirthList. Werfen wir einen Blick auf die Deklaration (die schon viel von der Definition enthält):


1:  // Datei: birthlist.h 


2:  #include <string> 


3:  #include "date.h" 


4:   


5:  //-------------------------------------- 


6:  // Klasse fuer Elemente der Liste 


7:  //-------------------------------------- 


8:  struct BirthListElement 


9:  { 


10:    BirthListElement* next; 


11:    string            name; 


12:    string            surname; 


13:    Date              date; 


14:     


15:    // Standardkonstruktor 


16:    BirthListElement() : 


17:      next(0) {} 


18:       


19:    // Spezieller Konstruktor 


20:    BirthListElement(const string& _name, 


21:      const string& _surname, 


22:      unsigned short _day, 


23:      unsigned short _month, 


24:      unsigned short _year) : 


25:      next(0), 


26:      name(_name), 


27:      surname(_surname), 


28:      date(_day, _month, _year)  


29:      {} 


30:  }; 


31:   


32:  //-------------------------------------- 


33:  // Listenklasse  


34:  //-------------------------------------- 


35:  class BirthList 


36:  { 


37:  private: 


38:    BirthListElement* first; 


39:    BirthListElement* last; 


40:    int size; 


41:     


42:  public: 


43:    BirthList() : 


44:      first(0), last(0), size(0) {} 


45:       


46:    virtual ~BirthList(); 


47:     


48:    bool empty() const  


49:      { return (size == 0); } 


50:     


51:    int getSize() const 


52:      { return size; } 


53:       


54:    void pushBack(const string& _name, 


55:      const string& _surname, 


56:      unsigned short _day, 


57:      unsigned short _month, 


58:      unsigned short _year); 


59:   


60:    void popFront(); 


61:     


62:    BirthListElement* front() 


63:      { return first; } 


64:   


65:    int load(const string& filename); 


66:   


67:    bool check(const Date& d); 


68:  }; 


Die einzelnen Daten werden dabei in Objekten vom Typ BirthListElement abgelegt (Zeile 8-30). Wie in Abbildung [*] (Seite [*]) speichern wir auch hier die Daten in Form einer einfach verketteten Liste. Das nächste Listenelement wird durch den Zeiger next ausgedrückt. Um aber bequem auf die Liste zugreifen zu können, speichern wir in der Klasse BirthList (Zeile 33-68) sowohl einen Zeiger auf das erste (first) als auch auf das letzte Element (last).

Figure: Die grafische Datenanzeige umfasst auch eingebettete Objekte sowie Zeiger auf weitere Objekte.



\resizebox*{1\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd5.eps}}



Wie können wir die enthaltenen Daten sichtbar machen? Sobald Sie mit der linken Maustaste im Quelltextfenster auf einen Bezeichner klicken, erscheint dieser im Argumentfeld (das ist die Combobox unterhalb des Hauptmenüs, das mit einem Klammerpaar davor gekennzeichnet ist). Alle print- oder display-Befehle, die Sie nun über das Kontextmenü oder die Schaltfläche der Werkzeugleiste aktivieren, nehmen darauf Bezug. (Daher taucht bei diesen auch immer das leere Klammernpaar auf.) Die so ausgeführten display-Kommandos erscheinen im Datenfenster in Form von Kästchen. Dort können Sie dann weitere Details in Erfahrung bringen.

Bei Datenstrukturen, die wieder andere Strukturen enthalten (wie unser BirthListElement), werden die Daten zunächst in knapper Form angezeigt, etwa als Pünktchen ... oder als Wert des Zeigers. Um diese zu expandieren, wählen Sie zunächst die gewünschte Struktur aus (durch eine Klick, durch mehrere Klicks mit festgehaltener $\Uparrow$-Taste oder durch Umranden der Kästchen mit einem Fangrechteck mittels Ziehen mit der linken Maustaste). Anschließend öffnen Sie das Kontextmenü und klicken auf SHOW ALL. Damit weisen Sie die Anzeige an, alle Verschachtelungen aufzulösen und sämtliche Daten darzustellen. Ein mögliches Resultat können Sie in Abbildung [*] sehen.

Figure: Mit SHOW ALL können Sie sämtliche Daten einer verschachtelten Struktur expandieren.



\resizebox*{1\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd6_2.eps}}

Über den Schalter SHOW beziehungsweise HIDE der Werkzeugleiste lässt sich das Expandieren noch selektiver gestalten. So zeigt etwa SHOW MORE die Inhalte der gerade verborgenen Datenstruktur an, aber nicht die Inhalte der in ihr enthaltenen. Mit SHOW JUST werden alle Details eingeschachtelter Strukturen verborgen und nur die aktuelle Ebene angezeigt. Als Gegenstück dazu verbirgt HIDE alles.

Die Pfeile zwischen den Kästchen (den so genannten Displays) verraten die Beziehung zwischen ihnen. In Abbildung [*] entspricht der rechten Kasten beispielsweise einer Dereferenzierung des Zeigers im linken, angedeutet durch *() über dem Pfeil. Wenn Sie jetzt auf den Eintrag next doppelt klicken, erscheint ein neuer Kasten mit dessen Inhalt; über dem Pfeil steht dann next.

Spätestens wenn Sie sich beispielsweise mit DATA | EDIT DISPLAYS eine Liste aller aktuellen Kästchen ausgeben lassen, werden Sie merken, dass diese recht lang werden kann. Standardmäßig wird für jede zu verfolgende Variable ein eigenes Kästchen angelegt. Um aber die Übersicht nicht zu verlieren, sollten Sie die Möglichkeit nutzen, mehrere Displays zu einem zusammenzufassen. Ein solch fusioniertes Display bezeichnet die DDD-Dokumentation als Cluster (Abbildung [*]). Markieren Sie zur Clusterbildung die gewünschten Kästchen (durch Anklicken bei gedrückter $\Uparrow$-Taste oder durch Umranden der Kästchen mit einem Fangrechteck), klicken Sie auf den Schalter UNDISP auf der Werkzeugleiste (gekennzeichnet mit einer Art Totenkopf, in Abbildung [*] ganz rechts) und wählen Sie aus dem sich öffnenden Menü den Eintrag CLUSTER(). Alle künftigen Displays können Sie zu einem bestehenden Cluster hinzufügen, indem Sie die Voreinstellung EDIT | PREFERENCES | DATA | CLUSTER DATA DISPLAYS aktivieren.

Figure: Mit einem Cluster lassen sich mehrere Kästchen in einem zusammenfassen.



\resizebox*{1\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/ddd7.eps}}

Fazit

Wie Sie gesehen haben, ist der DDD mehr als nur ein Frontend zum gdb. Er verfügt über eine Reihe eigener Fähigkeiten, von denen ich Ihnen hier leider nur einen kleinen Teil zeigen konnte. Lassen Sie sich als bei Ihrer Arbeit mit dem DDD durch den Tip of the day oder einen Blick ins Handbuch dazu anregen, weitere Möglichkeiten zu erkunden. Sie werden sehen: Es lohnt sich!


Fehlervermeidung durch die Überprüfung von Vorbedingungen

Bei den Fehlerquellen, die wir in Abschnitt [*] untersucht haben, blieb ein Typ völlig unberücksichtigt: falsch benutzte Methoden (oder Funktionen). Denn jede Methode, die nicht gerade konstant ist, ändert den Zustand des Objekts oder des ganzen Programms. Daher muss die Übergabe ungültiger Parameter oder die falsche Reihenfolge des Aufrufs zu einem undefinierten Zustand führen, der sich früher oder später auf das ganze Programm fatal auswirkt.

Um die Aufrufspezifikation exakt zu formulieren, sollte man Vor- und Nachbedingungen für jede Methode festlegen. Vorbedingungen sind die Erwartungen, die die Methode in die Parameter und den Aufrufkontext setzt (dass also zum Beispiel ein Parameter größer als null ist). Nachbedingungen drücken aus, was der Aufrufer von der Methode erwarten darf.

Auf diese Weise wird die Beziehung zwischen einer Funktion und deren Benutzer auf eine klar definierte Grundlage gestellt. Eine solche kann man auch als Vertrag zwischen beiden auffassen. Als einer der ersten hat Bertrand Meyer in seinem sehr lesenswerten Buch [MEYER 1997] auf diese Thematik hingewiesen. Er empfiehlt, sich bei der gesamten Entwicklung vom Vertragsgedanken zwischen den Softwarekomponenten leiten zu lassen und ein Design by Contract anzustreben.

Die Spezifikation des Vertrags sollte in der Header-Datei bei der jeweiligen Methode stehen, damit ein Benutzer sie auch schnell findet. Wie aber kann man das Einhalten der Vertragsbedingungen überprüfen? Und welche Sanktionen drohen bei Vertragsverletzung?

Eine einfache Möglichkeit, logische Annahmen zu verifizieren, bietet das Makro assert(), das in der Datei cassert der C++-Standardbibliothek definiert ist. Es überprüft eine Bedingung. Ist diese erfüllt, passiert gar nichts und das Programm kann ungestört weiter arbeiten. Ist sie jedoch verletzt, beendet assert() das Programm mit einer Fehlermeldung. (Das zugehörige Substantiv assertion heißt übrigens auf Deutsch Zusicherung.)

Als Beispiel betrachten wir eine Funktion, die einen String mehrfach hintereinander auf einen übergebenen Stream ausgibt:


1:  // Datei asserttest.cc 


2:   


3:  #include <string> 


4:  #include <iostream> 


5:  #include <cassert> 


6:   


7:  // Funktion: repeatedOutput 


8:  // Parameter:  


9:  //   ostream* _o: Ausgabestream 


10:  //   const string& _s: String 


11:  //   unsigned short _n: Wiederholung 


12:  // Bedingung: _o != 0 


13:  void repeatedOutput(ostream* _o,  


14:    const string& _s, unsigned short _n) 


15:  { 


16:    assert(_o);  // entspricht _o!=0 


17:    for(unsigned i=0; i<_n; i++) 


18:      *_o << _s; 


19:  } 


20:   


21:  int main() 


22:  { 


23:    repeatedOutput(&cout, "-", 30); 


24:    cout << endl; 


25:    repeatedOutput(0, "?", 30); 


26:   


27:    return 0; 


28:  } 


In Zeile 16 wird überprüft, ob die Vorbedingung, nämlich dass _o ungleich 0 ist, tatsächlich erfüllt wird. In Zeile 25 provozieren wir eine Vertragsverletzung und erhalten als Ausgabe:

---------------

asserttest: asserttest.cc:16: void 

repeatedOutput( ostream *, const string &, 

short unsigned int): Zusicherung »_o« nicht 

erfüllt.

Abort

Hier haben wir uns ja wirklich nicht an die Abmachung gehalten und einen ungültigen Parameter übergeben.

Sorgfältig getesteter Code sollte solche Vertragsverletzung nicht mehr enthalten. Daher kann für die endgültige Version Ihres Programms die ständige Überprüfung der Bedingungen entfallen, die ja auch selbst Rechenzeit kostet. Um assert() abzuschalten, müssen Sie den Schalter NDEBUG definieren und zwar vor dem Einbinden von cassert. Das können Sie beispielsweise mit der Präprozessor-Anweisung #define NDEBUG erledigen. Bequemer und sicherer ist aber die Übergabe dieser Definition direkt an den Compiler mittels des Schalters -DNDEBUG, also etwa:

g++ -g -DNDEBUG asserttest.cc -o asserttest 

Wenn wir unser obiges Programm solchermaßen übersetzen, das heißt mit abgeschaltetem assert(), ist die Ausgabe ein wenig anders:

---------------

Segmentation fault

Sie sehen, dass Sie mit dieser einfachen Technik Abstürze und aufwändiges Debuggen schon im Voraus vermeiden können.

Allerdings ist auch assert() kein Allheilmittel. Es ist zum Beispiel nicht ratsam, es außer bei der Überprüfung von Designfehlern auch noch zum Aufdecken von Laufzeitfehlern zu verwenden. Wenn ein ungültiger Zustand einer Variablen nicht aus einer falschen Ansteuerung der Schnittstelle, sondern aus laufzeitbedingten Einflüssen wie Benutzereingaben, Dateizugriffen oder Rechenoperationen herrührt, so handelt es sich dabei nicht um eine verletzte Zusicherung. Denn assert() ist immer ein sehr harter Eingriff, einfach weil das Programm dadurch beendet wird. Es sollte daher nicht missbraucht werden. (Sie können sich auch eine weichere Variante davon erstellen, in der Sie anstelle des abort() eine eigene Fehlermeldung, beispielsweise in eine Log-Datei ausgeben, aber das Programm anschließend fortsetzen.)

Zusammenfassung

Aus diesem Abschnitt sind folgende Aspekte hervorzuheben:


Versionskontrolle mit RCS

Unter Versionskontrolle versteht man die Verfolgung von Änderungen, die an einer Datei vorgenommen werden, mit der Möglichkeit des Rückgriffs auf eine frühere Version. Meist sorgen entsprechende Werkzeuge auch gleich dafür, dass konkurrierende Zugriffe verschiedener Entwickler verhindert werden, dass also eine Datei nicht von zwei verschiedenen Personen gleichzeitig bearbeitet wird.

Vielleicht denken Sie bei dieser Definition zuerst an große Projekte, die von vielen Mitarbeitern entwickelt werden und Hunderttausende von Programmzeilen umfassen. Wenn Sie tatsächlich einmal an einem solchen Projekt mitarbeiten, wird eine Versionskontrolle über die Quellen eines entsprechenden Programms sowieso unerlässlich sein. Doch selbst, wenn Sie nur alleine oder mit ein oder zwei anderen an einem Projekt arbeiten, kann Ihnen der Einsatz einer Revisionskontrollsoftware durchaus Nutzen bringen.

Stellen Sie sich nur folgendes Beispiel vor: Ein Entwickler (oder natürlich eine Entwicklerin) arbeitet an einem momentan lauffähigen Programm. Nun möchte er eine bestimmte Routine durch eine leistungsfähigere ersetzen. Er modifiziert den dazugehörigen Quellcode und arbeitet ein bis zwei Stunden an der entsprechenden Verbesserung, bis er sich schließlich so verheddert hat, dass gar nichts mehr geht. Liebend gern würde er nun auf die ursprüngliche Routine -- die zwar weniger leistungsfähig, dafür aber funktionsfähig war -- zurückgreifen. Nur hat er im Eifer des Gefechts vergessen, die Datei, an der er Modifikationen vornahm, zuerst zu sichern. In mühevoller Kleinarbeit schafft er es endlich, nach erneuten zwei Stunden, die ursprüngliche Version zwar nicht vollständig, dafür aber lauffähig wieder zu restaurieren.

Bevor Sie in eine solche Situation geraten, sollten Sie daher mit dem Einsatz einer Software zur Revisionskontrolle Vorsichtsmaßnahmen vor einem solchen oder ähnlichen GAU treffen. Hätte im oben beschriebenen Beispiel ein Revisionskontrollsystem alle gemachten Änderungen mitprotokolliert, wäre es ein Leichtes gewesen, die lauffähige Version des Programms innerhalb weniger Minuten wiederherzustellen.

Versionskontrolle mit Linux

Unter Unix gibt es traditionell zwei Systeme zur Versionskontrolle. Dies ist zum einen SCCS (Source code control system), das Bestandteil des X/Open-Standards von Unix ist und daher bei allen kommerziellen Unix-Systemen mitgeliefert wird. Da es standardisiert ist und Urheberrechten unterliegt, ist es für Linux ungeeignet. Hier wird vielfach die Alternative RCS (Revision control system, siehe org.gnu.de/software/rcs) verwendet, das als Open Source entwickelt wird.

Hervorgegangen aus RCS, aber mit mittlerweile eigener Codebasis ist CVS (Concurrent version system, siehe www.sourcegear.com/CVS), das in sehr vielen Open-Source-Projekten eingesetzt wird. Seine Bedienung aus Sicht des Benutzers ist der von RCS so ähnlich, dass Sie sicher gut damit umgehen können, wenn Sie die nachfolgende Beschreibung verstanden haben. Die Möglichkeiten von CVS gehen aber noch wesentlich weiter, was die Konfiguration aus Sicht des Servers beziehungsweise Administrators ziemlich kompliziert machen kann. Aus diesem Grund will ich in diesem Rahmen nicht weiter darauf eingehen.

Ein wirkliches Frontend zu RCS ist das ebenfalls freie PRCS (Project revision control system, siehe www.xcf.berkeley.edu/~jmacd/prcs.html). Während RCS nur dateiorientiert arbeitet, also Zusammengehörigkeiten von Dateien in Projekten nicht berücksichtigen kann, ist PRCS in der Lage, auch ganze Projekte mit konsistenten Revisionsnummern zu verwalten. Auch wenn sich die Syntax ein wenig unterscheidet, sind die Aufgaben, die mit PRCS erledigt werden können, im Wesentlichen dieselben wie bei RCS. Daher gehe ich davon aus, dass Sie sich mit den erworbenen Kenntnissen schnell einarbeiten könnten, und belasse es bei dieser Erwähnung.

Daneben gibt es auch immer mehr Unterstützung für Linux durch die kommerziellen Versionskontrollsysteme. So sind beispielsweise Linux-Clients verfügbar für ClearCase (von Rational, siehe www.rational.com/products/clearcase) und PVCS (ursprünglich von Merant, auf Linux portiert von Synergex, siehe www.pvcs.synergex.com). Selbst für Visual SourceSafe von Microsoft gibt es bereits eine Portierung (von MainSoft, siehe www.mainsoft.com/products/visual).

Wenn Sie noch weitere Informationen über das Thema Konfigurationsmanagement (also Versionskontrolle im weiteren Sinn) brauchen, empfehle ich Ihnen die Web-Seite mit den am häufigsten gestellten Fragen (FAQ) der entsprechenden Diskussionsgruppe im Usenet, zu erreichen unter www.iac.honeywell.com/Pub/Tech/CM/CMFAQ.html.

Vorgehensweise

Wie bereits angedeutet, liegt der Sinn der Versionskontrolle darin, von allen Projektdateien verschiedene Bearbeitungszustände zu speichern, um damit

Dabei dürfen Sie sich nicht unter dem Begriff Version eine fertige Programmversion vorstellen. Da es ja um Entwicklung geht, bezeichnet man hier damit den Fortschritt der Bearbeitung einer Datei. Immer wenn Sie meinen, eine Klasse oder Routine abgeschlossen zu haben, können Sie eine neue Version erzeugen. Wenn Sie mit anderen gemeinsam an einem Projekt arbeiten, sollten Sie damit aber vorsichtig sein. Da Ihre Kollegen jeweils mit der neuesten Version werden arbeiten wollen, sollte mit Abschluss der Bearbeitung nicht Abschluss der Codierung, sondern auch fehlerfreies Kompilieren und erfolgreiche Tests gemeint sein.

Dabei kann eine Version als Revision fungieren, also als eine Neufassung, die eine bestehende Version ersetzt, oder als Variante, die gleichzeitig mit einer anderen Version existieren soll. Im Folgenden werden wir meistens Revisionen betrachten.

Wichtige Begriffe

Dabei spielen zwei Begriffe eine wichtige Rolle: das Einchecken und das Auschecken. Das ist so ähnlich wie beim Fliegen:

Beim ersten Einchecken einer neuen Datei, was man auch als Registrieren bezeichnet, erhält diese von der Revisionsverwaltung die Revisionsnummer 1.1 -- sofern Sie nichts anderes angeben. Damit wird auch eine Kopie der Datei ins Archiv gelegt. Bei jeder neuen Revision erhält die Datei eine nächsthöhere Nummer, also 1.2, 1.3 etc., und die Änderungen werden gespeichert. Die Sammlung der RCS-Dateien eines Projekts bezeichnet man auch als Repository.

Eine weitere wichtige Funktion ist der Vergleich von Revisionen. Auf diese Weise können Sie mit einem einfachen Kommando erkennen, welche Unterschiede zwischen einer Revision und einer anderen bestehen. Übrigens arbeitet RCS auch intern so, dass es lediglich die erste Revision vollständig speichert und bei den weiteren jeweils nur die Unterschiede zur vorhergehenden festhält.

Revisionsverwaltung ist nicht nur für Programmquelltexte empfehlenswert, sondern für alle Dateien, die Sie von Zeit zu Zeit ändern, deren Entwicklung Sie aber verfolgen wollen. Es funktioniert für HTML-Dateien ebenso wie für Systemkonfigurationsdateien (etwa aus dem Verzeichnis /etc).

Konfiguration bei RCS-Projekten

RCS erzeugt zu jeder von ihm verwalteten Datei eine RCS-Datei, die denselben Namen wie die Datei trägt, allerdings mit einem angehängten ,v. Standardmäßig werden die RCS-Dateien in das gleiche Verzeichnis geschrieben, in dem sich auch die eigentlichen Dateien befinden. Das kann das Arbeitsverzeichnis sehr unübersichtlich machen. Legen Sie sich daher am besten vor dem Registrieren der ersten Datei ein Unterverzeichnis mit dem Namen RCS an. Ist nämlich ein solches vorhanden, werden die RCS-Dateien dort abgelegt.

Wenn Sie mit mehreren Kollegen an einem Projekt arbeiten, dann tauschen Sie Ihre Dateien vorwiegend über das lokale Netz aus, haben also eine gemeinsame Festplatte, die alle in ihr System eingebunden haben. Für die Organisation der Revisionsverwaltung gibt es dann zwei Möglichkeiten, von denen je nach Art und Umfang des Projekts eine gewählt werden sollte:


Registrieren

Als Beispiel wollen wir die Datei asserttest.cc aus dem vorherigen Kapitel verwenden (ab Seite [*]). Zum Registrieren einer Datei verwendet man das Kommando rcs -i, gefolgt vom Dateinamen. Dabei werden Sie noch gebeten, eine Beschreibung der Datei einzugeben. Diese kann aus einer oder mehreren Zeilen bestehen und muss mit einer Zeile, die nur einen Punkt enthält, oder Strg+d abgeschlossen werden.

mkdir RCS

rcs -i asserttest.cc

RCS file: asserttest.cc,v

enter description, terminated with single '.' 

or end of file:

NOTE: This is NOT the log message!

>> Testprogramm fuer assert-Kommando

>> .

done

%

Wenn wir anschließend einen Blick in das Verzeichnis werfen, finden wir folgende Dateien vor:

ls -l *

-rw-r-r-   1 thomas   users         513 

Dez  4 17:56 asserttest.cc

 

RCS:

total 1

-r-r-r-   1 thomas   users          99 

Dez  4 18:08 asserttest.cc,v

Es zeigt sich also, dass durch die Registrierung zwar eine RCS-Datei erzeugt wird, die Datei selbst aber noch bearbeitbar ist, mithin noch nicht eingecheckt. Wir wollen ja auch davon ausgehen, dass wir die Datei gerade erst angelegt haben, das Programm also erst noch schreiben müssen.


Einchecken

Sind wir mit der Bearbeitung fertig, geht es ans Einchecken. Dazu dient das Kommando ci (für check in). Dabei gibt es unterschiedliche Modi:

 $$
Ohne Angabe von Parametern löscht ci die Datei aus dem aktuellen Verzeichnis. Eigentlich ist sie ja dort auch nicht mehr nötig, da sich alle Informationen in der RCS-Datei befinden.
-u
Wollen Sie eine Kopie der Arbeitsdatei behalten, müssen Sie die Option -u angeben. Das verhindert das Löschen.
-l
Wollen Sie nicht nur eine lesbare, sondern eine bearbeitbare, ausgecheckte Revision behalten, trotzdem aber Ihren aktuellen Stand sichern, so verwenden Sie die Option -l.
-r
Möchten Sie nicht eine neue Revision, also eine Neufassung, die die bestehende ersetzt, einchecken, sondern eine Variante, die Sie gleichzeitig mit der bestehenden aufbewahren können, so geben Sie über die Option -r explizit eine Revisionsnummer an, zum Beispiel -r1.11. Die Möglichkeit, eine Revisionsnummer ausdrücklich anzugeben, haben Sie auch bei -u und -l.
Für unsere Datei erhalten wir folgende Ausgabe:

ci -u asserttest.cc

RCS/asserttest.cc,v  <-  asserttest.cc

initial revision: 1.1

done             

Da die Zugriffsverwaltung über das Linux-Dateisystem abgewickelt wird, ist die Datei jetzt auf Nur-Lesen gesetzt, damit Sie sie nicht versehentlich ändern.


Auschecken

Das analoge Kommando zum Auschecken ist co (für check out). Wenn Sie es ohne weitere Schalter verwenden, erhalten Sie eine Kopie der aktuellsten Revision, die Sie jedoch nur lesen und nicht bearbeiten können. Wenn Sie eine Änderung vornehmen wollen, müssen Sie die Option -l angeben. Dadurch wird die Datei einerseits beschreibbar und andererseits vor dem Zugriff von anderen durch eine Sperre (lock) geschützt.

co -l asserttest.cc

RCS/asserttest.cc,v  ->  asserttest.cc

revision 1.1 (locked)

done             

Anschließend ist das write-Flag für diesen Benutzer gesetzt. Wenn nun ein anderer Benutzer diese Datei ebenfalls auschecken will, ist ihm dies nicht erlaubt:

markus@sittich> co -l asserttest.cc

RCS/asserttest.cc,v  ->  asserttest.cc

co: RCS/asserttest.cc,v: Revision 1.1 is already 

locked by thomas.

Auf regulärem Weg kann sich der Benutzer markus keinen Zugang zu dieser Datei verschaffen, solange der Benutzer thomas sie nicht eincheckt. Solche Probleme sind besonders dann ärgerlich, wenn ein Kollege seine Dateien während seines Urlaubs ausgecheckt lässt. Meist hilft da nur noch der Eingriff des Systemadministrators.

Normalerweise erhalten Sie beim Auschecken immer die aktuellste Revision. Wenn Sie einmal auf eine ältere zugreifen wollen, geben Sie als Argument den Schalter -r gefolgt von der Revisionsnummer an:

co -r1.1 asserttest.cc

RCS/asserttest.cc,v  ->  asserttest.cc

revision 1.1

done           

Überblick über Änderungen

Nehmen wir an, wir wären mit dem Programm noch nicht so ganz zufrieden. So soll beispielsweise eine Ausgabe eingebaut werden, mit der sich das Programm am Anfang meldet. Ist dies erledigt, können wir die Datei wieder einchecken. Dabei fällt auf, dass RCS bei jedem Einchecken eine Beschreibung der Änderungen verlangt. Auch wenn es -- wie wir gleich sehen werden -- eine Möglichkeit gibt, diese automatisch herauszufinden, können mit solchen knappen Kommentaren doch am besten die vorgenommenen Änderungen dokumentiert werden. So kann man auch nach längerer Zeit noch nachvollziehen, wann welche Umbauten vorgenommen wurden -- die dann eventuell zu einem Fehler geführt haben.

RCS/asserttest.cc,v  <-  asserttest.cc

new revision: 1.2; previous revision: 1.1

enter log message, terminated with single '.' or 

end of file:

>> Programmbeschreibung wird nun am 

>> Anfang ausgegeben.

>> .

done      

Die Revisionsgeschichte

Wenn Sie die Abfolge der Änderungen (die Revisionsgeschichte) ansehen wollen, verwenden Sie das Kommando rlog. Es liefert Ihnen eine vollständige Liste aller Revisionen, einschließlich Datum, Autor und Kommentar.

rlog asserttest.cc

 

RCS file: RCS/asserttest.cc,v

Working file: asserttest.cc

head: 1.2

branch:

locks: strict

access list:

symbolic names:

keyword substitution: kv

total revisions: 2;     selected revisions: 2

description:

Testprogramm fuer assert-Kommando

--------------

revision 1.2

date: 2000/12/04 17:30:34;  author: thomas;  

state: Exp;  lines: +1 -0

Programmbeschreibung wird nun am Anfang ausgegeben

--------------

revision 1.1

date: 2000/12/04 17:25:11;  author: thomas;  

state: Exp;

Initial revision

=======================

Änderungen im Detail

Was hat sich denn nun genau geändert? Diese Frage beantwortet das Kommando rcsdiff. Es nimmt zwei Versionen aus dem Archiv und ruft das Programm diff auf, um die Änderungen herauszufinden. Die Form der Ausgabe ist sicher etwas gewöhnungsbedürftig.

rcsdiff -r1.1 -r1.2 asserttest.cc

=================================== 

RCS file: RCS/asserttest.cc,v

retrieving revision 1.1

retrieving revision 1.2

diff -r1.1 -r1.2

22a23

>   cout << "Testprogramm für assert()" << endl;

Die vorletzte Zeile weist darauf hin, dass die Einfügung zwischen den Zeilen 22 und 23 der Revision 1.1 stattgefunden hat. Wenn Sie solche Vergleiche öfter durchführen möchten, sollten Sie lieber ein grafisches Werkzeug einsetzen, das auf dieses Programm aufsetzt und eine visuelle Ausgabe erzeugt. Unter anderem erledigt der XEmacs diese Aufgabe (unter TOOLS | VC | DIFF BETWEEN REVISIONS).

Einheitliches Etikett

Gerade weil die zahlreichen Dateien, die zu einem Projekt gehören, verschieden oft bearbeitet werden und daher in der aktuellsten Revision ganz unterschiedliche Revisionsnummern tragen können, sollten Sie ab und zu einen Stand all Ihrer Quellen einfrieren, das heißt mit einem einheitlichen Etikett versehen. Auf diese Weise können Sie später genau wieder die Version identifizieren, die zu einem bestimmten Stand Ihrer Software gehörte.

Zu diesem Zweck verfügt das Kommando rcs über die Option -n. Daran schließt sich unmittelbar das Etikett an, dem wiederum ein Doppelpunkt folgt. Dahinter können Sie eine konkrete Revisionsnummer angeben, die mit diesem Etikett verbunden werden soll; wenn Sie dort aber nichts hinschreiben, wird die aktuellste Revision verwendet. Damit mehrere Dateien auf einmal etikettiert werden können, unterstützt dieses Kommando auch das Sternchen als Dateiname. Wenn wir also alle Quellen im aktuellen Verzeichnis mit dem Etikett Iteration1 versehen wollen, geben wir ein:

% rcs -nIteration1: RCS/*

Um ein bereits gesetztes Etikett überschreiben zu können, verwenden Sie die Option -N, die ansonsten derselben Syntax folgt.

In der Ausgabe von rlog erscheint das Etikett unter der Rubrik symbolic names und deutet damit an, welche Revisionsnummer mit dem Etikett versehen wurde:

...

access list:

symbolic names:

        Iteration1: 1.2

keyword substitution: kv

...

Das Etikett kann später bei Kommandos wie co -rEtikett oder rcsdiff -rEtikett zur Identifikation einer Revision verwendet werden.


Makros im Quelltext

Oft möchten Sie nicht nur auf der Kommandozeile, sondern direkt im Quelltext wissen, welche Revision Sie nun eigentlich gerade bearbeiten, wie RCS mit dieser Datei umgeht und so weiter. Dazu bietet RCS eine Reihe von Makros an, die bei jedem ci- oder co-Kommando aktualisiert werden. In Tabelle [*] finden Sie eine Übersicht.


Table: Makros im Quelltext helfen, Änderungen zu verfolgen.
Makro     Beschreibung
$Author$     Der Name des Benutzers, der diese beziehungsweise die letzte Revision bearbeitet hat
$Date$     Datum und Uhrzeit, an dem die Datei zuletzt eingecheckt wurde
$Header$     Eine Zusammensetzung aus dem Dateinamen mit vollständigem Pfad, der Revisionsnummer, Datum, Uhrzeit, Bearbeiter und gegebenenfalls Sperrmarke
$Id$     Der gleiche Text wie $Header$, allerdings ohne den Pfad vor dem Dateinamen
$Locker$     Der Name des Benutzers, der die Datei momentan gesperrt hat
$Log$     Fügt bei jedem Einchecken die Revisionsnummer und den Kommentar hinzu, wobei alle vorherigen Einträge erhalten bleiben. So kann die vollständige Revisionsgeschichte in der Datei dokumentiert werden.
$Name$     Das Etikett der aktuellen Revision (sofern vorhanden)
$RCSfile$     Der Name der zugehörigen RCS-Datei (ohne Pfad)
$Revision$     Die Nummer der aktuellen Revision
$Source$     Der Name der zugehörigen RCS-Datei mit vollständigem Pfad

Verwendung in Kommentaren

Diese Makros bauen Sie üblicherweise in Kommentare Ihres Quelltextes ein, um im Editor sofort die entsprechende Information finden zu können. In unserem Beispiel können wir etwa eingeben:

// Datei $RCSfile$

// zuletzt bearbeitet von $Author$ 

// $Date$, $Revision$

 

/*

 * $Log$

 */

Nach dem Einchecken sehen wir, dass RCS folgende Daten an die Stelle unserer Makros gesetzt hat:

// Datei $RCSfile: asserttest.cc,v $

// zuletzt bearbeitet von $Author: thomas $ 

// $Date: 2000/12/04 19:52:59 $, $Revision: 1.3 $

 

/*

 * $Log: asserttest.cc,v $

 * Revision 1.3  2000/12/04 19:52:59  thomas

 * RCS-Makros eingebaut

 *

 */

Revisionsinformationen in ausführbaren Dateien (Kommando ident)

Das $Header$- oder $Id$-Makro wird noch auf eine etwa raffiniertere Weise verwendet. Wenn Sie eine Textvariable anlegen, die eines dieser Makros enthält, wird diese Information auch in die Objektdateien und die ausführbaren Dateien übernommen. Dort können Sie sie dann mit dem Kommando ident abfragen. Fügen wir also ein:

const string Id = "$Id$";

Nach dem Einchecken hat RCS daraus Folgendes gemacht:

const string Id = "$Id: asserttest.cc,v 1.3 2000/12/04 

19:52:59 thomas Exp $";

Wenn wir nun das Programm übersetzen und das ident-Kommando anwenden, erhalten wir genau wieder diesen Text:

g++ -o asserttest asserttest.cc

ident asserttest

asserttest:

     $Id: asserttest.cc,v 1.3 2000/12/04 19:52:59 

thomas Exp $


Dabei ist es übrigens völlig unerheblich, ob Sie die Datei mit Debug-Informationen oder mit Optimierung oder ohne jegliche Option kompilieren -- die Ident-Daten werden immer übernommen, und zwar aus allen Zeichenketten vom Typ $Id$, die im Laufe der Kompilierung gefunden wurden.

Sie können sich sicher vorstellen, dass das nicht nur bei Implemetierungsdateien funktioniert, sondern auch bei Headern. Ja gerade bei diesen kann es sinnvoll sein, einen $Id$-Vermerk einzufügen, um die Abhängigkeit der übersetzten Dateien von den Versionen der einzelnen Header genau zu dokumentieren.

Zusammenfassung

In diesem Abschnitt haben Sie die Grundlagen des Konfigurationsmanagements mit RCS kennen gelernt. Daraus sollten Sie vor allem folgende Schlüsselbegriffe behalten:

Obwohl RCS weit verbreitet ist, ist es sicher nicht das beste Werkzeug für diesen Zweck. Insbesondere die Beschränkung der Arbeit auf einzelne Dateien und die fehlende Unterstützung gleichzeitigen Zugriffs machen es für viele Projekte kaum verwendbar. In größeren Linux-Projekten setzt man daher vorwiegend das bereits eingangs erwähnte CVS ein, das diese Schwierigkeiten überwindet und noch eine Reihe zusätzlicher Funktionalität bietet.


next up previous contents index
Next: Integrierte Entwicklungsumgebungen Up: C++-Entwicklung mit Linux Previous: Editoren für die Programmierung   Contents   Index
thomas@cpp-entwicklung.de

(C) T. Wieland, 2001. Alle Rechte vorbehalten.