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:
werden wir uns genauer damit beschäftigen.
lernen Sie den GNU-Debugger gdb
(und sein grafisches Frontend DDD) kennen, ein für jeden Programmierer
unverzichtbares Werkzeug, das Sie vielleicht auch bei der Arbeit an den bisherigen
Projekten und Aufgaben schon einmal eingesetzt haben.
beziehungsweise
), also der Verfolgung von Änderungen an Ihren
Programmdateien und der Beschränkung gleichzeitigen Zugriffs.
Bisher waren unsere Programme im Allgemeinen so klein, dass eine Datei für sie ausreichte. Schon bald aber werden Sie sicher größere Programme schreiben, bei denen Sie - gemäß meiner Tipps (?) - für jede Klasse eine eigene Datei anlegen. Wenn Sie dann an einer davon etwas ändern, ist der Aufwand nicht so groß, alle neu zu übersetzen.
Nehmen wir an, Sie wollen ein Programm erstellen, das Sie an die Geburtstage Ihrer Freunde erinnert, sobald Sie sich einloggen. Die Daten verwalten Sie in einer Liste; außerdem gibt es noch ein Hauptprogramm. Zum Kompilieren geben Sie dann an:
% g++ birthlist.cc birthday.cc -o birthcontrol
Date, die das Datum universell handhaben
kann. Damit erhalten Sie natürlich neue Programmdateien date.cc und
date.h, also schon drei Dateien im Projekt. Wenn Sie nur eine ein wenig
ändern, müssen Sie immer alle drei neu übersetzen.
Sicher könnten Sie nur die betroffene Datei kompilieren und dann lediglich den Linker aufrufen. Wie geht das aber bei geänderten Header-Dateien? Haben Sie immer im Kopf, welche Implementationsdatei welche Header-Datei eigentlich einbindet und daher bei deren Änderung neu übersetzt werden muss?
Diese Probleme bekommen Sie mit dem Werkzeug make endlich in den Griff (hier ist die unter Linux gebräuchliche GNU-Variante gemeint, siehe org.gnu.de/software/make/make.html). Es verwaltet die Kompilierung und das Linken aller Dateien Ihres Projekts. Dazu vergleicht es das Datum der Quelldateien mit dem der Objektdateien; ist die Quelle neuer, sorgt es für die Kompilierung; Ähnliches gilt für das Linken. In einem so genannten Makefile legen Sie die Abhängigkeiten zwischen den Dateien und die Regeln für die Erzeugung einer Datei aus einer anderen fest. Mit diesem wollen wir uns als Erstes beschäftigen.
Obwohl folgende Erklärungen für einfachere Projekte ausreichen sollten, können sie bei weitem nicht alle Details zu make abdecken. Wenn Sie mehr erfahren wollen, können Sie entweder die Onlinehilfe (in Form von man- und info-Seiten) zu Rate ziehen oder in [ORAM 1991] alles Wissenswerte nachschlagen.
Ein Makefile ist eine Textdatei, die Sie daher mit jedem Editor bearbeiten können.
(Natürlich hat der Emacs auch dafür einen besonderen Modus ..., siehe Seite
).
Im Allgemeinen hat sie auch den Namen makefile (oder Makefile)
und befindet sich im gleichen Verzeichnis wie Ihre übrigen Projektdateien. Wenn
Sie einen anderen Namen wählen, müssen Sie diesen bei jedem Aufruf von make
ausdrücklich angeben - dazu haben Sie sicher bald keine Lust mehr. Auch andere,
die einen Blick auf Ihr Projekt werfen, wissen am besten sofort, was gemeint
ist, wenn sie eine Datei Makefile vorfinden. Wenn ihr Name zudem noch
mit einem Großbuchstaben beginnt und damit vielleicht am Anfang aufgelistet
wird, fällt sie besonders ins Auge.
Bei sehr großen Projekten (wie vielen Linux-Anwendungen) sind die Quelldateien auf mehrere Verzeichnisse aufgeteilt. Da make nicht nur einen Compiler oder Linker, sondern jedes beliebige Programm einschließlich sich selbst aufrufen kann, gibt es dabei mehrere Make-Dateien für die verschiedenen Projektteile. Deren Zusammenspiel wird von einem zentralen Makefile gesteuert, das dann sehr komplex werden kann. Es wird nicht nur zum Übersetzen, sondern auch für Konfiguration, Installation und Ähnliches genutzt. Wenn Sie schon einmal Ihren Kernel übersetzt haben, haben Sie vielleicht davon etwas gespürt.
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:
birthcontrol: birthday.o birthlist.o date.o
birthday.o: birthday.cc birthlist.h
birthlist.o: birthlist.cc birthlist.h date.h
date.o: date.cc date.h
Wenn Sie mehrere Zieldateien gleichzeitig erzeugen wollen, dürfen Sie bei deren Angabe auch Wildcards wie * oder ? verwenden. Auf der anderen Seite ist es sogar erlaubt, Zieldateien ohne Quellen einzusetzen. Allerdings dürfen Sie eine Datei nur einmal als Ziel innerhalb eines Makefiles angeben.
Wie weiß make dann eigentlich, wann es fertig ist; was ist das endgültige
Ziel? Auf diese Frage gibt es im Grunde drei Antworten: Erstens: durch Angabe
des Ziels in der Kommandozeile, aber dazu kommen wir noch. Zweitens: Das als
Erstes im Makefile angegebene Ziel gilt als das zu erzeugende. Wenn dieses Ziel von
anderen abhängt, werden natürlich auch diese berücksichtigt. Drittens: Sie können
das endgültige Ziel auch ausdrücklich als Zieldatei all angeben, zum
Beispiel
all: birthcontrol
Somit gilt also das Datum der letzten Änderung einer Datei als Kriterium (Sie wissen vielleicht, dass das nur einer von mehreren Zeitpunkten ist, die unter Linux mit einer Datei gespeichert werden). Manchmal möchte man aber auch das Neuübersetzen eines Programms erzwingen, ohne dass man gleich alles löscht. Dazu können Sie das Hilfsprogramm touch verwenden. Es erwartet als Argument den Namen einer oder mehrerer Dateien (analog zu anderen Kommandos wie ls oder rm) und setzt deren Änderungsdatum auf die aktuelle Systemzeit. Damit sind die betreffenden Dateien auf alle Fälle neuer als ihre Ziele. Ein Beispiel:
% touch *.cc
Wenn Sie dann eine Abhängigkeit formuliert haben, sollten Sie noch angeben, durch welchen Befehl denn das Ziel aus der Quelle erzeugt werden kann. Dieses Kommando geben Sie genauso ein, wie Sie es auch in der Shell tun würden. Achten Sie aber darauf, die Befehlszeilen immer mit einem Tabulatorzeichen zu beginnen! Für unser Beispielprojekt lautet das vollständige Makefile etwa:
birthcontrol: birthlist.o birthday.o date.o
g++ birthlist.o birthday.o date.o
-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
Besonders bei größeren Projekten ist es lästig, für jede Datei eine eigene Regel anzugeben, die sich ja nur im Namen voneinander unterscheiden. Hier bietet make die Möglichkeit, die Erstellung durch so genannte implizite Regeln wesentlich zu vereinfachen. Für unser Beispiel könnten wir folgende Regel anwenden:
.cc.o:
g++ -c $<
$< ein Makro, das den Namen der Quelldatei bezeichnet. Derartige
Makros gibt es bei make noch einige weitere; wir werden gleich noch
darauf zurückkommen. Allgemein geben Sie also bei einer impliziten Regel zuerst
die Dateiendung der Quelldatei und dann die der Zieldatei an. Beide beginnen
jeweils mit einem Punkt, so dass ein zusätzliches Trennzeichen nicht nötig ist.
Auch bei impliziten Regeln müssen Sie darauf achten, die Befehlsliste mit einem
Tabulatorzeichen einzurücken.
Der Nachteil an impliziten Listen ist, dass damit auch die Abhängigkeiten implizit gesetzt werden, das heißt, es gilt dann nur die Abhängigkeit zwischen Quell- und Zieldatei. Eventuelle weitere Abhängigkeiten, etwa von Header-Dateien, bleiben unberücksichtigt. Aber auch dazu bietet Linux ein geeignetes Werkzeug - nur Geduld!
Eine implizite Regel findet immer dann Anwendung, wenn es für das gerade zu erstellende Ziel keine explizite Regel gibt. Wenn Sie also für einzelne Dateien besondere Optionen angeben wollen, formulieren Sie für diese explizite Regeln, denn diese haben Vorrang.
Natürlich hat Ihr Projekt im Allgemeinen nur eine Zieldatei, nämlich die ausführbare
Datei oder eine Bibliothek. Dieses Standardziel legen Sie mit all fest.
In vielen Fällen ist es aber sinnvoll und wünschenswert, noch weitere Ziele
zu haben. In vielen Projekten sind beispielsweise die in Tabelle
aufgeführten Ziele anzutreffen.
| Ziel | Beschreibung |
install |
|
clean |
löscht das Programm und alle abhängig generierten Dateien |
dist |
erzeugt ein Quelltextpaket, um es weiterzugeben |
check |
führt Tests durch, um die ordnungsgemäße Erzeugung des Programms zu überprüfen |
depend |
berechnet die Abhängigkeiten unter den einzelnen Dateien neu und speichert diese (meist im Makefile) |
Für unser Beispielprojekt könnte das Ziel clean etwa lauten:
clean:
-rm -f birthcontrol *.o
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:
% make clean
Darüber hinaus kennt make eine ganze Reihe von Kommandozeilenoptionen. Die wichtigsten sind:
-k-k veranlassen Sie,
trotzdem mit der Abarbeitung fortzufahren.
-n-n. Dann werden alle Befehle nur ausgedruckt,
aber nicht ausgeführt - eine Trockenübung also.
-f-f
an, also etwa make -f myprog.mak.
-p-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:
make: *** [birthday.o] Error 1
make: *** No rule to make target `brthlist.o',
needed by `birthcontrol'. Stop.
Eine eher harmlose Meldung erscheint, wenn Sie make aufrufen, obwohl sich keine Dateien gegenüber der letzten Erzeugung verändert haben:
make: `birthcontrol' is up to date.
Die Syntax für die Zuweisung eines Makros entspricht der in Bourne-Shell-Skripten, also
MAKRONAME = Inhalt
$-Zeichen angeben, also etwa $(MAKRONAME).
Eine häufige Anwendung von Makros ist die Definition von Compiler-Optionen, Compiler-Namen und so weiter. Auf diese Weise ist es nämlich möglich, an einer zentralen Stelle alle Optionen für die Übersetzung festzulegen; sollen diese einmal geändert werden (etwa, um von einer Debug- auf eine Release-Version zu schalten), muss man nur diese Zeile verändern. Ähnlich verhält es sich, wenn ein anderer Compiler ausgewählt werden soll, zum Beispiel wenn Sie das Programm auf einer anderen Plattform übersetzen wollen, wo kein GCC vorhanden ist (gibt es das?!). Auch die nötigen Bibliotheken lassen Sie so übersichtlich auflisten.
all: birthcontrol
# Name des Compilers
CXX=g++
# Pfad für zusätzliche Header-Dateien
INCLUDE=.
# Compiler-Schalter
CCFLAGS=-g -Wall
# zusaetzliche Bibliotheken
LIBS=
birthcontrol: birthlist.o birthday.o date.o
$(CXX) birthlist.o birthday.o date.o
-o $@ $(LIBS)
.cc.o:
$(CXX) -I$(INCLUDE) $(CCFLAGS) -c $<
finden Sie einige weitere vordefinierte Makros
(auch automatische Variablen genannt), die oft ganz praktisch sein können.
| Makro | Bedeutung | |
|
||
$* |
Name des Ziels, aber ohne Dateierweiterung | |
$< |
Name der ersten abhängigen Datei | |
$? |
Namen aller abhängigen Dateien, die neuer als das Ziel sind, getrennt durch Leerzeichen | |
$+ |
Namen aller abhängigen Dateien, getrennt durch Leerzeichen | |
$^ |
Namen aller abhängigen Dateien, getrennt durch Leerzeichen; doppelt vorkommende werden dabei weggelassen |
!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
(Seite
), so genügt es, wenn Sie
in der Shell aufrufen:
% make lotto
g++ lotto.cc -o lotto
Diese setzen sich selbst wieder aus Makros zusammen. Tabelle
zeigt Ihnen eine Liste wichtiger vordefinierter Makros. Diese können Sie sich
auch durch den Aufruf von make -p
ausgeben lassen.
| Makro | definiert als |
|
|
CXX |
g++ |
OPTION_OUTPUT |
-o $@ |
COMPILE.c |
$(CC) $(CFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
COMPILE.cc |
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(TARGET_ARCH) -c |
LINK.c |
$(CC) $(CFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH) |
LINK.cc |
$(CXX) $(CXXFLAGS) $(CPPFLAGS) $(LDFLAGS) $(TARGET_ARCH) |
AR |
ar |
ARFLAGS |
rv
|
Dabei sind die Makros $(CFLAGS), $(CPPFLAGS) und
$(TARGET_ARCH) sowie $(CXXFLAGS) und $(LDFLAGS)
nicht vordefiniert. Dass sie dennoch in den eingebauten Regeln verwendet
werden, bedeutet für Sie, dass Sie durch das Setzen dieser Definitionen eigene
Einstellungen in diese Regeln einbringen können. Dazu gibt es - wie immer
- mehrere Möglichkeiten:
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:
% make CXXFLAGS=''-g -Wall'' lotto
g++ -g -Wall lotto.cc -o lotto
Natürlich können Sie dabei auch eigene Makros übergeben.
In Tabelle
stehen sowohl die Definitionen der Makros für
die Übersetzung von C-Dateien als auch die für C++-Dateien. Den Typ der Datei
erkennt make an ihrer Endung. Als C++-Quelltext werden beispielsweise
die Dateien mit den Endungen .cc, .cpp oder .C erkannt.
Als eingebaute Regeln kommen dann folgende zum Einsatz:
%.o: %.cc
$(COMPILE.cc) $< $(OUTPUT_OPTION)
%: %.cc
$(LINK.cc) $^ $(LOADLIBS) $(LDLIBS) -o $@
%.
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:
% make -l 0.25
Als Alternative für große Projekte
bietet es sich an, möglichst viel gleichzeitig erledigen zu lassen. Denn oftmals hängen viele
Zwischenziele (etwa Objektdateien) nicht voneinander ab und könnten daher parallel
übersetzt werden. Auch dazu stellt make eine Option bereit. Mit -j
können Sie die Anzahl der Befehle angeben, die maximal gleichzeitig gestartet
werden. Für ein mittelgroßes Projekt ist ein Wert zwischen drei und fünf ein
guter Ausgangspunkt. Sie können ja die Ausführung beobachten (zum Beispiel durch
top in einem anderen Fenster) und dabei erkennen, ob noch mehr Prozesse
sinnvoll wären oder bereits die aktuelle Anzahl zu hoch ist. Wollen Sie etwa
5 als Maximalwert setzen, lautet der Aufruf:
% make -j 5
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 oder einem Mirror (zum Beispiel www.leo.org) beziehen.
So nützlich makedepend auch ist und so sehr ich Ihnen dieses Werkzeug bei allen von Ihnen selbst verwalteten Make-Dateien ans Herz legen möchte - auf ein Problem muss ich Sie noch hinweisen. Sie können makedepend auf zweierlei Arten verwenden, nämlich mit oder ohne Berücksichtigung der System-Header (wie cstdio oder iostream). Die Vorgabe ist, dass Sie diese Dateien berücksichtigen wollen. Leider passt sich makedepend nicht dem aktuellen Compiler an, sondern sucht die System-Header in Verzeichnissen, die bei seiner eigenen Erzeugung eingestellt waren. Das hat zur Folge, dass Sie immer wieder auf Fehlermeldungen treffen wie:
makedepend: warning: birthlist.cc, line 3:
cannot find include file "iostream"
not in ./iostream
not in /usr/local/lib/gcc-include/iostream
not in /usr/include/iostream
not in /usr/lib/gcc-lib/i486-linux/2.7.2.3/include/iostream
-I-Option,
die Sie vom Compiler kennen, den Pfad zu Ihren Systemdateien mitteilen, also
etwa:
MKDEPINC=-I$(GCC_DIR)/include/g++-3 \
-I$(GCC_DIR)/lib/gcc-lib/i686-pc-linux-gnu/2.95/include
Bereits bei kleineren Projekten leistet makedepend nützliche Dienste, da
Sie oft nicht alle Querverbindungen zwischen Ihren Dateien im Kopf haben (was
auch gar nicht nötig sein sollte!). Am besten legen Sie sich in Ihrem Makefile
ein Ziel namens depend an, das dann makedepend startet. (Wenn
Sie übrigens einen anderen Namen als makefile oder Makefile
verwenden, müssen Sie diesen auch bei makedepend mit der Option -f
ausdrücklich angeben.) Als Parameter erwartet dieses Tool übrigens nur eine Liste
mit Ihren Implementierungsdateien. Falls Sie eigene Pfade für Ihre Header-Dateien
mit -I beim Compiler-Aufruf angeben, müssen Sie diese Option natürlich
auch bei makedepend verwenden, da es sonst diese Header-Dateien nicht
finden kann. Empfehlenswert ist daher ein Makro $INCLUDE, das Sie dann
in beiden Fällen verwenden, gleichzeitig aber zentral pflegen können.
Für unser Beispiel birthcontrol lässt sich die beschriebene Technik etwa wie folgt realisieren:
INCLUDE=-I.
CXXFLAGS=-Wall -g $(INCLUDE)
OBJECTS=birthlist.o birthday.o date.o
SOURCES=birthlist.cc birthday.cc date.cc
all: birthcontrol
birthcontrol: $(OBJECTS)
g++ $(OBJECTS) -o birthcontrol
depend:
makedepend $(INCLUDE) $(SOURCES)
# DO NOT DELETE
birthlist.o: birthlist.h date.h
birthday.o: date.h birthlist.h
date.o: date.h
Übrigens ist im GCC eine ähnliche Funktion zur Bestimmung von Abhängigkeiten
eingebaut, wenn auch mit etwas geringerer Leistungsfähigkeit. Sie müssen dazu
die Option -MM verwenden, also
etwa
% g++ -MM birthlist.cc
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
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
).
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
if (result == 0)
text = ``Hier ist ein Fehler!'';
cerr << text << endl;
result abhängen.
Auf Seite
hatte ich Sie ja schon auf diese Probleme
hingewiesen.
In die gleiche Kategorie fallen übrigens auch vergessene breaks bei switch-Anweisungen
oder falsch gesetzte Strichpunkte, beispielsweise bei for-Schleifen:
for(k=0; k<n; k++); // Ein Semikolon zuviel!
x += k;
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:
#ifdef DEBUG
cout << ``Ergebnis: `` << res << endl;
#endif
#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 1989]). Ein Beispiel sind die Auslöschungen, die bei Verknüpfungen von sehr großen und sehr kleinen Zahlen miteinander auftreten. Dabei kann oft schon eine Vertauschung von Variablen innerhalb eines an sich assoziativen Ausdruckes zu völlig anderen Ergebnissen führen.
In die gleiche Kategorie fallen Überläufe. Ganzzahlige Datentypen haben
nur einen begrenzten Wertebereich. Führt eine Operation darüber hinaus, hat
die Variable anschließend einen völlig anderen, eventuell auch negativen Wert.
Auch mit dem Mischen von Werten mit und ohne Vorzeichen (signed und unsigned)
oder bei Zuweisungen beziehungsweise Parameterübergaben, die int auf
short abbilden, muss man sehr vorsichtig sein. Solche Probleme kann man
zwar relativ rasch aufspüren. Noch besser ist es aber, sie gleich zu vermeiden.
Abstürze sind unter Linux längst nicht so häufig wie unter Windows. Und wenn
ein Programm abstürzt, bleibt das übrige System davon im Allgemeinen unbeeindruckt.
Vorkommen können sie aber dennoch. Die häufigsten Ursachen für Totalabstürze
(Segmentation fault oder Bus error)
sind Fehler beim Umgang mit Zeigern oder der dynamischen Speicherverwaltung,
also delete auf bereits freigegebenen Speicher, Zuweisungen an Nullzeiger
und so fort. Der große Vorteil bei Linux ist dabei, dass Sie nicht mühsam versuchen
müssen, das Problem zu reproduzieren, sondern dass Ihnen oftmals gleichzeitig
mit dem Absturz ein Speicherauszug (core)
erstellt wird. Diesen können Sie dann in den Debugger laden, der Ihnen daraufhin
die genaue Stelle des Absturzes anzeigen kann. Die eigentliche Ursache müssen
Sie dann aber schon selbst finden.
So praktisch ein solcher Speicherauszug auch ist - selbstverständlich ist seine Erzeugung nicht. Da solche Core-Dateien oftmals immensen Speicher auf dem Datenträger verbrauchen, ist die Voreinstellung auf den meisten Linux-Systemen, dass überhaupt keine Speicherauszüge erzeugt werden; die maximale Größe für vom Benutzer generierte Core-Dateien ist dann 0. Um dies zu ändern, können Sie beispielsweise in der Bash mit dem Befehl
% ulimit -c unlimited
Als größte Fehlerquellen mit dem schlechtesten Einfluss auf die Stabilität des Programms haben wir also folgende identifiziert:
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
).
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:
cout << ``Schritt 1 fertig!'' << endl << flush;
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
ist eine
reelle Zahl, die unter anderem darüber Auskunft gibt, ob die
Matrix invertierbar ist (
)
oder nicht (
). 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
Der Konstruktor mit einer Ganzzahl soll dazu dienen, eine Matrix mit gegebener
Anzahl von Zeilen und Spalten zu initialisieren. Kopierkonstruktor und Destruktor
sollten Ihnen noch aus den Abschnitten
und
geläufig sein. (Wenn nicht, schlagen Sie gleich auf Seite
nach!)
Die Methode enter() erlaubt eine Matrixeingabe von Hand, während read()
die Elemente aus einer Datei liest. Die beiden Methoden at() erlauben
den Zugriff auf einzelne Elemente, und zwar in einer konstanten Version für Lese-
und einer anderen für Schreibzugriffe. Die Bildung der Untermatrix ist ebenfalls
als Methode der Klasse selbst (subMatrix()) vorgesehen, wogegen die Determinantenberechnung
in einer getrennten Funktion erfolgt, die allerdings mit der Klasse befreundet
ist (zu friend siehe Seite
).
Gehen wir zunächst einmal davon aus, dass alles korrekt programmiert ist, und
betrachten die Funktion main().
1:// Datei main.cc
2:#include <iostream>
3:#include <iomanip>
4:#include "matrix.h"
5:
6:using namespace std;
7:
8:int main()
9:{
10:string filename;
11:cout << "Datei mit Matrix: ";
12:cin >> filename;
13:
14:SymMatrix a;
15:if (a.read(filename))
16:return -1;
17:
18:double d=determinant(a);
19:cout << "Determinante ist "
20:<< setprecision(6) << d << endl;
21:
22:return 0;
23:}
Dort wird lediglich ein Dateiname vom Benutzer erfragt, die Datei eingelesen
und die Determinante nach ihrer Berechnung ausgegeben. Die Methode read()
ist so definiert, dass sie true im Erfolgsfall und false im Fehlerfall
zurückliefert. Wenn wir nun das Programm starten und einen Dateinamen eingeben,
passiert anschließend überhaupt nichts mehr. Insbesondere erhalten wir keinerlei
Ausgabe. Bei einem Lesefehler müsste die read()-Methode eine Fehlermeldung
ausgeben. Was ist also los? Wenn Sie dem Code nicht ansehen, wo der Fehler liegt,
müssen Sie den Debugger starten.
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
). 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
% gdb DebugTest
(gdb)
help), empfehle ich Ihnen die info-Seiten dazu
(siehe Seite
), die nicht nur deutlich umfangreicher
sind, sondern auch den Vorteil haben, dass sie in einem anderen Fenster laufen
und man in ihnen einfacher navigieren kann.
Wenn Ihr Programm beim Absturz einen Abzug des Speichers erzeugt (eine so genannte
Core-Datei, siehe auch Seite
), können Sie diesen
als zweites Argument beim Aufruf angeben. Der Debugger kann diesen Speicherauszug
laden und damit sofort die Stelle anzeigen, an der der Absturz passierte.
% gdb meinprog core
Nachdem der Debugger also läuft, wollen wir auch unser Programm starten. Dazu
geben wir das Kommando run ein.
Der gdb kennt auch eine Reihe von Kürzeln für besonders häufig benutzte
Kommandos. Anstelle von run reicht daher auch r.
(gdb) r
run angeben. Bei einem erneuten Start müssen Sie die Argumente nicht
wieder eingeben; sie werden intern gespeichert. Ein anderer Weg, Kommandozeilenargumente
zu übergeben, ist das Kommando set args. Unmittelbar dahinter setzen
Sie die gewünschten Parameter. Diese Einstellung gilt so lange, bis Sie etwas
anderes festlegen.
Ähnlich wie die Shell oder der Emacs verfügt der gdb auch über eine automatische Vervollständigungsfunktion für Kommandos. Wenn Sie nur einen Teil eines Kommandos eingeben und dann TAB drücken, wird bei Eindeutigkeit die Anweisung vervollständigt, ansonsten eine Liste mit möglichen Fortsetzungen angezeigt. Natürlich hat der GNU Debugger auch eine Liste der zurückliegenden Befehle, so dass Sie mit den Cursortasten blättern und auch editieren können.
Für unser Beispiel erhalten wir folgende Ausgabe:
(gdb) r
Starting program: /usr/thomas/kap05/DebugTest
Datei mit Matrix: cyclic.dat
Program exited with code 0377.
Current language: auto; currently c
(gdb)
Wir sehen, dass auch hier das Programm auf den ersten Blick ordnungsgemäß durchläuft,
ohne aber weitere Ausgaben zu produzieren. Beachten sollten Sie, dass der Rückgabewert
des Programms nicht 0 wie bei ordentlicher Beendigung ist, sondern 0377.
Das deutet darauf hin, dass das Programm bereits mit Zeile 16 beendet wurde.
Warum hat aber die Einlesemethode read() keine Fehlermeldung ausgegeben?
Deren Code (den Sie unter anderem auf beiliegender CD-ROM finden) macht eigentlich
einen ganz korrekten Eindruck. Woran liegt es dann? Das können wir nur erfahren,
wenn wir den Ablauf des Programms Schritt für Schritt verfolgen.
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.
(gdb) break 15
Breakpoint 1 at 0x804a72d: file main.cc,
line 15.
(gdb) break matrix.cc:103
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:
(gdb) b SymMatrix::read
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
!), 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:
(gdb) info breakpoints
Wie wird man einen Haltepunkt nun wieder los? Das Gegenstück zu break ist
clear. Dieses Kommando versteht dieselben Argumente, zum Beispiel
(gdb) clear matrix.cc:103
Deleted breakpoint 2
Eine andere Möglichkeit ist delete,
das jedoch als Argument die Nummer des Haltepunktes erwartet.
Wenn Sie nicht ganz auf den Haltepunkt verzichten wollen, können Sie ihn auch vorübergehend
deaktivieren. Das geschieht durch das Kommando disable, das wieder die
Nummer erwartet. Mit enable schaltet man ihn dann wieder ein. Eine Bestätigung
gibt es bei diesem Kommando nicht; den Zustand können Sie aber beispielsweise
aus der mit info breakpoints erhältlichen Liste entnehmen.
Wenn wir jetzt run eingeben, haben wir unser Ziel erreicht: Das Programm
steht. Auf dem Bildschirm lesen wir:
(gdb) r
Starting program: /usr/thomas/kap05/DebugTest
Datei mit Matrix: cyclic.dat
Breakpoint 1, main () at main.cc:13
15 if (a.read(filename))
(gdb)
Wenn wir an einer Stelle anhalten, möchten wir natürlich auch den Kontext wissen,
in dem wir uns gerade befinden. Am einfachsten geht das mit dem Kommando
list, kurz l. Damit erhalten Sie den Code einige Zeilen vor und
nach der aktuellen Position:
(gdb) list
10 string filename;
11 cout << "Datei mit Matrix: ";
12 cin >> filename;
13
14 SymMatrix a;
15 if (a.read(filename))
16 return -1;
17
18 double d=determinant(a);
19 cout << "Determinante ist "
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:
Breakpoint 1, main () at main.cc:15
15 if (a.read(filename))
(gdb) n
16 return -1;
(gdb) n
23 }
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:
Breakpoint 1, main () at main.cc:15
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();
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.
123 for(unsigned int i=0; i<size; i++)
(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.
140 return true;
(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:
if (a.read(filename))
return -1;
if (!a.read(filename))
return -1;
Als Beispiel nehme ich nun eine
zyklische Matrix vom Grad 3 (siehe Seite
).
Deren Determinante ist 18. Wenn wir unser Programm neu übersetzen, starten
und diese Matrix einlesen, beendet es sich mit einem Segmentation
fault. Wie in früheren Abschnitten (Seite
oder Seite
) beschrieben, ist eine häufige Ursache dafür ein falscher
Speicherzugriff - entweder eine Bereichsüberschreitung eines Feldes beziehungsweise
das Schreiben in nicht reservierten Speicher oder Freigeben von vorher nicht reserviertem
Speicher.
Wenn wir das Programm aus der Shell starten, erhalten wir jedoch außer der Fehlermeldung keinerlei Hinweis, wo das Problem liegen könnte. Aber dazu haben wir ja den Debugger. Hier erfahren wir:
Program received signal SIGSEGV,
Segmentation fault.
0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:179
179 u.at(i, uj++) = dat[i+1][j];
Sehen wir uns die Methode SymMatrix::subMatrix() etwas genauer an:
170:SymMatrix SymMatrix::subMatrix(
170:unsigned int _k) const
171:{
172:// Untermatrix eine Dimension kleiner
173:SymMatrix u(size-1);
174:
175:// Untermatrix nach der ersten Zeile
176:for(unsigned int i=0; i<size; i++)
177:for(unsigned int j=0, uj=0; j<size; j++)
177:// Betrachte nur Spalten ungleich _k
178:if(j != _k)
179:u.at(i, uj++) = dat[i+1][j];
180:
181:return u;
182:}
Hier wird die Untermatrix nach der ersten Zeile gebildet. Von wo aus wurde diese
Methode aber aufgerufen? Das erfahren wir durch das Kommando
backtrace, kurz bt. An dieser Stelle ist die Ausgabe:
(gdb) backtrace
#0 0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:179
#1 0x804b424 in determinant (_a=@0xbffff5a8)
at matrix.cc:211
#2 0x804a77e in main () at main.cc:18
Nun wissen wir zwar, dass _k den Wert 0 hat. Von den anderen Variablen
ist uns jedoch nichts bekannt. Diese können wir mit dem Kommando
print, kurz p, untersuchen. Interessant sind ja insbesondere die
Schleifenvariablen i und j.
(gdb) p i
$1 = 2
(gdb) p j
$2 = 1
(gdb) p uj
$3 = 1
u.at(2, 1) = dat[3][1];
Der erste Teil der Ausgabe ist eine interne Referenznummer. Über die können
Sie den Wert selbst weiterverwenden, ohne dass Sie sich ihn merken müssen. Noch kürzer
geht es mit $, das die letzte Ausgabe, und $$, welches die
vorletzte Ausgabe darstellt.
(gdb) p u.dat[$][$$]
$4 = 2
print ausgeben (natürlich erscheinen dabei
nur die Attribute):
(gdb) p u
$5 = {dat = 0x8063400, size = 2}
(gdb) p *this
$6 = {dat = 0x8063420, size = 3}
Gerade bei Feldern will man aber meist den Inhalt des ganzen Feldes wissen, und nicht
nur einzelne Werte. Dazu können Sie hinter ein Feldelement das Zeichen @ setzen,
gefolgt von einer Zahl (oder wieder Variablen). Damit weisen Sie den gdb
an, außer dem Element selbst noch so viele weitere Speicherstellen als Elemente
zu interpretieren und auszugeben, wie nach @
angegeben ist. Die erste Zeile unserer Matrix erhalten wir
beispielsweise mit:
(gdb) p dat[0][0]@size
$7 = {1, 2, 3}
display. Damit
können Sie erreichen, dass der gdb die angegebenen Variablen jedes
Mal ausgibt, wenn das Programm stoppt. Dazu muss der Debugger aber gerade in
einem Programmkontext sein, in dem diese Variablen bekannt sind. Ein display-Kommando
in einer anderen Funktion oder vorsichtshalber vor dem Programmstart funktioniert
daher nicht.
Setzen wir also in der Methode SymMatrix::subMatrix() einen Haltepunkt
und lassen das Programm bis dahin laufen. Dann können wir auch die Beobachtung
der Schleifenvariablen aktivieren:
Breakpoint 1, SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:173
173 SymMatrix u(size-1);
Current language: auto; currently c++
(gdb) display i
No symbol "i" in current context.
(gdb) n
176 for(unsigned int i=0; i<size; i++)
(gdb) n
177 for(unsigned int j=0, uj=0; j<size; j++)
(gdb) display i
1: i = 0
(gdb) n
179 if(j != _k)
1: i = 0
(gdb) display j
2: j = 0
continue eingeben),
landen wir schließlich in folgender Situation:
Program received signal SIGSEGV, Segmentation fault.
0x804b1c3 in SymMatrix::subMatrix
(this=0xbffff5a8, _k=0) at matrix.cc:179
179 u.at(i, uj++) = dat[i+1][j];
2: j = 1
1: i = 2
dat
ist eine Matrix mit drei Zeilen und drei Spalten. Da die Zählung in C++ immer
bei 0 beginnt, sind also Indizes von 0 bis 2 zulässig. Hier haben wir aber i+1=3
als Zeilenindex für dat verwendet, mit den bekannten Folgen. Das Problem
ist damit erkannt: Die Schleife über i läuft zu weit. Da wir eine Untermatrix
bilden wollen, die eine Zeile und eine Spalte weniger hat als das Original,
müssen wir natürlich auf die Bedingung i<size-1 achten anstatt
auf i<size. Wir müssen unser Programm daher an folgender Stelle korrigieren:
176: for(unsigned int i=0; i<size-1; i++)
Nachdem wir das Problem ausfindig gemacht haben, brauchen wir die Dienste des Debuggers
vorerst nicht mehr. Er lässt sich mit dem Kommando
quit, kurz q, beenden.
Wenn jedoch gerade ein Programm läuft und etwa an einem Haltepunkt steht, fragt der gdb erst noch einmal nach, ob es Ihnen mit dem Verlassen ernst ist:
The program is running. Exit anyway? (y or n)
Nehmen wir mal an, Sie wollten ordentlich sein und alles aufräumen, bevor Sie das
Werkzeug verlassen. Dann ist es das Beste, das Programm mit continue
bis zu seinem Ende weiterlaufen zu lassen, in unserem Fall bis zur Meldung:
Program terminated with signal SIGSEGV, Segmentation fault. The program no longer exists.
Wenn wir nun den Fehler korrieren, das Programm übersetzen und starten, erhalten wir endlich die erwartete Ausgabe:
Determinante ist 18
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:
).
Natürlich lassen sich auch Prozesssignale abfangen.
So leistungsfähig der gdb auch ist - seine Bedienung über die Kommandozeile setzt jedoch genaue Kenntnisse über die Befehle und ihre Argumente voraus. Vielen PC-Umsteigern mutet eine solche Arbeitsweise zudem sehr spartanisch an. Für all jene ist der grafische Data Display Debugger DDD zu empfehlen.
Er ist entstanden aus Diplom- und Doktorarbeiten von Dorothea Lütkehaus und Andreas
Zeller an der Technischen Universität Braunschweig. Obwohl die Autoren diese
Hochschule mittlerweile verlassen haben, wird er dort immer noch gepflegt und
archiviert. Da er ebenfalls unter der GNU General Public License (GPL, siehe
Seite
) steht, sind viele Entwickler weltweit an seiner Weiterentwicklung
beteiligt.
Sie können stets die neueste Version über die Webseite des GNU-Projekts www.gnu.org/software/ddd beziehen. Aber auch bei den meisten Linux-Distributionen ist der DDD mittlerweile enthalten.
Der Data Display Debugger ist keineswegs die einzige grafische Benutzerschnittstelle
zum gdb; neben den eigenständigen Frontends xxgdb und
tgdb bringen auch die meisten integrierten Entwicklungsumgebungen eigene
Aufsätze auf den gdb mit, beispielsweise Kdbg in
KDevelop (siehe Seite
)
oder der SNiFF-Debugger in SNiFF+ (Seite
). Aufgabe jedes dieser Programme ist
es, dem Benutzer die Eingabe der gdb-Befehle auf der Kommandozeile
zu ersparen und die Bedienung vorwiegend auf Mausklicks zu beschränken; dabei
legt man natürlich besonderen Wert auf die gebräuchlichsten Befehle und lässt
seltenere außen vor.
Die Besonderheit am DDD, der er auch seine Popularität verdankt, ist seine Fähigkeit, komplexe Datenstrukturen als Graphen zu visualisieren. Durch einfache Mausklicks kann der Benutzer Zeiger dereferenzieren oder die Inhalte von Objekten darstellen lassen. Besonders bei verschachtelten Objekten lassen sich die Zusammenhänge sehr gut durch automatisch verwaltete Bäume veranschaulichen. Auf Wunsch dringt die Anzeige dabei immer tiefer in die Verschachtelungen vor.
In diesem Abschnitt setze ich voraus, dass Sie die Grundbegriffe des Debuggens
und des Einsatzes des gdb kennen, wie ich sie im letzten Abschnitt
ab Seite
beschrieben habe. Zunächst wollen wir uns ansehen,
wie die gdb-Kommandos über den DDD erreichbar sind. Anschließend
werden wir einen Blick auf die Visualisierung von Datenstrukturen werfen.
Die Art des Aufrufes ist genau dieselbe wie beim gdb; Sie müssen lediglich die ersten zwei Buchstaben austauschen. Als Argument können Sie den Namen des Programms und eventuell eine Speicherauszugsdatei oder eine Prozessnummer angeben. Für unser Beispiel heißt das:
% ddd DebugTest
|
Gleich anschließend sehen Sie das DDD-Fenster vor sich (Abbildung
).
Die Aufteilung des Fensters dürfte klar sein: Menüleiste, Werkzeugleiste, Quelltextfenster,
Debugger-Konsole und Statusleiste. Je nach Situation können noch weitere Teile
hinzukommen, zum Beispiel das grafische Datenfenster. Wenn Sie möchten, stellt
Ihnen der DDD auch alle Teile in separaten Fenstern dar (siehe EDIT
| PREFERENCES | STARTUP | WINDOW LAYOUT).
Besonders praktisch ist das separate Werkzeugfenster für die wichtigsten Kommandos (in
Abbildung
über der rechten Hälfte des Quelltextfensters). Darüber
können Sie die Einzelschrittausführung bequem steuern. Im Folgenden wollen wir
dieses Fenster kurz als Kommandoleiste bezeichnen. (Über EDIT
| PREFERENCES | SOURCE | TOOL BUTTONS LOCATION lassen sich die enthaltenen
Schaltflächen aber auch im Quelltextfenster platzieren. Überhaupt können Sie
mit dem PREFERENCES-Dialog den DDD sehr weitreichend an
Ihre persönlichen Vorlieben anpassen.)
Am einfachsten starten Sie Ihr Programm über die Schaltfläche RUN der Kommandoleiste oder mit der Taste F2. Wenn Sie Kommandozeilenargumente brauchen, können Sie diese über den Dialog festlegen, den Sie mittels des Menüpunktes PROGRAM | RUN erhalten. Der DDD speichert dort sogar mehrere Folgen von Argumenten, aus denen Sie sich eine aussuchen können.
Auch für die Festlegung von Haltepunkten gibt es mehrere Möglichkeiten. Am einfachsten
ist der Doppelklick am Anfang der Codezeile. Zur Kennzeichnung des Haltepunktes
erscheint ein Stoppschild. Alternativ gibt es den Punkt SET BREAKPOINT
aus dem Kontextmenü (öffnet sich durch Klick auf die rechte Maustaste) oder
dem Stopp-Symbol aus der Werkzeugleiste. Wenn Sie Haltepunkte über Funktionsnamen
festlegen wollen oder sonstige komplexere Vorgaben beabsichtigen, ist der Menüpunkt
SOURCE | EDIT BREAKPOINTS das Richtige. Schließlich steht es Ihnen
auch frei, das Kommando break in die gdb-Konsole einzugeben.
Standardmäßig lädt DDD die Quelltextdatei, die die main()-Funktion enthält.
Alle weiteren beteiligten Dateien können Sie über FILE | OPEN SOURCE
auswählen und laden.
Ein Programm in einzelnen Schritten unter Beobachtung ablaufen zu lassen, ist beim
DDD überaus einfach. Starten Sie das Programm über RUN auf
der Kommandoleiste und lassen Sie es bis zu einem eingestellten Haltepunkt laufen.
Dann stehen wieder die Befehle step, next, until und finish
zur Verfügung (siehe Seite
) - alles über die gleichnamigen
Schaltflächen auf der Kommandoleiste. Auch der Befehl cont zum Fortsetzen
ist darüber erreichbar.
Wenn Sie innerhalb einer Funktion zum Halten kommen, können Sie sich alle Funktionen
ausgeben lassen, über die das Programm hierher gelangt ist. Was beim gdb
noch backtrace heißt, findet sich hier unter dem Menüpunkt STATUS
| BACKTRACE. Die Darstellung erfolgt über ein Dialogfenster (Abbildung
),
das noch eine weitere Hilfestellung in sich birgt. Mittels der Schaltflächen
UP und DOWN können Sie auch im Quelltextfenster zu den jeweiligen
Programmstellen springen, an denen der Aufruf steht. Dort können Sie dann nach
Wunsch weitere Untersuchungen anstellen.
Dabei wollen Sie sicher auch die Werte der verschiedenen Variablen überprüfen. Bei Standardtypen geht das am einfachsten, indem Sie mit dem Mauszeiger darauf deuten und einen Augenblick warten. Sofort erscheint ein kleines Hilfefenster, das den Wert der jeweiligen Variablen (oder auch Konstanten) enthält. Diese Information wird zudem gleichzeitig in der Statuszeile ausgegeben.
Wollen Sie alle lokalen Variablen verfolgen, genügt die Auswahl des Menüpunktes DATA | DISPLAY LOCAL VARIABLES. In einem Kästchen im Datenfenster werden Sie dann ständig über die aktuellen Werte informiert. Für Grafik-Freunde ist sogar eine Ausgabe von einer oder mehrerer Variablen als Plot über die gleichnamige Schaltfläche der Werkzeugleiste möglich (sofern Sie gnuplot installiert haben). Sie müssen lediglich die zu untersuchende Variable im Datenfenster oder im Quelltextfenster markiert haben.
Natürlich findet sich auch im DDD die dauerhafte Verfolgung von Variablenwerten
wieder, die wir beim gdb als display-Kommando kennen gelernt
haben. Eine Möglichkeit bietet das Kontextmenü. Klicken Sie über der interessierenden
Variablen im Quelltextfenster die rechte Maustaste; hier können Sie nun sowohl
eine Anzeige des Wertes selbst als auch, bei Zeigern, des dereferenzierten Inhalts
aktivieren. Die Ausgabe erfolgt in einem Kästchen des Datenfensters.
Die Anzeige von Feldern geht nicht ganz so automatisch. Hier ist wieder Ihr
gdb-Know-how gefragt. Auf Seite
hatten wir
festgestellt, dass man mehrere Einträge eines Feldes zusammen ausgeben kann,
indem man das @-Zeichen und die Anzahl der Werte hinter die Angabe des ersten
Wertes setzt. Diese Möglichkeit wählen wir auch hier: Klicken Sie auf die Schaltfläche
DISPLAY der Werkzeugleiste und halten Sie sie für einen Moment gedrückt.
Aus dem sich öffnenden Menü wählen wir OTHER. Dadurch gelangen wir
zu einem Dialog (Abbildung
), in dem wir unsere Eingabe,
beispielsweise
this->dat[0][0]@size
). In großen Feldern, in denen derselbe Wert mehr als
zehn Mal hintereinander vorkommt, wird dieser nur einmal angezeigt und seine
Häufigkeit durch ein nachgestelltes <Nx> deutlich gemacht.
Wenn Sie beim Debuggen Ihren Fehler entdeckt haben, können Sie natürlich zu Ihrer Entwicklungsumgebung zurückkehren, die Codestelle korrigieren und das Programm übersetzen. Gehören Sie jedoch zu den Kommandozeilenprogrammierern, die alle Arbeit an ihren Programmen von der Shell aus erledigen, können Sie sich diesen Umweg sparen. Über EDIT aus der Kommandoleiste können Sie ein Fenster öffnen, das Ihnen das Editieren der aktuellen Datei mittels des vi-Editors (siehe Seite
) erlaubt. Änderungen erscheinen sofort nach Schließen
des Editors im Quelltextfenster. Wenn Sie nun noch auf MAKE klicken,
wird die geänderte Datei (bei einem korrekten Makefile) sofort übersetzt und
das Programm aktualisiert. Der DDD merkt übrigens beim Starten eines
Programms, ob es in einem anderen Prozess neu gebaut wurde und liest die Symboltabelle
gegebenenfalls nochmals ein.
Und wenn Sie mal gar nicht mehr weiter wissen, gibt Ihnen HELP | WHAT NOW? immer einen freundlichen Hinweis, was Sie als Nächstes tun könnten. Um mehr über die Arbeit mit dem DDD zu erfahren, finden Sie unter HELP | DDD REFERENCE ein ausführliches Handbuch (das zudem der Installation auch im PostScript-Format beiliegt). Bei jedem Start begrüßt Sie der DDD außerdem mit einem Tip of the day; anhand dieser Tipps können Sie auch viel über die effiziente Bedienung dieses Werkzeugs lernen.
Insgesamt erkennen Sie, dass der DDD alle Fähigkeiten des gdb in bequemerer Form zugänglich macht, gleichzeitig aber einige eigene Funktionalität mitbringt. Dieser wollen wir uns nun widmen.
Als Beispiel verwende ich diesmal nicht die Determinantenberechnung aus dem letzten
Abschnitt; für diese Funktionen brauchen wir etwas komplexere Datenstrukturen.
Wir sehen uns daher das Programm birthcontrol aus Abschnitt
(ab Seite
) an, das Sie an Geburtstage Ihrer Familie und
Freunde erinnern soll. Die zentrale Rolle dabei spielt die Klasse BirthList.
Werfen wir einen Blick auf die Deklaration (die schon viel von der Definition
enthält):
1:// Datei: birthlist.h
2:#include <string>
3:#include "date.h"
4:
5:using std::string;
6:
7://---------------------------------------------
8:// Klasse fuer Elemente der Liste
9://---------------------------------------------
10:struct BirthListElement
11:{
12:BirthListElement* next;
13:string name;
14:string surname;
15:Date date;
16:
17:// Standardkonstruktor
18:BirthListElement() :
19:next(0) {}
20:
21:// Spezieller Konstruktor
22:BirthListElement(const string& _name,
23:const string& _surname, unsigned short _day,
24:unsigned short _month, unsigned short _year) :
25:next(0),
26:name(_name),
27:surname(_surname),
28:date(_day, _month, _year)
29:{}
30:};
31:
32://---------------------------------------------
33:// Listenklasse
34://---------------------------------------------
35:class BirthList
36:{
37:private:
38:BirthListElement* first;
39:BirthListElement* last;
40:int size;
41:
42:public:
43:BirthList() :
44:first(0), last(0), size(0) {}
45:
46:virtual ~BirthList();
47:
48:bool empty() const
49:{ return (size == 0); }
50:
51:int getSize() const
52:{ return size; }
53:
54:void pushBack(const string& _name,
55:const string& _surname, unsigned short _day,
56:unsigned short _month, unsigned short _year);
57:
58:void popFront();
59:
60:BirthListElement* front()
61:{ return first; }
62:
63:int load(const string& filename);
64:
65:bool check(const Date& d);
66:};
Die einzelnen Daten werden in Objekten vom Typ BirthListElement
abgelegt (Zeile 10-30). Wie in Abbildung
(Seite
)
speichern wir auch hier die Daten in Form einer einfach verketteten Liste. Das
nächste Listenelement wird durch den Zeiger next ausgedrückt. Um
aber bequem auf die Liste zugreifen zu können, speichern wir in der Klasse BirthList
(Zeile 35-66) sowohl einen Zeiger auf das erste (first) als auch auf
das letzte Element (last).
|
Wie können wir die enthaltenen Daten sichtbar machen? Sobald Sie mit der linken
Maustaste im Quelltextfenster auf einen Bezeichner klicken, erscheint dieser
im Argumentfeld (das ist die Combobox unterhalb des Hauptmenüs, das mit einem
Klammerpaar davor gekennzeichnet ist). Alle print- oder display-Befehle,
die Sie nun über das Kontextmenü oder die Schaltfläche der Werkzeugleiste aktivieren,
nehmen darauf Bezug. (Daher taucht bei diesen auch immer das leere Klammernpaar
auf.) Die so ausgeführten display-Kommandos erscheinen im Datenfenster
in Form von Kästchen. Dort können Sie dann weitere Details in Erfahrung bringen.
Bei Datenstrukturen, die wieder andere Strukturen enthalten (wie unser BirthListElement),
werden die Daten zunächst in knapper Form angezeigt, etwa als Pünktchen ... oder als Wert des Zeigers. Um diese zu expandieren, wählen Sie zunächst die
gewünschte Struktur aus (durch einen Klick, durch mehrere Klicks mit festgehaltener
-Taste oder durch Umranden der Kästchen mit einem Fangrechteck
mittels Ziehen mit der linken Maustaste). Anschließend öffnen Sie das Kontextmenü
und klicken auf SHOW ALL. Damit weisen Sie die Anzeige an, alle Verschachtelungen
aufzulösen und sämtliche Daten darzustellen. Ein mögliches Resultat können Sie
in Abbildung
sehen.
Über den Schalter SHOW beziehungsweise HIDE der Werkzeugleiste lässt sich das Expandieren noch selektiver gestalten. So zeigt etwa SHOW MORE die Inhalte der gerade verborgenen Datenstruktur an, aber nicht die Inhalte der in ihr enthaltenen. Mit SHOW JUST werden alle Details eingeschachtelter Strukturen verborgen und nur die aktuelle Ebene angezeigt. Als Gegenstück dazu verbirgt HIDE alles.
Die Pfeile zwischen den Kästchen (den so genannten Displays) verraten die
Beziehung zwischen ihnen. In Abbildung
entspricht der rechte
Kasten beispielsweise einer Dereferenzierung des Zeigers im linken, angedeutet
durch *() über dem Pfeil. Wenn Sie jetzt auf den Eintrag next
doppelt klicken, erscheint ein neuer Kasten mit dessen Inhalt; über dem Pfeil
steht dann next.
Spätestens wenn Sie sich beispielsweise mit DATA | EDIT DISPLAYS eine Liste aller aktuellen
Kästchen ausgeben lassen, werden Sie merken, dass diese recht lang werden kann.
Standardmäßig wird für jede zu verfolgende Variable ein eigenes Kästchen angelegt.
Um aber die Übersicht nicht zu verlieren, sollten Sie die Möglichkeit nutzen,
mehrere Displays zu einem zusammenzufassen. Ein solch fusioniertes Display bezeichnet
die DDD-Dokumentation als Cluster (Abbildung
).
Markieren Sie zur Clusterbildung die gewünschten Kästchen (durch Anklicken bei
gedrückter
-Taste oder durch Umranden der Kästchen mit einem
Fangrechteck), klicken Sie auf den Schalter UNDISP auf der Werkzeugleiste
(gekennzeichnet mit einer Art Totenkopf, in Abbildung
ganz rechts) und wählen Sie aus dem sich öffnenden Menü den Eintrag CLUSTER().
Alle künftigen Displays können Sie zu einem bestehenden Cluster hinzufügen,
indem Sie die Voreinstellung EDIT | PREFERENCES | DATA | CLUSTER DATA
DISPLAYS aktivieren.
Wie Sie gesehen haben, ist der DDD mehr als nur ein Frontend zum gdb. Er verfügt über eine Reihe eigener Fähigkeiten, von denen ich Ihnen hier leider nur einen kleinen Teil zeigen konnte. Lassen Sie sich also bei Ihrer Arbeit mit dem DDD durch den Tip of the day oder einen Blick ins Handbuch dazu anregen, weitere Möglichkeiten zu erkunden. Sie werden sehen: Es lohnt sich!
Bei den Fehlerquellen, die wir in Abschnitt
untersucht
haben, blieb ein Typ völlig unberücksichtigt: falsch benutzte Methoden (oder Funktionen).
Denn jede Methode, die nicht gerade konstant ist, ändert den Zustand des Objekts
oder des ganzen Programms. Daher muss die Übergabe ungültiger Parameter oder
die falsche Reihenfolge des Aufrufs zu einem undefinierten Zustand führen, der
sich früher oder später auf das ganze Programm fatal auswirkt.
Um die Aufrufspezifikation exakt zu formulieren, sollte man Vor- und Nachbedingungen für jede Methode festlegen. Vorbedingungen sind die Erwartungen, die die Methode in die Parameter und den Aufrufkontext setzt (dass also zum Beispiel ein Parameter größer als null ist). Nachbedingungen drücken aus, was der Aufrufer von der Methode erwarten darf.
Auf diese Weise wird die Beziehung zwischen einer Funktion und deren Benutzer auf eine klar definierte Grundlage gestellt. Eine solche kann man auch als Vertrag zwischen beiden auffassen. Als einer der ersten hat Bertrand Meyer in seinem sehr lesenswerten Buch [MEYER 1997] auf diese Thematik hingewiesen. Er empfiehlt, sich bei der gesamten Entwicklung vom Vertragsgedanken zwischen den Softwarekomponenten leiten zu lassen und ein Design by Contract anzustreben.
Die Spezifikation des Vertrags sollte in der Header-Datei bei der jeweiligen Methode stehen, damit ein Benutzer sie auch schnell findet. Wie aber kann man das Einhalten der Vertragsbedingungen überprüfen? Und welche Sanktionen drohen bei Vertragsverletzung?
Eine einfache Möglichkeit, logische Annahmen zu verifizieren, bietet das Makro
assert(), das in der Datei cassert der C++-Standardbibliothek
definiert ist. Es überprüft eine Bedingung. Ist diese erfüllt, passiert gar
nichts und das Programm kann ungestört weiterarbeiten. Ist sie jedoch verletzt,
beendet assert() das Programm mit einer Fehlermeldung. (Das zugehörige
Substantiv assertion heißt übrigens auf Deutsch Zusicherung.)
Als Beispiel betrachten wir eine Funktion, die einen String mehrfach hintereinander auf einen übergebenen Stream ausgibt:
1:// Datei asserttest.cc
2:
3:#include <string>
4:#include <iostream>
5:#include <cassert>
6:
7:using namespace std;
8:
9:// Funktion: repeatedOutput
10:// Parameter:
11:// ostream* _o: Ausgabestream
12:// const string& _s: String
13:// unsigned short _n: Wiederholung
14:// Bedingung: _o != 0
15:void repeatedOutput(ostream* _o,
16:const string& _s, unsigned short _n)
17:{
18:assert(_o); // entspricht _o!=0
19:for(unsigned i=0; i<_n; i++)
20:*_o << _s;
21:}
22:
23:int main()
24:{
25:repeatedOutput(&cout, "-", 30);
26:cout << endl;
27:repeatedOutput(0, "?", 30);
28:
29:return 0;
30:}
In Zeile 18 wird überprüft, ob die Vorbedingung, nämlich dass _o ungleich
0 ist, tatsächlich erfüllt wird. In Zeile 27 provozieren wir eine Vertragsverletzung
und erhalten als Ausgabe:
---------------
asserttest: asserttest.cc:18: void
repeatedOutput( ostream *, const string &,
short unsigned int): Zusicherung »_o« nicht
erfüllt.
Abort
Sorgfältig getesteter Code sollte solche Vertragsverletzungen nicht mehr enthalten. Daher
kann für die endgültige Version Ihres Programms die ständige Überprüfung der
Bedingungen entfallen, die ja auch selbst Rechenzeit kostet. Um assert()
abzuschalten, müssen Sie den Schalter NDEBUG definieren, und zwar vor
dem Einbinden von cassert. Das können Sie beispielsweise mit der Präprozessor-Anweisung
#define NDEBUG erledigen. Bequemer und sicherer ist aber die Übergabe
dieser Definition direkt an den Compiler mittels des Schalters -DNDEBUG,
also etwa:
% g++ -g -DNDEBUG asserttest.cc -o asserttest
assert(), ist die Ausgabe ein
wenig anders:
---------------
Segmentation fault
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 lässt sich die Veränderung
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.
Ein wirkliches Frontend zu RCS ist das ebenfalls freie PRCS (Project revision control system, siehe www.xcf.berkeley.edu/~jmacd/prcs.html). Während RCS nur dateiorientiert arbeitet, also Zusammengehörigkeiten von Dateien in Projekten nicht berücksichtigen kann, ist PRCS in der Lage, auch ganze Projekte mit konsistenten Revisionsnummern zu verwalten. Auch wenn sich die Syntax ein wenig unterscheidet, sind die Aufgaben, die mit PRCS erledigt werden können, im Wesentlichen dieselben wie bei RCS. Daher gehe ich davon aus, dass Sie sich mit den erworbenen Kenntnissen schnell einarbeiten könnten, und belasse es bei dieser Erwähnung.
Daneben gibt es auch immer mehr Unterstützung für Linux durch die kommerziellen Versionskontrollsysteme. So sind beispielsweise Linux-Clients verfügbar für ClearCase (von Rational, siehe www.rational.com) und PVCS (ursprünglich von Merant, auf Linux portiert von Synergex, siehe www.pvcs.synergex.com). Selbst für Visual SourceSafe von Microsoft gibt es bereits eine Portierung (von MainSoft, siehe www.mainsoft.com/products/visual).
Wenn Sie noch weitere Informationen über das Thema Konfigurationsmanagement (also Versionskontrolle im weiteren Sinn) brauchen, empfehle ich Ihnen die Webseite mit den am häufigsten gestellten Fragen (FAQ) der entsprechenden Diskussionsgruppe im Usenet, zu erreichen unter www.iac.honeywell.com/Pub/Tech/CM/CMFAQ.html.
Wie bereits angedeutet, liegt der Sinn der Versionskontrolle darin, von allen Projektdateien verschiedene Bearbeitungszustände zu speichern, um damit
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.
Dabei spielen zwei Begriffe eine wichtige Rolle: das Einchecken und das Auschecken. Das ist so ähnlich wie beim Fliegen:
Eine weitere wichtige Funktion ist der Vergleich von Revisionen. Auf diese Weise können Sie mit einem einfachen Kommando erkennen, welche Unterschiede zwischen einer Revision und einer anderen bestehen. Übrigens arbeitet RCS auch intern so, dass es lediglich die erste Revision vollständig speichert und bei den weiteren jeweils nur die Unterschiede zur vorhergehenden festhält.
Revisionsverwaltung ist nicht nur für Programmquelltexte empfehlenswert, sondern für alle Dateien, die Sie von Zeit zu Zeit ändern, deren Entwicklung Sie aber verfolgen wollen. Es funktioniert für HTML-Dateien ebenso wie für Systemkonfigurationsdateien (etwa aus dem Verzeichnis /etc).
Zunächst wollen wir uns den einfacheren Fall, nämlich die Arbeit mit
RCS ansehen, damit Sie daran die grundlegende
Vorgehensweise am praktischen Beispiel erlernen. Im nächsten
Abschnitt ab Seite
gehe ich dann auf CVS
ein.
RCS erzeugt zu jeder von ihm verwalteten Datei eine RCS-Datei, die denselben Namen wie die Datei trägt, allerdings mit einem angehängten ,v. Standardmäßig werden die RCS-Dateien in das gleiche Verzeichnis geschrieben, in dem sich auch die eigentlichen Dateien befinden. Das kann das Arbeitsverzeichnis sehr unübersichtlich machen. Legen Sie sich daher am besten vor dem Registrieren der ersten Datei ein Unterverzeichnis mit dem Namen RCS an. Ist nämlich ein solches vorhanden, werden die RCS-Dateien dort abgelegt.
Wenn Sie mit mehreren Kollegen an einem Projekt arbeiten, dann tauschen Sie Ihre Dateien vorwiegend über das lokale Netz aus, haben also eine gemeinsame Festplatte, die alle in ihr System eingebunden haben. Für die Organisation der Revisionsverwaltung gibt es dann zwei Möglichkeiten, von denen je nach Art und Umfang des Projekts eine gewählt werden sollte:
Als Beispiel wollen wir die Datei asserttest.cc aus Abschnitt
verwenden (ab Seite
). Zum Registrieren einer Datei benutzt
man das Kommando rcs -i, gefolgt vom Dateinamen. Dabei werden Sie noch
gebeten, eine Beschreibung der Datei einzugeben. Diese kann aus einer oder mehreren
Zeilen bestehen und muss mit einer Zeile, die nur einen Punkt enthält, oder
Strg+d abgeschlossen werden.
% mkdir RCS
% rcs -i asserttest.cc
RCS file: asserttest.cc,v
enter description, terminated with single '.'
or end of file:
NOTE: This is NOT the log message!
>> Testprogramm fuer assert-Kommando
>> .
done
%
% ls -l *
-rw-r-r- 1 thomas users 513
Dez 4 17:56 asserttest.cc
RCS:
total 1
-r-r-r- 1 thomas users 99
Dez 4 18:08 asserttest.cc,v
Sind wir mit der Bearbeitung fertig, geht es ans Einchecken. Dazu dient das Kommando ci (für check in). Dabei gibt es unterschiedliche Modi:
-u-u
angeben. Das verhindert das Löschen.
-l-l.
-r-r explizit eine Revisionsnummer
an, zum Beispiel -r1.11. Die Möglichkeit, eine Revisionsnummer ausdrücklich
anzugeben, haben Sie auch bei -u und -l.
% ci -u asserttest.cc
RCS/asserttest.cc,v <- asserttest.cc
initial revision: 1.1
done
Das analoge Kommando zum Auschecken ist co
(für check out). Wenn Sie es ohne weitere Schalter verwenden, erhalten
Sie eine Kopie der aktuellsten Revision, die Sie jedoch nur lesen und nicht
bearbeiten können. Wenn Sie eine Änderung vornehmen wollen, müssen Sie die Option
-l angeben. Dadurch wird die Datei einerseits beschreibbar und andererseits
vor dem Zugriff von anderen durch eine Sperre (lock) geschützt.
% co -l asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
revision 1.1 (locked)
done
markus@sittich> co -l asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
co: RCS/asserttest.cc,v: Revision 1.1 is already
locked by thomas.
Normalerweise erhalten Sie beim Auschecken immer die aktuellste Revision. Wenn
Sie einmal auf eine ältere zugreifen wollen, geben Sie als Argument den Schalter
-r gefolgt von der Revisionsnummer an:
% co -r1.1 asserttest.cc
RCS/asserttest.cc,v -> asserttest.cc
revision 1.1
done
Nehmen wir an, wir wären mit dem Programm noch nicht so ganz zufrieden. So soll beispielsweise eine Ausgabe eingebaut werden, mit der sich das Programm am Anfang meldet. Ist dies erledigt, können wir die Datei wieder einchecken. Dabei fällt auf, dass RCS bei jedem Einchecken eine Beschreibung der Änderungen verlangt. Auch wenn es - wie wir gleich sehen werden - eine Möglichkeit gibt, diese automatisch herauszufinden, können die vorgenommenen Änderungen mit solchen knappen Kommentaren doch am besten dokumentiert werden. So kann man auch nach längerer Zeit noch nachvollziehen, wann welche Umbauten vorgenommen wurden - die dann eventuell zu einem Fehler geführt haben.
RCS/asserttest.cc,v <- asserttest.cc
new revision: 1.2; previous revision: 1.1
enter log message, terminated with single '.' or
end of file:
>> Programmbeschreibung wird nun am
>> Anfang ausgegeben.
>> .
done
Wenn Sie die Abfolge der Änderungen (die Revisionsgeschichte) ansehen wollen, verwenden Sie das Kommando rlog. Es liefert Ihnen eine vollständige Liste aller Revisionen, einschließlich Datum, Autor und Kommentar.
% rlog asserttest.cc
RCS file: RCS/asserttest.cc,v
Working file: asserttest.cc
head: 1.2
branch:
locks: strict
access list:
symbolic names:
keyword substitution: kv
total revisions: 2; selected revisions: 2
description:
Testprogramm fuer assert-Kommando
--------------
revision 1.2
date: 2002/12/04 17:30:34; author: thomas;
state: Exp; lines: +1 -0
Programmbeschreibung wird nun am Anfang ausgegeben
--------------
revision 1.1
date: 2002/12/04 17:25:11; author: thomas;
state: Exp;
Initial revision
=======================
Was hat sich denn nun genau geändert? Diese Frage beantwortet das Kommando rcsdiff. Es nimmt zwei Versionen aus dem Archiv und ruft das Programm diff auf, um die Änderungen herauszufinden. Die Form der Ausgabe ist sicher etwas gewöhnungsbedürftig.
% rcsdiff -r1.1 -r1.2 asserttest.cc
===================================
RCS file: RCS/asserttest.cc,v
retrieving revision 1.1
retrieving revision 1.2
diff -r1.1 -r1.2
22a23
> cout << "Testprogramm für assert()" << endl;
Gerade weil die zahlreichen Dateien, die zu einem Projekt gehören, verschieden oft bearbeitet werden und daher in der aktuellsten Revision ganz unterschiedliche Revisionsnummern tragen können, sollten Sie ab und zu einen Stand all Ihrer Quellen einfrieren, das heißt mit einem einheitlichen Etikett versehen. Auf diese Weise können Sie später genau wieder die Version identifizieren, die zu einem bestimmten Stand Ihrer Software gehörte.
Zu diesem Zweck verfügt das Kommando rcs über die Option -n.
Daran schließt sich unmittelbar das Etikett an, dem wiederum ein Doppelpunkt
folgt. Dahinter können Sie eine konkrete Revisionsnummer angeben, die mit diesem
Etikett verbunden werden soll; wenn Sie dort aber nichts hinschreiben, wird
die aktuellste Revision verwendet. Damit mehrere Dateien auf einmal etikettiert
werden können, unterstützt dieses Kommando auch das Sternchen als Dateiname.
Wenn wir also alle Quellen im aktuellen Verzeichnis mit dem Etikett Iteration1
versehen wollen, geben wir ein:
% rcs -nIteration1: RCS/*
-N, die ansonsten derselben Syntax folgt.
In der Ausgabe von rlog erscheint das Etikett unter der Rubrik symbolic names und deutet damit an, welche Revisionsnummer mit dem Etikett versehen wurde:
...
access list:
symbolic names:
Iteration1: 1.2
keyword substitution: kv
...
Oft möchten Sie nicht nur auf der Kommandozeile, sondern direkt im Quelltext
wissen, welche Revision Sie nun eigentlich gerade bearbeiten, wie RCS
mit dieser Datei umgeht und so weiter. Dazu bietet RCS eine Reihe
von Schlüsselwörtern an, die bei jedem ci- oder co-Kommando aktualisiert
werden. In Tabelle
finden Sie eine Übersicht.
Diese Schlüsselwörter bauen Sie üblicherweise in Kommentare Ihres Quelltextes ein, um im Editor sofort die entsprechende Information finden zu können. In unserem Beispiel können wir etwa eingeben:
| Schlüsselwort | Beschreibung | |
|
||
$Date$ |
Datum und Uhrzeit, an dem die Datei zuletzt eingecheckt wurde | |
$Header$ |
Eine Zusammensetzung aus dem Dateinamen mit vollständigem Pfad, der Revisionsnummer, Datum, Uhrzeit, Bearbeiter und gegebenenfalls Sperrmarke | |
$Id$ |
Der gleiche Text wie $Header$, allerdings ohne den
Pfad vor dem Dateinamen |
|
$Locker$ |
Der Name des Benutzers, der die Datei momentan gesperrt hat | |
$Log$ |
Fügt bei jedem Einchecken die Revisionsnummer und den Kommentar hinzu, wobei alle vorherigen Einträge erhalten bleiben. So kann die vollständige Revisionsgeschichte in der Datei dokumentiert werden. | |
$Name$ |
Das Etikett der aktuellen Revision (sofern vorhanden) | |
$RCSfile$ |
Der Name der zugehörigen RCS-Datei (ohne Pfad) | |
$Revision$ |
Die Nummer der aktuellen Revision | |
$Source$ |
Der Name der zugehörigen RCS-Datei mit vollständigem Pfad |
// Datei $RCSfile$
// zuletzt bearbeitet von $Author$
// $Date$, $Revision$
/*
* $Log$
*/
// Datei $RCSfile: asserttest.cc,v $
// zuletzt bearbeitet von $Author: thomas $
// $Date: 2002/12/04 19:52:59 $, $Revision: 1.3 $
/*
* $Log: asserttest.cc,v $
* Revision 1.3 2002/12/04 19:52:59 thomas
* RCS Keywords eingebaut
*
*/
Das
$Header$- oder $Id$-Schlüsselwort wird noch auf eine etwa raffiniertere
Weise verwendet. Wenn Sie eine Textvariable anlegen, die eines dieser Schlüsselwörter
enthält, wird diese Information auch in die Objektdateien und die ausführbaren
Dateien übernommen.
Dort können Sie sie dann mit dem Kommando ident
abfragen. Fügen wir also ein:
const string Id = "$Id$";
const string Id = "$Id: asserttest.cc,v 1.3 2002/12/04
19:52:59 thomas Exp $";
% g++ -o asserttest asserttest.cc
% ident asserttest
asserttest:
$Id: asserttest.cc,v 1.3 2002/12/04 19:52:59
thomas Exp $
%
$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.
Wie bereits oben betont, sind RCS und CVS sehr ähnlich, da sie aus einer gemeinsamen Codebasis hervorgegangen sind. Trotzdem unterscheidet sich der Umgang mit ihnen teilweise erheblich. Auch die Syntax der Kommandos weicht deutlich voneinander ab. Die Gemeinsamkeiten sehen Sie daher eher im Ergebnis als in der Handhabung. Auch wenn Sie also schon RCS kennen, bleibt Ihnen das Durcharbeiten dieses Abschnitts nicht erspart, wenn Sie CVS verwenden wollen.
Mehr zu CVS erfahren Sie natürlich auf der Homepage des
Projekts unter www.cvshome.org, aber auch in der Link-Sammlung
von Pascal Molli unter molli/cvs-index.html">www.loria.fr/
molli/cvs-index.html. Ein
sehr lohnenswertes Buch ist [], das in Teilen unter der
Adresse cvsbook.red-bean.com
auch online verfügbar ist.
Während RCS streng dateiorientiert arbeitet und die Versionsverwaltung gleich im lokalen Verzeichnis abwickelt, spielt bei CVS das zentrale Archiv eine bedeutende Rolle. Dieses Archiv - ich verwende diesen Begriff hier synonym mit dem englischen Repository - enthält alle Projektdateien und die Historie ihrer Änderungen. Es wird von einem Administrator angelegt und gepflegt.
Im Archiv gibt es ein Verzeichnis CVSROOT, das eine Reihe von Dateien enthält, die zur Verwaltung des Archivs dienen. Dazu gehören
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
)
Welche Art des Zugriffs Sie verwenden, hängt von Art und Umfang Ihres Projekts, von Verteilung und Ausstattung der Entwickler und nicht zuletzt von Sicherheitsüberlegungen ab. Im Folgenden will ich die jeweiligen Schritte zur Einrichtung des Archivs und des Zugriffes erläutern.
Das Archiv legen Sie zunächst wie ein normales Verzeichnis an. Sie müssen dazu
nicht unbedingt Super-User sein, sollten es aber nicht in Ihrem Home-Verzeichnis
erzeugen, sondern an etwas zentralerer Stelle, wo Sie aber auch die nötigen
Rechte besitzen müssen. Außerdem müssen später ja auch andere die Möglichkeit
des Zugriffs darauf haben - doch dazu gleich mehr. Gehen wir also davon aus,
dass root die Erstellung vornimmt.
Nehmen wir an, wir wollen es unter /home/cvsroot anlegen, dann müssen Sie eingeben
# mkdir /home/cvsroot
Fast jedes CVS-Kommando muss wissen, wo sich das Archiv befindet. Das können
Sie auch jeweils separat über die Option -d angeben. Bequemer und konsistenter
ist es aber, wenn Sie die Umgebungsvariable
$CVSROOT verwenden und auf das Verzeichnis des Archivs setzen.
# export CVSROOT=/home/cvsroot
# cvs init
Wenn Sie Ihre Entwickler lokal auf das Archiv zugreifen lassen möchten, müssen
sie Schreibrechte darauf haben. Um diese getrennt von den übrigen Rechten
im System vergeben zu können, ist es empfehlenswert, dafür eine eigene Benutzergruppe
anzulegen und das Archiv einem separaten Benutzer innerhalb dieser Gruppe zuzuordnen.
Legen wir also eine Gruppe cvs und einen User cvs in dieser Gruppe
an (wieder als root):
# groupadd cvs
# useradd -d $CVSROOT -g cvs cvs
# chown -R cvs.cvs $CVSROOT
# chmod 770 $CVSROOT
cvs aufnehmen. Das geschieht mit dem Befehl usermod.
Dabei müssen Sie neben der Gruppe cvs auch noch alle anderen Gruppen angeben,
denen der jeweilige Benutzer außerdem angehört; fehlt eine, wird seine Mitgliedschaft darin gelöscht.
Wenn also der Benutzer thomas vorher in users und dev war,
lautet der Befehl:
# usermod -G users,dev,cvs thomas
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:
cvspserver 2401/tcp # cvspserver
# in der ersten Spalte auskommentiert. In diesem
Fall müssen Sie nur das Kommentarzeichen entfernen.
Der Passwortserver ist kein Demon, der bereits beim Systemstart aktiv sein muss. Es genügt, ihn bei Bedarf über den Inet-Demon inetd zu starten. Daher müssen Sie den Passwortserver in die Konfigurationsdatei /etc/inetd.conf eintragen. Der Eintrag besteht aus einer Zeile, was ich hier leider aus drucktechnischen Gründen nicht genauso machen kann, und umfasst den Portnamen, den Befehl und das $CVSROOT-Verzeichnis:
cvspserver stream tcp nowait root /usr/sbin/tcpd
/usr/bin/cvs -f -allow-root=/home/cvsroot pserver
# /etc/init.d/inetd restart
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:Die wichtigste Funktion daran ist#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 41 aufgerufen wird. Sie verlangt als Parameter das Passwort im
Klartext sowie zwei zufällige Buchstaben, die klein oder groß sein dürfen, und
die wir in der Funktion getSaltChar() einzeln erzeugen. Das hat zur Folge,
dass die Verschlüsselung immer wieder andere Ausgaben produziert, die
sich aber alle auf denselben Text zurückführen lassen. Für das Passwort Linux könnte der Eintrag in die Passwortdatei beispielsweise lauten:
wieland:VGFifgAElW.kc:thomas
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:mxR8118Ph1eoE:cvsguest
anonymous der Datei readers hinzu.
# useradd -d $CVSROOT -g cvs -p guest \
-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 RCS
in Bezug auf das Sperren von Dateien verhält. Normalerweise ist es bei CVS ja
erlaubt, dass mehrere Benutzer gleichzeitig eine Datei ausgecheckt haben. Durch
% cvs admin -L
-U schaltet wieder auf den Ursprungszustand zurück.
Manchmal ist es nötig, das gesamte Archiv oder einzelne Revisionen daraus zu sperren,
um einen aktuellen Stand einzufrieren. Das erledigen Sie mit der
Option -l, also etwa
% cvs admin -l
-u.
Weitere Optionen dieses Befehls erfahren Sie durch
% cvs -H admin
Die Arbeit mit CVS ist aus Sicht des Entwicklers der mit RCS naturgemäß sehr
ähnlich. Auch hier gibt es ein Auschecken und ein Einchecken (siehe
Seite
). Allerdings müssen Sie beachten, dass CVS
im Normalfall die gleichzeitige Bearbeitung einer Datei durch mehrere Benutzer
zulässt. Das bedeutet, dass auch eine von Ihnen ausgecheckte Datei sich zu dem
Zeitpunkt, da Sie sie wieder einchecken möchten, bereits verändert haben kann.
CVS hat ausgefeilte Algorithmen dafür, solche Verschmelzungen verschiedener
Versionen zu automatisieren - doch dazu später mehr.
Die allgemeine Vorgehensweise ist daher:
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
% export CVSROOT=/home/cvsroot
$CVSROOT hier die Form :ext:user@host:path haben, was zum
Beispiel heißen kann:
% export CVSROOT=:ext:wieland@gonzo:/home/cvsroot
ext
muss es pserver heißen, also :pserver:user@host:path, beispielsweise
% export CVSROOT=:pserver:thomas1@\
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:
% cvs login
(Logging in to thomas1@cvs.myproject.org)
CVS password:
cvs checkout das gewünschte
Modul aus (siehe BeispielSec:CVS-Checkout).
Jedes Verzeichnis innerhalb Ihres Arbeitsbereichs enthält ein Unterverzeichnis CVS, in dem ein paar Dateien stehen, die CVS für die interne Buchführung benötigt. Im Normalfall können Sie diese ignorieren.
Wenn Sie in Ihrem Benutzerverzeichnis bereits ein Projekt haben, das Sie zum CVS-Archiv hinzufügen möchten, verwenden Sie den Befehl cvs import. Dieser Befehl erwartet einen Modulnamen gefolgt von zwei Etiketten (Tags) als Argumenten: ein vendortag, das Sie als den Hersteller dieses Moduls kennzeichnet, und ein releasetag, das den Namen der Version angibt, die Sie gerade importieren. Beachten Sie, dass Version hier eine textuelle Bezeichnung sein muss; dieses Tag muss mit einem Buchstaben beginnen und darf keinen Punkt oder ähnliche Sonderzeichen enthalten. Das Format ist also
cvs import modul vendortag releasetag
-m
übergeben, wo Sie den Text in Anführungszeichen direkt hinter die Option, getrennt
durch ein Leerzeichen, eintippen können. Fehlt diese Option, startet CVS automatisch
den Editor. Dieser ist nach der Voreinstellung der vi. Wenn Sie an
dieser Stelle einen anderen Editor verwenden möchten, können Sie diesen über
die Umgebungsvariable $EDITOR angeben, die auf den vollständigen Pfad
zeigen sollte, zum Beispiel
% export EDITOR=/usr/bin/X11/nedit
Der Import selber geht ganz einfach: Wechseln Sie in das Verzeichnis, welches das Projekt enthält (und auch noch weitere Unterverzeichnisse haben kann). Geben Sie nun den entsprechenden Befehl ein, beispielsweise
% cvs import driver thomas_w first
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
) verwenden.
Das Auschecken dafür läuft folgendermaßen ab:
% cvs checkout DebugTest
cvs checkout: Updating DebugTest
U DebugTest/Makefile
U DebugTest/cyclic.dat
U DebugTest/main.cc
U DebugTest/matrix.cc
U DebugTest/matrix.h
Oftmals werden Sie auch Dateien neu erzeugen, die Sie dann zum CVS-Archiv hinzufügen
wollen. Dazu dient der Befehl cvs add. Ähnlich wie
beim Importieren gibt es auch dabei die Option -m, mit der Sie einen
Kommentar für diese Datei(en) oder dieses Verzeichnis angeben können. Fehlt
diese Option, startet CVS einen Editor, damit Sie den Kommentar direkt eingeben
können.
An dieser Stelle sehen wir ein für CVS typisches Verhalten: Die meisten Operationen, die Sie als Benutzer auf dem Archiv vornehmen, sind nicht sofort gültig, sondern erst dann, wenn Sie die Änderungen mit einem cvs commit freigeben. Das Hinzufügen erfolgt also nicht sofort, sondern gilt nur als eine der Aufgaben, die beim nächsten Commit erledigt werden.
Nehmen wir an, wir hätten eine Eingabedatei für unsere Beispielapplikation namens cyc4.dat erzeugt, die wir nun zum Archiv hinzufügen möchten. Wir benötigen dazu zunächst das add und dann das commit:
% cvs add cyc4.dat
cvs add: scheduling file `cyc4.dat' for addition
cvs add: use 'cvs commit' to add this file permanently
% cvs commit
cvs commit: Examining .
RCS file: /home/cvsroot/DebugTest/cyc4.dat,v
done
Checking in cyc4.dat;
/home/cvsroot/DebugTest/cyc4.dat,v <- cyc4.dat
initial revision: 1.1
done
Bevor Sie die Änderungen, die Sie in Ihrem Arbeitsverzeichnis an den Projektdateien vorgenommen haben, wieder einchecken, müssen Sie nachsehen, ob nicht in der Zwischenzeit andere Benutzer ebenfalls Änderungen eingebracht haben. Dazu dient das Kommando cvs update.
Wenn Sie es ohne Argumente aufrufen, werden alle Änderungen des Repository sofort in Ihr Arbeitsverzeichnis übernommen. Dies kann manchmal zu unerwünschten Nebeneffekten führen. Besser ist es da, zunächst einmal den Status nur zu prüfen, ohne gleich Dateien zu kopieren oder automatisch zu aktualisieren. Dafür eignet sich die Befehlssequenz
% cvs -n update -d
-n wird das Kopieren verhindert, -d legt jedoch neu hinzugekommene
Verzeichnisse schon einmal an. Sie erhalten eine Liste der Dateien, bei denen
es Abweichungen zwischen dem Archiv und Ihrem Arbeitsverzeichnis gibt. In der
ersten Spalte steht dabei ein Buchstabe, der den Status angibt:
A
C
M
R
U
?Der einfachste Fall ist, dass es keine Konflikte mit der Arbeit anderer Benutzer gibt. Wenn Sie in unserem Beispiel etwa die Datei matrix.cc bearbeitet haben, gibt Ihnen CVS bei einem Update aus:
% cvs update
cvs update: Updating .
M matrix.cc
% cvs update
cvs update: Updating .
U DebugTest
RCS file: /home/cvsroot/DebugTest/matrix.cc,v
retrieving revision 1.1.1.1
retrieving revision 1.2
Merging differences between 1.1.1.1 and 1.2 into
matrix.cc
rcsmerge: warning: conflicts during merge
cvs update: conflicts found in matrix.cc
C matrix.cc
U matrix.h
CVS versucht dabei, die Datei auf den Stand des Archivs zu bringen, markiert aber
offensichtliche Abweichungen, wo es Änderungen in beiden Dateien gab. In unserem
Fall stellen wir fest, dass der Kollege die Signatur der Methode SymMatrix::print()
von void auf std::ostream& geändert hat, wir selbst aber gleichzeitig
ein endl entfernt haben. Das Ergebnis ist:
void SymMatrix::print(std::ostream& _o) const
{
for(unsigned i=0; i<size; i++)
{
_o << "(";
for(unsigned j=0; j<size; j++)
<<<<<<< matrix.cc
cout << at(i,j) << " ";
cout << ")" << endl;
=======
_o << at(i,j) << " ";
_o << ")" << endl << endl;
>>>>>>> 1.2
}
}
% cvs update
cvs update: Updating .
M matrix.cc
% cvs stat matrix.cc
==========================================
File: matrix.cc Status: Up-to-date
Working revision: 1.3
Sun Mar 17 19:12:51 2002
Repository revision: 1.3
/home/cvsroot/DebugTest/matrix.cc,v
Sticky Tag: (none)
Sticky Date: (none)
Sticky Options: (none)
-m oder im Editor einzugeben ist.
% cvs commit -m "Eine Absatzmarke entfernt"
cvs commit: Examining .
Checking in matrix.cc;
/home/cvsroot/DebugTest/matrix.cc,v <- matrix.cc
new revision: 1.3; previous revision: 1.2
done
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:
% rm cyc4.dat
% cvs remove cyc4.dat
cvs remove: scheduling `cyc4.dat' for removal
cvs remove: use 'cvs commit' to remove this file
permanently
% cvs commit
cvs commit: Examining .
Removing cyc4.dat;
/home/cvsroot/DebugTest/cyc4.dat,v <- cyc4.dat
new revision: delete; previous revision: 1.1
done
% cvs add cyc4.dat
cvs add: re-adding file cyc4.dat (in place of dead
revision 1.2)
cvs add: use 'cvs commit' to add this file permanently
Wenn Sie die Abfolge der Änderungen (die Revisionsgeschichte) ansehen wollen, verwenden Sie das Kommando cvs log. Es liefert Ihnen eine vollständige Liste aller Revisionen, einschließlich Datum, Autor und Kommentar. Als Argument geben Sie dabei den Dateinamen an.
% cvs log matrix.cc
RCS file: /home/cvsroot/DebugTest/matrix.cc,v
Working file: matrix.cc
head: 1.3
branch:
locks:
access list:
symbolic names:
keyword substitution: kv
total revisions: 4; selected revisions: 4
description:
--------------
revision 1.3
date: 2002/12/17 09:17:46; author: thomas;
state: Exp; lines: +2 -2
Eine Absatzmarke entfernt
--------------
revision 1.2
date: 2002/12/17 09:02:47; author: markus;
state: Exp; lines: +4 -4
Print-Methode hinzugefuegt
--------------
revision 1.1
date: 2002/12/15 10:42:20; author: thomas;
state: Exp;
branches: 1.1.1;
Initial revision
--------------
===========================================
-r geben Sie
dabei die Revisionsnummer der ersten und gegebenenfalls auch der zweiten zu vergleichenden
Version an. Alternativ können Sie diese mit -D auch über deren Datum
spezifizieren. Wenn Sie nichts angeben, vergleichen Sie damit die Version in
Ihrem Arbeitsverzeichnis mit der, von der sie ursprünglich kopiert wurde. (Also
nicht mit dem aktuellen Archiv-Zustand!). Genau wie bei RCS wird
auch hier das Programm diff aufgerufen,
um die Änderungen herauszufinden. Für ein Beispiel kann ich Sie daher auf Seite
zurückblättern lassen.
Auch ganz analog zu RCS können Sie entweder einzelne Dateien, besser noch den aktuellen Stand eines ganzen Moduls mit einem einheitlichen Etikett (englisch tag) versehen. Das dient hauptsächlich dazu, später einmal genau wieder die Version einer Datei identifizieren zu können, die zu einem bestimmten Stand Ihrer Software gehörte.
Bei CVS muss sich dieses Etikett an ein vorgeschriebenes Format halten, dem wir
beim Import oben bereits begegnet sind: Das erste Zeichen muss ein Buchstabe
sein, die folgenden entweder Zahlen, Buchstaben oder Striche (-) beziehungsweise
Unterstriche (_). Alle anderen Sonderzeichen sind nicht erlaubt, also
auch keine Punkte. Wenn Sie etwa Ihr Programm als Release 1.0 kennzeichnen
möchten, so müssen Sie es in CVS etwa Release-1_0 nennen.
Das Kommando dazu lautet cvs tag. Als Argument erwartet
es den Namen des Etiketts, optional noch einen oder mehrere Dateinamen; wenn
Sie diese weglassen, werden alle Dateien des aktuellen Verzeichnisses mit diesem
Etikett versehen. Die Option -R erweitert dies auf alle Unterverzeichnisse.
% cvs tag Release-1_0
cvs tag: Tagging .
T Makefile
T cyc4.dat
T cyclic.dat
T main.cc
T matrix.cc
T matrix.h
-d löscht ein Etikett, das
nicht mehr benötigt wird oder versehentlich vergeben wurde; -F vergibt
ein bereits bestehendes Etikett neu und -c führt zu einer Warnung, falls
die lokal vorhandenen Dateien neuer sind als die im Archiv. Und falls Sie nicht
die letzte Version einer Datei mit dem Etikett versehen möchten, können Sie
mit -r die gewünschte Revisionsnummer beziehungsweise mit -D das
gewünschte Datum angeben.
CVS erlaubt Ihnen auch, unabhängig von Ihrem Arbeitsverzeichnis Dateien und Module mit einem Etikett zu versehen. Dazu dient das Kommando cvs rtag, welches auch außerhalb eines Arbeitsverzeichnisses aufgerufen werden kann. Es hat im Wesentlichen dieselbe Syntax wie tag und auch die Optionen sind weitgehend gleich.
Auf das so oder so gesetzte Etikett können Sie sich bei verschiedenen Operationen beziehen:
-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:
% cvs checkout -r Release-1_0 DebugTest/matrix.cc
U DebugTest/matrix.cc
Auch beim Thema Ersetzung von Schlüsselwörten verleugnet CVS seinen
Ursprung in RCS nicht.
Sie können mit CVS ebenfalls die in Tabelle
auf Seite
vorgestellten Schlüsselwörter
verwenden. Die Möglichkeiten, auf welche Weise die Schlüsselwörter ersetzt werden,
gehen aber bei CVS noch etwas weiter.
Die Kommandos add, checkout, diff, import und update
kennen die Option -k, welche den Umgang mit den Schlüsselwörtern regelt.
Nach einem Leerzeichen erwartet diese Option noch ein Flag aus folgender Liste:
k
kv
o
v $-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
).
|
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 RCS und CVS kennen gelernt. Daraus sollten Sie vor allem folgende Schlüsselbegriffe behalten:
Unter Linux verbreitete Werkzeuge dazu sind das Revision Control System RCS und das Concurrent Version System CVS. Für RCS können Sie sich merken:
rcs -i <Dateiname>.
ci -u <Dateiname>.
co -l <Dateiname>.
rlog <Dateiname>
ansehen.
rcsdiff -r<Revision1> -r<Revision2> <Dateiname> auf.
$RCSfile$ für den Namen der
RCS-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.
Obwohl RCS weit verbreitet ist, ist es sicher nicht das beste Werkzeug für diesen Zweck. Insbesondere die Beschränkung der Arbeit auf einzelne Dateien und die fehlende Unterstützung gleichzeitigen Zugriffs machen es für viele Projekte kaum verwendbar. In größeren Linux-Projekten setzt man daher vorwiegend CVS ein, das diese Schwierigkeiten überwindet und noch eine Reihe zusätzlicher Funktionalitäten bietet.