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:
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äß meinen 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:
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 www.gnu.org/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 Büchern wie [HEROLD 2003] oder [PESCHEL-FINDEISEN 2004] alles Wissenswerte nachschlagen.
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 617.) 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.
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.
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:
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:
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:
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.
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:
g++ birthday.o birthlist.o date.o \
-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:
g++ -c $<
Dabei ist $¡ ein Makro, das den Namen der Quelldatei bezeichnet. Es gibt
noch einige weitere derartige Makros bei make; 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.
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 6.1 aufgeführten Ziele anzutreffen.
| ||||||||||||||||||||||||||||||
Für unser Beispielprojekt könnte das Ziel clean etwa lauten:
-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.
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:
Darüber hinaus kennt make eine ganze Reihe von Kommandozeilenoptionen. Die wichtigsten sind:
-k veranlassen Sie,
trotzdem mit der Abarbeitung fortzufahren.
-n. Dann werden alle Befehle nur ausgedruckt, aber nicht
ausgeführt - eine Trockenübung also.
-f an, also etwa make
-f myprog.mak.
-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.
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:
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:
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:
Die Syntax für die Zuweisung eines Makros entspricht der in Bourne-Shell-Skripten, also
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.
# Name des Compilers
CXX=g++
# Pfad für zusätzliche Header-Dateien
INCLUDE=.
# Compiler-Schalter
CCFLAGS=-g -Wall
# zusaetzliche Bibliotheken
LIBS=
birthcontrol: birthday.o birthlist.o date.o
$(CXX) birthday.o birthlist.o date.o \
-o $@ $(LIBS)
.cc.o:
$(CXX) -I$(INCLUDE) $(CCFLAGS) -c $<
In Tabelle 6.2 finden Sie einige weitere vordefinierte Makros (auch automatische Variablen genannt), die oft ganz praktisch sein können.
| ||||||||||||||||||||||||||||||||
!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.
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 3 (Seite 433), so genügt es, wenn Sie in der Shell aufrufen:
Daraufhin wird der Compiler mit den nötigen Optionen gestartet. Auf dem Bildschirm erscheint:
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 6.3 zeigt
Ihnen eine Liste wichtiger vordefinierter Makros. Diese können Sie sich
auch durch den Aufruf von make -p ausgeben lassen.
| ||||||||||||||||||||||||||||||||||||||
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:
MAKRONAME=Wert. Wollen Sie beispielsweise noch
die Flags -g -Wall beim Compiler-Aufruf berücksichtigt haben,
können Sie bei unserer Beispieldatei in der Kommandozeile
schreiben:
Daraufhin erscheint wie gewünscht auf dem Bildschirm:
Natürlich können Sie dabei auch eigene Makros übergeben.
In Tabelle 6.3 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:
$(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.
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.
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:
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.
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:
(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 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.
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 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:
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:
-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:
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:
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
Probieren Sie es doch einfach mal aus!
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:
zieldatei: quelldatei1
quelldatei2 sowie
.cc.o:). Bei diesen Befehlen können Sie dann auch die
automatischen Variablen verwenden (wie $¡ für die aktuelle
Quelldatei).
CXXFLAGS definieren.
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.
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 468 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:
assert(), wie Sie also auch jenseits des
Debuggers in Ihren Programmen Fehler aufspüren können (ab
Seite 700).
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.
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
text = ``Hier ist ein Fehler!'';
cerr << text << endl;
Sicher sollte hier die Ausgabe auch noch von dem Wert von result
abhängen. Auf Seite 290 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:
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.
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.
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:
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.
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 2004]). 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 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
die Größe auf unbegrenzt setzen. Dann sollte bei jedem schweren Speicherfehler eine Core-Datei erstellt werden.
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.
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 678).
Beachten 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:
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.
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 (|A| := det(A)
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.

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:


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 |
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 151).
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: | } |
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.
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.
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 126). 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.
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:
Das Programm meldet sich mit einer Copyright-Notiz und wartet dann auf weitere Eingaben.
Obwohl der gdb über eine ausführliche Onlinehilfe verfügt (erreichbar mit
dem Kommando help), empfehle ich Ihnen die info-Seiten dazu (siehe Seite
134), 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 679), 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.
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.
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:
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
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.
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.
und erhalten die Bestätigung:
line 15.
Wenn Sie sich auf eine andere Datei beziehen wollen, geben Sie deren Namen vor der Zeilennummer an, getrennt durch einen Doppelpunkt, zum Beispiel:
Breakpoint 2 at 0x804ac87: file matrix.cc,
line 103.
break auch den Namen einer
Funktion oder Methode angeben. Dann hält das Programm bei der
ersten Anweisung dieser Funktion an. Bei Methoden ist noch der
Klassenname erforderlich. Nur bei Gefahr von Verwechslungen sollten
Sie noch den Dateinamen davorschreiben.
Nun ein Beispiel:
Breakpoint 3 at 0x804ac52: file matrix.cc,
line 96.
Ein Problem gibt es aber in C++ mit dieser Vorgehensweise: Wie Sie sich sicher erinnern (sonst: Seite 176!), ist in C++ eine Funktion erst durch Name und Signatur eindeutig bestimmt. Folglich kann es mehrere Funktionen mit demselben Namen geben. In diesem Fall können Sie noch die Typen in Klammern dahinter setzen oder sich auf einen Service des gdb verlassen: Bei Mehrdeutigkeit erhalten Sie eine nummerierte Liste aller in Frage kommenden Funktionen und deren Positionen, aus denen Sie sich die gewünschte(n) aussuchen können.
Wenn Sie bei vielen Haltepunkten schon den Überblick verloren haben, geben Sie ein:
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:
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:
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.
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:
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.
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:
next, kurz n: Damit können Sie in der gerade betrachteten
Funktion Zeile um Zeile weitergehen. Steht in einer Zeile ein
Funktionsaufruf, so wird dieser als Ganzes ausgeführt und das
Programm stoppt in der darauf folgenden Zeile wieder.
Bei unserem Beispiel gelangen wir somit unmittelbar von Zeile 15 nach Zeile 16:
15 if (a.read(filename))
(gdb) n
16 return -1;
(gdb) n
23 }
Daran erkennen wir zumindest, wo unser Programm endet.
step, kurz s: Mit diesem Befehl gehen Sie ebenfalls immer um eine
Zeile voran. Steht in dieser aber ein Funktionsaufruf, so springen Sie
in die erste Zeile der Unterfunktion und können dort wieder Zeile für
Zeile abarbeiten lassen.
Wenn wir in unserem Beispielprogramm so vorgehen, führt das zu folgender Ausgabe:
15 if (a.read(filename))
(gdb) s
SymMatrix::read (this=0xbffff5a8, _fname=
@0xbffff5c8) at matrix.cc:96
96 if (size != 0)
(gdb) s
103 ifstream in(_fname.c_str());
(gdb) s
_ZNKSs5c_strEv (this=0xbffff2c4) at /usr/include/
g++-v3/bits/basic_string.h:716
716 size_type __n = this->size();
Dabei erkennen Sie auch die Gefahr dieser Vorgehensweise: Wenn
Sie nicht aufpassen, geraten Sie immer tiefer in System- oder
Bibliotheksroutinen und verlieren Ihr eigenes Programm ganz aus den
Augen. Hier sind wir beispielsweise in der Methode c_str() der
string-Klasse gelandet - auch wenn das aus dieser Ausgabe nicht
sonderlich deutlich wird.
until, kurz u: Dieser Befehl ist next sehr ähnlich. Der Unterschied
macht sich allerdings bei Schleifen bemerkbar. Wenn Sie sich am
Ende einer Schleife befinden (und eine weitere Iteration ansteht),
gelangen Sie mit next wieder an den Anfang. Wenn Sie jedoch until
eingeben, werden alle noch ausstehenden Iterationen sofort
ausgeführt und das Programm stoppt in der Zeile, die auf die
Schleife folgt. Auf diese Weise können Sie sich nervenaufreibende
Einzelschritte durch sich oft wiederholende Schleifen sparen.
Bei unserem Beispiel können Sie den Unterschied bei der
Einlesefunktion SymMatrix::read() erkennen.
(gdb) n
124 for(unsigned int j=0; j<size; j++)
(gdb) n
127 in >> x;
(gdb) n
128 at(i,j) = x;
(gdb) n
131 if (in.eof())
(gdb) n
138 }
(gdb) n
127 in >> x;
(gdb) n
128 at(i,j) = x;
(gdb) n
131 if (in.eof())
(gdb) n
138 }
(gdb) u
140 return true;
finish: Dieses Kommando dient dazu, die Funktion, in der Sie sich
aktuell befinden, zu beenden. Das bedeutet aber nicht, dass Sie die
Ausführung abrupt beenden und sofort zurückspringen (dafür gibt es
auch im gdb den Befehl return). Bei finish werden alle Anweisungen
bis zum regulären Ende der Funktion abgearbeitet; anschließend
stoppt das Programm bei der nächsten Zeile in der aufrufenden
Funktion (sofern Sie nicht finish in der main()-Funktion eingegeben
haben).
Wenn wir unser Beispiel an der Stelle fortsetzen, an der wir bei until
stehen geblieben waren, gelangen wir mit finish zu main()
zurück.
(gdb) finish
Run till exit from #0 SymMatrix::read
(this=0xbffff5a8, _fname=@0xbffff5c8)
at matrix.cc:140
0x804a73d in main () at main.cc:15
15 if (a.read(filename))
Value returned is $2 = true
(gdb)
Dabei enthält die Ausgabe auch den Rückgabewert.
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:
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:
return -1;
Damit hätten wir schon den (ersten) Fehler gefunden.
Als Beispiel nehme ich nun eine zyklische Matrix vom Grad 3 (siehe Seite 683). 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 352 oder Seite 679) 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:
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: | } |
backtrace, kurz bt. An dieser Stelle ist die
Ausgabe:
#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.
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.
$1 = 2
(gdb) p j
$2 = 1
(gdb) p uj
$3 = 1
Die Anweisung war also
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 $$, das die
vorletzte Ausgabe darstellt.
$4 = 2
Auch ganze Objekte lassen sich mit print ausgeben (natürlich erscheinen
dabei nur die Attribute):
$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:
$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:
(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:
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:
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:
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:
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:
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.
Bei den Fehlerquellen, die wir in Abschnitt 6.2.1 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: | } |
_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:
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.)
Aus diesem Abschnitt sind folgende Aspekte hervorzuheben:
flush nach jeder Ausgabe.
-g übersetzt
worden sein.
cont fortsetzen, mit next zur
nächsten Codezeile gehen, dabei aber Funktionsaufrufe sofort
ausführen, mit step immer zur nächsten Codezeile gehen, auch
wenn diese in einer aufgerufenen Funktion liegt, mit until zur
nächsten Codezeile gehen, auch wenn diese außerhalb der gerade
durchlaufenen Schleife liegt, und mit finish alle Befehle bis zum
Ende der aktuellen Funktion ausführen und dann zum Aufrufer
zurückkehren.
backtrace gibt die Funktionen und Methoden aus,
die nacheinander aufgerufen wurden, um zur aktuellen Stelle zu
kommen (den Aufrufstack).
print kann man Variableninhalte einmalig, mit display
wiederholt bei jedem weiteren Schritt ausgeben. Auf diese Weise
lassen sich die Veränderungen der Daten eines Programms
verfolgen.
assert() kann man leicht das Einhalten von Vorbedingungen
in Funktionen und Methoden überprüfen und so Designfehler
aufspüren.
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.
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.
Erstaunlicherweise verwenden die Entwickler des Linux-Kernels selbst aber mittlerweile kein CVS mehr. Sie setzen nun auf das Programm Bitkeeper (www.bitkeeper.com), das allerdings einen "Schönheitsfehler" hat: Es ist ein kommerzielles Produkt. Immerhin bietet der Hersteller Open-Source-Projekten ein kostenloses Hosting an, das eben auch vom Linux-Kernel (ab 2.4) genutzt wird. Bitkeeper unterstützt neben Linux auch noch viele weitere Unix- und Windows-Plattformen und bietet eine Reihe interessanter Funktionen.
Für größere bzw. komplexere Projekte ist das freie CVS nicht immer geeignet, zumal die Weiterentwicklung schon längere Zeit ins Stocken geraten ist. Hier bietet sich Subversion an (subversion.tigris.org), das zum Ziel hat, weitgehend mit CVS kompatibel zu sein, aber darüber hinaus viel neue Funktionalität zusätzlich zur Verfügung stellt. Dazu gehören die Versionverwaltung des Verzeichnisbaumes, Integration mit Webservern, effiziente Verwaltung von Binärdateien usw.
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.
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.
Nun wollen wir uns die wichtigsten Arbeitsschritte und Vorgehensweisen beim Umgang mit CVS im Detail ansehen. Mehr zu CVS erfahren Sie übrigens auf der Homepage des Projekts unter www.cvshome.org, aber auch in der Linksammlung von Pascal Molli unter www.loria.fr/~molli/cvs-index.html. Ein sehr lohnenswertes Buch ist [FOGEL und BAR 2003], das in Teilen unter der Adresse cvsbook.red-bean.com auch online verfügbar ist.
Bei CVS spielt 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:
CVS_RSH gibt an, welches von beiden verwendet wird), das
heißt typischerweise erfolgt der Zugriff innerhalb eines LAN auf
einen anderen Rechner
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, in [FOGEL und BAR 2003] oder [PURDY 2004].
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 Zugriffs erläutern.
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
Homeverzeichnis 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
(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.
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:
Damit haben Sie ein leeres Archiv angelegt, in das nun Code eingefügt werden kann.
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):
# useradd -d $CVSROOT -g cvs cvs
Dann können wir das Archiv diesem User zuordnen und die Zugriffsrechte auf die Gruppe erweitern:
# 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:
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 näher liegenden 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.
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:
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 (etwa /etc/inetd.conf) eintragen.
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: | } |
crypt(), die in Zeile 40 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:
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:
anonymous
der CVS-Passwortdatei hinzu (Passwort zum Beispiel "guest") und
legen Sie dafür auch einen lokalen Benutzer cvsguest an. In der
Passwortdatei entspricht dies der Zeile:
anonymous der Datei readers hinzu.
-s /bin/false cvsguest
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.
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
etwa 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
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:
Um es wieder freizugeben, verwenden Sie -u. Weitere Optionen dieses
Befehls erfahren Sie durch
Bei der Arbeit mit CVS müssen Sie beachten, dass das Programm im Normalfall die gleichzeitige Bearbeitung einer Datei durch mehrere Benutzer zulässt (daher auch der Name "concurrent"). 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:
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.
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:
$CVSROOT lediglich auf den Pfad
des Archivs gesetzt werden, also beispielsweise:
$CVSROOT hier die Form :ext:user@host:path haben, was zum
Beispiel heißen kann:
ext muss es
pserver heißen, also :pserver:user@host:path, beispielsweise:
cvs.myproject.org:/pub/cvsroot
:gserver:user@host:path, daher kann ich mir das Beispiel sicher
sparen.
$CVSROOT hier
dem Format :kgserver:user@host:path folgen.
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:
(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 Beispiel auf der nächsten Seite).
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:
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:
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:
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.
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 682) verwenden. Das Auschecken dafür läuft folgendermaßen ab:
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.
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: 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
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
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:
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: 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: 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:
{
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: 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.
==========================================
File: matrix.cc Status: Up-to-date
Working revision: 1.3
Sun Mar 17 19:12:51 2004
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: 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 ...
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:
% 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: 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.
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.
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: 2004/06/17 09:17:46; author: thomas;
state: Exp; lines: +2 -2
Eine Absatzmarke entfernt
----------------------------
revision 1.2
date: 2004/06/17 09:02:47; author: markus;
state: Exp; lines: +4 -4
Print-Methode hinzugefuegt
----------------------------
revision 1.1
date: 2004/06/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! Das Programm diff findet dabei die Änderungen zwischen
den Versionen.
Mit CVS können Sie entweder einzelne Dateien, besser noch den aktuellen Stand eines ganzen Moduls, mit einem einheitlichen Etikett (engl. 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: Tagging .
T Makefile
T cyc4.dat
T cyclic.dat
T main.cc
T matrix.cc
T matrix.h
Sie können noch ein paar weitere hilfreiche Optionen angeben: -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 ein einmal gesetztes Etikett können Sie sich bei verschiedenen Operationen beziehen:
-v, dass auch die
dafür definierten Etiketten angegeben werden.
-r, mit der sich spezifizieren
lässt, auf welche Revisionsnummer sich dieses Kommando
beziehen soll. Hierbei ist statt einer Nummer auch ein Etikett
zulässig. Wollen Sie etwa nicht die letzte Version einer Datei
auschecken, sondern eine frühere, verwenden Sie dazu die Option
-r, beispielsweise in der Form:
U DebugTest/matrix.cc
Oft möchten Sie nicht nur auf der Kommandozeile, sondern direkt im Quelltext wissen, welche Revision Sie nun eigentlich gerade bearbeiten, wie CVS mit dieser Datei umgeht und so weiter. Dazu bietet CVS eine Reihe von Schlüsselwörtern an, die bei jedem Ein- oder Auscheck-Kommando aktualisiert werden. Diese Schlüsselwörter wurden dabei von RCS übernommen - was stellenweise noch deutlich erkennbar ist. In Tabelle 6.4 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:
| ||||||||||||||||||||||||||||||||||||||||
// zuletzt bearbeitet von $Author$
// $Date$, $Revision$
/*
* $Log$
*/
Nach dem Einchecken sehen wir, dass CVS folgende Daten an die Stelle unserer Schlüsselwörter gesetzt hat:
// zuletzt bearbeitet von $Author: thomas $
// $Date: 2004/06/04 19:52:59 $, $Revision: 1.3 $
/*
* $Log: asserttest.cc,v $
* Revision 1.3 2004/06/04 19:52:59 thomas
* RCS/CVS Keywords eingebaut
*/
Das $Header$- oder $Id$-Schlüsselwort wird noch auf eine etwas
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:
Nach dem Einchecken hat CVS daraus Folgendes gemacht:
19:52:59 thomas Exp $";
Wenn wir nun das Programm übersetzen und das ident-Kommando anwenden, erhalten wir genau wieder diesen Text:
% ident asserttest
asserttest:
$Id: asserttest.cc,v 1.3 2004/06/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.
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:
$-Begrenzer. Damit geht die
Schlüsselworteigenschaft verloren, so dass sich dieser Modus nur
eignet, wenn Sie die Datei nicht weiter mit CVS pflegen wollen.
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 6.1).
|
|
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!
In diesem Abschnitt haben Sie die Grundlagen des Konfigurationsmanagements mit CVS kennen gelernt. Daraus sollten Sie vor allem folgende Schlüsselbegriffe behalten:
Ein unter Linux weit verbreitetes Werkzeug dazu ist das Concurrent Version System CVS.
$RCSfile$ für den
Namen der zu pflegenden Datei. Gibt man eine Textvariable im
Programm an, die das Schlüsselwort $Id$ enthält, so kann man
diese Information mit dem Kommando ident auch aus Objekt-
und ausführbaren Dateien extrahieren.