Tutorial 4: Debugging in C++
Jedes Programm bzw. Spiel das man programmiert wird nicht von Anfang an laufen.
Sicher werden sich irgendwo Fehler, egal ob logischer oder programmtechnischer
Natur, eingeschlichen haben. Und da kommt das Debugging ins Spiel.
Normalerweise gibt es ja zwei Konfigurationen, die bei einem neuen Projektsetup
erstellt werden. Nämlich die Konfiguration 'Win32 Debug' und 'Win32 Release'.
Der Code ändert sich zwischen diesen beiden Konfigs zwar nicht, jedoch
wird bei der Debug Konfiguration nicht auf Geschwindigkeit sondern eben auf
Fehlerprüfungen gesetzt. Erst wenn die Debugläufe heil überstanden
wurden, kann man das Endprodukt als Release weitergeben.
Es gibt unter Visual C++ natürlich den Debugger um Programme zu überprüfen.
Wir gehen jedoch einen anderen Weg und werden das Debugging im Code direkt machen,
denn zb. in Direct3D - Vollbild - Spielen kann man den Debugger schlecht verwenden.
Methode 1: Asserts
Dier erste Methode die es gibt um Code zu debuggen sind die Asserts. Es gibt
mehrere Assert-Makros, jedoch ist nur eins für uns wichtig da alle anderen
nur für MFC-Anwendungen gelten.
Der Vorteil von Asserts ist, dass sie leicht zu programmieren sind und im Release-Built
einfach verschwinden. Man braucht sie nicht extra entfernen hierfür. Ein
Nachteil ist, dass sie doch ein bisschen sehr langsam sind. Das ist jedoch verkraftbar.
Ein Assert-Makro überprüft nur, ob der angegebene Ausdruck wahr also
true ist oder nicht. Wenn die übergebene Funktion oder Variable gleich
0 ist, wird das Proramm abgebrochen und es erscheint ein Dialog 'DEBUG ASSERTION
FAILED!'. In diesem Dialogfeld wird angegeben, in welcher Quelldatei und in
welcher Zeile der Fehler auftrat. Ein Assert-Makro funktioniert also ganz einfach:
assert( Variable );
!!!ACHTUNG!!! Bei der Verwendung von Asserts kann ein Fehler passieren:
FILE *p;
assert( p = fopen("file.txt", "r+") );
Im Debug-Built funktioniert alles. Die Datei 'file.txt' wird geöffnet.
Jedoch im Release-Built wird ja genau diese Funktion aus dem Programm entfernt.
Dadurch wird die Datei nie geöffnet und p bleibt NULL. Dies kann man jedoch
verhindern, indem man anstatt:
assert( Variable );
lieber das Makro:
verify( Variable );
Diese Makro wird auch im Release-Built ausgeführt jedoch zeigt es bei
einem Fehler keinen Dialog an.
Methode 2: Ausnahmebehandlung oder Exception Handling
Diese Methode zur Behandlung von Fehlersituationen ist nicht umbedingt auf
allen Compilern verfügbar. Der VC++ Compiler verfügt jedenfalls über
diese Methode.
Diese Methode teilt sich in insgesamt drei Abschnitte auf:
1. der try-Block
2. der throw-Ausdruck
3. der catch-Ausdruck oder auch auch Exception Handler genannt
Ein Beispiel:
void func1()
{
...
if (g_pVar == NULL)
throw 0;
}
void main()
{
try
{
func1();
}
catch (int)
{
...
}
}
Die Erklärung ist ganz einfach: Wenn in der Funktion func1() die Variable
g_pVar gleich NULL ist, wirft diese Funktion einen Fehler aus. Dieser Fehler
ist hier die Zahl 0 (also ein Wert von Typ int). Dadurch, dass wir die Funktion
in einem try-Block untergebracht haben, wird diese Fehlermeldung aufgehoben.
Im catch-Block überprüfen wir nun, ob ein Fehler ausgeworfen wurde.
Genauer: Wir überprüfen, ob ein Fehler vom Typint ausgeworfen wurde.
Wenn dies passiert ist, wird der catch-Block ausgeführt.
Man kann auch mehrere catch-Blöcke hintereinander schreiben:
class CErr { ...};
try
{
// Anweisungen
}
catch(int) { // Fehlerbehandlung }
catch(char*) { // Fehlerbehandlung }
catch(CErr) { // Fehlerbehandlung }
catch(...) { // Fehlerbehandlung }
Der letzte catch-Block zeigt, dass es auch möglich ist den Platzhalter
... anstelle konkreter Typbezeichnungen anzugeben. Dieser unspezifische Block
kann dazu verwendet werden, um all jene Fehlertypen zu behandeln, die von den
spezialisierten catch-Blöcken nicht erfasst werden.
Wenn man nun in einem try-Block mehrere int Werte auswerfen kann, weiss der
catch-Block ja nicht, wo genau der Fehler auftratt. Um auf den ausgeworfenen
Wert Zugriff zu erlangen schreibt man eben dies:
catch(int ErrVal) { // Fehlerbehandlung }
Nun kann man durch die Variable ErrVal auf den ausgeworfenen int Wert zugreifen.
Erweiterte Deklaration
Um, z.B. in einem Header, den Programmierer wissen zu lassen, dass eine Funktion
einen Fehler auswerfen kann, kann man dies explizit kennzeichnen. In einem Header
könnte also zb. diese stehen:
int Func1(int x) throw(float);
int Func2(int x) throw(int, char*);
int Func3(double y) throw();
Dadurch erkennt man, dass Func1 einen float auswerfen kann, Func2 einen int
und einen String, die Funktion Func3 aber gar keinen Fehler auswirft. Diese
Zusatzangaben bei der Deklaration, müssen dann aber auch bei der Funktionsdefinition
stehen.
Diese Art des Debuggen ist nicht nur für den Debug-Built geeignet, sondern
auch für die Release-Version. Jedoch sollte man sie nur an Schlüsselstellen
verwenden.
Methode 4: Log-Dateien
Die Verwendung von Log-Dateien ist eine meiner liebsten Debugging Methoden.
Man deklariert einfach eine globale Log-Datei und schreibt in diese Informationen
oder Errorstrings. Um noch zusätzlich die genaue Fehlerquelle zu ermitteln,
gibt es unter C++ zwei Makros die uns dabei helfen:
__LINE__ und __FILE__
Das Line-Makro wird dabei in eine dezimale Ganzzahlkonstante expandiert, die
die Zeilennummer der Quelldatei angibt. Das File-Makro wird in eine Zeichenfolge
(eingeschlossen von Anführungszeichen) expandiert, die die Quelldatei angibt.
Dadurch kann man die Fehlerquelle sehr genau orten.
!!!ACHTUNG!!! Bei der Verwendung von einer Dateiausgabe, mit z.B. fprintf,
sollte die Datei nach der Ausgabe immer wieder geschlossen werden, damit auch
wirklich der String noch geschrieben wird. Wird die Datei nicht geschlossen,
kann bei einem Abbruch des Programmes, wegen eines Fehlers, Die Log-Datei leer
sein.
Ein Beispiel für diese Debugging-Methode findest du zum downloaden in
der Zip-Datei weiter unten. Dieser Code stammt aus meiner etwas älteren
D3D Engine.
Methode 5: Eine Debug-Klasse
Diese Debug-Klasse ist eine Abwandlung der Debug-Klasse aus dem Artikel von
André LaMothe (siehe unten).
Das Ziel ist, mit wenig Code Variablen auf ihre Werte zu überprüfen.
Wie könnte man das woll machen? Ganz einfach! Wir entwickeln eine Klasse,
die die Adressen dieser Variablen speichern und diese zu bestimmten Zeiten in
eine Log-Datei schreiben.
Die Funktionen
CDebug(LPSTR psFile = "debug.txt");
Konstruktor. psFile ist die Datei, in die geschrieben wird
~CDebug();
Destruktor
void EnableOutput();
Erlaubt das Schreiben in die Datei.
void DisableOutput();
Verbietet das Schreiben in die Datei. Wenn nach einem Aufruf von DisableOutput()
die Funktion Print() oder UpdateWatches() augerufen wird, wird das Schreiben
verhindert.
BOOL OutputState();
Gibt den Status zurück, ob man in die Datei schreiben darf.
SetTimeOutput(BOOL bTime);
Setzt den Status, ob man die Systemzeit in die Datei schreiben soll, bei einem
Aufruf von Print() oder UpdateWatches().
BOOL GetTimeOutput();
Gibt den Status zur´ück, ob die Systemzeit geschrieben wird.
void Print(LPSTR psString);
Schreibt den String in die Datei.
void UpdateWatches();
Schreibt alle Variablen, die in der Klasse registriert wurden, in die Datei.
int AddWatch(VOD *pData, int iType, LPSTR psName = " ");
Fügt einen Zeiger einer Variable der Liste hinzu. pData ist dabei der
Zeiger auf die Variable. iType der Typ von Variable. psName der Name der zusätzlich
in die Datei geschrieben werden soll.
Mögliche Werte für iType sind:
DEBUG_WATCH_CHAR,
DEBUG_WATCH_UCHAR,
DEBUG_WATCH_SHORT,
DEBUG_WATCH_USHORT,
DEBUG_WATCH_INT,
DEBUG_WATCH_UINT,
DEBUG_WATCH_STRING,
DEBUG_WATCH_FLOAT,
DEBUG_WATCH_PTR
void DeleteWatches();
Löscht alle Zeiger auf die Variablen.
Download der Source Codes
Links
André
LaMothe: Xtreme Debugging: Using the Monochrome Display to Debug Your Apps
Copyright (c) by Kongo
Bei Fragen, Beschwerden, Wünschen schreib an kongo@codeworx.org
Tutorial vom 16.07.2001