EinfĂĽhrung in die Arbeit mit Valgrind und Memcheck, Teil 2

Seite 2: Funktionsweise

Inhaltsverzeichnis

Valgrind ist kein Allzweckmittel, um sämtliche Fehler eines Programms zu detektieren. Um die Möglichkeiten, aber auch Grenzen zu verstehen, ist es sinnvoll, sich ein wenig mit den Interna von Valgrind und den auf ihm aufsetzenden Werkzeugen zu befassen.

Prinzipiell instrumentiert das Debugging-Tool den auszuführenden Code, um so das Laufzeitverhalten des Clients zu analysieren. Die Instrumentierung des Codes geschieht nicht auf der Ebene der spezifischen Maschinensprache, sondern auf einer Zwischenform, dem VEX IR. Ein erster Schritt besteht folglich darin, die Instruktionen der Binärdatei in sie zu überführen.

Je nach verwendetem Tool wird der VEX-IR-Code im Anschluss instrumentiert, also um weitere Instruktionen ergänzt, was die Analyse erst ermöglicht. So verwaltet Memcheck zusätzlich einen Schattenspeicher. In diesem wird für jedes durch das Programm reservierte Byte eine Art Metazustand festgehalten, der angibt, ob es bereits initialisiert oder freigegeben wurde. Bei Instruktionen, die zum Beispiel Daten aus dem Speicher einlesen, wird als zusätzlicher Aufwand der Metazustand der Daten eingelesen. Instruktionen, die Daten verarbeiten, instrumentiert Memcheck so, dass sie den Metazustand des zu verarbeitenden Datums entsprechend anpassen und gegebenenfalls mit Valgrind interagieren, um auf potenzielle Fehler hinzuweisen. Eine Instruktion, die einen bedingten Sprung auf Grundlage eines nicht initialisierten Datums ausführen soll, wirft beispielsweise die mittlerweile bekannte Meldung "Conditional jump or move depends on uninitialised value(s)" aus. Das Ergebnis einer Multiplikation mit einer 0 würde hingegen zum Metazustand "initialisiert" führen.

Nachdem der Code instrumentiert wurde, erfolgt eine Übersetzung zurück in den Maschinencode der Zielplattform, der schließlich nativ inklusive des zusätzlichen Aufwands ausgeführt wird. Abgesehen davon, dass VEX-IR-Code so aufgebaut ist, dass die Datenflussanalyse recht einfach umgesetzt werden kann, ermöglicht dieser Zwischencode also, die eigentliche Instrumentierung unabhängig von der Zielprozessorarchitektur zu gestalten.

Memcheck und die meistens Tools der Suite sind dennoch nicht plattformunabhängig, da sie selbst eine enge Verzahnung mit dem Betriebssystem aufweisen, was auch der Grund ist, warum Valgrind am Besten mit Linux zusammenarbeitet. So leitet Memcheck alle Aufrufe von malloc() auf eine eigene Implementierung auf Ebene des dynamischen Binärladers um. Das hat zur Folge, dass Memcheck seine Dienste versagt, wenn man die C-Bibliothek statisch dazu linkt, wovon man sich leicht überzeugen kann, indem man beim Linkeraufruf des Primzahltests die Option -static hinzufügt. Einige der Einschränkungen lassen sich allerdings mit den sogenannten Valgrind-Client-Requests umgehen. Sie stellen eine Schnittstelle zu Valgrind dar, was aber auch bedeutet, dass die Programme speziell anzupassen sind.

Aus der Funktionsweise von Memcheck ergeben sich Möglichkeiten für falsch positive Meldungen. Das ist zum Beispiel bei folgendem Code der Fall, wobei bei der Übersetzung die Optimierung der Zeichenkettenlänge, die moderne Compiler beherrschen, ausgeschaltet sein muss (ab GCC 4.7 via -fno-optimize-strlen):

char *s = malloc(strlen(str)+1);
strcpy(s,str);
printf("%d\n",strlen(s)); /* Zeile 9 */

Falls str eine Zeichenkette der Länge 6 ist, könnte je nach Prozessorarchitektur und Compilerversion folgende Meldung nach dem Aufruf von Memcheck erscheinen:

==8644== Invalid read of size 4
==8644== at 0x400517: main (false-positive.c:9)
==8644== Address 0x51db044 is 4 bytes inside a block of size 7 alloc'd
==8644== at 0x4C284C0: malloc (in /usr/lib/valgrind/
vgpreload_memcheck-amd64-linux.so)
==8644== by 0x400505: main (false-positive.c:7)

Die Ursache dafür ist, dass die Implementierung von strlen() aus Geschwindigkeitsgründen nicht byteweise vorgeht, sondern jeweils vier Bytes gleichzeitig in die Prozessorregister einliest, um darin das Nullzeichen zu finden. Für s wurden allerdings nur 7 Bytes reserviert.

Falsch positive Meldungen wie diese oder solche, über die man als Entwickler keine Kontrolle hat, weil sie beispielsweise durch eine Bibliothek hervorgerufen werden, lassen sich durch Valgrind unterdrücken. Die Anweisungen hierzu gibt man einer separaten Textdatei, deren Dateiname man dem --suppressions-Parameter weitergibt. Mit Hilfe der Option --gen-suppressions lässt sich der Inhalt automatisch generieren.

Besteht das Projekt aus Unit-Tests, ist der Weg zu Continuous Integration nicht weit. Valgrind besitzt mit --xml=yes die Option, die potenziellen Probleme in eine Datei im XML-Format auszuschreiben, sodass andere Tools sie leichter verarbeiten können. An Jenkins beispielsweise lässt sich Valgrind dank eines speziellen Plug-ins vergleichsweise einfach binden. Es erweitert Jenkins um den Valgrind Runner, der es ermöglicht, diverse Valgrind-Tools durch die Umgebung zu starten. Der interessantere Part ist der Valgrind Publisher, da sich erst durch diese Komponente die erzeugten XML-Dateien analysieren und ansehnlich aufarbeiten lassen. Je nach Einstellungen wird in Abhängigkeit des Ergebnisses der Build als gut oder schlecht bewertet. Den Publisher kann man unabhängig vom Valgrind Runner aktivieren, sodass sich die nützliche Report-Funktion auch für bereits existierende Build-Pipelines nutzen lässt.

Die Fehlermeldungen und der Stacktrace sind oftmals schon ausreichend, um einen Fehler zu finden. Für hoffnungslose Fälle möchte man jedoch häufig gerne einen Debugger wie GDB benutzen. Da Valgrind im Prinzip das zu debuggende Programm auf einem virtuellen Prozessor ausführt, scheitert der direkte Ansatz zunächst. GDB lässt sich jedoch auch für das Remote-Debugging nutzen und definiert dazu ein Protokoll, das von einem sogenannten GDB Stub auf dem Remote-System verstanden wird.

So einen GDB Stub implementiert auch Valgrind, wenn es je nach gewünschter Akkuratesse zusätzlich mit der Option --vgdb=yes beziehungsweise --vgdb=full gestartet wird. Mit der Option --vgdb-error=<WERT> gibt man an, nach welcher Anzahl von Fehlern Valgrind das Programm anhält und den Stub aktiviert. Ein Wert von 0 bedeutet, dass man das Programm gleich zu Beginn in dem Modus startet. Instruktionen, wie man GDB startet, um sich mit dem Stub zu verbinden, gibt Valgrind selbst auf dem Bildschirm aus.

In diesem Artikel wurde die Kommandozeile benutzt, um Valgrind zu starten und die Ergebnisse auszuwerten. Neben dem bereits erwähnten Plug-in für Jenkins gibt es darüber hinaus Software, die eine grafische Oberfläche für Valgrind bereitstellt. Sie präsentiert sowohl die Optionenals auch die Auswertung der Fehler deutlich nutzerfreundlicher.

Wer zum Beispiel Eclipse CDT benutzt, der kann sich das Valgrind-Plug-in aus dem Eclipse Linux Tools Project näher anschauen. Es erlaubt das Starten von unterschiedlichen Valgrind-Tools innerhalb der Entwicklungsumgebung und annotiert den Quelltext mit den gefundenen Fehlern. Ebenso bietet der Qt Creator eine Integration von Memcheck und Callgrind an. Von den Valgrind-Entwicklern selbst stammt das ebenfalls auf Qt aufsetzende Valkyrie, das jedoch nicht auf die aktuelle Version von Valgrind abgestimmt ist.

Wie im ersten Teil des Artikels erwähnt, existieren noch weitere Tools in der Valgrind-Suite, die für die Analyse der Programme von großem Nutzen sind. Kurz erwähnt seien Helgrind, mit dem sich Fehler in Zusammenhang mit paralleler Programmierung wie Race Conditions, detektieren lassen und Callgrind, das beim Profiling hilfreiche Dienste erweist. Komplementär einsetzbar zu Memcheck, aber noch experimentell ist SGCheck, mit dessen Hilfe man etwa Pufferüberläufe auf dem Stack erkennen kann.