3.2. Techniken der Treiberprogrammierung

Da Treiberprogrammierung Kernelprogrammierung ist, muss der Treiberentwickler die Standardtechniken des Kernels kennen. Welche stillschweigenden Übereinkünfte die Linux-Community in puncto Programmierung des Codes gefunden hat, wird in dem folgenden Kapitel ebenso behandelt wie die Standardmechanismen des Kernels und die Besonderheiten der objektorientierten Programmierung. Generell gilt: Innerhalb des Kernels wird auf besonders effiziente und auf hardwareunabhängige Programmierung geachtet. Entwickelt wird in der Sprache C, in Ausnahmefällen auch in Assembler.

3.2.1. Coding Style: Kernelcode lesen und Kernelcode schreiben

Wie Quelltexte geschrieben bzw. strukturiert werden, ist von Programmierer zu Programmierer unterschiedlich. Der Stil jedoch ist wichtig, wenn es um die Wartbarkeit und Lesbarkeit eines Quelltextes für andere Entwickler geht. Daher hat auch Linus Torvalds Stellung zum Thema Coding Style im Linux-Kernel genommen. Das sehr lesenswerte Dokument (Documentation/CodingStyle) befindet sich in den Kernelquellen. Solange jemand Code schreibt, von dem auszugehen ist, dass nicht nur der Programmierer selbst den Code liest, sollten die Hinweise ernst genommen werden.

Linus Torvalds empfiehlt, den so genannten »Kernighan und Ritchie (K&R)«-Stil anzuwenden. Dieser eher kompakte, auch in diesem Buch verwendete Programmierstil setzt beispielsweise die öffnende Klammer eines Blockes immer direkt hinter das den Block einleitende Statement:

if( vptr==NULL ) {
        // Blockinhalt
        ...
}

Daneben wird empfohlen, Einrückungen (Tabulator) immer auf acht Zeichen zu stellen.

Variablen und Funktionsnamen sollen beschreibenden Charakter haben. Der Typ braucht nicht in den Namen mit einkodiert zu werden.

Bereits zu Beginn einer jeden Programmierausbildung lernt man, die Quelltexte ausreichend zu kommentieren. Doch genauso, wie darauf zu achten ist, Code überhaupt zu kommentieren, ist eine Überkommentierung zu vermeiden, da sie die Lesbarkeit eines Quelltextes verschlechtert.

Ein Stück Code sollte möglichst selbsterklärend sein. Ist ein Kommentar notwendig, sollte dieser erläutern, was der Code macht, keinesfalls, wie der Code arbeitet.

Die Ausführungen von Linus Torvalds sind allerdings nur ein Mosaik aus den zahlreichen Diskussionen der Linux-Szene zum Thema Programmierung. Mittlerweile haben sich einige Besonderheiten fest etabliert. Zum Beispiel: Ist ein Stück Code nicht komplett fertiggestellt, so setzt man dem Kommentar das Wort TODO voran.

/* TODO: Handle BREAK signal */

Ist der Entwickler nicht sicher, ob die Funktionalität komplett richtig implementiert ist, wird das Schlüsselwort FIXME verwendet.

/* FIXME! Get the fault counts properly! */

Und wenn etwas »auf die Schnelle« implementiert wurde und zu einem späteren Zeitpunkt noch »vernünftig« implementiert werden soll, wird ein Kommentar mit drei X (XXX, mit der englischen Bedeutung für »kludge«, »Hack«) begonnen.

/* XXX This function is NOT 64-bit clean! */

In vielen Programmen sieht man immer wieder, dass Codestücke mit Hilfe von Kommentarzeichen auskommentiert werden. Das ist nicht effizient, denn sobald eine der auskommentierten Zeilen ebenfalls ein Kommentarzeichen enthält, muss jede Zeile einzeln bearbeitet werden.

Kernelhacker deaktivieren den Codeblock im Regelfall mit Hilfe des Präprozessor-Statements #if 0 (siehe Beispiel Auskommentieren von Codeblöcken).

Beispiel 3-3. Auskommentieren von Codeblöcken

#if 0
// dieser Teil ist jetzt deaktiviert
for( i=0; i<10; i++ ) {  /* Da kann auch ruhig */
    var[i] = glob_var+time; /* Kommentar stehen.  */
}
#endif

Um das Codestück wieder zu aktivieren, wird aus dem #if 0 ein #if 1 gemacht.

3.2.2. Kernelcode kodieren

Ein Treiber ist kein eigenständiges Stück Software, sondern eine zusätzliche Komponente für ein bestehendes und sogar laufendes Programm, den Kernel. Kernelcode ist sorgsam zu planen und zu implementieren – allzu leicht ist es möglich, das gesamte System zum Absturz zu bringen.

Wer für den Kernel programmiert, sollte neben den stilistischen Gesichtspunkten auch programmtechnische Aspekte beachten.

Nutzung von Bibliotheksfunktionen. Innerhalb des Betriebssystemkerns stehen die Funktionen der C-Standardbibliothek nicht zur Verfügung. Der Programmierer kann nur wenige, ausgewählte Funktionen, beispielsweise die Funktionen zur String-Bearbeitung, nutzen.

Fließkomma-Operationen. Da innerhalb des Kernels die Fließkommavariablen aus Effizienzgründen nicht gerettet werden, sind im Treiber ohne besondere Maßnahmen keine Floating Point-Operationen erlaubt.

Eingeschränkter Stackbereich. Bei der Treiberentwicklung ist zu beachten, dass innerhalb des Betriebssystemkerns begrenzte Stack-Ressourcen zur Verfügung stehen. Konsequenterweise dürfen innerhalb des Treibers keine rekursiven Funktionen verwendet werden. Auch sollten keine großen Datenbereiche auf dem Stack reserviert werden. Werden kurzfristig größere Datenbereiche benötigt, sollten diese – solange keine zeitlichen Aspekte dagegen sprechen – per kmalloc dynamisch alloziert werden.

Speicherschutz. Der auf Applikationsebene vorhandene Speicherschutz zwischen Rechenprozessen ist innerhalb des Kernels aufgelöst. Ein Treiber hat damit Zugriff auf den gesamten Kernel-Space; Fehler können deshalb zu Instabilitäten bis hin zum Systemabsturz führen.

Goto im Kernel. Moderne Programmiersprachen wie etwa Java kommen ohne goto aus. C lässt sich ebenfalls vollkommen ohne goto programmieren, auch wenn dieses Schlüsselwort innerhalb der Programmiersprache definiert ist. Im Rahmen der strukturierten Programmierung ist goto zu vermeiden.

Daher mag es überraschen, dass innerhalb des Linux-Kernels an diversen Stellen mit goto programmiert wird. Wenn ein goto dabei hilft, effizienteren und auch übersichtlicheren Code zu erstellen, ist die Verwendung legitim und im Kernel sogar erwünscht.

Im Wesentlichen wird innerhalb des Linux-Kernels goto immer im gleichen Konstrukt verwendet, nämlich um aus einer Funktion auszuspringen und dabei durchgeführte Initialisierungen wieder rückgängig zu machen.

Benötigt beispielsweise ein Treiber drei Ressourcen, wovon die ersten beiden alloziert werden konnten, die dritte aber nicht, so müssen die ersten beiden wieder freigegeben werden. Ist bereits die Reservierung der zweiten Ressource gescheitert, ist nur die erste allozierte Ressource wieder freizugeben. Der entsprechende Code sieht ohne goto wie folgt aus:

if( register_chardev( ...) not failed ) {
    if( request_region(...) failed ) {
        unregister_chardev(...);
        return -EIO;
    }
    if( request_irq(...) failed ) {
        free_region(...);
        unregister_chardev(...);
        return -EIO;
    }
    return 0; // success
}
return -EIO;

Dieser Code ist nicht effizient, da beispielsweise der Aufruf der Funktion unregister_chardev(...) mehrfach vorkommt. Bei Verwendung von goto lässt sich dies vermeiden. Das folgende Listing zeigt den Code, wie er in etwa an vielen Stellen des Linux-Kernels und insbesondere in Treibern zu finden ist:

if( register_chardev( ...) not failed ) {
    if( request_region(...) failed )
        goto out_region;
    if( request_irq(...) failed )
        goto out_irq;
    return 0; // success
} else {
    goto out;
}
out_irq:
    free_region(...);
out_region:
    unregister_chardev( ... );	
out:
    return -EIO;

3.2.3. Objektbasierte Programmierung im Kernel

Objektorientiertheit fängt im Kopf, nicht mit der Programmiersprache an. Bei einer objektorientierten Programmiersprache werden objektorientierte Programmierparadigmen zwar durch die Sprache unterstützt, doch kann der Entwickler auch ohne diese objektorientiert programmieren. Zentrale Teile des in C geschriebenen Linux-Kernels sind objektorientiert, Entsprechendes gilt für die Treiber.

Im Wesentlichen finden sich hierbei zwei zusammengehörige Elemente:

  1. die mehrfach verwendbaren Funktionen (der Code)

  2. und die objektbasierte Datenhaltung (die Daten).

Das zugrunde liegende Prinzip lautet: Code und Daten sind strikt zu trennen. Die bei der Abarbeitung einer Funktion möglicherweise auftretenden und zu einem späteren Zeitpunkt benötigten Zustandsdaten werden nicht innerhalb der Funktion bzw. einer globalen Variablen gespeichert, sondern in einem Datenbereich, dem Objekt, mit dem die Funktion arbeitet. Als Konsequenz ist die Funktion selbst zustandslos, verfügt also für sich über keinerlei Datenbereiche, die über den Aufruf einer Funktion hinweg gültig bleiben. Alle Daten sind lokal, liegen also auf dem Stack oder aber werden in den beim Aufruf übergebenen Datenbereich (dem Objekt) abgelegt.

Derart gestaltete Funktionen sind vielfältig und insbesondere mehrfach zu verwenden.

Abbildung 3-4. Verkettete Liste

Ein typischer Anwendungsfall ist die Implementierung einer verketteten Liste. Benötigt man nur eine einzige verkettete Liste, könnte man den Zeiger auf das erste Element der Liste (root) global definieren. Bedarf es jedoch mehrerer, unabhängiger Listen bzw. will man eine allgemeine Lösung programmieren, dann interpretiert man jede Liste als Objekt, das insbesondere durch den Zeiger auf das erste Element (Root-Zeiger) definiert ist. Dieser Root-Zeiger wird als Parameter mit übergeben (siehe Beispiel Verkettete Liste mit und ohne objektbasierter Datenhaltung).

Beispiel 3-4. Verkettete Liste mit und ohne objektbasierter Datenhaltung

typedef struct _liste {
    struct _liste *next;
    void *element;
} liste;

static liste *root = NULL; 

// Liste mit globalem Root-Zeiger.
// Nicht objektorientiert - nicht mehrfach verwendbar.
static liste *liste_add( liste *new_element )
{
    liste *lptr;

    if( root == NULL )
        return( NULL );
    for( lptr=root; lptr->next; lptr=lptr->next )
        ;
    lptr->next = calloc( 1, sizeof(liste) );
    lptr->next->element = new_element;
    return lptr->next;
}

// Mehrfach verwendbare Liste (mit objektorientierter Datenhaltung)
// Der aufrufspezifische Parameter ist rootptr. Dieser speichert
// Informationen über die Aufrufe hinweg, für jede Instantiierung
// (der Liste) jedoch getrennt.
static liste *liste_add( list *rootptr, liste *new_element )
{
    liste *lptr;

    if( rootptr == NULL )
        return NULL;
    for( lptr=rootptr; lptr->next; lptr=lptr->next )
        ;
    lptr->next = calloc( 1, sizeof(liste) );
    lptr->next->element = new_element;
    return lptr->next;
}

	

Abbildung 3-5. Lebenszyklus eines Kernel-Objektes

Innerhalb des Linux-Kernels wird sehr viel mit Objekten und mit der Trennung von Code und Daten gearbeitet. Abbildung Lebenszyklus eines Kernel-Objektes stellt den Lebenszyklus eines solchen Objektes dar. Das Anlegen eines Objektes innerhalb des Kernels bedeutet nichts weiter als den Speicher zur Verfügung zu stellen, sei es durch eine statische Definition oder aber durch das dynamische Allozieren des Speichers. Die Übergabe des initialisierten Objektes an den Kernel beziehungsweise an ein Subsystem des Kernels erfolgt durch Aufruf einer Funktion, die meist subsystem_register heißt, wobei subsystem durch den eigentlichen Namen des Subsystems zu ersetzen ist. Das Abmelden vollzieht sich analog, per subsystem_unregister.

Die Objekte innerhalb des Kernels sind mit Hilfe von Datenstrukturen beschrieben. Da zu einem Objekt nicht nur Variablen, sondern auch Methoden (die Funktionen, die auf die Daten wirken) gehören, ist wesentlicher Teil dieser Datenstruktur eine Reihe von Funktionspointern. Der folgende Codeausschnitt zeigt die Initialisierung eines Timer-Objektes (mit dem Namen my_timer) und die Übergabe an den Kernel (mit Hilfe der Funktion add_timer). Das Timer-Objekt speichert die Daten im Element data. Es besitzt eine Methode, deren Adresse im Element function abgelegt wird.

    my_timer.data = zaehler_obj;  // Objekt (Zustands-Daten)
    my_timer.function = zaehlen;  // Adresse der Funktion (Code)
    add_timer( &my_timer );   // Übergabe von Code und Daten

Eine weitere, immer wieder anzutreffende Standardtechnik im Umgang mit Objekten ist der Schutz vor vorzeitiger Entfernung aus dem Kernel.

Generell muss in einem Treiber sichergestellt werden, dass Ressourcen so lange nicht freigegeben werden, wie sie von irgendwelchen Instanzen genutzt werden. Aus diesem Grund wird meist an einer die Ressource repräsentierenden Datenstruktur, dem Objekt, ein Zähler (innerhalb des Kernels wird dafür gern der Name usecount verwendet) angefügt. Jedes Mal, wenn die Ressource durch eine Instanz angefordert wird, wird der Zähler erhöht. Beendet die Instanz den Zugriff auf die Ressource, wird der Zähler wieder erniedrigt. Die Ressource selbst wird nur dann freigegeben, wenn der Wert des Zählers »0« ist.

Abbildung 3-6. Schutz vor vorzeitiger Freigabe

Innerhalb des Kernels werden Referenz-Counts beispielsweise benötigt, um sicherzustellen, dass ein Treiber so lange aktiv bleibt, wie Applikationen darauf zugreifen. Ist der Treiber als zur Laufzeit des Kernels ladbares Modul realisiert und bereits wieder entladen, wenn eine Applikation eine Funktion des Treibers aufruft (triggert), würde der Kernel Code ausführen wollen, der sich schon längst nicht mehr im Speicher befindet. Es kommt zu einem Fehler.

Das Mitzählen bei Modulen wird durch die Kernelfunktionen automatisch vorgenommen, zumindest in den Fällen, in denen die zum Treiber gehörigen Datenstrukturen (z.B. die struct file_operations *) richtig initialisiert werden. Dafür ist der Programmierer zuständig.


Lizenz