Erste Schritte mit dem GDB

Immer wieder sehe ich erfahrene Softwareentwickler, die zwar einigermaßen einen grafischen Debugger bedienen können (sofern ihre Entwicklungsumgebung einen einfachen Button mit der Aufschrift “Im Debugger starten” hat), aber sobald sie unter Linux oder UNIX entwickeln, plötzlich ihr Programm wieder mit Debug-Ausgaben vollpflastern, wenn sie einen Fehler suchen. Klar gibt es auch hier IDEs wie Code::Blocks oder Eclipse, die eine GDB-Anbindung haben, und wem das ausreicht, der braucht hier nicht weiterlesen. Dieser Artikel ist für Leute, die wissen wollen, wie der Debugger im Textmodus zu betreiben ist.

Gründe, warum man das wollen könnte, gibt es viele, ein paar davon sind:

  • Mehr Flexibilität. Meistens beschränken sich die grafischen Debugger-Oberflächen auf einige wenige Funktionen, die zwar oft gnügen, aber auf der Jagd nach einem sehr hartnäckigen Bug reichen ein paar Breakpoints, schrittweise Ausführung und das Anzeigen von Variablenwerten manchmal einfach nicht aus.
  • Die Bedienung ist überall gleich. Wenn ich den GDB im Textmodus bedienen kann, kann ich exakt die gleichen Kommandos benutzen, egal ob ich ein Linux-Desktop-System, ein Embedded System ohne Grafikkarte, ein MacOS X, Windows (mit MSYS) oder einen Prozess auf einem anderen Rechner über Netzwerk debugge. Ich brauche mir keine Sorgen zu machen, dass es meine innig geliebte Entwicklungsumgebung nicht gibt.
  • Es lässt sich viel automatisieren. Da alles auf Textkommandos basiert, kann ich auch sehr einfach solche Kommandos automatisch ausführen lassen, wenn ein Breakpoint erreicht wird. Der GDB bringt eine vollständige Scriptsprache mit.
  • Mit der Tastatur geht einiges schneller als mit der Maus. Für einige wichtige Sachen (wie z.B. Single-Step) bietet eigentlich jeder grafische Debugger einen Tastatur-Shortcut an. Aber sobald man etwas mehr möchte, Breakpoints setzen, Speicher untersuchen, Stack-Trace anzeigen, braucht man unweigerlich die Maus. Im Terminal sind es ein paar Tastendrucke.

Es lassen sich sich noch einige weitere Gründe finden, aber für mich reichen die hier schon aus, um wann immer möglich den GDB in einem Terminal (-emulator) zu benutzen.

Ich will aber auch nicht verschweigen, dass besonders die ersten Schritte eine sehr viel höhere Einstiegshürde haben als ein grafischer Debugger und dass man zumindest am Anfang erstmal eine Weile lang wesentlich langsamer ist als in der eigenen Lieblings-IDE. Aber das ändert sich schnell, wenn man den Debugger regelmäßig benutzt.

Eigenes Programm mit Debug-Informationen übersetzen

Bevor der Debugger überhaupt benutzt werden kann, muss beim Übersetzen des Programms darauf geachtet werden, dass der Compiler Debuginformationen mit in das fertige Binary packt. Ich gehe davon aus, dass jeder, hier den GCC (oder ein Build-Tool seiner Wahl) zumindest in den Grundzügen bedienen kann. Zu Illustrationszwecken gehe ich hier mal davon aus, dass das zu debuggende Programm nur aus einer C-Datei besteht.

Wenn das ursprüngliche Programm mit einer Kommandozeile wie

$ gcc -o mein_programm mein_programm.c

übersetzt wurde, muss eigentlich nur noch ein Flag angehangen werden:

$ gcc -g -o mein_programm mein_programm.c

Das -g sorgt dafür, dass der Compiler alle nötigen Informationen in das fertige Programm schreibt, die der Debugger später benötigt, um seine Arbeit zu erledigen.

CMake

Unter CMake gibt es mehrere Variablen, die beeinflussen, ob Debug-Informationen erzeugt werden. Am wichtigsten ist CMAKE_BUILD_TYPE, die dazu benutzt werden kann, zwischen verschiedenen Compiler-Flags für die zu übersetzenden Dateien zu unterscheiden. Setzt man diese Variable auf “DEBUG” und ändert sonst nichts an den Standardeinstellungen, fügt CMake in alle Compileraufrufe die nötigen Flags ein. Das geschieht, indem die Compiler-Flags statt aus einer Variable namens CMAKE_C_FLAGS_RELEASE aus CMAKE_C_FLAGS_DEBUG (in der -g standardmäßig vorkommt) genommen werden. Das gleiche gilt für C++ mit anderen Variablennamen, was aber CMake alles übernimmt.

Sonstige Makefiles

Bei vielen anderen Tools, die Makefiles erzeugen werden einige Konventionen beachtet, wie z.B. dass beim Übersetzen von C-Dateien die Umgebungsvariable CFLAGS mit an die Compilerkommandozeile angehangen wird (oder CXXFLAGS für C++). Sorgt man dafür, dass in dieser Umgebungsvariable das Flag -g auftaucht, und hat nicht grade ein total verhunztes Makefile, taucht das -g in der Kommandozeile des Compilers auf und schon erhält man ein debugfähiges Programm. Die Umgebungsvariable lässt sich bei allen mir bekannten Shells direkt vor die make-Befehlszeile schreiben, alternativ als Parameter an make:

$ CFLAGS=-g make mein_programm
$ make CFLAGS=-g mein_programm

Bibliotheken

Grade im Umgang mit (fremden) Libraries ist es manchmal etwas schwierig, ordentliches Debugging hinzubekommen. Zunächst mal braucht man die Bibliothek mit Debugging-Informationen oder die Debug-Informationen in einer separaten Datei. Das Thema ist insgesamt einigermaßen komplex, weshalb ich dem ganzen später noch einen eigenen Artikel widme. Vorerst gehe ich davon aus, dass alle benutzen Bibliotheken mit Debuginformationen daherkommen und auch entsprechend in das Programm gelinkt werden.

Die wichtigsten Befehle

Natürlich kann man darüber streiten, was die wichtigsten Befehle sind, aber ich denke, man kann sich darauf einigen, dass das Erstellen von Breakpoints, Start und Stop des Programms, Single-Stepping und das Untersuchen von Variablen zu den am häufigsten genutzten Funktionen eines Debuggers gehören und somit ganz bestimmt auch sehr wichtig sind. Also erkläre ich kurz genau diese Funktionen.

Um alles etwas anschaulicher zu machen, benutze ich folgendes Programm, das sicherlich nicht mal ansatzweise die effizienteste oder schönste Möglichkeit ist, Fibonacci-Zahlen zu erzeugen, aber super geeignet ist, um die Funktionen des Debuggers zu zeigen:

#include <stdio.h>

int fib(int i) {
    if(i < 2)
        return i;
    else
        return fib(i-2) + fib(i-1);
}

int main() {
    printf("Hallo!\n");
    printf("%d\n", fib(5));
    return 0;
}

Debugger starten

Vorher muss der Debugger natürlich erstmal gestartet werden. Am einfachsten geht das mit

$ gdb mein_programm

Daraufhin begrüßt uns der Debugger und meldet sich mit seinem eigenen Prompt:

(gdb)

Das Programm lässt sich jetzt mit dem Befehl “run” starten, dem man auch Kommandozeilenargumente mitgeben kann, wenn das zu debuggende Programm welche benötigt:

(gdb) run arg1 arg2

Das Programm startet dann wie gewohnt, vor allem beim ersten Start in der aktuellen Debugger-Sitzung kann der Debugger durchaus noch ein paar eigene Ausgaben machen, z.B. über geladene Debug-Symbole oder ähnliches.

Die Befehle lassen sich übrigens immer abkürzen. Solang der Name dann eindeutig ist, reicht es, ein paar der ersten Buchstaben des Befehls zu tippen (und wenn es nicht eindeutig ist, sagt der Debugger, welche Mehrdeutigkeiten es gibt). Für run reicht z.B. auch ein einfaches “r”.

Einige Befehle haben auch eigene Abkürzungen wie z.B. “bt” für “backtrace”.

Breakpoint setzen

Breakpoints lassen sich mit dem Befehl “break” (oder kurz “br”) setzen. Der Breakpoint wird entweder an der aktuellen Stelle im Code gesetzt, an dem sich der aktuelle Thread grade befindet (und zwar in dem Stackframe, in dem man sich im Debugger grade befindet – mehr dazu später). Alternativ kann die Stelle, an der der Breakpoint gesetzt werden soll, mit angegeben werden. Um die Stelle anzugeben gibt es drei Möglichkeiten:

  1. Funktionsname (bei C++-Code kann der auch Namespaces, Klassen und die Parametertypen enthalten)
  2. Datei + Zeilennummer. Dabei werden Datei und Zeilennummer durch einen Doppelpunkt getrennt. Wenn der Dateiname fehlt, nimmt er die aktuelle Datei.
  3. Eine Adresse (auch wenn ich diese Möglichkeit selbst noch nie benutzt oder gebraucht habe)

Um einen Breakpoint an den Anfang von main zu setzen:

(gdb) br main
Breakpoint 1 at 0x4005bc: file mein_programm.c, line 11.

Bei unserem kleinen Beispielprogramm muss der Breakpoint natürlich vor dem Starten des Programms gesetzt werden, sonst läuft es durch, bevor wir irgendwas damit tun können.

Breakpoints lassen sich übrigens mit “delete” löschen (dem man dann die Nummer des Breakpoints angibt, wie z.B. bei dem Breakpoint aus dem Beispiel von eben die 1). Mit “disable” lässt sich ein Breakpoint abschalten, ohne ihn zu löschen, mit “enable” wieder einschalten. Mit “info breakpoints” (oder dem angenehmeren “i br”) kann man alle Breakpoints anzeigen.

Programm anhalten und fortsetzen

Das Programm hält an, sobald ein Breakpoint erreicht wird. Außerdem lässt es sich mit Ctrl+C manuell unterbrechen. Der Debugger zeigt dann an, an welcher Stelle im Code sich der aktuelle Thread grade befindet. Das kann dann so aussehen:

Breakpoint 1, main () at mein_programm.c:11
11	    printf("Hallo!\n");
(gdb)

Die erste Zeile zeigt an, wo sich der aktuelle Thread gerade befindet und dass das Programm angehalten wurde, weil der erste Breakpoint erreicht wurde. Danach wird die aktuelle Code-Zeile ausgegeben.

Mit “list” (oder kurz “li”) lässt sich übrigens Code anzeigen. Nur “li” zeigt den Code an, der sich um die aktuelle Stelle herum befindet. Führt man noch mal “li” aus, werden die nächsten Zeilen angezeigt. “li -” zeigt die vorherigen Zeilen an. Probieren wir das mal aus:

(gdb) li
6	    else
7	        return fib(i-2) + fib(i-1);
8	}
9	
10	int main() {
11	    printf("Hallo!\n");
12	    printf("%d\n", fib(5));
13	    return 0;
14	}
(gdb) li -
1	#include 
2	
3	int fib(int i) {
4	    if(i < 2)
5	        return i;
(gdb) 

Der erste Befehl zeigt die Codezeilen um die aktuelle Zeile 11 herum an, der zweite Befehle die Zeilen davor (bis Zeile 1 in diesem Fall).

Single-Step

Wie bei jedem anderen Debugger lässt sich das Programm jetzt zeilenweise durchgehen. Dabei können wir entweder Funktionsaufrufe komplett ausführen, oder in die Funktionen hineinspringen und dort weiterdebuggen. Wenn wir die aktuelle Zeile komplett ausführen wollen, nehmen wir “next” (oder n):

(gdb) n
Hallo!
12	    printf("%d\n", fib(5));

Das printf aus Zeile 11 wird also komplett ausgeführt (und die Ausgabe erscheint auf dem Bildschirm) und der Debugger hält in Zeile 12 wieder an. Um in eine Funktion hineinzuspringen und dort weiter zu debuggen, nehmen wir den Befehle “step” (oder s):

(gdb) s
fib (i=5) at mein_programm.c:4
4	    if(i < 2)

Wir sehen also, dass wir (so wie wir es erwarten würden) in der Funktion fib gelandet sind.

Wenn wir das Programm komplett weiterlaufen lassen wollen, nehmen wir den Befehl “continue” (kurz c). Danach läuft das Programm weiter, als hätte es den Breakpoint gar nicht gegeben. Es gibt noch ein paar andere Möglichkeiten, die Ausführung zu steuern, die würden aber dann langsam den Rahmen hier sprengen.

Stack-Trace

Um zu gucken, wie der Stack momentan aussieht (d.h. um zu sehen, welche Funktion welche andere aufgerufen hat um zur aktuellen Stelle im Code zu kommen), benutzen wir den Befehl “backtrace” (oder bt):

(gdb) bt
#0  fib (i=5) at mein_programm.c:4
#1  0x00000000004005d0 in main () at mein_programm.c:12

Ganz oben steht die aktuelle Funktion, ganz unten normalerweise main (oder der Einstiegspunkt des Programmes oder Threads). Außerdem werden die Parameter an die jeweiligen Funktionen angezeigt.

Mit den Befehlen “up” und “down” können wir uns im Stacktrace bewegen. Dabei beziehen sich die Namen “up” und “down” auf die Nummern der Stackframes, die ganz links stehen. Das heißt um von dem aktuellen Stackframe #0 zu #1 (also zu der Stelle, an der fib von main aufgerufen wurde) zu kommen, benutzen wir also up:

(gdb) up
#1  0x00000000004005d0 in main () at mein_programm.c:12
12	    printf("%d\n", fib(5));

Und wieder zurück mit down:

(gdb) down
#0  fib (i=5) at mein_programm.c:4
4	    if(i < 2)

Variablen untersuchen

Es gibt eine ganze Menge Möglichkeiten, vom aktuellen Stackframe aus sichtbare Variablen anzeigen zu lassen. Die häufigste und intuitivste ist “print”, dem man einfach den Namen der Variablen mitgibt:

(gdb) print i
$1 = 5

Statt Variablen können auch komplexere Ausdrücke benutzt werden, ich belasse es jetzt aber erstmal dabei.

Beenden

Jetzt haben wir die wichtigsten Punkte durch, bleibt nur noch die Frage, wie der Debugger zu beenden ist. Dazu benutzen wir den Befehl “quit” (oder q oder einfach Ctrl+D). Wenn das Programm allerdings noch nicht beendet ist, fragt der Debugger sicherheitshalber nach, ob er auch wirklich beenden soll:

(gdb) q
A debugging session is active.

	Inferior 1 [process 6583] will be killed.

Quit anyway? (y or n) n
Not confirmed.

Threads

Wenn das Programm aus mehreren Threads besteht, muss man natürlich zwischen den Threads wechseln können um sie zu untersuchen. Zunächst können alle Threads mit “info threads” (oder “i thr”) angezeigt werden. Mit “thread” und der Nummer des Threads kann in diesen Thread gewechselt werden. Danach bezieht sich der Stack-Trace und alle angezeigten Daten auf diesen Thread.

Das Ende

Ich denke, das war erstmal das wichtigste um mit dem GDB überhaupt arbeiten zu können. Interessant ist noch der Befehl “help”, der genau das tut, was man von ihm erwarten würde. Es gibt noch wahnsinnig viel, was der GDB kann, und mit dem, was wir hier besprochen haben, haben wir ungefähr den Funktionsumfang eines einfaches grafischen Debuggers abgedeckt und gleichzeitig die Funktionalität, die man 95% der Zeit beim Debuggen benötigt.

In späteren Artikeln zum Thema Debugging werde ich mich dann mit tiefgreifenderen Themen beschäftigen. Ich habe noch eine lange Liste von Themen, über die ich gerne Schreiben möchte.

Tags: , , , ,

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s

%d bloggers like this: