Dieser Beitrag stammt von http://www.linux-magazin.de


Programmieren mit Linux

Programm ab!

von Günther Röhrich


Ein häufiger Grund für den Einsatz von Linux stellt nach wie vor die Programmierung dar. Alle Distributionen kommen mit einem überwältigenden Angebot an Programmiersprachen und Tools daher. Als Einsteiger ist es jedoch schwierig herauszufinden, was man alles tun muß, um ein lauffähiges Programm zu erstellen, und welche Tools dabei helfen können. Dieser Artikel soll ein wenig Licht ins Dunkel bringen.


Was ist eigentlich ein Programm?

Ein Programm ist nichts anderes als eine Folge von Anweisungen, die dem Computer sagen, was er zu tun hat. Diese Anweisungen sind in einer bestimmten Sprache verfaßt, der Programmiersprache. Fast alle Sprachen sind so aufgebaut, dass sie aus einer mehr oder weniger großen Anzahl an Textzeilen bestehen, die sequentiell abgearbeitet werden. Sequentiell heißt, dass im Text von oben nach unten und innerhalb von Zeilen von links nach rechts vorangegangen wird. Auf Wunsch des Programmierers kann es natürlich auch Verzweigungen geben. Der Inhalt des Textes muß einer wohldefinierten Syntax gehorchen, die bei jeder Programmiersprache eine andere ist.

Dieses Abarbeiten von Programmen erledigt in einem Computer der Prozessor. Die einzige Sprache, die ein Prozessor versteht, ist die sog. Maschinensprache. Da es für einen Programmierer zu umständlich wäre, alles in dieser Maschinensprache zu verfassen, exisitieren für alle Sprachen geeignete Übersetzer. Man unterscheidet hier zwischen Compiler und Interpreter. Ein Compiler übersetzt das komplette Programm in die Maschinensprache und speichert es dann ab, während ein Interpreter Zeile für Zeile übersetzt, im Zuge des Programmablaufs. Das Ergebnis der Übersetzung wird dann in der Regel vom Interpreter wieder verworfen. Im Falle von Schleifen müssen somit ein und dieselben Anweisungen immer wieder aufs neue übersetzt werden. Es ist einsichtig, dass mit einem Compiler eine schnellerere Programmausführung erreicht werden kann. Zudem muß bei der Gestaltung der Sprache keine Rücksicht auf den Aufwand der Übersetzung genommen werden.

Interpretierte Sprachen

Mit einer interpretierten Sprache ist sicherlich schon jeder Linux-Benutzer konfrontiert worden: nämlich der Shell. Nach Eingabe einer Zeile auf dem Terminal wird diese interpretiert und ausgeführt. Man kann aber auch mehrere Zeilen in eine Textdatei schreiben und diese zur Ausführung bringen. Das wird dann als ein Skript bezeichnet. In Abb. 1 ist ein solches Skript dargestellt.


Abb. 1: Einfaches Shell-Skript

Zur Eingabe kann ein beliebiger Texteditor verwendet werden. In der Abb. 1 ist es der Standard-Editor des KDE-Desktops. Der Dateiname, unter dem man das Skript abspeichert, ist beliebig. In unserem Fall habe ich die Endung .sh verwendet, welche sich als Standard für Shell-Skripte eingebürgert hat. Aufgabe des Skriptes ist es, alle Dateien im aktuellen Verzeichnis mit der Endung .foo in Dateien mit der Endung .bar umzubenennen. Zur Ausführung kommt das Skript durch Eingabe des Befehls:

bash test.sh

Das Programm bash ist der Interpreter. Vereinfachen läßt sich dies, indem man das Skript als ausführbare Datei kennzeichnet:

chmod u+x test.sh

Nun kann der Start ohne Angabe des Interpreters erfolgen:

test.sh

Um nach diesem Verfahren auch Skripte in anderen Sprachen ausführen zu können, gibt man in der ersten Zeile den zu verwendenden Interpreter an.

Compilersprachen

Die Compilersprache, die am häufigsten unter Linux eingesetzt wird, ist die Sprache C. Da der Kernel und die meisten Linux-Programme in C verfaßt sind, kann man sie auch als die "Muttersprache" von Linux bezeichnen. Für größere Anwedungsprogramme wird meistens C++ eingesetzt, das eine Weiterentwicklung von C ist. Nachfolgend ist als Beispiel ein Algorithmus in C implementiert. Auch ohne genaue Kenntnis der Syntax sollte man nachvollziehen können, was dabei gemacht wird.

Listing 1: Unsinniger Algorithmus in C

#include <stdio.h>

int main()
{
  int i = 1, x = 1;

  while(x <= 20000)
  {
    x = (i+3) * x / i;
    printf("%d. Durchlauf, Zahl x: %d\n", i, x);
    i = i + 1;
  }
} 

Assembler

Assembler ist nichts weiter als eine Darstellung der Maschinensprache in einer für den Menschen besser lesbaren Form. So muß z.B. dem Programmautor nicht die Bitkombinationen zur Darstellung der einzelnen Befehle bekannt sein. Es werden stattdessen leicht einprägsame Namen verwendet. Eine weitere Erleichterung ist, dass bei Sprüngen die Entfernung zum Sprungziel, d.h. die Anzahl der zu überspringenden Bytes, automatisch berechnet wird. Als "Assembler" wird auch das Programm bezeichnet, mit dem die Sprache Assembler in die Maschinensprache übersetzt wird.

Nachteilig ist, dass Programme in Assembler sehr schwer nachzuvollziehen und damit fehlerträchtig sind. Während z.B. bei Interpreter- und Compilersprachen mathematische Formeln nahezu unverändert in den Programmtext übernomen werden können, müssen sie bei Assembler in einzelne elementare Befehle zerlegt werden. Dies wird deutlich an der Implementierung des oben stehenden Algorithmus. Um mir die Arbeit zu sparen, habe ich einfach das Ergebnis der Übersetzung vom C-Compiler übernommen. Zu beachten ist, dass unterschiedliche Prozessorfamilien unterschiedliche Maschinensprachen verstehen. Das nachfolgende Beispiel ist für die x86-Serie von Intel sowie dazu kompatible Prozessoren gültig.

Listing 2: Unsinniger Algorithmus in Assembler

.section        .rodata
.LC0:
        .string "%d. Durchlauf, Zahl x: %d\n"
.text
        .align 16
.globl main
        .type    main,@function

main:
        pushl %ebp
        movl %esp,%ebp
        pushl %esi
        movl $1,%esi
        pushl %ebx
        movl $1,%ebx
        .align 4
.L4:
        leal 3(%ebx),%eax
        imull %esi,%eax
        cltd
        idivl %ebx
        movl %eax,%esi
        pushl %esi
        pushl %ebx
        pushl $.LC0
        call printf
        incl %ebx
        addl $12,%esp
        cmpl $20000,%esi
        jle .L4
        leal -8(%ebp),%esp
        popl %ebx
        popl %esi
        movl %ebp,%esp
        popl %ebp
        ret

Es werden wesentlich mehr Anweisungen benötigt als im C-Programm. Für den Laien ist nicht mehr nachvollziehbar, was das Programm macht. Assembler zählt deshalb zu den niederen Programmiersprachen. Als Hochsprachen bezeichnet man Sprachen, mit deren Hilfe Algorithmen und mathematische Formeln sehr ähnlich zur Vorlage implementiert werden können. Sie werden auch eingesetzt, um Programme unabhängig von der jeweiligen Prozessorfamile erstellen zu können.

Java

Die Sprache Java stellt ein Zwischending zwischen einer Compiler- und einer Interpretersprache dar. Nach Abschluß der Übersetzung mit dem Java-Compiler entsteht ein Zwischencode, der dann bei der Ausführung interpretiert wird. Der Vorteil ist, dass die Unabhängigkeit vom jeweiligen Prozessor gewahrt bleibt. Andererseits ist ein Teil der Übersetzung bereits erledigt und damit der Verlust an Geschwindigkeit nicht so groß wie bei einem reinen Interpreter. Natürlich ist es möglich, eine Compilersprache und auch die Maschinensprache vollständig zu interpretieren. Letzteres wird bei Emulatoren angewandt.

Erste Schritte in C

Wie bereits angedeutet ist es für den Neuling sinnvoll, sich als erstes mit der "Muttersprache" von Linux, nämlich der Sprache C zu befassen. Folgende Komponenten werden benötigt, um in C programmieren zu können:

Weiterhin existieren noch viele Tools, die das Leben des Programmierers erleichtern sollen. Ich werde später einige davon vorstellen. Wenn alles in einem einzigen Programm vereint ist, dann spricht man von einer integrierten Entwicklungsumgebung, oder abgekürzt IDE. Mit dem Editor wird das Programm in der Sprache C eingetippt und als Datei abgespeichert. Die Datei sollte die Endung .c haben. Man bezeichnet sie als den Quelltext (engl. Source Code). Hilfreich sind Editoren, welche den Text entsprechend der Syntax einfärben. Die Abb. 2 zeigt den bereits bekannten Algorithmus nach der Eingabe im "fortgeschrittenen Editor" des KDE-Desktops.


Abb. 2: Quelltext mit Syntax-Einfärbung

Wer tiefer in die Materie einsteigen will und selber programmieren möchte, der sollte sich ein gutes Buch zu dem Thema besorgen. Es gibt so viele davon, dass ich keine besondere Empfehlung aussprechen möchte. Nach dem Abspeichern erfolgt das Übersetzen, oder Neudeutsch: Compilieren. Unter Linux geschieht dies mit dem Befehl:

cc -o ctest ctest.c

Die Option -o gibt stets den Namen der Ausgabedatei an. Der Name braucht sich nicht an der Quelltext-Datei zu orientieren. Das Programm cc ist lediglich ein sogenanntes Frontend. Je nach der Endung des Dateinamens werden unterschiedliche Aktionen gestartet, eventuell sogar ein Compiler für eine andere Programmiersprache. Mit der Option -v kann man in Erfahrung bringen, welcher Compiler mit welcher Versionsnummer verwendet wird. In meinem Fall ist es der egcs, welcher auf dem GNU C Compiler (abgekürzt: GCC) basiert.

Es werden nacheinander der Präprozessor, C-Compiler, Assembler und Linker gestartet und mit den richtigen Parametern versorgt. Als Ergebnis wird eine Datei mit dem Namen ctest erzeugt. Sie enthält den Algorithmus in der Maschinensprache, die von dem eingesetzten Prozessor abgearbeitet werden kann. Bis dahin ist es ein weiter Weg, den ich näher erläutern werde.

Präprozessor

Der Präprozessor bindet die sogenannten Headerdateien ein und löst alle Makros gemäß ihrer Definition auf. In unserem Fall wird die Datei stdio.h eingelesen. Alternativ dazu könnte man den Inhalt der Datei von Hand in den Quelltext hineinkopieren. Üblicherweise enthalten Headerdateien keinen Programmcode sondern nur Typdefinitionen, Makrodefinitionen, usw. Falls eine solche Datei einmal nicht gefunden werden sollte, muß man dem Präprozessor das Verzeichnis angeben, wo er suchen soll. Hierzu ein Beispiel:

cc -I/usr/include/ -o ctest ctest.c

Eine weitere häufige Anwendung ist die bedingte Kompilierung. Im nachfolgenden Beispiel kann wahlweise ein Programm mit oder ohne Bildschirmausgabe erzeugt werden:

Listing 3: Bedingte Kompilierung

#include <stdio.h>

int main()
{
  int i = 1, x = 1;

  while(x <= 20000)
  {
    x = (i+3) * x / i;
#ifdef AUSGABE
    printf("%d. Durchlauf, Zahl x: %d\n", i, x);
#endif
    i = i + 1;
  }
} 

Die beiden Versionen können wie folgt erzeugt werden:

cc -o ctest_ausgabe -DAUSGABE ctest.c
cc -o ctest ctest.c

Natürlich könnte man dazu auch eine if-Abfrage im Quelltext verwenden, ohne den Präprozessor heranzuziehen. Der Vorteil der bedingten Kompilierung ist aber, dass die nicht mehr benötigten Teile im fertigen Programm nicht mehr enthalten sind. Somit wird das Programm kürzer und schneller. Um den Kompiliervorgang nach dem Präprozessor abzubrechen, wird die Option -E verwendet. Dateien, welche schon vom Präprozessor bearbeitet wurden, sollten die Endung .i haben.

Compiler

Der Compiler prüft den vom Präprozessor bearbeiteten Quelltext auf syntaktische Fehler. Falls keine vorhanden sind, wird die Übersetzung nach Assembler durchgeführt. Im Laufe der Bearbeitung werden eventuell noch Warnungen ausgegeben. Dabei handelt es sich um Anweisungen, die zwar syntaktisch korrekt sind, die aber möglicherweise nicht das tun, was sich der Programmierer vorstellt. Die Option -Wall veranlasst den Compiler, dabei besonders pedantisch vorzugehen und alle möglichen Warnungen anzuzeigen.

Zur Beschleunigung des Programmablaufs kann der Compiler veranlaßt werden, den Assemblercode soweit wie möglich zu optimieren. Die Optimierung kann in mehreren Stufen über die Optionen -O, -O2 und -O3 eingestellt werden. Bei der Optimierung muß der Quelltext tiefer analysiert werden, wobei eine größere Zahl an Warnungen erkannt und angezeigt werden kann. Unter Umständen kann sich das fertige Programm anders verhalten als ohne Optimierung. Die Änderungen bewegen sich jedoch ausschließlich innerhalb dessen, was in den Standards für die Sprache C festgelegt ist. Beispiele:

Was es alles gibt, hängt natürlich vom verwendeten Compiler und der eingesetzten Optimierungsstufe ab. Eine weitere Beschleunigung läßt sich erreichen, indem der Prozessortyp genauer spezifiziert wird und der Maschinencode noch besser den Erfordernissen angepaßt wird. Es kann sein, dass das Programm anschließend nicht mehr auf allen Mitgliedern einer Prozessorfamilie lauffähig ist. Beispiel:

cc -mcpu=ultrasparc -o ctest ctest.c

Assembler

Der Assembler übersetzt die Assembler-Anweisungen und erzeugt eine sog. Objektdatei mit der Endung .o. Sie enthält bereits den Programmcode in Maschinensprache. Damit die Objektdatei erhalten bleibt, muß beim Kompilieren bzw. Assemblieren die Option -c angegeben werden.

Linker

Ein Programm besteht in der Regel aus mehreren solcher Objektdateien, die auch als Module bezeichnet werden. Um das Rad nicht immer wieder neu zu erfinden, kann man Daten und Funktionen aus fertigen Bibliotheken benutzen, wie in unserem Fall die Funktion printf. Aufgabe des Linkers ist es, aus den einzelnen Modulen sowie den Bibliotheken das fertige Programm zusammenzustellen. Verweise zwischen den Modulen werden dabei aufgelöst. Der Assembler hat nämlich als Adresse für den Zugriff auf Daten und Funktionen, die sich nicht in dem bearbeiteten Modul befinden, nur Dummy-Werte eingefügt. Das Einsetzen der richtigen Werte erfolgt durch den Linker. In unserem Beispiel wird das fertige Programm erzeugt mit:

cc -o ctest ctest.o

Aufgerufen wird es einfach durch Eingabe des Dateinamens ctest.

Bibliotheken

Der Programmierer kann sich selber aus einzelnen Modulen eine Bibliothek erzeugen. Dies geschieht mit dem Befehl ar. Beispiel:

ar r libbeispiel.a funktion1.o funktion2.o

Es wird eine Bibliothek mit dem Namen beispiel erzeugt. Der Dateiname von Bibliotheken sollte mit lib beginnen und die Endung .a haben. Jede Funktion sollte in einem eigenen Modul stehen. Beim Linken werden dann automatisch nur die Module zum Programm hinzugefügt, die tatsächlich benötigt werden. Anzumerken ist, dass es auch innerhalb von Bibliotheken zu gegenseitigen Verweisen kommen kann. Eine Bibliothek kann somit auf Daten und Funktionen anderer Bibliotheken zugreifen. Beim Linken werden Bibliotheken mit der Option -l angegeben. Beispiel:

cc -o programm programm.c -lbeispiel

Es ist möglich, die Bibliothek ganz normal wie eine Objektdatei anzugeben:

cc -o programm programm.c libbeispiel.a

Bei großen Bibliotheken kann mit dem Befehl ranlib ein Index erstellt werden, so dass die Suche nach Modulen und Referenzen innerhalb der Bibliothek beschleunigt wird. Funktionen wie printf werden von fast jedem C-Programm benötigt. Es wäre eine große Verschwendung an Speicherplatz, wenn in jeder Programmdatei die komplette Funktion printf enthalten sein würde. Um dem abzuhelfen, gibt es unter Linux dynamische Bibliotheken. Sie werden nur einmal in den Arbeitsspeicher geladen und können dann von beliebig vielen Programmen gleichzeitig benutzt werden. Im Programm ist nur noch ein Verweis auf den Namen und die Version der dynamischen Bibliothek enthalten, wo sich die benötigte Funktion befindet. Mit ldd kann man sich anzeigen lassen, welche dynamischen Bibliotheken ein Programm benötigt. Für unser Beispielprogramm erhält man die Ausgabe:

libc.so.6 => /lib/libc.so.6 (0x40007000)
/lib/ld-linux.so.2 => /lib/ld-linux.so.2 (0x2aaaa000)

Wie man sieht, hat der Linker das Programm dynamisch mit der C-Bibliothek gelinkt. Um das statische Linken zu erzwingen, wird die Option -static verwendet:

cc -static -o ctest ctest.c

Das ist nicht zu empfehlen, denn es wird mehr als das zehnfache an Speicherplatz benötigt. Das statische Linken kann aber in manchen Fällen sinnvoll sein. Falls man kommerzielle oder sonstige ungewöhnliche Bibliotheken verwendet, sollte man diese statisch linken, wenn das Programm weitergegeben werden soll. Alle anderen Bibliotheken können weiterhin dynamisch gelinkt bleiben. Hierzu ist es erforderlich, das Programm gemischt statisch und dynamisch zu linken.

Fehlersuche

Bei der Programmierung macht man unweigerlich Fehler. Man unterscheidet zwischen Fehler, die vom Compiler gefunden werden können, sowie Fehler, die sich erst beim Programmablauf bemerkbar machen. Letztere sind besonders tückisch, so dass ich mich mit ihnen näher beschäftigen werde. Es nützt nämlich nichts, wenn man ein geniales Programm mit noch nie dagewesenen Funktionen geschrieben hat, und es noch voller Fehler ist. Der Benutzer wird auch weiterhin auf das vermeintlich schlechtere Programm zurückgreifen.

Tools, welche die Fehlersuche unterstützen, werden Debugger genannt. Unter Linux wird für Compilersprachen und Assembler fast ausschließlich der Debugger gdb eingesetzt. Neben C und C++ unterstützt er noch eine Menge weiterer Sprachen. Bei Interpretersprachen wird in der Regel der Interpreter selbst zur Fehlersuche eingesetzt. Um den gdb einsetzten zu können, benötigt man auf jeden Fall ein fertig übersetztes Programm sowie einige Zusatzinformationen, die sog. Debugging-Informationen. Mit der Option -g wird veranlaßt, dass sie im Verlauf der Kompilierung an das eigentliche Programm angehängt werden.

cc -g -o ctest ctest.c

Zur Anzeige des Quelltextes sind aber nach wie vor die Quelltext-Dateien erforderlich. Auf Optimierungen sollte verzichtet werden. Das Umorganisieren des Programmcodes kann den Debugger verwirren. Anschließend startet man den Debugger und übergibt ihm als Parameter den Namen des Programms, um das er sich kümmern soll. Da der gdb recht umständlich zu bedienen ist, werden meistens Frontends eingesetzt. Am gelungensten finde ich den ddd [1] (siehe Abb. 3).


Abb. 3: Der Debugger DDD, ein Frontend zum gdb

Als erstes setzt man einen Haltepunkt (engl. breakpoint). In Abb. 3 ist er durch das Stopp-Schild gekennzeichnet. Man setzt ihn sinnvollerweise an die Stelle, ab der man im Programmablauf den Fehler vermutet. Oder aber ganz einfach an den Einstiegspunkt im Programm, die Funktion main. Nun kann mit "Run" das Programm gestartet werden. Sobald der Haltepunkt erreicht ist, bleibt das Programm stehen, und die Kontrolle wird an den Debugger übergeben. Es gibt jetzt eine Vielzahl an Möglichkeiten. Man kann mit "Step" und "Next" die Abarbeitung Zeile für Zeile fortsetzen. Variablen können angeschaut und verändert werden. Und natürlich kann man weitere Haltepunkte setzen. Zu beachten ist, dass man mit dem Debugger nur etwas machen kann, wenn sich das Programm im angehaltenen Zustand befindet. Während das Programm auf eine Eingabe wartet oder am Rechnen ist, ist der Debugger ebenfalls nicht verwendbar.

Damit sollte es möglich sein, den Ablauf des Programms genauestens zu verfolgen und Fehler zu finden. Schwierig zu finden und sehr unangenehm sind sporadische Fehler sowie Abstürze, die dem Programmierer beim Testen nicht aufgefallen sind. Man kann in der Regel den Benutzer nicht dazu verdonnern, dass er sich mit dem Debugger selber auf Fehlersuche begibt. Neben Fehlern im Programm kann auch eine defekte Hardware beim Benutzer die Ursache sein. Häufig anzutreffen bei PCs sind fehlerhafte Speichermodule, überhitzte und übertaktete Prozessoren, Wackelkontakte an Steckverbindern, Schwankungen der Spannungsversorgung, lange IDE-Kabel, instabile Motherboards, usw.

Programme mit Fehlern oder unzureichend getestete sollten unbedingt als Beta-Version gekennzeichnet werden bevor man sie in Umlauf bring. Bei sehr großen Projekten sollte das Beta-Stadium erst dann verlassen werden, wenn eine ausreichend große Zahl an "normalen" Benutzern mit dem Programm arbeiten und keine Fehler mehr gemeldet werden. Eigene Tests reichen nicht aus. Wer den Quelltext veröffentlicht und einem breiten Publikum zugänglich macht, kann damit rechnen, dass neben Fehlerbeschreibungen auch fertige Vorschläge zu deren Behebung gemeldet werden.

Abstürze und core dumps

Zur Untersuchung der Ursache von Abstürzen gibt es unter Linux die core-Dateien. Im Falle eines Absturzes wird in eine solche Datei der Inhalt sämtlicher Variablen und sonstiger Daten des Programms geschrieben. Man spricht auch von einem core dump. Das Erzeugen solcher core dumps wird in der Shell aktiviert über den Befehl:

ulimit -c unlimited

Da core-Dateien recht groß werden können, ist mit ulimit die Beschränkung auf eine maximale Größe möglich. Zur Demonstration kann man z.B. folgendes Programm verwenden:

Listing 4: ctest2.c

#include <stdio.h>

const char teststring[] = "Teststring";

int main()
{
  teststring[2] = 0;
} 

Der Versuch, den konstanten String teststring zu verändern, wird zu einem Absturz führen. Unter Linux werden als konstant deklarierte Speicherbereiche vor dem Verändern geschützt. Das muß auf anderen Betriebssystemen oder mit einem anderen Compiler nicht unbedingt der Fall sein. Nach dem Start sollte folgende Meldung auf dem Bildschirm erscheinen:

Segmentation fault (core dumped)

Im aktuellen Verzeichnis findet sich eine Datei mit dem Namen core. Nach dem Aufruf von ddd lädt man zuerst das Programm, das den Absturz verursacht hat (Open Program...) und anschließend die core-Datei (Open Core Dump...). Sofort wird im Quelltext die Stelle das Absturzes angezeigt. Natürlich kann auch der Inhalt sämtlicher Variablen und andere Dinge angezeigt werden, so als ob das Programm ständig unter der Kontrolle des Debuggers gelaufen wäre. Im Übrigen braucht man sich bei Abstürzen unter Linux keine großen Sorgen zu machen. Die Beeinträchtigung anderer Programme ist ausgeschlossen. Das Betriebssystem räumt die Hinterlassenschaften des abgestürzten Programmes auf und gibt alle in Anspruch genommenen Ressourcen wieder frei. Ein Neustart des gesamtem Systems ist nicht notwendig. Die Debugging-Informationen machen die Dateilänge eines Programm unnötig groß. Man kann sie nachträglich entfernen mit dem Befehl:

strip ctest

Für das Erzeugen von core-Dateien ist es nicht erforderlich, dass sich Debugginginformationen im Programm befinden. Vor der Weitergabe an die Benutzer sollte daher ein solcher "Strip" durchgeführt werden. Im Falle eines Absturzes bittet man den Benutzer, das erzeugte core-File zuzuschicken. Ein umsichtiger Entwickler hat sich noch eine Version des Programms behalten, das mit der des Benutzers identisch ist, und das noch nicht "gestrippt" wurde. Und natürlich den bei der Kompilierung verwendeten Quelltext. Somit kann er der Ursache des Fehlers leicht nachgehen.

Abstürze können aber auch innerhalb von Bibliotheksfunktionen stattfinden, die man gar nicht selber entwickelt hat, und zu denen man keinen Quelltext installiert hat. Der Debugger kann dann nichts Brauchbares anzeigen. In der Regel liegt der Fehler aber an den falschen Argumenten, die man der Funktion übergibt. Im DDD kann man mit "Backtrace" an die Stelle zurückkehren, von wo der Funktionsaufruf erfolgt ist.

Speicherverwaltung unter Linux

Eigentlich herrschen heutzutage paradiesische Zustände: Speicher ist billig zu haben, die Standardausstattung heutiger Rechner ist so um die 64MB. Der Linux-Kernel sorgt automatisch für die effiziente Verwaltung und dafür, dass sich die Programme nicht gegenseitig in die Quere kommen. Warum soll man sich noch großartig Gedanken über das Thema machen? Jeder Programmierer, der so denkt, wird es früher oder später bereuen.

Jedes Programm besitzt einen eigenen Speicherbereich, der vor dem Zugriff durch andere Programme geschützt ist. Weder Lesen, noch Schreiben ist möglich. Die Ausnahme bilden Shared-Memory-Bereiche, die auf Wunsch des Programmierers eingerichtet werden können. Eine Variante davon sind die bereits bekannten dynamischen Bibliotheken, die von einer Vielzahl an Programmen gemeinsam genutzt werden können. Der Zugriff ist natürlich nicht uneingeschränkt möglich, denn eine Veränderung der Bibliothek durch eines der Programme ist ausgeschlossen.

Der Speicherbereich eines Programmes muß sich nicht unbedingt im Hauptspeicher befinden. Es kann sein, dass Teile davon auf eine Festplatte ausgelagert sind, auf die sog. Swap-Datei bzw. Swap-Partition. Es kann aber auch sein, dass ein Bereich noch nirgendwo physikalisch existiert. Man spricht daher von einem virtuellen Speicher. Das Betriebssystem sorgt automatisch dafür, dass bei einem versuchten Zugriff der gewünschte Bereich im Hauptspeicher zur Verfügung steht. Der Programmierer braucht sich darum nicht zu kümmern.

Im Programm speichertest.c wird ein 70MB großer dynamischer Speicher alloziert. Das kann durchaus mehr sein, als im Rechner physikalisch vorhanden ist. Anschließend wird an den Anfang, in die Mitte und an das Ende geschrieben. Beim Durchlauf des Programmes findet keine hektische Aktivität statt, weil lediglich einige winzige Teile des gesamten allokierten Bereiches benutzt und damit dem Programm zur Verfügung gestellt werden müssen. Ähnlich wie beim Luftverkehr wird der Speicherplatz sogar überbucht. Das heißt, der an Programme reservierte Speicher kann die Größe des Hauptspeichers und des Swap-Bereiches um ein Vielfaches überschreiten. Es kommt erst zu Problemen, wenn alle Programme gleichzeitig den von ihnen reservierten Bereich nutzen möchten.

Listing 5: speichertest.c

#include <stdio.h>
#include <stdlib.h>

/* Wert muß kleiner als Hauptspeicher+Swap sein */
#define MEMSIZE 70000000
/* wie oft soll die Allokierung wiederholt werden */
#define STRESSFAKTOR 10

int main()
{
  char *buffer[STRESSFAKTOR];
  int i;

  for(i = 0; i < STRESSFAKTOR; i++)
  {
    buffer[i] = (char *)malloc(MEMSIZE);
    if(buffer == NULL)
    {
      printf("Speicher konnte nicht alloziert werden!\n");
      exit(EXIT_FAILURE);
    }
    buffer[i][0] = 1;
    printf("Habe an den Anfang geschrieben.\n");
    buffer[i][MEMSIZE / 2] = 2;
    printf("Habe in die Mitte geschrieben.\n");
    buffer[i][MEMSIZE - 1] = 3;
    printf("Habe an das Ende geschrieben.\n");
  }

  /* alle Speicherbereiche freigeben */
  for(i = 0; i < STRESSFAKTOR; i++) free(buffer[i]);
  printf("Habe mich von der Last befreit.\n");
}

Das genaue Verhalten bei Speicherallokierungen wird von der jeweiligen Version der malloc-Bibliotheksfunktion bestimmt. Um die Verwaltung des Speichers effizienter zu machen, wird unter Linux zwischen mehreren Kategorien von Speicher unterschieden:

Eine Klasse ist der Programmspeicher, also der Bereich, in dem die einzelnen Maschinenbefehle abgelegt sind. Die konstanten Daten und Variablen können auch dazugezählt werden. Auf diesen Bereich ist nur ein Lesezugriff möglich. Ein Schreibzugriff führt zu einem Segmentation Fault, wie es in dem Programm weiter oben demonstriert wurde. Das bedeutet aber auch, dass Programme ihren eigenen Programmcode zur Laufzeit nicht verändern dürfen. In Zeiten des C64 war das ein beliebter Trick, um mehr Leistung und Funktionalität aus dem mageren Befehlssatz herauszuholen. Selbstmodifizierender Code ist aber möglich, sofern er in einen beschreibbaren Speicher untergebracht wird. Da sich der Programmspeicher nicht verändern kann, und in der Programmdatei ein genaues Abbild des Speichers existiert, braucht er vom Betriebssystem beim Auslagern in den Swap-Bereich nicht gesichert zu werden. Der auszulagernde Bereich wird einfach verworfen. Falls er wieder gebraucht wird, wird er aus der Programmdatei nachgeladen. Daher sollte man diese Datei nie löschen oder modifizieren, solange das betreffende Programm noch läuft. Ein Absturz wird früher oder später die Folge sein! Übrigens wird beim Start ein Programm nie komplett in den Hauptspeicher geladen. Es wird immer nur soviel geladen, wie für die Programmausführung benötigt wird. Ungenutzte Teile landen eventuell nie im Hauptspeicher. Bei einem erneuten Start desselben Programmes wird natürlich der gleiche, bereits geladene Teil verwendet. Es werden nicht mehrere Kopien angelegt.

Dann gibt es den Datenspeicher, der vom Programm verändert werden kann. Bereits unmittelbar nach dem Start kann ein solcher Datenspeicher zur Verfügung stehen. Falls er initialisiert sein soll, werden die Startwerte aus der Programmdatei gelesen. Nicht-initialisierter Speicher braucht hingegen keinen Platz in der Programmdatei. Es wird einfach ein Bereich mit ausreichender Größe beim Start dem Programm zur Verfügung gestellt. Um die Größe der Programmdatei zu reduzieren, sollte ein Programmierer daher so weit wie möglich auf initialisierte Daten verzichten. Zur Laufzeit kann natürlich noch weiterer Speicher alloziert werden. In der Sprache C geschieht dies üblicherweise mit dem Befehl malloc. Als C-Programmierer sollte man nie davon ausgehen, dass der nicht-initialisierte Speicher mit einem bestimmten Wert gefüllt ist, auch wenn das bei vielen Betriebssystemen so der Fall ist. Durch das Auffüllen wird verhindert, dass ein Programm die Daten anderer Programme lesen kann, die kurz zuvor beendet wurden.

Eine weitere Kategorie ist der Stapelspeicher, oder Stack. Auf ihm werden die lokalen Variablen eines C-Programmes abgelegt. Beim Einsprung in Unterprogramme dient er auch zum Ablegen der Funktionsargumente sowie der Rücksprungadresse. Je mehr Unterprogramm-Einsprünge und lokale Variablen man verwendet, umso mehr Stapelspeicher wird benötigt. Natürlich wird der Platz auf dem Stack wieder freigegeben, wenn das Unterprogramm verlassen wird, bzw. wenn die lokalen Daten nicht mehr benötigt werden. Die Größe des Stacks ändert sich ständig und ist vom Programmierer nicht ohne weiteres vorhersehbar. Daher wird der Stack automatisch vom Betriebssystem bei Bedarf vergrößert oder verkleinert. Mit dem Befehl ulimit -s kann eine maximale Größe eingestellt werden. Datenspeicher und Stack, die ständigen Veränderungen unterworfen sind, können nicht von Programmen gemeinsam genutzt werden. Die Ausnahme bilden Shared-Memory-Bereiche, auf die ich an dieser Stelle aber nicht eingehe. Mit

ulimit -a

werden die derzeit eingestellten Grenzen aller Parameter für neu zu startende Programme angezeigt. Die Ausgabe dieses Befehls sieht in etwa wie folgt aus:

core file size (blocks)     0
data seg size (kbytes)      unlimited
file size (blocks)          unlimited
max locked memory (kbytes)  unlimited
max memory size (kbytes)    unlimited
open files                  1024
pipe size (512 bytes)       8
stack size (kbytes)         unlimited
cpu time (seconds)          unlimited
max user processes          256
virtual memory (kbytes)     unlimited

Vom Administrator oder System vorgegebene Grenzen können natürlich nicht nachträglich vergrößert werden. Mit Hilfe von Prozeßmanagern wie dem kpm kann die Größe der einzelnen Speicherbereiche für die laufenden Prozesse angezeigt werden. (s. Abb. 4)


Abb. 4: Speicheranzeige mit kpm

Schutzzäune mit Electric Fence

In der Programmiersprache C gibt es aus Laufzeitgründen keine Überprüfung auf die Einhaltung der Grenzen von Datenfeldern (arrays). Ein beliebter Fehler besteht darin, dass über die Grenze eines Arrays oder eines allokierten Speicherbereiches hinaus gelesen oder geschrieben wird. Beispiel:

Listing 6: ctest3.c

#include <stdio.h>
#include <stdlib.h>

#define SIZE 302

int main()
{
  int *pointer;
  int i;

  /* Speicher allozieren */
  pointer = (int *)malloc(sizeof(int) * SIZE);
  if(pointer == NULL)
  {
    printf("Speicher ist ausgegangen!!\n");
    exit(EXIT_FAILURE);
  }

  /* etwas tun */
  for(i = 0; i <= SIZE; i++)
        pointer[i] = i;
  printf("Speicher wurde gefüllt!\n");


  /* Speicher wieder freigeben */
  free(pointer);

  /* noch etwas tun */
  for(i = 0; i <= SIZE; i++)
        pointer[i] = i;
  printf("Speicher wurde noch einmal gefüllt!\n");
} 

Offensichtlich ist alles in Ordnung. Das Programm läuft ohne Probleme durch. Bei genauerer Betrachtung erkennt man einige Unregelmäßigkeiten. In der ersten Schleife wird während des letzten Durchlaufs (i = SIZE) außerhalb des vorher allozierten Bereiches geschrieben! In der zweiten Schleife wird zudem in den bereits freigegebenen Block erneut geschrieben. Wieso bekommt man keinen Segmentation Fault, wo es doch unter Linux einen Speicherschutz geben sollte? Nun, das Betriebssystem kann den Speicher nur in Blöcken verwalten, die der Seitengröße des Prozessors entsprechen. Bei Intel-Prozessoren sind es 4K pro Seite. Solange die Grenze einer solchen Seite nicht erreicht ist, kann problemlos gelesen und geschrieben werden. Im obigen Beispiel stehen noch über 3K zur Verfügung, sofern der malloc-Befehl eine frische Seite bereitgestellt hat. Bei nachfolgenden Allozierungen wird natürlich versucht, den verbleibenden Speicher innerhalb der Seite aufzufüllen. Es kann also passieren, dass direkt im Anschluß an unseren Speicherblock ein weiterer folgt, oder dass sich dort genau die Seitengrenze befindet. Nur in letzterem Fall wird man sich wegen eines Segmentation Fault des Problems unmittelbar bewußt.

Die Freigabe von Speicher mit dem free-Befehl sorgt noch lange nicht dafür, dass auf den betreffenden Bereich kein Zugriff mehr möglich ist. Es kann aber vorkommen, dass nachfolgende malloc-Befehle den Bereich wieder in Anspruch nehmen. Oder aber das Betriebssystem nimmt sich die Seite irgendwann wieder zurück und vergibt sie einem anderen Programm, sofern sie vorher komplett leergeräumt wurde. Ein gewissenhafter Programmautor sollte alles tun, um Fehler wie in obigem Programm frühzeitig zu erkennen. Ein wichtiges Tool für solche Zwecke ist Electric Fence [2]. Die Benutzung ist sehr simpel: Einfach das zu testende Programm mit der Bibliothek efence linken:

cc -o ctest3 ctest3.c -lefence

Diesmal sollte die folgende Ausgabe erscheinen (je nach verwendeter Version):

Electric Fence 2.0.5 Copyright (C) 1987-1995 Bruce Perens.
Segmentation fault

Es wurde bereits der Fehler in der ersten Schleife erkannt. Überflüssig zu erwähnen, dass auch der nachträgliche Zugriff auf einen bereits freigegebenen Speicherbereiche sicher erkannt wird. kann Wie ist das möglich? Nun, in der Bibliothek efence werden die Speicherrroutinen malloc, free, usw. durch Spezialversionen ersetzt. Sie sorgen dafür, dass bei jeder Belegung eine eigene Seite verwendet wird. Durch Hinzufügen eines Offsets wird dafür gesorgt, dass das Ende des allozierten Bereichs tatsächlich an der Grenze der Seite liegt. Die kleinste Überschreitung führt sofort zu einem Segmentation Fault. Selbst in großen Programmen findet man die fehlerhafte Stelle leicht mit Hilfe eines Debuggers. Da sich an eine Seite eine zweite Seite desselben Programmes anschließen kann, wird von Electric Fence eine zusätzliche Seite als "Schutzwall" alloziert. Sie kann weder gelesen, noch beschrieben werden, und schließt sich unmittelbar an die zu begrenzende Seite an. Die Anordnung zum Testen auf Überlauf ist in Abb. 5 zu sehen.


Abb. 5: Von efence allokierte Seiten zum Erkennen von Überläufen

Sobald das getestete Programm versucht, auf das Element 9 des Arrays zuzugreifen, wird ein segmentation fault generiert. Ein Unterschreiten des angeforderten Speichers, z.B. ein Zugriff auf Element -1, kann nicht sicher erkannt werden. Für einen Test auf Unterlauf setzt man die Umgebungsvariable EF_PROTECT_BELOW auf den Wert 1 und startet das Programm erneut:

export EF_PROTECT_BELOW=1

Der Schutzwall schließt dann an Element 0 an. Mehr Informationen zu Electric Fence sind in der Manual-Page libefence zu finden. Zu beachten ist, dass der Speicherverbrauch eines Programms dramatisch zunehmen kann. Insbesondere dann, wenn sehr viele und sehr kleine Bereiche alloziert werden. Es werden ja immer 2 Seiten (also 8K auf Intel-Prozessoren) verbraten, auch wenn nur wenige Bytes angefordert wurden. Ärgerlich ist auch, wenn sich die Speicherfehler innerhalb von Bibliotheksfunktionen befinden. Unter Linux sollte das eigentlich nicht vorkommen. Im Falle des Falles kann man einen solchen Fehler selbst beheben, oder ihn zumindest dem Entwickler der Bibliothek mitteilen. Bei kommerziellen Unix-Systemen und kommerziellen Bibliotheken wird man natürlich schlechte Karten haben. Leider kann man nach dieser Methode nur Speicherfehler in dynamisch alloziertem Speicher finden. Weitere Tools zur Fehlersuche findet man unter [3].

Der Macher: Make

Bei größeren Programmen mit vielen Modulen wird es sehr umständlich, wenn alle Befehle zum Kompilieren von Hand eingegeben werden müssen. Zudem brauchen nur Module neu übersetzt zu werden, deren Quelltext geändert wurde. Diese Aufgabe und vieles mehr erledigt das Make-Tool. Dazu muß man eine Datei mit dem Namen makefile im aktuellen Verzeichnis erstellen. Anschließend genügt die Eingabe von make auf der Kommandozeile. Nachfolgend ist als Beispiel ein Makefile für ein Programm bestehend aus 3 Modulen (modul1.c, modul2.c, modul3.c) sowie einer Headerdatei dargestellt.

Listing 7: Makefile

#Beispiel für ein Makefile

#Name des Compilers
CC= gcc
#Optionen für den Compiler
CFLAGS= -O3 -s
#Optionen für den Linker
LDFLAGS= -s

OBJ= modul1.o modul2.o modul3.o

testprg: $(OBJ) makefile
        $(CC) $(LDFLAGS) -o testprg $(OBJ)

HEADERS= testprg.h
$(OBJ): $(HEADERS) makefile

Zu beachten ist, dass die Zeile

$(CC) $(LDFLAGS) -o testprg $(OBJ)

mit einem Tabulator beginnen muß. Bei Veränderung einer der 3 Quelltextdateien wird lediglich die zugehörige Objektdatei neu kompiliert und anschließend der Linker gestartet. Bei Veränderung der Headerdatei oder des makefile selbst werden alle Module neu übersetzt.

Geschwindigkeitsoptimierung

Ein gutes Programm zeichnet sich dadurch aus, dass es die ihm zugedachte Aufgabe nicht nur ohne Abstürze, sondern auch in möglichst kurzer Zeit erledigt. Die größte Beschleunigung erreicht man in der Regel durch eine Verbesserung des zugrunde liegenden Algorithmus. Falls man da nicht mehr weiterkommt, bieten sich folgende Möglichkeiten an:

Innere Schleifen möglichst klein. Dies wird erreicht, indem möglichst viele Aufgaben in die äußeren Schleifen gelegt wird, die weniger oft durchlaufen werden. Zum Teil macht das bereits der Compiler. Neben der Reduzierung der Anweisungen sollte auch der verwendete Speicherbereich möglichst klein gehalten werden. Dadurch findet der Prozessor seine Daten im Cache und muß nicht so häufig auf den langsameren Hauptspeicher zugreifen. Die Größe und Art der Caches (Zwischenspeicher) hängen vom eingesetzten Prozessortyp und der Hauptplatine ab.

Profiler einsetzen. Unerfahrene Programmierer wissen oft gar nicht, in welchen Teilen des Programms die meiste Rechenzeit "verbraten" wird. Oft ist es so, dass 1% des Programmcodes 99% der Rechenzeit verbraucht. Oder es ist gar nicht der eigene Code, sondern eine Bibliotheksfunktion. Dies erfährt man mit Hilfe eines Profilers. Unter Linux wird gprof eingesetzt. Die Verwendung ist einfach. Zuerst muß das Programm mit der Option -pg kompiliert werden. Anschließend läßt man es einmal durchlaufen. Es wird eine Datei mit dem Namen gmon.out erzeugt. Diese wird mit gprof analysiert:

gprof programmname gmon.out

Die anschließende Ausgabe sollte selbsterklärend sein. Man erhält Informationen über die Laufzeit von Funktionen im eigenen Programm sowie Angaben, wie oft sie aufgerufen wurden. Laufzeiten von Bibliotheksfunktionen sind nur dann enthalten, wenn mit einer speziellen Profiling-Version der Bibliothek gelinkt wurde. Das ist in der Regel nicht der Fall.

Optimierungsmöglichkeiten des Compilers ausnutzen. Optimierungen, welche zu einer erheblichen Vergrößerungen des Programms führen, werden in der Regel auch bei der höchsten Optimierungsstufe nicht aktiviert. Dazu zählen z.B. "loop unrolling" und das Ersetzen von Funktionsaufrufe durch inline-Funktionen. Beides kann manuell aktiviert werden. Es lohnt sich nur für die Module, wo sich wirklich ein Laufzeitgewinn ergibt.

Berechnungen durch Tabellenzugriff ersetzen. Trigonometrische Funktionen, Logarithmen, usw. kosten sehr viel Rechenzeit. Ein beliebter Trick besteht darin, sich eine Tabelle mit fertig berechneten Werten anzulegen. Dann ersetzt man z.B. a = sin(x); durch

a = sin_table[(int)(x * 100.0)];

Die Tabelle sin_table muß natürlich vorher mit geeigneten Werten gefüllt werden. Diese Methode geht nur dann, wenn es nicht auf eine hohe Genauigkeit ankommt. Zudem sollte man vor dem Zugriff auf die Tabelle die Einhaltung der Grenzen überprüfen.

Graphische Oberflächen

Die Akzeptanz eines Programmes kann durch eine graphische Oberfläche wesentlich erhöht werden. Für viele Anwendungen, wie z.B. bei einem Zeichenprogramm, ist sie sogar zwingend notwendig. Im Standard der Sprache C sind keine Befehle zur Programmierung von Graphik enthalten. Man benötigt dazu externe Funktionsbibliotheken sowie die zugehörigen Header-Dateien. Unter Linux kommt in der Regel das System X-Window für die graphische Ausgabe zur Anwendung. Die angebotenen Funktionen sind jedoch sehr "primitiv". Es existiert z.B. kein Befehl, mit dem man einen Bedienknopf oder eine Rolleiste (scrollbar) zeichnen könnte. Daher nimmt man noch zusätzliche Bibliotheken zur Hilfe. Die bekanntesten sind Motif/Lesstif, Qt, gtk. Die verwendete Bibliothek bestimmt in großem Maße das äußere Erscheinungsbild einer Anwendung und sollte daher sorgfältig gewählt werden. Es gibt unter Linux keinen einheitlichen Standard, wie die Bedienelemente eines Programmes auszusehen haben.

Bei der Programmierung können sogenannten Oberflächengeneratoren eine Hilfestellung bieten. Man zeichnet die Bedienelemente mit der Maus und erhält anschließend den zugehörigen Quelltext. In der Regel ist dann noch viel Handarbeit am erzeugten Code notwendig, bis er den Anforderungen entspricht und ein sinnvolles Programm entsteht.

Ausblick

Gängige Linux-Distributionen enthalten bereits alles was man braucht, um professionell programmieren zu können. Es ist nicht unbedingt notwendig, viel Geld in Programmiersprachen, Tools, Updates, Mitgliedschaften in Entwicklerorganisationen, usw. zu investieren. In dieser Hinsicht ist Linux nach wie vor ungeschlagen.

Infos

[1] http://www.cs.tu-bs.de/softech/ddd/
[2] http://perens.com/FreeSoftware/
[3] http://www.linuxprogramming.com/
Der Autor

Günther Röhrich ist zur Zeit als Planungsingenieur bei einem Festnetz- und Mobilfunknetzbetreiber tätig. Um dem alltäglichen Windows NT und Sun Solaris zu entkommen, setzt er zu Hause fast ausschließlich auf Linux.

Copyright © 2000 Linux-Magazin Verlag