Viele Software-Entwickler träumen von Tools, die automatisch Fehler finden und damit beim lästigen Debugging helfen. Sehr leistungsfähig ist Splint: Das Semantik-Prüfprogramm liest und versteht den Code und findet eine Vielzahl typischer Fehler.
Programmierfehler ärgern User und Entwickler. Es gilt also, sie möglichst früh zu entdecken und zu beheben – bevor die ersten Anwender das Programm benutzen. Je später man Bugs bemerkt, desto größer ist der Kurrekturaufwand. Trotz aller Vorsicht und Planung sagt eine grobe Schätzung, dass nach einem Monat Entwicklungsarbeit immer ein weiterer Monat für das Debugging folgt, bevor der Code einsatzbereit ist.
Splint versucht, den Sourcecode zu verstehen
Splint[1] (Secure Programming Lint, ehemals Lclint) ist ein Semantik-Prüfprogramm, das den Code liest und versteht. Dabei soll es herausfinden, ob der Programmierer wirklich das geschrieben hat, was er meint. Im Gegensatz dazu melden Compiler lediglich Syntaxfehler, die das weitere Übersetzen unmöglich machen, zum Beispiel nicht definierte Variablen. Sind alle Compiler-Warnungen aktiviert, kann man zwar bereits einige Semantikfehler entdecken, aber in den meisten Fällen reicht das nicht aus. Das folgende Code-Beispiel »t.c« verursacht beim Übersetzen mit »gcc t.c« keinerlei Warnungen:
#include <stdio.h>
int main(int argc, char **argv)
{
int a=0;
if (a = 4);
return 0;
}
Mit der Option »-Wall« gibt GCC immerhin eine aufschlussreiche Warnung aus: »suggest parentheses around assignment used as truth value«. Die If-Bedingung ist eine Wertzuweisung; das ist zwar gültiger Code, aber doch recht ungewöhnlich. GCC schlägt vor, diesen Umstand durch eine zusätzliche Klammerung deutlicher zu kennzeichnen.
Splint gegen GCC: Fünf zu eins in Führung
Splint entdeckt insgesamt fünf mögliche Fehler und beschreibt sie auch noch recht ausführlich:
- Die Testbedingung in der If-Anweisung ist eine Zuweisung
»=« und kein Vergleich »==«. - Der Testausdruck ist vom Typ »int« und nicht, wie
erwartet, ein boolescher Wert. - Der Body des If-Statements ist ein leerer Ausdruck: Nach den
Klammern folgt ein Strichpunkt »;«. - Die Main-Funktion benutzt den Parameter »argc«
nicht. - Auch der Parameter »argv« wird nicht
verwendet.
Splint muss in der Lage sein, den Code genau so zu lesen und auszuwerten wie ein Compiler. Wer mit besonderen Include-Verzeichnissen arbeitet, muss sie auch Splint mitteilen. Dazu dient – wie beim GCC – der Parameter »-I«, alternativ kann man die Umgebungsvariable »LARCH_PATH« setzen.
In Listing 1 ist der Quellcode von »sptest.c« zu sehen, er dient im Folgenden als Beispielprogramm. Der Code ist von minderer Qualität und enthält ganz bewusst Bugs. Dennoch lässt er sich zu hundert Prozent fehlerfrei übersetzen, sogar mit den strikten GCC-Parametern »-Wall -ansi -pedantic«. Diese Einstellungen mögen vielleicht etwas zu konservativ erscheinen; bei kritischem Code, der eventuell für mehrere Systeme portiert werden muss, sind diese Optionen aber die Norm.
Mystischer Code
Im Sptest-Programm geht es um Numerologie (mystische Zahlenlehre), es berechnet eine magische Zahl anhand eines Namens. Die Zahl wird zum Wahrsagen benutzt oder um die Persönlichkeit einer Testperson zu beschreiben. Auch weitere Eigenschaften wie die Gesundheit, den Kontostand und die Leichtgläubigkeit des Kandidaten soll die Zahl ausdrücken.
Der Code besteht aus 46 Zeilen, er erzeugt keine Warnungen. Trotzdem ist einiges falsch: Er endet nicht mal, sondern scheint sich ewig im Kreis zu drehen. Beim Aufspüren des Fehlers ist Hilfe willkommen – und naht mit Splint. Das Prüfprogramm gibt tatsächlich 14 Hinweise (siehe Listing 2).
|
Splint (Secure |
|---|
|
Lizenz: GPL Bezugsquelle: [http://www.splint.org] Aufgabe: Typische Programmierfehler automatisiert aufspüren Sprache: Nur C (auch kein C++) Typ: Konsolenprogramm |
Das hässliche Ergebnis: 14 Warnungen
Ganze 14 Warnungen bei einem perfekt wirkenden Code. Jede Warnung nennt an erster Stelle den Namen der Datei, auf die sie sich bezieht, dann Zeile und Spalte der fehlerhaften Stelle, gefolgt von einer kurzen Beschreibung des Bugs. Passt diese Beschreibung nicht in eine Zeile, dann sind die Folgezeilen so weit eingerückt, dass der Text bündig untereinander steht. Darauf folgen meist weitere Zeilen: Um zwei Leerzeichen eingerückt ist eine genauere Erklärung des Bugs; ein eventueller Hinweis auf eine weitere Codestelle ist immer um drei Leerzeichen eingerückt.
Auffällig sind die beiden »Parameter … not used«-Meldungen für Zeile 28, die »argc« und »argv« als unbenutzte Parameter aufführen. Beide Variablen sind in der »main()«-Funktion vorhanden, sie kommen aber nicht zum Einsatz. Im vorliegenden Fall kann man diese Fehlermeldung ignorieren, das Programm benötigt die Variablen nicht.
Meist weist ein unbenutzter Parameter aber darauf hin, dass wichtige Daten einfach in der Luft hängen; solche Fehler müsste man auf jeden Fall korrigieren. Ignoriert der Programmierer den Parameter absichtlich, sollte er das im Code auch deutlich kennzeichnen, zum Beispiel mit:
argc = argc; argv = argv;
Daraufhin meldet Splint keine Fehler mehr für die beiden Parameter »argc« und »argv«, alle anderen unbenutzten Parametern kreidet Splint aber an.
|
Installation |
|---|
|
Splint ist als Binär- und als Quelltextarchiv verfügbar. Die aktuelle Version 3.0.1.6 umfasst etwa 1,5 MByte. Der Quellcode wurde mit Standard-GNU-Komponenten geschrieben und sollte sich auf jedem modernen Unix-System kompilieren lassen. Der übliche Dreisatz »./configure«, »make« und »make install« erledigt das Übersetzen und Installieren, für Letzteres sind meist Root-Rechte erforderlich. Wer sich das Übersetzen sparen will oder auf Probleme stößt, kann sich mit Binärpaketen behelfen: Auf der Splint-Homepage sind Pakete für Linux, FreeBSD, Windows und Solaris zu finden, die ohne externe Abhängigkeiten auskommen. Ob die Installation erfolgreich war, lässt sich leicht feststellen: $ splint --help version Eine freundliche Splint-Meldung nennt die Versionsnummer, die E-Mail-Adresse des Maintainers sowie die Parameter, mit denen Splint selbst übersetzt wurde. |
Fehlende Parameter
Mit einem speziellen Parameter ignoriert Splint alle Fehler dieser Art im gesamten Code:
$ splint --paramuse sptest.c
Nun sind nur noch zwölf Warnungen übrig. Die meisten Warnungen sind in Kategorien eingeteilt, die Splint über Befehlszeilenschalter wie »–paramuse« ignoriert. So lassen sich ganze Fehlertypen ausblenden, wenn sie für das jeweilige Projekt nicht relevant sind. Insgesamt stehen über 100 Schalter zur Verfügung. Der folgender Befehl zeigt eine entsprechende Übersicht an:
$ splint --help flags
Für die einzelnen Kategorien (etwa »memory«, »pointers« oder »parameter«) zeigt ein weiteres Kommando, welche Schalter darin konkret enthalten sind. Die Ausgabe enthält auch eine kurze Beschreibung.
$ splint --help flags memory
Schritt für Schritt zum fehlerfreien Programm
Nachdem die einfachen Fälle korrigiert sind, ist es sinnvoll, die restlichen Fehler der Reihe nach zu beheben. Nach jeder Änderung sollte man Splint erneut ausführen: Das stellt sicher, dass der Fehler verschwunden ist und dass die Änderungen keine neuen Probleme verursachen. Die erste Zeile der Fehlerausgabe in Listing 2 zeigt, dass Zeile 10 von »sptest.c« (Listing 1) problematisch ist: »sptest.c:10:19: Function parameter array declared as manifest array (size constant is meaningless)«.
Der Programmautor wollte durch die eckigen Klammern vermutlich ausdrücken, dass diese Funktion ein Array als Argument erwartet. Das ist aber nicht möglich: C kann keine Arrays übergeben, sondern lediglich Zeiger auf den Anfang eines Arrays. Auch die Größenangabe ist hinfällig. Der Code sollte daher wie folgt lauten:
int num_calc(char *array)
Der Compiler erzeugt für beide Versionen zwar den gleichen Code, die korrigierte Variante verhindert aber, dass ein unerfahrener Programmierer denkt, an dieser Stelle werde tatsächlich ein Array übergeben. Aufgrund dieser falschen Annahme könnte er weitere Fehler einbauen.
|
Listing 1: |
|---|
01 #include <stdio.h>
02
03 int mapping[] = {
04 1, 1, 4, 2, 4, 4, 2, 1,
05 5, 4, 4, 2, 1, 5, 2, 5,
06 5, 5, 4, 3, 3, 3, 3, 2,
07 5, 5,
08 };
09
10 int num_calc(char array[11])
11 {
12 int i;
13 int total=0;
14 char c;
15
16 for(i=0; i<sizeof(array)/sizeof(array[0]); i++)
17 {
18 c = array[i];
19 if (c >= 'A' && c <= 'Z')
20 total += mapping[c-'A'];
21 else if (c >= 'a' && c <= 'z')
22 total += mapping[c-'a'];
23 }
24
25 return total;
26 }
27
28 int main(int argc, char **argv)
29 {
30 char message[11] = {"Mystic Meg"};
31 unsigned int num;
32
33 if ((num = num_calc(message)))
34 {
35 /* reduzieren bis negativ */
36 do
37 num -= 10;
38 while(num>=0);
39
40 /* Da wir über das Ziel hinausgeschossen
41 sind, wieder zehn hinzufügen */
42 num += 10;
43
44 printf("Die magische Zahl für '%s'
45 beträgt %d.n", message, num);
46 }
47
48 return 0;
49 }
|
Korrektur mit fataler Nebenwirkung
Nach einem erneuten Splint-Aufruf sind nur noch zehn Fehler übrig: Die Korrektur hatte eine unerwartete Nebenwirkung, sie verdeckt einen potenziellen Fehler. Ein weiterer guter Grund dafür, Änderungen schrittweise und nicht alle auf ein Mal zu prüfen. Der verloren gegangene Bug betrifft den »sizeof«-Operator in Zeile 16. Sizeof ergibt bei einem Pointer (auf einer 32-Bit-Maschine) lediglich 4 Bytes. Da er den Pointer so genannt hat, als wäre er ein Array, hat der Programmierer eine Länge von 11 Bytes (die Größe des Arrays) impliziert. Als Lösung bietet sich an, die Länge mit »strlen()« zu berechnen:
for(i=0; i<strlen(array); i++)
Der nächste Fehler bezieht sich auf einen Type Mismatch (nicht übereinstimmende Datentypen) in Zeile 16. Statt den Typ blindlings zu ersetzen, sollte man erst sicherstellen, dass dadurch keine Komplikationen auftreten.
Im Beispiel gibt die »strlen()«-Funktion einen Wert vom Typ »size_t« zurück. Es handelt sich um einen systemspezifischen Typ (in »malloc.h« definiert), der jede mögliche Speicheradresse aufnehmen kann. Der Schleifenzähler »i« ist jedoch vom Typ »int«. Korrekter wäre »size_t«, da die Schleife in der Lage sein muss, ein beliebiges Array zu referenzieren, das theoretisch den gesamten Speicher belegen könnte.
Die Typänderung verursacht zwar an dieser Stelle keine Probleme, insbesondere weil bei den üblichen 32-Bit-Maschinen »size_t« als »unsigned int« definiert ist, der Unterschied also nur im Vorzeichen liegt (»int« ist Vorzeichen-behaftet). In vielen Fällen ist das Ändern von »signed« auf »unsigned« oder umgekehrt aber gefährlich.
Das nächste Problem bezieht sich ebenfalls auf Typen und tritt zweimal auf (in Zeile 20 und 22): Der Code benutzt »char«, um ein Element eines Arrays zu referenzieren. Da C diese Technik zulässt (durch automatische Typumwandlung), ist das unkritisch. Statt einen zusätzlichen Schalter an das Splint-Programm zu übergeben, lässt sich die Meldung auch durch explizites Type-Casting umgehen: »array[(int) (c-\’A\’)]«.
Falsche Initialisierung
In Zeile 30 wird das »message«-Array mit einer überflüssigen Klammerung initialisiert: Statt jedem Element des Arrays ein einzelnes Zeichen zuzuordnen, legt die Zeile eigentlich ein Array von Strings an, das aber nur ein Element enthält. Strings sind in C nichts anderes als NULL-terminierte Zeichen-Arrays, sodass der Speicherbereich für »message« beinahe zufällig doch wie gewünscht gesetzt wird. Die korrekte Version verzichtet in Zeile 30 auf die beiden geschweiften Klammern »{« und »}«.
Die If-Bedingung in Zeile 33 erzeugt zwei Warnungen: Splint warnt davor, dass einer »unsigned int«-Variablen ein »int«-Wert zugewiesen wird, außerdem ist die Bedingung kein boolescher Ausdruck. Die Zuweisung taucht im GCC nicht als Warnung auf, da der Ausdruck von zwei Klammern umgeben ist. Dieser Trick funktioniert nicht bei Splint, also muss man die Warnung auf einem anderen Weg vermeiden:
num = num_calc(message);
if (num > 0)
{
etc...
Der Ausdruck »num > 0« führt nicht nur zu einem booleschen Ergebnis, er betont auch das korrekte Ergebnis. Obwohl die »num_calc()«-Funktion momentan keine Werte zurückgibt, die kleiner als null sind, würden diese (etwa zur Fehlerbehandlung) jetzt auch richtig interpretiert werden. Nach diesen Änderungen bleiben noch fünf Warnungen übrig.
|
Listing 2: |
|---|
Splint 3.0.1.6 --- 07 Jan 2003
sptest.c:10:19: Function parameter array declared as manifest array (size
constant is meaningless)
A formal parameter is declared as an array with size. The size of the array
is ignored in this context, since the array formal parameter is treated as a
pointer. (Use -fixedformalarray to inhibit warning)
sptest.c: (in function num_calc)
sptest.c:16:21: Parameter to sizeof is an array-type function parameter:
sizeof((array))
Operand of a sizeof operator is a function parameter declared as an array.
The value of sizeof will be the size of a pointer to the element type, not
the number of elements in the array. (Use -sizeofformalarray to inhibit
warning)
sptest.c:16:13: Operands of < have incompatible types (int, arbitrary unsigned
integral type): i < sizeof((array)) / sizeof((array[0]))
To ignore signs in type comparisons use +ignoresigns
sptest.c:20:19: Array fetch using non-integer, char: mapping[c - 'A']
To allow char types to index arrays, use +charindex. (Use +charindex to
inhibit warning)
sptest.c:22:19: Array fetch using non-integer, char: mapping[c - 'a']
sptest.c: (in function main)
sptest.c:30:23: Initializer block for message has 1 element, but declared as
char [11]: "Mystic Meg"
Initializer does not define all elements of a declared array. (Use
-initallelements to inhibit warning)
sptest.c:33:9: Assignment of int to unsigned int: num = num_calc(message)
sptest.c:33:8: Test expression for if not boolean, type unsigned int:
(num = num_calc(message))
Test expression type is not boolean or int. (Use -predboolint to inhibit
warning)
sptest.c:38:13: Comparison of unsigned value involving zero: num >= 0
An unsigned value is used in a comparison with zero in a way that is either a
bug or confusing. (Use -unsignedcompare to inhibit warning)
sptest.c:45:19: Format argument 2 to printf (%d) expects int gets unsigned int:
num
sptest.c:44:49: Corresponding format code
sptest.c:28:14: Parameter argc not used
A function parameter is not used in the body of the function. If the argument
is needed for type compatibility or future plans, use /*@unused@*/ in the
argument declaration. (Use -paramuse to inhibit warning)
sptest.c:28:27: Parameter argv not used
sptest.c:3:5: Variable exported but not used outside sptest: mapping
A declaration is exported, but not used outside this module. Declaration can
use static qualifier. (Use -exportlocal to inhibit warning)
sptest.c:10:5: Function exported but not used outside sptest: num_calc
sptest.c:26:1: Definition of num_calc
Finished checking --- 14 code warnings
|
Mit oder ohne Vorzeichen
Der erste der beiden Fehler in Zeile 33 wurde noch nicht korrigiert. Der Typ der Variablen »num« verursacht aber noch zwei weitere Meldungen für Zeile 38 und 45. Der erste Fehler scheint ein einfacher Type Mismatch (»int« und »unsigned int« gemischt) zu sein. Statt den Typ von »num« kurzerhand anzupassen, sollte man zuerst untersuchen, wie das Programm diese Variable nutzt.
Die Funktion »num_calc()« gibt zunächst einen Integer zurück und versucht, diesen Wert an eine vorzeichenlose Integer-Variable zuzuweisen. Man könnte entweder die Funktion oder die Variable ändern. Da die Funktion (in einer verbesserten Version) einen Fehlercode als negative Zahl zurückgeben könnte, ist der Typ des Rückgabewerts gut gewählt. Die nächste problematische Zeile (38) zeigt aber, wo das Problem wirklich liegt: Ein Wert ohne Vorzeichen kann per Definition nicht negativ sein. Damit kann der Ausdruck »num >= 0« niemals FALSE sein – folglich hängt das Programm. Offenbar haben weder der Programmierer noch GCC dies erkannt. Da die Zahl aber negativ sein muss, um die Schleife zu beenden (Zeilen 36 bis 38), sollte »num« ein Integer mit Vorzeichen sein.
Die dritte Fehlermeldung (für Zeile 45) zeigt, dass die Formatangabe in »printf()« ebenfalls falsch formuliert ist. Das bekräftigt den Verdacht, dass der Programmierer in Zeile 31 eigentlich »int num;« schreiben wollte. Die letzten beiden Fehler (Zeile 3 und 10) hängen ebenfalls zusammen. Beide beziehen sich auf exportierte Identifier: eine Variable und eine Funktion. In C ist es möglich, auf Variablen von einer anderen Datei aus zuzugreifen, indem man sie dort als »extern« deklariert.
Solange das Programm nur aus einer Datei besteht, sind damit keine Probleme zu erwarten. Man sollte im Allgemeinen aber private Funktionen (die sich nur auf eine Datei beziehen) mit dem Schlüsselwort »static« kennzeichnen. Es geht wieder darum, dem Compiler mitzuteilen, was der Programmierer genau meint, statt es dem Zufall zu überlassen. Obwohl diese Änderung sehr einfach ist und etwas trivial erscheinen mag, ist sie wichtig, man sollte den Fehler auf keinen Fall durchgehen lassen.
Mit diesen Änderungen ist das Programm lint-free (flusenfrei), Splint kritisiert nichts mehr. Die Korrekturen waren nicht besonders aufwändig, aber das Ergebnis ist eine weitaus stabilere Ausgangsbasis für die weitere Arbeit. Viele Stilfehler, die später für Verwirrung gesorgt hätten, wurden verbessert. Jetzt lassen sich weitere Features leichter hinzufügen und die bestehenden besser erweitern.
Das Ergebnis: Saubere Software ohne Flusen
Mit Splint erkennt der Programmierer Fehler, bevor sie sich zu Problemen entwickeln. Das Programm gehört genau wie »gcc -Wall« als Teil des Build-Prozesses in den normalen Entwicklungszyklus, um den Aufwand für das Bugfixing zu verkürzen und dem Programmierer mehr Zeit für das eigentliche Entwickeln zu verschaffen. (fjl)
|
Infos |
|---|
|
[1] Splint-Homepage: [http://www.splint.org] [2] Splint-Manual: [http://www.splint.org/manual/] |
|
Die |
|---|
|
Steven Goodwin ist Chefprogrammierer und hat gerade sein fünftes Computerspiel fertig gestellt. Er hat mehr Bugs gesehen, als die meisten Menschen warme Mahlzeiten gegessen haben; allerdings behauptet er, dass sie allesamt von Dean Wilson stammen. Dean Wilson programmiert Perl, C und Shellskripte bei der Webperform Group Ltd in London. Sein Bug-Count ist größer als das Bruttosozialprodukt von Japan; allerdings behauptet er, dass sie allesamt von Steven Goodwin stammen. |
|
Grenzen von |
|---|
|
Obwohl Splint viele semantische Fehler entdeckt, die GCC nicht bemerkt, ist das Programm nicht unfehlbar. Da es die Gedanken des Programmierers nicht lesen kann, geht Splint von (gelegentlich falschen) Annahmen über den Code aus. Splint kann beispielsweise Situationen übersehen, in denen Funktionen ohne Format-Deklarationen genutzt werden. Das kann fatal sein, wenn der Return-Typ einer Funktion eine Fließkommazahl ist und C die Deklaration implizit und fälschlicherweise als Integer annimmt. Zum Glück erkennt der Compiler diesen bestimmten Fehler – daher sollten man nicht auf strikte Compiler-Optionen verzichten. Manchmal führt das menschliche Element zu Fehlern, die Splint unmöglich erkennen kann. Im folgenden Programmfragment kann Splint die Fehler nicht finden – der Programmierer kommt meist aber auch nicht dahinter: int a = 14l; int b = 020; int c; c = a/b; In diesem Fall lautet das Ergebnis von »c« nicht »7«, sondern »0«. Denn der Wert von »a« ist nicht »141«, sondern »14l« – mit einem kleinen L am Ende. Visuell besteht kaum ein Unterschied zwischen 1 und l. Solche Probleme lassen sich nur durch Standards für die Codierung lösen, die etwa beim »L«-Suffix einen Großbuchstaben und einen Kommentar vorschreiben. Das Gleiche gilt für Zahlen, die mit einer Null beginnen: Der C-Compiler liest sie als Oktalzahlen. Das Codebeispiel lautet also eigentlich: int a = 14; int b = 16; int c; c = a/b; Das Ergebnis der Division 14/16 ist als Ganzzahl ausgedrückt »0«. |





