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 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 können. 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 fest. 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 Onlinehilfe (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 Compiler-Optionen ü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
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 ein 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 Compiler-Optionen, Compiler-Namen 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 man nur diese Zeile verändern. Ä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=.

 

# Compiler-Schalter

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
         

$@

        $* 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 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 Kapitel [*] (Seite [*]), 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

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 Portabilitä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 Compiler-Aufrufe 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, 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 Compiler-Aufruf 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 Kodierungskonventionen für GNU-Projekte auch einen Abschnitt, wie Makefiles in solchen Projekten aufgebaut sein sollten. Und mit dem Sourcecode 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.

Schwerer wiegend 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 gravierende 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 geschweifte Klammer für den Block 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 (oder 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 Determinante 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)^{n+1} \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 std::string& _fname);
17: double at(unsigned int _i, unsigned int _j) const;
18: double& at(unsigned int _i, unsigned int _j);
19: unsigned int getSize() const;
20: SymMatrix subMatrix(unsigned int _k) const;
21:
22: friend double determinant(const SymMatrix& _a);
23:
24: private:
25: double** dat;
26: unsigned int size;
27:
28: void initMemory(unsigned int _n);
29: void freeMemory();
30: void print();
31: };
32:
33: #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: using namespace std;
7:
8: int main()
9: {
10: string filename;
11: cout << "Datei mit Matrix: ";
12: cin >> filename;
13:
14: SymMatrix a;
15: if (a.read(filename))
16: return -1;
17:
18: double d=determinant(a);
19: cout << "Determinante ist "
20: << setprecision(6) << d << endl;
21:
22: return 0;
23: }

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 gegebenenfalls 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 bestehen Programme aus reiner Maschinensprache, die keinen Bezug mehr zu Elementen der Programmiersprache wie Variablen oder Funktionen aufweist. Wir wollen uns aber beispielsweise Zeile für Zeile 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 [*]). Sie sollten 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 zusammenzulinken, 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 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 der gdb über eine ausführliche Onlinehilfe 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

Sollte Ihr Programm Kommandozeilenargumente erwarten, 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 16 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:103

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

15        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 15 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

10        string filename;

11        cout << "Datei mit Matrix: ";

12        cin >> filename;

13

14        SymMatrix a;

15        if (a.read(filename))

16          return -1;

17

18        double d=determinant(a);

19        cout << "Determinante ist "

Der list-Befehl versteht eine ganze Reihe von Argumenten (ähnlich wie break): eine Zeilennummer oder einen Funktionsnamen (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ßerhalb 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:179

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

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


170:  SymMatrix SymMatrix::subMatrix(

170: unsigned int _k) const
171: {
172: // Untermatrix eine Dimension kleiner
173: SymMatrix u(size-1);
174:
175: // Untermatrix nach der ersten Zeile
176: for(unsigned int i=0; i<size; i++)
177: for(unsigned int j=0, uj=0; j<size; j++)
177: // Betrachte nur Spalten ungleich _k
178: if(j != _k)
179: u.at(i, uj++) = dat[i+1][j];
180:
181: return u;
182: }

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:179

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

     at matrix.cc:211

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

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 dass Sie sich ihn merken 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 also 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:173

173       SymMatrix u(size-1);

Current language:  auto; currently c++

(gdb) display i

No symbol "i" in current context.

(gdb) n

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

(gdb) n

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

(gdb) display i

1: i = 0

(gdb) n

179        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:179

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

2: j = 1

1: i = 2     

Wir sind 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:

176:  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 Webseite des GNU-Projekts 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.

Die Besonderheit am DDD, der er auch seine Popularität verdankt, ist seine Fähigkeit, komplexe Datenstrukturen als Graphen zu visualisieren. 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 korrigieren 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: using std::string;
6:
7: //---------------------------------------------
8: // Klasse fuer Elemente der Liste
9: //---------------------------------------------
10: struct BirthListElement
11: {
12: BirthListElement* next;
13: string name;
14: string surname;
15: Date date;
16:
17: // Standardkonstruktor
18: BirthListElement() :
19: next(0) {}
20:
21: // Spezieller Konstruktor
22: BirthListElement(const string& _name,
23: const string& _surname, unsigned short _day,
24: unsigned short _month, 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, unsigned short _day,
56: unsigned short _month, unsigned short _year);
57:
58: void popFront();
59:
60: BirthListElement* front()
61: { return first; }
62:
63: int load(const string& filename);
64:
65: bool check(const Date& d);
66: };

Die einzelnen Daten werden in Objekten vom Typ BirthListElement abgelegt (Zeile 10-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 35-66) 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 einen 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 rechte 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 also 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 weiterarbeiten. 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: using namespace std;
8:
9: // Funktion: repeatedOutput
10: // Parameter:
11: // ostream* _o: Ausgabestream
12: // const string& _s: String
13: // unsigned short _n: Wiederholung
14: // Bedingung: _o != 0
15: void repeatedOutput(ostream* _o,
16: const string& _s, unsigned short _n)
17: {
18: assert(_o); // entspricht _o!=0
19: for(unsigned i=0; i<_n; i++)
20: *_o << _s;
21: }
22:
23: int main()
24: {
25: repeatedOutput(&cout, "-", 30);
26: cout << endl;
27: repeatedOutput(0, "?", 30);
28:
29: return 0;
30: }

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

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

asserttest: asserttest.cc:18: 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 Vertragsverletzungen 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 ausgeben, beispielsweise in eine Log-Datei, aber das Programm anschließend fortsetzen.)

Zusammenfassung

Aus diesem Abschnitt sind folgende Aspekte hervorzuheben:


Versionskontrolle mit RCS und CVS

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 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 wiederherzustellen.

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 www.gnu.org/software/rcs) verwendet, das als Open Source entwickelt wird.

Hervorgegangen aus RCS, aber mit mittlerweile eigener Codebasis ist CVS (Concurrent version system, siehe www.cvshome.org), das in sehr vielen Open-Source-Projekten eingesetzt wird und in diesem Bereich der De-facto-Standard ist. Gegenüber RCS hat es zum einen den Vorteil, dass es nicht nur jeweils eine Datei einzeln, sondern ganze Projekte auf einmal verwalten kann. Außerdem erlaubt CVS - wie der Name sagt - den gleichzeitigen Zugriff mehrerer Entwickler auf dieselbe Datei, da es über ausgefeilte Mechanismen zum Abgleich von Änderungen verfügt. Dass es zudem in einer Client/Server-Architektur aufgebaut ist, macht es zum idealen Werkzeug für die (etwa über das Internet) verteilte Entwicklung.

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) 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 Webseite 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 um Entwicklung geht, bezeichnet man in diesem Kontext 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 Kodierung, sondern auch fehlerfreies Kompilieren und erfolgreiches Testen 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).


Versionsverwaltung mit RCS

Zunächst wollen wir uns den einfacheren Fall, nämlich die Arbeit mit RCS ansehen, damit Sie daran die grundlegende Vorgehensweise am praktischen Beispiel erlernen. Im nächsten Abschnitt ab Seite [*] gehe ich dann auf CVS ein.

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 Abschnitt [*] verwenden (ab Seite [*]). Zum Registrieren einer Datei benutzt 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 die vorgenommenen Änderungen mit solchen knappen Kommentaren doch am besten 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      

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: 2002/12/04 17:30:34;  author: thomas;  

state: Exp;  lines: +1 -0

Programmbeschreibung wird nun am Anfang ausgegeben

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

revision 1.1

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

state: Exp;

Initial revision

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

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.

Schlüsselwörter 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 Schlüsselwörtern an, die bei jedem ci- oder co-Kommando aktualisiert werden. In Tabelle [*] finden Sie eine Übersicht.

Diese Schlüsselwörter 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:


Table: Ersetzung von Schlüsselwörtern im Quelltext hilft, Änderungen zu verfolgen.
       
    Schlüsselwort Beschreibung
     

$Author$

    $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


// Datei $RCSfile$

// zuletzt bearbeitet von $Author$ 

// $Date$, $Revision$

 

/*

 * $Log$

 */

Nach dem Einchecken sehen wir, dass RCS folgende Daten an die Stelle unserer Schlüsselwörter gesetzt hat:

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

// zuletzt bearbeitet von $Author: thomas $ 

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

 

/*

 * $Log: asserttest.cc,v $

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

 * RCS Keywords eingebaut

 *

 */

Das $Header$- oder $Id$-Schlüsselwort wird noch auf eine etwa raffiniertere Weise verwendet. Wenn Sie eine Textvariable anlegen, die eines dieser Schlüsselwörter 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 2002/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 2002/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 Implementierungsdateien 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.


Versionsverwaltung mit CVS

Wie bereits oben betont, sind RCS und CVS sehr ähnlich, da sie aus einer gemeinsamen Codebasis hervorgegangen sind. Trotzdem unterscheidet sich der Umgang mit ihnen teilweise erheblich. Auch die Syntax der Kommandos weicht deutlich voneinander ab. Die Gemeinsamkeiten sehen Sie daher eher im Ergebnis als in der Handhabung. Auch wenn Sie also schon RCS kennen, bleibt Ihnen das Durcharbeiten dieses Abschnitts nicht erspart, wenn Sie CVS verwenden wollen.

Mehr zu CVS erfahren Sie natürlich auf der Homepage des Projekts unter www.cvshome.org, aber auch in der Link-Sammlung von Pascal Molli unter molli/cvs-index.html">www.loria.fr/$\sim$molli/cvs-index.html. Ein sehr lohnenswertes Buch ist [], das in Teilen unter der Adresse cvsbook.red-bean.com auch online verfügbar ist.

Grundlegende Konzepte

Während RCS streng dateiorientiert arbeitet und die Versionsverwaltung gleich im lokalen Verzeichnis abwickelt, spielt bei CVS das zentrale Archiv eine bedeutende Rolle. Dieses Archiv - ich verwende diesen Begriff hier synonym mit dem englischen Repository - enthält alle Projektdateien und die Historie ihrer Änderungen. Es wird von einem Administrator angelegt und gepflegt.

Im Archiv gibt es ein Verzeichnis CVSROOT, das eine Reihe von Dateien enthält, die zur Verwaltung des Archivs dienen. Dazu gehören

Nicht alle diese Dateien sind von Anfang an vorhanden; einige werden erst durch entsprechende Kommandos angelegt. Man bezeichnet übrigens die Verzeichnisse, die auf der obersten Ebene des Archivs angelegt sind, als Module. Mit Hilfe der Datei modules können Sie zusätzliche logische Module definieren, zum Beispiel mehrere Module zu einem neuen Modul unter einem Alias-Namen zusammenfassen.

Von den Projekten im Archiv hat jeder Entwickler eine lokale Arbeitskopie (wird im Englischen auch als Sandbox bezeichnet), die er kompilieren, debuggen und gegebenenfalls auch modifizieren kann. Die übliche Vorgehensweise ist, dass sich der Entwickler den aktuellen Stand aus dem Archiv in sein Arbeitsverzeichnis holt, dort seine Änderungen und Erweiterungen vornimmt, dann diese übersetzt und testet. Wenn er (oder sie) mit dem erreichten Stand zufrieden ist, bringt er die veränderten beziehungsweise die neuen Dateien ins Archiv ein.

CVS unterstützt fünf verschiedene Zugriffswege auf das Repository:

  1. lokal, das heißt der direkte Dateizugriff, gegebenenfalls auch über NFS
  2. extern, also über rsh oder ssh (die Umgebungsvariable CVS_RSH gibt an, welches von beiden verwendet wird), das heißt typischerweise erfolgt der Zugriff innerhalb eines LAN auf einen anderen Rechner
  3. über einen Passwortserver, der nur autorisierte Benutzer zugreifen lässt (siehe Seite [*])
  4. über einen GSS-API-Server (General Security Services API, umfasst Kerberos 5)
  5. über einen Kerberos-4-Server
Kerberos ist nicht nur der Hund, der die Hölle in der griechischen Mythologie bewacht, sondern auch ein Netzauthentifizierungsprotokoll, das den sicheren Informationsaustausch in unsicheren Netzen wie dem Internet erlaubt. Die Partner können dabei die gegenseitige Identität zweifelsfrei feststellen und auch unbefugtes Abhören oder Paketmodifizierungen ausschließen. Kerberos ist eine Entwicklung des Massachusetts Institute of Technology und steht unter einer sehr liberalen, Open-Source-ähnlichen Lizenz. Somit bietet CVS mit Kerberos (Zugriffswege 4 und 5) die größte Sicherheit und Flexibilität, erfordert jedoch einen hohen und komplexen Konfigurationsaufwand, auf den ich in diesem Rahmen leider nicht näher eingehen kann. (Sie müssen auch separat installiert werden und gehören nicht zur Standardinstallation, die bei einer Linux-Distribution normalerweise mitgeliefert wird.) Mehr dazu finden Sie in der CVS-Dokumentation oder in [].

Welche Art des Zugriffs Sie verwenden, hängt von Art und Umfang Ihres Projekts, von Verteilung und Ausstattung der Entwickler und nicht zuletzt von Sicherheitsüberlegungen ab. Im Folgenden will ich die jeweiligen Schritte zur Einrichtung des Archivs und des Zugriffes erläutern.

Erstellen des CVS-Archivs

Das Archiv legen Sie zunächst wie ein normales Verzeichnis an. Sie müssen dazu nicht unbedingt Super-User sein, sollten es aber nicht in Ihrem Home-Verzeichnis erzeugen, sondern an etwas zentralerer Stelle, wo Sie aber auch die nötigen Rechte besitzen müssen. Außerdem müssen später ja auch andere die Möglichkeit des Zugriffs darauf haben - doch dazu gleich mehr. Gehen wir also davon aus, dass root die Erstellung vornimmt.

Nehmen wir an, wir wollen es unter /home/cvsroot anlegen, dann müssen Sie eingeben

mkdir /home/cvsroot

(Der Name ist dabei völlig frei. Es könnte auch /soft/lib/donald/duck heißen.)

Fast jedes CVS-Kommando muss wissen, wo sich das Archiv befindet. Das können Sie auch jeweils separat über die Option -d angeben. Bequemer und konsistenter ist es aber, wenn Sie die Umgebungsvariable $CVSROOT verwenden und auf das Verzeichnis des Archivs setzen.

export CVSROOT=/home/cvsroot

Nun müssen Sie noch das Verzeichnis initialisieren, das heißt das Modul CVSROOT (nicht zu verwechseln mit der Umgebungsvariablen!) mit den oben genannten Dateien erzeugen. Dies geschieht mit dem Befehl:

cvs init

Damit haben Sie ein leeres Archiv angelegt, in das nun Code eingefügt werden kann.

Konfiguration für die lokale Entwicklung

Wenn Sie Ihre Entwickler lokal auf das Archiv zugreifen lassen möchten, müssen sie Schreibrechte darauf haben. Um diese getrennt von den übrigen Rechten im System vergeben zu können, ist es empfehlenswert, dafür eine eigene Benutzergruppe anzulegen und das Archiv einem separaten Benutzer innerhalb dieser Gruppe zuzuordnen. Legen wir also eine Gruppe cvs und einen User cvs in dieser Gruppe an (wieder als root):

groupadd cvs

useradd -d $CVSROOT -g cvs cvs

Dann können wir das Archiv diesem User zuordnen und die Zugriffsrechte auf die Gruppe erweitern:

chown -R cvs.cvs $CVSROOT

chmod 770 $CVSROOT

Nun müssen Sie noch alle Entwickler, die mit dem Archiv arbeiten sollen, in die Gruppe cvs aufnehmen. Das geschieht mit dem Befehl usermod. Dabei müssen Sie neben der Gruppe cvs auch noch alle anderen Gruppen angeben, denen der jeweilige Benutzer außerdem angehört; fehlt eine, wird seine Mitgliedschaft darin gelöscht. Wenn also der Benutzer thomas vorher in users und dev war, lautet der Befehl:

usermod -G users,dev,cvs thomas

Das Problem bei dieser Zugriffsart ist das Schreibrecht auf das Archiv für alle Entwickler. Damit können sie nicht nur ihre Codedateien aus- und einchecken, sondern auch das gesamte Archiv modifizieren, schlimmstenfalls zerstören. Disziplin und Vertrauen sind daher die Voraussetzungen, um diesen Weg einschlagen zu können. Innerhalb einer Arbeitsgruppe in einer Firma sollte es damit keine Probleme geben. Sie sollten sich jedoch jederzeit der Risiken bewusst bleiben. Auf alle Fälle - auch schon aus viel nahe liegenderen Gründen - sollte ein häufiges Backup des Archivs selbstverständlich sein.

Die gleiche Vorgehensweise ist auch für das Arbeiten über eine Remote-Shell sinnvoll. Nur müssen Sie dabei darauf achten, dass entweder die lokalen Benutzerkonten auf dem CVS-Rechner und den Entwicklungsrechnern gleich sind oder am besten gleich mit NIS arbeiten. Die Befehle zum Anlegen einer NIS-Gruppe entnehmen Sie bitte der diesbezüglichen Dokumentation.


Konfiguration des Passwortservers

Sie können CVS auch in einem Client/Server-Modus betreiben. Es läuft dann als eigener Server, der die Benutzerautorisierung und die weiteren Zugriffe verwaltet. Dieser Passwortserver arbeitet auf dem Port 2401, so dass Sie diesen gegebenenfalls in Ihrer Firewall-Konfiguration freischalten, auf jeden Fall aber auf dem Serverrechner aktivieren müssen. Dazu stellen Sie sicher, dass sich in der Datei /etc/services eine Zeile ähnlich der folgenden befindet:

cvspserver      2401/tcp        # cvspserver

Bei vielen Linux-Installationen ist eine solche Zeile bereits vorhanden, allerdings durch ein # in der ersten Spalte auskommentiert. In diesem Fall müssen Sie nur das Kommentarzeichen entfernen.

Der Passwortserver ist kein Demon, der bereits beim Systemstart aktiv sein muss. Es genügt, ihn bei Bedarf über den Inet-Demon inetd zu starten. Daher müssen Sie den Passwortserver in die Konfigurationsdatei /etc/inetd.conf eintragen. Der Eintrag besteht aus einer Zeile, was ich hier leider aus drucktechnischen Gründen nicht genauso machen kann, und umfasst den Portnamen, den Befehl und das $CVSROOT-Verzeichnis:

cvspserver stream tcp   nowait  root    /usr/sbin/tcpd

/usr/bin/cvs -f -allow-root=/home/cvsroot pserver

Nachdem Sie diese Eintragungen vorgenommen haben, müssen Sie den Inet-Demon neu starten. Auf vielen Systemen geht das mit dem Kommando

/etc/init.d/inetd restart

Auf diesen Server können dann alle Benutzer zugreifen, die das System bereits kennt, also alle mit lokalen oder NIS-Konten. Wenn Sie auf diese Weise eine über das Internet verteilte Entwicklung aufsetzen möchten, dürfte es nicht der beste Weg sein, allen Mitentwicklern ein Konto auf dem Server einzurichten. Daher sieht CVS auch eine eigene Benutzerverwaltung vor. Dazu tragen Sie alle berechtigten Benutzer in eine Datei names passwd ein, die Sie ins Verzeichnis $CVSROOT/CVSROOT ablegen. Diese enthält pro Zeile jeweils einen Benutzernamen und dessen Passwort, getrennt durch einen Doppelpunkt. Optional können Sie dahinter, nach einem weiteren Doppelpunkt, noch den Namen eines lokalen Benutzerkontos angeben, unter dem der jeweilige Benutzer dann auf dem Server agieren soll, was sich dann auf Zuordnungen von neuen Dateien etc. auswirkt.

Das Passwort muss dabei wie in /etc/shadow, die die lokalen Passwörter enthält, in verschlüsselter Form angegeben sein. Leider bringt CVS kein eigenes Werkzeug mit, um diese Datei anzulegen und die Passwörter zu verschlüsseln, so dass Sie für diese Aufgabe die entsprechenden Systemroutinen heranziehen müssen. Diese können wir natürlich auch aus C++ aufrufen. Ein kleines Programm, das ein Klartextpasswort von der Kommandozeile liest und die verschlüsselte Version ausgibt, könnte folgendermaßen aussehen:


1:  #include <iostream> 

2: #include <stdlib.h>
3: #include <time.h>
4: #include <unistd.h>
5:
6: using namespace std;
7:
8: char getSaltChar()
9: {
10: // erzeuge Zahl zwischen 0 und 25
11: char s = (char) (26.0*random()/(RAND_MAX+1.0));
12:
13: // addiere 65 (Großbuchstaben) oder 97
14: // (Kleinbuchstaben) hinzu
15: s += (random()/(RAND_MAX+1.0) > 0.5) ? 65 : 97;
16: return s;
17: }
18:
19: int main(int argc, char** argv)
20: {
21: // initialisiere den Zufallszahlengenerator
22: srandom(time(0));
23:
24: // brich das Programm ab, wenn es ohne Argument
25: // aufgerufen wurde
26: if (argc < 2)
27: {
28: cerr << "Verwendung: pwcrypt <passwort>"
29: << endl << endl;
30: return -1;
31: }
32:
33: char sa[2];
34:
35: // erzeuge zwei zufällige Zeichen
36: sprintf(sa, "%c%c", getSaltChar(),
37: getSaltChar());
38:
39: // verschlüssele Passwort und gib es aus
40: cout << crypt(argv[1], sa) << endl;
41:
42: return 0;
43: }
Die wichtigste Funktion daran ist crypt(), die in Zeile 41 aufgerufen wird. Sie verlangt als Parameter das Passwort im Klartext sowie zwei zufällige Buchstaben, die klein oder groß sein dürfen, und die wir in der Funktion getSaltChar() einzeln erzeugen. Das hat zur Folge, dass die Verschlüsselung immer wieder andere Ausgaben produziert, die sich aber alle auf denselben Text zurückführen lassen. Für das Passwort Linux könnte der Eintrag in die Passwortdatei beispielsweise lauten:

wieland:VGFifgAElW.kc:thomas

Wenn Sie einem Benutzer nur Leserechte einräumen wollen, müssen Sie ihn zusätzlich noch in die Datei readers eintragen. Dies bietet sich besonders dann an, wenn Sie das CVS-Archiv der Öffentlichkeit zur Verfügung stellen wollen. Mein Tipp zur Vorgehensweise dabei:

Der Passwortserver eignet sich schon gut für eine verteilte Entwicklung, da er Zugriffe nach dem Client/Server-Prinzip erlaubt und eine eigene Benutzerverwaltung unterstützt. Er arbeitet aber nicht mit verschlüsselten Zugriffen; sowohl das Passwort beim Anmelden als auch die Dateien selber werden alle im Klartext übertragen. Wenn es sich um eine Kommunikation im Intranet oder ein kleines Open-Source-Projekt im Internet handelt, können diese Einschränkungen noch akzeptabel sein. Wenn Sie höhere Sicherheitsanforderungen erfüllen müssen, sollten Sie auf den GSS-Server ausweichen, der eine echte sichere Kommunikation unterstützt. Diesen kann ich hier aber leider nicht ausführlicher behandeln.

Weitere administrative Eingriffe

Zur Verwaltung des Archivs und des Servers gibt es das Kommando admin. Wie alle CVS-Kommandos wird auch dieses als erstes Argument hinter dem Befehl cvs angegeben; danach erst folgen die Argumente dieses Kommandos. Normalerweise dürfen alle Benutzer diesen Befehl verwenden. Erst wenn Sie eine Gruppe cvsadmin angelegt haben, ist der Aufruf auf diese Gruppe beschränkt.

Die Option -L sorgt beispielsweise dafür, dass CVS sich ähnlich wie RCS in Bezug auf das Sperren von Dateien verhält. Normalerweise ist es bei CVS ja erlaubt, dass mehrere Benutzer gleichzeitig eine Datei ausgecheckt haben. Durch

cvs admin -L

verhindern Sie dieses und schreiben das so genannte pessimistische Sperren vor, so dass eine ausgecheckte Datei vor Bearbeitungen durch andere stets geschützt ist. Die Option -U schaltet wieder auf den Ursprungszustand zurück.

Manchmal ist es nötig, das gesamte Archiv oder einzelne Revisionen daraus zu sperren, um einen aktuellen Stand einzufrieren. Das erledigen Sie mit der Option -l, also etwa

cvs admin -l

Um es wieder freizugeben, verwenden Sie -u. Weitere Optionen dieses Befehls erfahren Sie durch

cvs -H admin

Vorgehensweise eines Entwicklers

Die Arbeit mit CVS ist aus Sicht des Entwicklers der mit RCS naturgemäß sehr ähnlich. Auch hier gibt es ein Auschecken und ein Einchecken (siehe Seite [*]). Allerdings müssen Sie beachten, dass CVS im Normalfall die gleichzeitige Bearbeitung einer Datei durch mehrere Benutzer zulässt. Das bedeutet, dass auch eine von Ihnen ausgecheckte Datei sich zu dem Zeitpunkt, da Sie sie wieder einchecken möchten, bereits verändert haben kann. CVS hat ausgefeilte Algorithmen dafür, solche Verschmelzungen verschiedener Versionen zu automatisieren - doch dazu später mehr.

Die allgemeine Vorgehensweise ist daher:

  1. Checkout: Dabei wird die aktuelle Version eines oder mehrerer Module aus dem Archiv in das Arbeitsverzeichnis des Entwicklers kopiert. Alle Dateien sind sofort beschreibbar. Ein separates zusätzliches Auschecken zum Bearbeiten einer Datei gibt es nicht, da keiner ein exklusives Bearbeitungsrecht daran hat.
  2. Update: Wenn Sie mit der Bearbeitung eines Projekts so weit sind, dass Sie Ihre Änderungen wieder einchecken möchten, müssen Sie zunächst eine Aktualisierung Ihres Arbeitsverzeichnisses durchführen. Es kann durchaus vorkommen, dass sich einige Dateien im Archiv seit Ihrem letzten Auschecken wieder verändert haben. Nach dem Update müssen Sie Ihren Code erneut übersetzen und testen, ob er auch mit der aktuellsten Version des Projekts fehlerfrei arbeitet.
  3. Commit: Erst wenn jetzt alles wie gewünscht läuft, können Sie Ihre Änderungen ins Archiv übertragen. Da andere Benutzer kurz danach ein Update machen könnten, sollten Sie generell nur Code per Commit ins Archiv einspielen, der auch wirklich funktioniert. Ein Einchecken von halbfertigen Zwischenständen, nur um ein Backup zu haben, kann sich bei CVS verheerend auswirken und das ganze Team lahm legen.
Zuvor muss es natürlich erst einmal Code im Archiv geben. Dieser kann entweder als einzelne Datei hinzugefügt oder als ganzes Modul importiert werden. Das wollen wir uns als Nächstes ansehen.

Einrichten eines Arbeitsverzeichnisses und Importieren

Der erste Schritt, wenn Sie beginnen, mit einem CVS-Archiv zu arbeiten, ist das Setzen der Variablen $CVSROOT. Dies ist die zentrale Einstellung, mit deren Hilfe alle CVS-Kommandos das Archiv finden. Bei einem lokalen Archiv zeigt sie auf den Pfad - das ist Ihnen sicher schon klar. Doch wie ist das bei einem Passwortserver? Hier die Konfigurationen im Einzelnen:

Wenn Sie auf einen entfernten Rechner zugreifen wollen, müssen Sie sich meist erst einmal autorisieren. Das geschieht mit dem Befehl cvs login. Dieser erwartet keine weiteren Argumente, denn er kennt den Server und den Benutzernamen bereits aus $CVSROOT. Sie müssen dann nur noch Ihr Passwort eingeben, so wie in folgendem Beispiel:

%  cvs login

(Logging in to thomas1@cvs.myproject.org)

CVS password:

Als nächsten Schritt legen Sie das Arbeitsverzeichnis an. Dazu erzeugen Sie ein Verzeichnis, wechseln in dieses und checken mit cvs checkout das gewünschte Modul aus (siehe BeispielSec:CVS-Checkout).

Jedes Verzeichnis innerhalb Ihres Arbeitsbereichs enthält ein Unterverzeichnis CVS, in dem ein paar Dateien stehen, die CVS für die interne Buchführung benötigt. Im Normalfall können Sie diese ignorieren.

Wenn Sie in Ihrem Benutzerverzeichnis bereits ein Projekt haben, das Sie zum CVS-Archiv hinzufügen möchten, verwenden Sie den Befehl cvs import. Dieser Befehl erwartet einen Modulnamen gefolgt von zwei Etiketten (Tags) als Argumenten: ein vendortag, das Sie als den Hersteller dieses Moduls kennzeichnet, und ein releasetag, das den Namen der Version angibt, die Sie gerade importieren. Beachten Sie, dass Version hier eine textuelle Bezeichnung sein muss; dieses Tag muss mit einem Buchstaben beginnen und darf keinen Punkt oder ähnliche Sonderzeichen enthalten. Das Format ist also

cvs import modul vendortag releasetag

Für den Import wird auch ein Eintrag in die Log-Datei des Archivs vorgenommen. Für diesen müssen Sie zusätzlich einen Text angeben. Diesen können Sie gleich beim Aufruf von cvs import über die Option -m übergeben, wo Sie den Text in Anführungszeichen direkt hinter die Option, getrennt durch ein Leerzeichen, eintippen können. Fehlt diese Option, startet CVS automatisch den Editor. Dieser ist nach der Voreinstellung der vi. Wenn Sie an dieser Stelle einen anderen Editor verwenden möchten, können Sie diesen über die Umgebungsvariable $EDITOR angeben, die auf den vollständigen Pfad zeigen sollte, zum Beispiel

export EDITOR=/usr/bin/X11/nedit

Wenn Sie dafür einen Editor wie NEdit verwenden, der in einem eigenen Fenster läuft, müssen Sie diesen nach der Eingabe beenden, damit der Import-Vorgang abgeschlossen werden kann.

Der Import selber geht ganz einfach: Wechseln Sie in das Verzeichnis, welches das Projekt enthält (und auch noch weitere Unterverzeichnisse haben kann). Geben Sie nun den entsprechenden Befehl ein, beispielsweise

cvs import driver thomas_w first

Dabei ist driver der Name des Projekts, unter dem es dann im Archiv erscheint und abgerufen werden kann. Nach Eingabe des Log-Kommentars erhalten Sie eine Liste aller importierten Dateien. Der erste Buchstabe in jeder Zeile zeigt den Status der jeweiligen Datei an: N steht für neu, U für Update, also bereits vorhanden, und so weiter.


Auschecken

Das Auschecken bedeutet bei CVS lediglich ein Kopieren der jeweiligen Dateien vom CVS-Archiv in Ihr lokales Arbeitsverzeichnis. (Die Ausnahme ist, wenn das Archiv so konfiguriert wurde, dass beim Auschecken tatsächlich eine Sperre verhängt wird.) Sie checken daher mit dem Befehl cvs checkout meistens ganze Module aus,

Im Folgenden will ich als Beispiel das Programm zur Determinantenberechnung aus dem vorangegangenen Unterkapitel (ab Seite [*]) verwenden. Das Auschecken dafür läuft folgendermaßen ab:

cvs checkout DebugTest

cvs checkout: Updating DebugTest

U DebugTest/Makefile

U DebugTest/cyclic.dat

U DebugTest/main.cc

U DebugTest/matrix.cc

U DebugTest/matrix.h

Damit wird ein Unterverzeichnis DebugTest im aktuellen Verzeichnis erzeugt und die Arbeit daran kann beginnen. Wenn dieses Projekt noch weitere eigene Unterverzeichnisse enthalten würde, so würden natürlich auch diese mit ausgecheckt.

Hinzufügen

Oftmals werden Sie auch Dateien neu erzeugen, die Sie dann zum CVS-Archiv hinzufügen wollen. Dazu dient der Befehl cvs add. Ähnlich wie beim Importieren gibt es auch dabei die Option -m, mit der Sie einen Kommentar für diese Datei(en) oder dieses Verzeichnis angeben können. Fehlt diese Option, startet CVS einen Editor, damit Sie den Kommentar direkt eingeben können.

An dieser Stelle sehen wir ein für CVS typisches Verhalten: Die meisten Operationen, die Sie als Benutzer auf dem Archiv vornehmen, sind nicht sofort gültig, sondern erst dann, wenn Sie die Änderungen mit einem cvs commit freigeben. Das Hinzufügen erfolgt also nicht sofort, sondern gilt nur als eine der Aufgaben, die beim nächsten Commit erledigt werden.

Nehmen wir an, wir hätten eine Eingabedatei für unsere Beispielapplikation namens cyc4.dat erzeugt, die wir nun zum Archiv hinzufügen möchten. Wir benötigen dazu zunächst das add und dann das commit:

cvs add cyc4.dat

cvs add: scheduling file `cyc4.dat' for addition

cvs add: use 'cvs commit' to add this file permanently

cvs commit

cvs commit: Examining .

RCS file: /home/cvsroot/DebugTest/cyc4.dat,v

done

Checking in cyc4.dat;

/home/cvsroot/DebugTest/cyc4.dat,v  <-  cyc4.dat

initial revision: 1.1

done

Aktualisieren und Freigeben

Bevor Sie die Änderungen, die Sie in Ihrem Arbeitsverzeichnis an den Projektdateien vorgenommen haben, wieder einchecken, müssen Sie nachsehen, ob nicht in der Zwischenzeit andere Benutzer ebenfalls Änderungen eingebracht haben. Dazu dient das Kommando cvs update.

Wenn Sie es ohne Argumente aufrufen, werden alle Änderungen des Repository sofort in Ihr Arbeitsverzeichnis übernommen. Dies kann manchmal zu unerwünschten Nebeneffekten führen. Besser ist es da, zunächst einmal den Status nur zu prüfen, ohne gleich Dateien zu kopieren oder automatisch zu aktualisieren. Dafür eignet sich die Befehlssequenz

cvs -n update -d

Durch das -n wird das Kopieren verhindert, -d legt jedoch neu hinzugekommene Verzeichnisse schon einmal an. Sie erhalten eine Liste der Dateien, bei denen es Abweichungen zwischen dem Archiv und Ihrem Arbeitsverzeichnis gibt. In der ersten Spalte steht dabei ein Buchstabe, der den Status angibt:

A
Added. Zeigt an, dass eine Datei dieses Namens neu zum Archiv hinzukam, aber noch nicht freigegeben wurde.

C
Conflict. Sowohl die archivierte Version dieser Datei als auch die in Ihrem Arbeitsverzeichnis haben sich geändert - und zwar so, dass CVS keine automatische Zusammenführung machen kann.

M
Modified. Gibt an, dass sich die Version in Ihrem Arbeitsverzeichnis seit dem letzten Auschecken oder Freigeben verändert hat. Sollte es auch Änderungen am Archiv gegeben haben, so konnte CVS diese problemlos mit den Ihrigen zusammenführen.

R
Removed. Zeigt an, dass eine Datei dieses Namens aus dem Archiv gelöscht werden soll, dies aber noch nicht durch ein Commit bestätigt wurde.

U
Updated. Die Datei wurde in Ihrem Arbeitsverzeichnis aktualisiert.

?
Unknown. Die Datei existiert zwar in Ihrem Arbeitsverzeichnis, aber nicht im Archiv.

Der einfachste Fall ist, dass es keine Konflikte mit der Arbeit anderer Benutzer gibt. Wenn Sie in unserem Beispiel etwa die Datei matrix.cc bearbeitet haben, gibt Ihnen CVS bei einem Update aus:

cvs update

cvs update: Updating .

M matrix.cc

Nehmen wir aber an, Sie haben die Datei matrix.cc editiert, gleichzeitig hat aber ein anderer Benutzer sowohl matrix.cc als auch die Header-Datei matrix.h verändert. In diesem Fall meldet Ihnen CVS:

cvs update

cvs update: Updating .

U DebugTest

RCS file: /home/cvsroot/DebugTest/matrix.cc,v

retrieving revision 1.1.1.1

retrieving revision 1.2

Merging differences between 1.1.1.1 and 1.2 into 

matrix.cc

rcsmerge: warning: conflicts during merge

cvs update: conflicts found in matrix.cc

C matrix.cc

U matrix.h

CVS versucht dabei, die Datei auf den Stand des Archivs zu bringen, markiert aber offensichtliche Abweichungen, wo es Änderungen in beiden Dateien gab. In unserem Fall stellen wir fest, dass der Kollege die Signatur der Methode SymMatrix::print() von void auf std::ostream& geändert hat, wir selbst aber gleichzeitig ein endl entfernt haben. Das Ergebnis ist:

void SymMatrix::print(std::ostream& _o) const

{

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

  {

    _o << "(";

    for(unsigned j=0; j<size; j++)

<<<<<<< matrix.cc

      cout << at(i,j) << " ";

    cout << ")" << endl;

=======

      _o << at(i,j) << " ";

    _o << ")" << endl << endl;

>>>>>>> 1.2

  }

}

Dabei befindet sich die lokale Version im ersten Teil dieser Markierung, die archivierte im zweiten. Wenn wir jetzt einfach diesen zweiten Teil entfernen und auf unserer Variante bestehen, akzeptiert auch CVS das. Ein erneutes Update liefert:

cvs update

cvs update: Updating .

M matrix.cc

Um den Status einzelner Dateien zu überprüfen, können Sie auch das Kommando cvs status verwenden. Damit erfahren Sie, ob Ihre Version gerade die aktuelle ist oder ob sich in der Zwischenzeit etwas geändert hat.

cvs stat matrix.cc

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

File: matrix.cc         Status: Up-to-date

 

   Working revision:    1.3     

Sun Mar 17 19:12:51 2002

   Repository revision: 1.3     

/home/cvsroot/DebugTest/matrix.cc,v

   Sticky Tag:          (none)

   Sticky Date:         (none)

   Sticky Options:      (none)

Nach einer letzten Kompilierung, um die Korrektheit unseres gegenwärtigen Codes zu überprüfen, können auch wir unsere Änderungen freigeben. Das geschieht mit cvs commit. Auch hier erwartet CVS einen Kommentar, der entweder über die Option -m oder im Editor einzugeben ist.

cvs commit -m "Eine Absatzmarke entfernt"

cvs commit: Examining .

Checking in matrix.cc;

/home/cvsroot/DebugTest/matrix.cc,v  <-  matrix.cc

new revision: 1.3; previous revision: 1.2

done

Das Commit bezieht sich dabei auf alle Dateien, die im aktuellen Verzeichnis und dessen Unterverzeichnissen geändert wurden. Auch werden damit Hinzufügungen und Löschungen umgesetzt.

Selbst wenn es in den von Ihnen modifizerten Dateien keine Konflikte gibt, kann das Update aber doch immer auch zu kleineren Änderungen bestehender Dateien oder zum Auftauchen neuer Dateien führen. Sie sollten daher stets vor einem Commit das Projekt noch einmal kompilieren, am besten auch eine Testreihe laufen lassen, um die Korrektheit sicherzustellen. Ist einmal fehlerhafter Code ins Archiv eingecheckt worden, kann das das gesamte Projekt in Schwierigkeiten bringen. Auf alle Fälle werden Sie sich eine gute Ausrede für die anderen Teammitglieder ausdenken müssen ...

Dateien aus dem Archiv löschen

Zuweilen enthält ein Archiv auch Dateien, die nicht mehr benötigt werden, etwa weil ihre Aufgaben durch andere Dateien übernommen wurden. Solche Reste müssen Sie zunächst aus Ihrem Arbeitsverzeichnis löschen, erst dann können Sie sie mit dem Befehl cvs remove aus dem Archiv entfernen. Aber auch dies bedeutet nicht das sofortige Löschen, sondern lediglich eine Vormerkung. Erst beim nächsten Commit entfernt CVS die Dateien dann tatsächlich.

Sehen wir uns das am Beispiel an: Nehmen wir an, wir hätten eine Eingabedatei für unsere Beispielapplikation namens cyc4.dat erzeugt, die wir nun nicht mehr benötigen. Wir geben also ein:

rm cyc4.dat

cvs remove cyc4.dat

cvs remove: scheduling `cyc4.dat' for removal

cvs remove: use 'cvs commit' to remove this file

permanently

cvs commit

cvs commit: Examining .

Removing cyc4.dat;

/home/cvsroot/DebugTest/cyc4.dat,v  <-  cyc4.dat

new revision: delete; previous revision: 1.1

done

Doch selbst hier darf man das permanently nicht wörtlich nehmen. Die Datei wird zwar beim Auschecken nicht mehr kopiert und zählt damit nicht mehr zu den Projektdateien, ihre Historie bleibt jedoch erhalten. Sie können die gelöschten Dateien mit einem erneuten cvs add wiederbeleben:

cvs add cyc4.dat

cvs add: re-adding file cyc4.dat (in place of dead

revision 1.2)

cvs add: use 'cvs commit' to add this file permanently

Beim nächsten Commit wird die Datei wiederhergestellt.

Überblick über Änderungen

Wenn Sie die Abfolge der Änderungen (die Revisionsgeschichte) ansehen wollen, verwenden Sie das Kommando cvs log. Es liefert Ihnen eine vollständige Liste aller Revisionen, einschließlich Datum, Autor und Kommentar. Als Argument geben Sie dabei den Dateinamen an.

cvs log matrix.cc

 

RCS file: /home/cvsroot/DebugTest/matrix.cc,v

Working file: matrix.cc

head: 1.3

branch:

locks:

access list:

symbolic names:

keyword substitution: kv

total revisions: 4;     selected revisions: 4

description:

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

revision 1.3

date: 2002/12/17 09:17:46;  author: thomas;  

state: Exp;  lines: +2 -2

Eine Absatzmarke entfernt

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

revision 1.2

date: 2002/12/17 09:02:47;  author: markus;  

state: Exp;  lines: +4 -4

Print-Methode hinzugefuegt

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

revision 1.1

date: 2002/12/15 10:42:20;  author: thomas;  

state: Exp;

branches:  1.1.1;

Initial revision

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

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

Wenn Sie nun wissen möchten, was sich genau geändert hat, rufen Sie das Kommando cvs diff auf. Mit der Option -r geben Sie dabei die Revisionsnummer der ersten und gegebenenfalls auch der zweiten zu vergleichenden Version an. Alternativ können Sie diese mit -D auch über deren Datum spezifizieren. Wenn Sie nichts angeben, vergleichen Sie damit die Version in Ihrem Arbeitsverzeichnis mit der, von der sie ursprünglich kopiert wurde. (Also nicht mit dem aktuellen Archiv-Zustand!). Genau wie bei RCS wird auch hier das Programm diff aufgerufen, um die Änderungen herauszufinden. Für ein Beispiel kann ich Sie daher auf Seite [*] zurückblättern lassen.

Einheitliches Etikett

Auch ganz analog zu RCS können Sie entweder einzelne Dateien, besser noch den aktuellen Stand eines ganzen Moduls mit einem einheitlichen Etikett (englisch tag) versehen. Das dient hauptsächlich dazu, später einmal genau wieder die Version einer Datei identifizieren zu können, die zu einem bestimmten Stand Ihrer Software gehörte.

Bei CVS muss sich dieses Etikett an ein vorgeschriebenes Format halten, dem wir beim Import oben bereits begegnet sind: Das erste Zeichen muss ein Buchstabe sein, die folgenden entweder Zahlen, Buchstaben oder Striche (-) beziehungsweise Unterstriche (_). Alle anderen Sonderzeichen sind nicht erlaubt, also auch keine Punkte. Wenn Sie etwa Ihr Programm als Release 1.0 kennzeichnen möchten, so müssen Sie es in CVS etwa Release-1_0 nennen.

Das Kommando dazu lautet cvs tag. Als Argument erwartet es den Namen des Etiketts, optional noch einen oder mehrere Dateinamen; wenn Sie diese weglassen, werden alle Dateien des aktuellen Verzeichnisses mit diesem Etikett versehen. Die Option -R erweitert dies auf alle Unterverzeichnisse.

cvs tag Release-1_0

cvs tag: Tagging .

T Makefile

T cyc4.dat

T cyclic.dat

T main.cc

T matrix.cc

T matrix.h

Noch ein paar weitere hilfreiche Optionen: -d löscht ein Etikett, das nicht mehr benötigt wird oder versehentlich vergeben wurde; -F vergibt ein bereits bestehendes Etikett neu und -c führt zu einer Warnung, falls die lokal vorhandenen Dateien neuer sind als die im Archiv. Und falls Sie nicht die letzte Version einer Datei mit dem Etikett versehen möchten, können Sie mit -r die gewünschte Revisionsnummer beziehungsweise mit -D das gewünschte Datum angeben.

CVS erlaubt Ihnen auch, unabhängig von Ihrem Arbeitsverzeichnis Dateien und Module mit einem Etikett zu versehen. Dazu dient das Kommando cvs rtag, welches auch außerhalb eines Arbeitsverzeichnisses aufgerufen werden kann. Es hat im Wesentlichen dieselbe Syntax wie tag und auch die Optionen sind weitgehend gleich.

Auf das so oder so gesetzte Etikett können Sie sich bei verschiedenen Operationen beziehen:

Schlüsselwörter im Quelltext

Auch beim Thema Ersetzung von Schlüsselwörten verleugnet CVS seinen Ursprung in RCS nicht. Sie können mit CVS ebenfalls die in Tabelle [*] auf Seite [*] vorgestellten Schlüsselwörter verwenden. Die Möglichkeiten, auf welche Weise die Schlüsselwörter ersetzt werden, gehen aber bei CVS noch etwas weiter.

Die Kommandos add, checkout, diff, import und update kennen die Option -k, welche den Umgang mit den Schlüsselwörtern regelt. Nach einem Leerzeichen erwartet diese Option noch ein Flag aus folgender Liste:

k  
Verhindert die Schlüsselwortersetzung ganz und lässt nur die Namen der Schlüsselwörter stehen. Dies kann für den automatisierten Vergleich zweier Revisionen sinnvoll sein.

kv  
Dies ist das Standardverhalten, das wir auch schon von RCS kennen. Die Schlüsselwörter selbst bleiben stehen, die Werte werden hinter einem Doppelpunkt eingefügt.

o  
Verwendet die Werte aus dem Archiv, statt aktuellere zu erzeugen.

v  
Ersetzt die Schlüsselwörter vollständig durch ihre Werte, einschließlich der $-Begrenzer. Damit geht die Schlüsselworteigenschaft verloren, so dass sich dieser Modus nur eignet, wenn Sie die Datei nicht weiter mit CVS pflegen wollen.


Cervisia

Vielleicht kennen Sie sie noch aus Asterix: Die lauwarme Cervisia war das Bier, das die Römer so gern tranken. Es gibt aber auch ein Werkzeug mit gleichem Namen, der durch Einfügen von Vokalen aus CVS gebildet wurde. Es ist eine Anwendung für die KDE-Oberfläche und kommt mit dem Paket KDE-SDK, das in allen Distributionen enthalten ist, die auch KDE mitliefern. Mit Cervisia können Sie die CVS-Operationen von einer grafischen Benutzeroberfläche aus verwenden und müssen sich nicht mehr die etwas undurchschaubaren CVS-Kommandos und deren Optionen merken.

Seit KDE 3.0 ist Cervisia direkt in den Dateimanager Konqueror integriert. Wenn es sich bei dem Verzeichnis, das Sie gerade betrachten, um eines handelt, das unter CVS-Verwaltung steht, können Sie durch das Backstein-Symbol in der Werkzeugleiste die entsprechende Anzeige aktivieren (siehe Abbildung [*]).

Figure: Cervisia bietet, allein oder im Konqueror integriert, eine leichte grafische Bedienung aller CVS-Operationen.

\resizebox*{1\columnwidth}{!}{\includegraphics{images/cervisia.eps}}

In den Menüs DOKUMENT, ANSICHT, ERWEITERT und ARCHIV finden Sie alle Arten von Operationen, die wir im Laufe dieses Abschnitts besprochen haben - und noch ein paar mehr. Wenn Sie die Grundprinzipien der Arbeit mit CVS verstanden haben, können Sie mit einem Werkzeug wie diesem sicher viel bequemer und fehlerfreier arbeiten. Probieren Sie es aus!

Zusammenfassung

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

Unter Linux verbreitete Werkzeuge dazu sind das Revision Control System RCS und das Concurrent Version System CVS. Für RCS können Sie sich merken:

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 CVS ein, das diese Schwierigkeiten überwindet und noch eine Reihe zusätzlicher Funktionalitäten 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