Wer Programme schreibt, baut ahnungslos oft auch gut versteckte Bugs ein. Die richtigen Tools und etwas Geduld vereinfachen das Debugging. Dieser Workshop erklärt die Grundlagen der Fehlersuche und vermittelt den Umgang mit dem prominentesten Vertreter der Gattung, dem GNU-Debugger GDB.
Die Existenz aller Bugs geht auf ein grundlegendes Missverständnis zurück: Etwas, was Sie für richtig gehalten haben, ist in Wirklichkeit falsch – egal ob sich der Fehler auf den Wert einer Variablen oder die Gültigkeit einer Speicheradresse oder eines Zeigers bezieht. Wie sich die Bugs auswirken, können Sie nicht vorhersagen. Vielleicht stürzt das Programm aufgrund eines Speicherzugriff-Fehlers ab, vielleicht bleibt es in einer Endlosschleife hängen oder es liefert falsche Ergebnisse. Aber eines steht fest: Alle Bugs sind wichtig und als Programmierer kommen Sie nicht darum herum, sie zu beseitigen.
Gründlich informieren
Als konventioneller Lösungsansatz dienen so genannte Trace-Meldungen. Sie geben aktuelle Zwischenergebnisse des Programms aus. In der Programmiersprache C fügt man üblicherweise Code hinzu, der auf dem Kanal zur Fehlerausgabe ausgiebig Bericht erstattet:
fprintf(stderr, "Entering CalcAverage now (num=%d, total=%d)",num,total); .... fprintf(stderr, "Leaving CalcAverage now (return av=%d)", average);
Solche Befehle dokumentieren Funktionsaufrufe und Variablenwerte. Diese Informationen helfen bei der weiteren Suche nach der Fehlerursache.
Für Faultiere
Obwohl diese Methode für kleinere Programme angebracht sein mag, wird sie mit zunehmendem Code-Umfang sehr mühsam. Zusätzlich schleichen sich dabei schnell neue Fehler ein – schließlich kann auch der hinzugefügte Debugging-Code wieder Fehler enthalten.
Faulheit gilt bei Programmierern als eine Tugend, ebenso wie das Verschieben auf morgen. Bevor Sie sich also kopfüber ins Debugging stürzen, lehnen Sie sich zurück, um in Ruhe über prophylaktische Maßnahmen nachzudenken.
Erstens: Wird das Programm sauber kompiliert? Gibt der Compiler Warnungen aus und können diese Warnungen mit dem fehlerhaften Verhalten des Programms zusammenhängen? Kompilieren Sie mit den Optionen »-Wall« und »-O3«; Letztere führt zusätzliche Checks durch, die bei der nicht optimierten Kompilierung fehlen, »-Wall« gibt sämtliche Warnungen aus. Dann sehen Sie sich die Ausgabe erneut an.
Zweitens: Überprüfen Sie den Code mit anderen Tools wie Splint[2]; es meldet Fehler, die GCC ignoriert. Auf diese Art stoßen Sie auf Programmabschnitte, die unter bestimmten Umständen den Dienst verweigern. Typische Problemfälle sind zum Beispiel:
- Eine Zahl des Typs »unsigned« nimmt einen negativen
Wert an. - Aus dem Gleichheitssymbol »==« wird durch einen
Tippfehler eine Zuweisung (»=«). - Beim Type-Casting (explizit oder implizit) gehen Informationen
verloren. - Schleifenanfänge oder -enden liegen um eins neben dem
gewünschten Wert (Off-by-One-Fehler).
Drittens: Melden andere Tools Probleme mit dem Programm? Zum Beispiel ein Memory-Debugger, der »malloc«-Probleme oder den Zugriff auf Daten außerhalb des gültigen Bereichs abfängt.
Dieses Problem tritt am häufigsten bei Zeichenketten auf, weil ein Test auf Grenzen (Bounds-Checking) hier typischerweise vernachlässigt wird. Allerdings kann das Problem jedes Array, jeden Zeiger sowie dynamisch allozierten Speicher betreffen.
|
Tabelle 1: |
||
|---|---|---|
|
Kürzel |
Befehl |
Beschreibung |
|
r |
run [ Args] |
Programm starten |
|
c |
continue |
Ablauf (vom aktuellen Breakpoint aus) fortsetzen |
|
n |
next |
Nächste Anweisung, schrittweise Funktionsaufrufe |
|
s |
step |
Zur nächsten Zeile oder Funktion gehen |
|
p |
|
Variable anzeigen |
|
pt |
ptype |
Gibt den Typ einer Variablen sowie Details der Struktur |
|
bt |
backtrace |
Listet einen Stack aller Funktionen auf, die bis zu diesem |
|
l |
list |
Listet einen Programmabschnitt auf |
|
h |
help |
Spezifische Hilfe zum Beispiel mit »help |
|
q |
quit |
GDB beenden |
Bekannte Memory-Debugger (siehe[3]) sind unter anderem Valgrind, CCMalloc, und Electric Fence von Bruce Perens, dem ehemaligen Leiter des Debian-Projekts. Nicht alle diese Tools funktionieren auf die gleiche Weise. So setzt Valgrind auf Just-in-Time-Debugging und interpretiert dazu den Quellcode selbst. Im Gegensatz dazu verlinkt der Entwickler bei CCMalloc die kompilierte Datei mit der CCMalloc-Bibliothek, um Speicherlecks aufzuspüren.
Verweigert Ihr Programm nach allen Bemühungen weiterhin den Dienst, müssen Sie den Code zeilenweise durchforsten, um festzustellen, wo und warum etwas schief läuft. Das Tool der Wahl für diesen Zweck heißt GDB, auf Wunsch mit Hilfe eines der grafischen Frontends wie DDD (siehe Artikel in diesem Heft) oder XXGDB.
Auch grafische Entwicklungsumgebungen wie KDevelop (siehe Abbildung 1), Anjuta und sogar Eclipse suchen Fehler mit Hilfe des GDB-Debuggers. Mit ihm steuern Sie den Programmablauf interaktiv, halten ein Programm an beliebiger Stelle an, untersuchen die Variablen und beeinflussen sogar die Ablaufreihenfolge des Code im laufenden Programm.
Der GNU Debugger ist einer der ältesten Bestandteile der GNU-Entwicklungsumgebung und in praktische jeder Linux-Distribution enthalten. Bereits 1988 rief Richard Stallman das Projekt ins Leben. Trotz oder gerade wegen dieser langen Geschichte ist GDB weiterhin eine reine Befehlszeilenanwendung, ausgestattet mit einer großen Vielfalt an mächtigen Befehlen.
Für den Linux-Kernel gibt es eine spezielle Version des Debuggers: KGDB. Sie unterstützt unter anderem das Remote-Kernel-Debugging über eine serielle Leitung. Mit Hilfe von GDB Server lässt sich GDB außerdem über eine TCP/IP-Verbindung steuern.
Symbol-Informationen
Der Einsatz von GDB setzt voraus, dass der GCC dem Programm einige Informationen hinzufügt. Diese so genannten Symbol-Informationen beschreiben unter anderem, wo die Funktionen und Variablen im Speicher liegen, und ermöglichen es dem Debugger damit, Detailinformationen über ein Programm zu liefern. Um Symbol-Informationen aufzunehmen, kompilieren Sie das betreffende Programm mit GCC neu, setzen aber beim Aufruf zusätzlich die Compiler-Option »-g«:
$ gcc -g -Wall -O3 change.c -o change
|
Listing 1: Einfaches |
|---|
01 #include <stdio.h>
02 #include <stdlib.h>
03
04 void CalcChangeFor(int cent)
05 {
06 int i, val;
07 int coins[] = { 200,100,50,20,10,5,2,1,};
08
09 if (cent == 0)
10 return; /* genauer Betrag */
11 if (cent < 0)
12 return; /* ungültiger Betrag */
13
14 for(i=0;i<7;i++) { /* für jede Münze */
15 val = coins[i];
16 for(i=cent;i>0;i++)
17 if (val*i <= cent)
18 printf("%d x %dcn", i, val);
19 CalcChangeFor(cent - i*val);
20 return;
21 }
22 }
23
24 void usage(void)
25 {
26 fprintf(stderr, "Eingabe: change <amount>n");
27 }
28
29 int main(int argc, char **argv)
30 {
31 if (argc > 1)
32 CalcChangeFor(atoi(argv[1]));
33 else
34 usage();
35 return 0;
36 }
|
|
Start nach Wahl |
|---|
|
Es gibt verschiedene Möglichkeiten zum Start von GDB. Sie können einen Programmnamen als Argument übergeben oder in der GDB-Eingabeaufforderung mit »file« ein Programm öffnen. Außerdem hängt sich der Debugger auf Wunsch an einen bereits laufenden Prozess an (»gdb ProgrammPID«). Alternativ erstellen Sie einen so genannten Core-Dump mit »generate-core-file« oder nur »gcore«. Letzterer schreibt einen Speicherauszug im letzten Augenblick der Programmausführung. |
Zurzeit unterstützt GDB die Sprachen C, C++, Modula-2, Java, Ada, Fortran und Objective-C. Listing 1 zeigt ein C-Programm als Beispiel zur Demonstration der GDB-Funktionen. Es liefert die minimale Anzahl von Münzen (Stückelung) als Wechselgeld für einen bestimmten Betrag.
Ein mit der Option »-g« kompiliertes Programm lässt sich genauso ausführen, wie jedes andere. Dass es sich mit GDB analysieren lässt, ist lediglich eine Zusatzoption. Den Debugger und das kompilierte Programm laden Sie mit dem Befehl »gdb change«. Er startet GDB und lädt das Programm namens »change« in den Speicher. Ausgeführt wird das Programm allerdings erst dann, wenn Sie GDB den gewählten Startbefehl geben (siehe Kasten “Start nach Wahl”). GDB verfügt über eine eigene Eingabeaufforderung, die auch mit einer History-Funktion hilft.
|
Listing 2: |
|---|
(gdb) list change.c:main
25 {
26 fprintf(stderr, "Eingabe: change <amount>n");
27 }
28
29 int main(int argc, char **argv)
30 {
31 if (argc > 1)
32 CalcChangeFor(atoi(argv[1]));
33 else
34 usage();
|
|
Listing 3: |
|---|
(gdb) info break Num Type Disp Enb Address What 1 breakpoint keep y 0x080485e0 in main at change.c:31 |
Das Kommando »run« führt das geladene Programm vom Anfang an aus; der Befehl lässt sich mit »r« abkürzen. Wie bei den meisten GDB-Befehlen können Sie hier zusätzliche Argumente angeben, die als Kommandozeilenparameter interpretiert werden, etwa »(gdb) r 23«.
Ausgangspunkt
Um den Quelltext anzuzeigen, benötigen Sie mit GDB keinen externen Editor. Der »list«-Befehl gibt zehn Zeilen Code ab einer beliebigen Stelle aus (Listing 2). Das Kommando »list change.c:main« zeigt die Funktion »main« sowie einige Zeilen davor. Diese Informationen liest GDB aus dem Quellcode, er muss also auf dem Rechner vorliegen.
|
Threads debuggen |
|---|
|
Auch Programme mit mehreren Threads[4] lassen sich mit Hilfe von GDB debuggen. Dazu dienen die folgenden Befehle:
Darüber hinaus lassen sich Breakpoints in einen bestimmten Thread einfügen, mit einem Kommando wie »break 20 thread 1«. Watchpoints dagegen erfüllen ihren Zweck in Programmen mit mehreren Threads kaum, denn sie beobachten ausschließlich Veränderungen innerhalb des aktuellen Thread. |
Wenn Sie [Eingabe] drücken ohne ein Kommando einzugeben, wendet GDB den letzten Befehl erneut an. Im Falle von »list« gibt er die nächsten zehn Zeilen aus. Weisen Sie GDB beispielsweise an, nur die nächste Anweisung im Programm auszuführen, tasten Sie sich anschließend mit [Eingabe] Zeile für Zeile durch das Programm.
Im Programm aus Listing 1 führt bei der Fehlersuche ein Zwischenstopp vor der »if«-Abfrage in Zeile 31 auf die richtige Spur. Dazu setzten Sie einen so genannten Breakpoint. Das bedeutet, dass das Programm normal abläuft, bis es die festgelegte Stelle erreicht. An diesem Punkt stoppt der Debugger und bietet über seine Eingabeaufforderung die Möglichkeit zum Eingreifen. Nun lassen Sie die Werte von Variablen ausgeben oder die nächsten Anweisungen schrittweise ausführen. Der »break«-Befehl setzt einen solchen Breakpoint:
(gdb) break change.c:31 Breakpoint 1 at 0x80485e0: file change.c, line 31.
Breakpoints bilden die Grundlage des interaktiven Debugging, dementsprechend viele Optionen hängen damit zusammen. Breakpoints können Sie wie in diesem Beispiel für eine bestimmte Zeilennummer, am Anfang einer Funktion, bei einer bestimmten Speicheradresse oder bedingt setzen; zum Beispiel wenn ein Schleifenzähler einen gewissen Wert erreicht hat.
Innerhalb eines Programms sind beliebig viele Breakpoints möglich; sie müssen nicht vor der Programmausführung definiert werden, sondern lassen sich in der GDB-Eingabeaufforderung jederzeit hinzufügen und entfernen. Eine Liste aller gesetzten Breakpoints gibt das Kommando »info break« aus (Listing 3). Tabelle 2 enthält eine Liste weiterer gängiger Breakpoint-Befehle.
Immer unterwegs
Sobald Sie das Programm angehalten haben, gibt es drei wichtige Befehle, um es wieder in Gang zu setzen. Das Kürzel »n« (Next) führt die nächste Anweisung aus und kehrt zum GDB-Prompt zurück. »s« (Step) schreitet zur nächsten Anweisung, im Gegensatz zu »n« auch dann, wenn sie sich innerhalb einer Funktion befindet. Das Kommando »c« (Continue) setzt die Programmausführung bis zum Erreichen des nächsten Breakpoints oder bis zum Programmende fort.
Die von den Distributionen installierten Standardbibliotheken wurden üblicherweise ohne Debugging-Informationen kompiliert, deshalb lassen sie sich nicht mit dem Befehl »s« schrittweise abarbeiten. Allerdings gibt es dafür zum Teil separate Pakete, zum Beispiel bei Fedora »glibc-debuginfo- Version.rpm«.
Wenn Sie das Programm mit dem Parameter »23« starten, berechnet es die Stückelung für 23 Cent. Die Funktion »CalcChangeFor()« in Listing 1 berechnet dazu zuerst die nötige Anzahl an 2-Euro-Münzen (200 Cent). Bei einem Betrag von 23 Cent sollte die Funktion merken, dass sie dafür keine 2-Euro-Münzen braucht. Sie bleibt aber in der Prüfschleife hängen.
Um den Grund herauszufinden, sehen Sie sich die Variablen an. Die Symbol-Informationen enthalten alle Details der Variablen, einschließlich des Typs und des Gültigkeitsbereichs (lokal oder global). Der Befehl »p« oder »print« gibt den Wert einer Variablen im aktuellen Gültigkeitsbereich aus. Haben Sie sich schrittweise mit »s« zu Zeile 17vorgearbeitet, geben Sie Folgendes ein:
(gdb) print val $1 = 200 (gdb) p i $2 = 23
Der Wert hinter dem »$«-Symbol erhöht sich mit jedem »print«-Befehl. Eine Variable dauerhaft beobachten ist Aufgabe des Kommandos »display« (Tabelle 3):
(gdb) display val 1: val = 200 (gdb) disp i 2: i = 23
Während Sie sich anschließend mit »s« weiter durch den Code bewegen, beobachten Sie die Werte, um zu verstehen, warum die Schleife nicht endet.
|
Tabelle 2: |
|
|---|---|
|
Befehl |
Beschreibung |
|
b [ Dateiname:] Zeilennummer |
Setzt Breakpoint in Zeile |
|
b [ Dateiname:] Funktion |
Setzt Breakpoint am Anfang einer Funktion |
|
b [ Dateiname:] Zeilennummer if expr |
Setzt bedingten Breakpoint |
|
watch Ausdruck |
Fügt Watchpoint für Variable/Ausdruck hinzu |
|
info break |
Listet Breakpoints und Watchpoints auf |
|
delete |
Löscht alle Breakpoints |
|
delete [N] |
Breakpoint-Nummer #N (gem. Ausgabe von »info |
|
disable [N] |
Deaktivert Breakpoint [N] (oder alle, falls [N] nicht |
|
Tabelle 3: |
|
|---|---|
|
Befehl |
Beschreibung |
|
print [/F] [ Ausdruck] |
Gibt Ausdruck sofort aus, z. B.: /x = hex, /d = dez, /u |
|
display [/F] [ Ausdruck] |
Gibt Ausdruck vor jedem Befehlsprompt aus |
|
display |
Listet Werte für alle Ausdrücke |
|
undisplay |
Entfernt alle Ausdrücke aus der Displayliste |
|
undisplay [N] |
Entfernt bestimmten Ausdruck aus der Liste (wie mit dem |
Der Fehler im Beispielprogramm kommt an Hand der Variablen »i« zum Vorschein: Sie sollte kleiner werden, wird aber hochgezählt, denn im Zählerabschnitt der »for«-Schleife in Zeile 16 steht »i++« statt »i–«.
|
Breakpoint-Nummern |
|---|
|
Breakpoints werden auch dann fortlaufend nummeriert, wenn Sie einen wieder gelöscht haben. Wenn Sie zum Beispiel mit zwei Breakpoints (1 und 2) beginnen und dann »delete 2« eingeben, erhält der nächste Breakpoint trotzdem die Nummer 3. So bleiben Ihnen bei Änderungen Verwechslungen zwischen alt und neu erspart. Das Gleiche gilt für Display-Variablen und Watchpoints. |
Erbarmungslos – Teil II
Nach der Korrektur des Fehlers kompilieren Sie das Programm neu. Dazu müssen Sie zwar den Debugger nicht beenden, aber den laufenden Prozess, denn sonst erhalten Sie beim Linken die Fehlermeldung, dass die Datei noch in Benutzung ist:
(gdb) kill Kill the program being debugged? (y or n) y
Nach dem Kompilieren des Programms, nutzen Sie den Befehl »file«, um es wieder in den Speicher zu laden. Da Sie den Debugger in diesem Fall zuvor nicht beendet haben, bleiben alle Breakpoints erhalten – das spart vor allem bei größeren Projekten viel Zeit:
(gdb) file change Load new symbol table from "change"? (y or n) y Reading symbols from "change"...done. (gdb) r
Beachten Sie, dass der »r«-Befehl das letzte Argument beibehält, sodass Sie auf die Eingabe des Parameters wie bei »r 23« verzichten können. Wenn Sie sich nun schrittweise durch die Schleife tasten, stellen Sie fest, dass die Zählervariable »i« korrekt heruntergezählt wird. Sie können jetzt alle Breakpoints mit »delete« entfernen (Tabelle 2) und das Programm weiterlaufen lassen.
Nach ungewöhnlich langer Verzögerung gibt der Debugger einen Speicherzugriffsfehler aus und kehrt zurück zur Eingabeaufforderung (siehe Listing 4). Es gibt also einen weiteren Bug. Als Erstes müssen Sie feststellen, wo genau das Programm momentan steht. Dazu sehen Sie sich den Call Stack (oder Backtrace) an, der die aufgerufenen Funktionen sowie deren Parameter anzeigt.
Diese Ausgabe aus Listing 5 zeigt, dass der Algorithmus in einer rekursiven Schleife steckt. Das Programm ruft dieselbe Funktion immer wieder mit dem gleichen Parameter auf. Daher wird die Abbruchbedingung (»cent==0«) niemals ausgelöst.
|
Listing 4: |
|---|
Program received signal SIGSEGV, Segmentation fault. 0x080485a7 in CalcChangeFor (cent=23) at change.c:19 19 CalcChangeFor(cent - i*val); |
|
Listing 5: Stack |
|---|
(gdb) backtrace #1 0x08048505 in CalcChangeFor (cent=23) at change.c:19 #2 0x08048505 in CalcChangeFor (cent=23) at change.c:19 #3 0x08048505 in CalcChangeFor (cent=23) at change.c:19 #4 0x08048505 in CalcChangeFor (cent=23) at change.c:19 ..... |
|
Listing 6: |
|---|
(gdb) b 15 Breakpoint 2 at 0x8048554: file change.c, line 15. (gdb) r Starting program: /home/steev/code/change 23 Breakpoint 2, CalcChangeFor (cent=23) at change.c:15 15 val = coins[i]; |
Wenn Sie den Debugger neu starten – da Sie die Datei nicht verändert haben, reichen dazu die Befehle »kill« und »run« – und Breakpoints für jeden rekursiven Aufruf für »CalcChangeFor()« setzen, erkennen Sie, dass der Wert von »cent« unverändert bleibt, denn »i*val = 0«.
Wertfreie Variable
Wenn Sie jetzt die Variablen »i« und »val« untersuchen, sehen Sie, dass »val« zwar der richtigen Summe entspricht (also 200 Cent), dass aber »i« gleich null ist. Weil das nur am Ende der gesamte Schleife passieren sollte und der Wert von »i« innerhalb der Schleife immer größer als null sein müsste – da die Endbedingung in Zeile 16 »i>0« lautet -, liegt die Vermutung nahe, dass das Programm diese Schleife bereits verlassen hat. Eine nähere Untersuchung der »if«-Abfrage zeigt, dass die geschweiften Klammern um die Zeilen 18 bis 20 fehlen. Der Compiler interpretiert sie also nicht wie gewünscht; das verursacht den Speicherzugriffsfehler.
Wie zuvor kompilieren und starten Sie das Programm neu. Dieses Mal gibt es überhaupt keine Ausgabe und daher leider ebensowenig einen verwertbaren Call Stack, der weitere Hinweise liefern könnte. Das gibt Grund zu der Annahme, dass das Programm in einer Schleife hängt. Da es insgesamt nur zwei Schleifen verwendet, sollte der Bug leicht aufzuspüren sein. Mit einem Breakpoint in der äußeren Schleife (Zeilen 14 bis 22) stellen Sie fest, ob sie korrekt iteriert. Kommt das Programm überhaupt nicht zum Ausgangspunkt zurück, muss der Fehler also in der inneren Schleife stecken.
Weitersuchen
Setzen Sie also in Zeile 15 einen neuen Breakpoint. Trotz des vorherigen »delete« heißt er Breakpoint 2 statt 1 (siehe Kasten “Breakpoint-Nummern”). Starten Sie das Programm im Debugger neu. Listing 6 zeigt, wie es am Breakpoint in der Funktion »CalcChangeFor()« stoppt. Beobachten Sie anschließend die Zählvariable »i« der Schleife:
(gdb) disp i 3: i = 0 (gdb) c Continuing. Breakpoint 2, CalcChangeFor (cent=23) at change.c:15 15 val = coins[i]; 3: i = 1
Die erste Iteration sieht gut aus, der Zähler steht, wie er soll, auf »1«. Die Suche geht also weiter, wenn Sie das Programm fortsetzen:
(gdb) c Continuing. Breakpoint 2, CalcChangeFor (cent=23) at change.c:15 15 val = coins[i]; 3: i = 1
Obwohl der Ausdruck »i++« in Zeile 14 den »i«-Wert um 1 erhöht, steigt der Wert der Variablen nicht. Beim Finden der dafür verantwortlichen Programmabschnitt helfen Watchpoints.
Alles im Blick
Watchpoints halten das Programm an, sobald sich eine bestimmte Variable ändert – auch dann, wenn die betreffende Zeile die Variable nicht explizit über ihren Namen anspricht. Stattdessen überwacht ein Watchpoint die Speicheradresse der Variablen und hält den Programmablauf an, wenn dort ein Wert geschrieben wird. Einen solchen Watchpoint setzt der Befehl »watch«:
(gdb) watch i Hardware watchpoint 3: i
Sobald Sie das Programm fortsetzen, ist der Watchpoint aktiv:
(gdb) c Continuing. Hardware watchpoint 3: i Old value = 1 New value = 23 0x080484db in CalcChangeFor (cent=23) at change.c:16 16 for (i=cent;i>0;i--) 3: i = 23
Die Ausgabe zeigt, dass das Programm aus Listing 1 die Variable »i« in beiden »for«-Schleifen als Zähler verwendet. Das demonstriert neben der Arbeitsweise von Watchpoints den Sinn durchdachter Namenskonventionen.
Zu Testzwecken lässt sich der Wert einer Variablen beliebig verändern, um in den Programmablauf einzugreifen. Dazu dient der Befehl »set«:
(gdb) set var i = 0
Auch eine Funktion können Sie direkt mit Parametern aufrufen. Nimmt eine Funktion keine Parameter entgegen, verwenden Sie leere Klammern:
(gdb) call CalcChangeFor(20)
Haben Sie den Namen einer Schleifenvariablen geändert, verwenden Sie beispielsweise in der inneren »for«-Schleife als Zähler »j« statt »i«. Kompilieren Sie dann das Programm neu und beim nächsten Ausführen von »change« erhalten Sie als Ergebnis:
$ change 23 1 x 20c 1 x 2c
Allerdings stimmt dieses Ergebnis offensichtlich nicht. Ausprobieren ergibt, dass dieser Fehler bei jeder ungeraden Ausgangszahl auftritt. Eine neue Fehler- suchen-Runde beginnt. Wenn Sie einen Bug entdecken, hilft es generell weiter, die Fälle einzugrenzen, in denen er auftritt. Testen Sie die Grenzen des Programms, etwa mit negativen Zahlen, ungeraden Zahlen, geraden Zahlen, sehr großen Zahlen, sehr kleinen Zahlen. Diese Methode hilft dabei, den Codeabschnitt einzugrenzen, der für den Fehler verantwortlich sein könnte.
Da der Fehler in diesem Fall nur bei ungeraden Zahlen auftritt, liegt der Verdacht nahe, dass die Routine, die die einzelnen Cents behandelt, damit zu tun hat. Um genau diesen Fall zu untersuchen, setzen Sie einen bedingten Breakpoint, der das Programm erst dann anhält, wenn die Ein-Cent-Münzen an der Reihe sind:
(gdb) b 16 if val==1 Breakpoint 5 at 0x804855e: file change.c, line 16.
Da ein Breakpoint das Programm anhält, bevor es die Anweisung der betreffenden Zeile ausführt, setzen Sie den Breakpoint in der Zeile nach der Zuweisung der Variable »val«. Bedingte Breakpoints können sehr kompliziert werden, sodass hier Vorsicht geboten ist, damit sich in den angewandten Debugging-Techniken selbst keine Bugs einschleichen.
Exakt zählen
Das Programm hält beim erneuten Ausführen aber nicht an, der bedingte Breakpoint wird nicht ausgelöst. Die Variable »val« hat also nie den Wert 1 angenommen. Daraus lässt sich schließen, dass das Programm die Ein-Cent-Münzen ignoriert. Um der Ursache auf die Spur zu kommen, setzen Sie einen neuen Breakpoint auf die Zwei-Cent-Münzen:
(gdb) b 16 if val==2 Note: breakpoint 5 also set at pc 0x804855e. Breakpoint 6 at 0x804855e: file change.c, line 16.
|
Listing 7: Korrigierte Version |
|---|
01 #include <stdio.h>
02 #include <stdlib.h>
03
04 void CalcChangeFor(int cent)
05 {
06 int i, j, val;
07 int coins[] = { 200,100,50,20,10,5,2,1,};
08
09 if (cent == 0)
10 return; /* ungültiger Betrag */
11 if (cent < 0)
12 return; /* genauer Betrag */
13
14 for(i=0;i<sizeof(coins)/sizeof(coins[0]);i++) { /* für jede Münze */
15 val = coins[i];
16 for(j=cent;j>0;j--)
17 if (val*j <= cent) {
18 printf("%d x %dcn", j, val);
19 CalcChangeFor(cent - j*val);
20 return;
21 }
22 }
23 }
24
25 void usage(void)
26 {
27 fprintf(stderr, "Eingabe: change <amount>n");
28 }
29
30 int main(int argc, char **argv)
31 {
32 if (argc > 1)
33 CalcChangeFor(atoi(argv[1]));
34 else
35 usage();
36 return 0;
37 }
|
|
Auf Tuchfühlung |
|---|
|
Den Debugger sollten Sie nicht als korrigierende Maßnahme nach der Programmierung betrachten, sondern als selbstverständlichen Bestandteil des Entwicklungszyklus. Setzen Sie den Debugger bei neuem Code grundsätzlich ein. Arbeiten Sie sich zeilenweise vor und denken über jede Zeile nach. Wird jede Zeile ausgeführt? Werden die Fehlerbedingungen korrekt abgefangen? Werden die Schleifen korrekt beendet? Bedenken Sie dabei, dass auch Tastatureingaben ein Datenstrom sind, der ein EOF-Zeichen liefern kann. Das sind nur einige der Fragen, die Sie sich und Ihrem Debugger stellen sollten, bevor Sie das Programm ausführen, um festzustellen, ob es schon läuft. Wenn Sie den Code schrittweise ausführen und dabei erkennen, dass er sich wie erwartet verhält, wird Ihr Programm wahrscheinlich schon beim ersten Versuch funktionieren. |
Dieses Mal stoppt der Breakpoint das Programm und Sie können Schritt für Schritt beobachten, wie es mit den Zwei-Cent-Münzen umgeht. Danach verlässt es die Schleife, da es die siebte Iteration durchlaufen hat. Beim Nachzählen ergibt sich aber, dass das Array »coins« acht Elemente enthält. Fehler dieser Art entstehen häufig beim nachträglichen Einbau neuer Features. Listing 7 zeigt das korrigierte Programm.
Nach Abschluss des Debugging brauchen Sie das Programm nicht neu zu kompilieren, um die Debugging-Informationen daraus zu entfernen. Das erledigt das Programm »strip«:
strip -g change
Bei großen Programmen ist es mühsam, mit Hilfe von Breakpoints jede Schleife komplett zu durchlaufen. Deshalb gibt es die Binärer-Split-Technik. Damit grenzen Sie den Programmabschnitt ein, in dem ein Bug auftritt. Setzen Sie einen Breakpoint nach der ersten Hälfte des Code und führen das Programm aus. Läuft bis dahin alles nach Plan, liegt der Fehler wohl in der zweiten Hälfte. Halbieren Sie diese wiederum mit einem Breakpoint. So arbeiten Sie sich immer näher heran, bis Sie den fehlerhaften Bereich auf einen möglichst kleinen Abschnitt reduziert haben.
Natürlich funktioniert auch der binäre Split nicht in allen Fällen. Es kommt stets darauf an, die Tools und Techniken zu beherrschen und zu verstehen, um sie sinnvoll einzusetzen. (csc/ofr)
|
Infos |
|---|
|
[1] GDB-Dokumentation: [http://www.gnu.org/software/gdb/documentation/] [2] Splint: Herwert Kiram, “Vollwaschmittel”: Linux-Magazin 06/03, S. 52 [3] Memory-Debugger: Herwert Kiram, “Gedächtnis-Training”: Linux-Magazin 02/04, S. 102 [4] NPTL-Threads: Aleksandar Colovic, “Sauber eingefädelt”: Linux-Magazin 01/05, S. 90 |
|
Der Autor |
|---|
|
Steven Goodwin verbringt ein Drittel seiner Zeit damit, Code zu planen, das andere Drittel damit, Code zu schreiben, und den Rest damit, den Code zu debuggen. |






