2.5    Eingabe und Ausgabe

Kommen wir nun zur Ein- und Ausgabe, einer der wichtigsten Funktionen eines Betriebssystems. Wir wollen diese dabei einmal prinzipiell und einmal konkret am Beispiel des Dateisystems behandeln.

Mittlerweile besteht rund die Hälfte des Kernelcodes aus Quelldateien für verschiedenste Treiber. Die anderen Komponenten des Kernels sind architekturspezifischer Code (dieser ist Treibern zumindest in seiner Hardwareabhängigkeit ähnlich), Code des Soundsystems, Dokumentation, Dateisystemimplementierungen, Headerdateien, Netzwerk-Stacks sowie diverse kleinere Komponenten – etwa zur Interprozesskommunikation oder kryptografische Routinen. Im Folgenden wollen wir zunächst erklären, was Sie sich unter Treibern vorstellen können.

2.5.1    Hardware und Treiber

Damit ein Gerät angesprochen werden kann, muss man »seine Sprache sprechen«. Man muss genau definierte Daten in spezielle Hardwareregister oder auch Speicherstellen laden, um bestimmte Effekte zu erzielen. Daten werden hin und her übertragen, und am Ende druckt ein Drucker eine Textdatei oder man liest Daten von einer CD.

Zur Übersetzung einer Schnittstelle zwischen den Benutzerprogrammen, die beispielsweise einen CD-Brenner unterstützen, und den eventuell von Hersteller zu Hersteller unterschiedlichen Hardwareschnittstellen benötigt man Treiber. Für die Anwenderprogramme werden die Geräte unter Unix als Dateien visualisiert. Als solche können sie natürlich von Programmen geöffnet und benutzt werden: Man sendet und empfängt Daten über Syscalls. Wie die zu sendenden Steuerdaten genau auszusehen haben, ist von Gerät zu Gerät unterschiedlich.

Auch das vom Treiber bereitgestellte Interface kann sich von Gerät zu Gerät unterscheiden. Bei USB-Sticks oder CD-ROM-Laufwerken wird es sicherlich so beschaffen sein, dass man die Geräte leicht in das Dateisystem integrieren und auf die entsprechenden Dateien und Verzeichnisse zugreifen kann. Bei einem Drucker jedoch möchte man dem Gerät die zu druckenden Daten schicken; das Interface wird also eine völlig andere Struktur haben. Auch kann ein Gerät durch verschiedene Treiber durchaus mehrere Schnittstellen anbieten: Eine Festplatte kann man sowohl über eine Art Dateisystem-Interface als auch direkt über das Interface des IDE-Treibers ansprechen.

Module

Die meisten Treiber sind unter Linux als Module realisiert. Solche Module werden zur Laufzeit in den Kernel eingebunden und stellen dort dann eine bestimmte Funktionalität zur Verfügung. Dazu sind aber einige Voraussetzungen zu erfüllen:

Was aber wäre die Alternative zu Treibern in Modulform? Treiber müssen teilweise privilegierte Befehle zur Kommunikation mit den zu steuernden Geräten nutzen, daher müssen sie zumindest zum großen Teil im Kernelmode ablaufen. Und wenn man sie nicht zur Laufzeit in den Kernel laden kann, müssten sie schon von Anfang an in den Kernelcode integriert sein.

Würde man jedoch alle verfügbaren Treiber »ab Werk« direkt in den Kernel kompilieren, wäre der Kernel sehr groß und damit langsam sowie speicherfressend. Daher sind die meisten Distributionen dazu übergegangen, ihre Kernels mit in Modulform kompilierten Treibern auszuliefern. Der Benutzer bzw. die Benutzerin kann dann alle benötigten Module laden – oder das System erledigt diese Aufgabe automatisch.

Zeichenorientierte Treiber

Treiber müssen ins System eingebunden werden, mit anderen Worten: Man benötigt eine einigermaßen uniforme Schnittstelle. Aber kann man zum Beispiel eine USB-Webcam und eine Festplatte in ein einheitliches und trotzdem konsistentes Muster bringen? Nun ja, Unix hat es zumindest versucht. Es unterscheidet zwischen zeichenorientierten und blockorientierten Geräten und klassifiziert damit auch die Treiber entsprechend. Der Unterschied ist dabei relativ simpel und doch signifikant:

Ein zeichenorientiertes Gerät sendet und empfängt Daten direkt von Benutzerprogrammen.

Der Name der zeichenorientierten Geräte leitet sich von der Eigenschaft bestimmter serieller Schnittstellen ab, nur jeweils ein Zeichen während einer Zeiteinheit übertragen zu können. Diese Zeichen konnten nun aber direkt – also ohne Pufferung – gesendet und empfangen werden. Eine weitere wichtige Eigenschaft ist die, dass auf Daten im Allgemeinen nicht wahlfrei zugegriffen werden kann. Man muss eben mit den Zeichen vorliebnehmen, die gerade an der Schnittstelle anliegen.

Blockorientierte Treiber

Bei blockorientierten Geräten werden im Unterschied dazu meist ganze Datenblöcke auf einmal übertragen. Der klassische Vertreter dieser Gattung ist die Festplatte, bei der auch nur eine blockweise Übertragung der Daten sinnvoll ist. Der Lesevorgang bestimmter Daten gliedert sich nämlich in diese Schritte:

  1. Aus der Blocknummer – einer Art Adresse – wird die physische Position der Daten ermittelt.

  2. Der Lesekopf der Platte bewegt sich zur entsprechenden Stelle.

  3. Im Mittel muss nun eine halbe Umdrehung gewartet werden, bis die Daten am Kopf anliegen.

  4. Der Lesekopf liest die Daten.

Die meiste Zeit braucht nun aber die Positionierung des Lesekopfs, denn wenn die Daten einmal am Kopf anliegen, geht das Einlesen sehr schnell. Mit anderen Worten: Es ist für eine Festplatte praktisch, mit einem Zugriff gleich mehrere Daten – zum Beispiel 512 Bytes – zu lesen, da die zeitaufwendige Positionierung dann eben nur einmal statt 512-mal erfolgen muss.

Blockorientierte Geräte haben die gemeinsame Eigenschaft, dass die übertragenen Daten gepuffert werden. Außerdem kann auf die gespeicherten Blöcke wahlfrei, also in beliebiger Reihenfolge zugegriffen werden. Darüber hinaus können Datenblöcke mehrfach gelesen werden.

Bei einer Festplatte hat diese Tatsache nun gewisse Vorteile wie auch Nachteile: Während des Arbeitens bringen zum Beispiel Schreib- und Lesepuffer eine hohe Performance. Wenn ein Benutzer bzw. eine Benutzerin die ersten Bytes einer Datei lesen möchte, kann man schließlich auch gleich ein Readahead machen und die darauf folgenden Daten schon einmal vorsichtshalber im Hauptspeicher puffern. Dort können sie dann ohne Zeitverzug abgerufen werden, wenn ein Programm – was ziemlich wahrscheinlich ist – in der Datei weiterlesen will. Will es das nicht, gibt man den Puffer nach einiger Zeit wieder frei.

Beim Schreibpuffer sieht das Ganze ähnlich aus: Um performanter zu arbeiten, werden Schreibzugriffe in der Regel nicht sofort, sondern erst in Zeiten geringer Systemauslastung ausgeführt. Wenn ein System nun aber nicht ordnungsgemäß heruntergefahren wird, kann es zu Datenverlusten bei eigentlich schon getätigten Schreibzugriffen kommen. Wenn die Daten n"amlich in den Puffer, aber eben noch nicht auf die Platte geschrieben wurden, sind sie weg.

Ein interessantes Beispiel für die Semantik dieser Treiber ist eine USB-Festplatte. Es handelt sich bei diesem Gerät schließlich um eine blockorientierte Festplatte, die über einen seriellen, zeichenorientierten Anschluss mit dem System verbunden ist. Sinnvollerweise wird die Funktionalität der Festplatte über einen blockorientierten Treiber angesprochen, der aber intern wiederum über den USB-Anschluss und damit über einen zeichenorientierten Treiber die einzelnen Daten an die Platte schickt bzw. von ihr liest.

Der wahlfreie Zugriff auf die Datenblöcke der Festplatte wird also über die am seriellen USB-Anschluss übertragenen Daten erledigt. Der Blocktreiber nutzt eine bestimmte Sprache zur Ansteuerung des Geräts, und der zeichenorientierte USB-Treiber überträgt dann die »Wörter« dieser Sprache und gegebenenfalls zu lesende oder zu schreibende Daten.

2.5.2    Interaktion mit Geräten

Da wir im letzten Abschnitt die unterschiedlichen Treiber allgemein beschrieben haben, wollen wir im Folgenden den Zugriff auf sie aus dem Userspace heraus betrachten und dabei ihren internen Aufbau analysieren.

Gehen wir also wieder ein paar Schritte zurück, und führen wir uns vor Augen, dass Geräte unter Linux allesamt als Dateien unterhalb des /dev-Verzeichnisses repräsentiert sind. Die Frage ist nun, wie man diese Geräte und Ressourcen nutzen kann und wie der Treiber diese Nutzung unterstützt.

Den passenden Treiber finden

Das Programm udev (»userspace /dev«) verwaltet das /dev-Dateisystem. Es überwacht Hotplug-Ereignisse des Rechners, also Ereignisse wie »ein Gerät wurde mit einem USB-Port verbunden/von ihm getrennt«. udev identifiziert die verbundenen Geräte über Serien-, Hersteller- oder Produktnummern und legt auf Basis vordefinierter Regeln Gerätedateien im /dev-Dateisystem an.

Auf das Gerät zugreifen

Geräte sind also Dateien, auf die man im Wesentlichen mit den üblichen Syscalls zur Dateibearbeitung zugreifen wird. Bei der Kommunikation mit Gerätedateien werden die C-Funktionen fopen(), fprintf() usw. in der Regel nicht verwendet. Zwar greifen diese Funktionen intern auch auf die Syscalls zurück, allerdings wird standardmäßig die gepufferte Ein-/Ausgabe benutzt, was im Regelfall für die Kommunikation mit Geräten nicht ideal ist. Die typischen Syscalls für den Gerätezugriff sind dabei:

Diese Syscalls müssen nun natürlich vom Treiber als Callbacks bereitgestellt werden. Callbacks sind Funktionen, die genau dann ausgef"uhrt werden, wenn ein entsprechender Event – in diesem Fall der Aufruf des entsprechenden Syscalls auf eine Gerätedatei – auftritt.

Wenn eine Applikation also mittels open() eine Gerätedatei öffnet, stellt der Kernel den zugehörigen Treiber anhand einer sogenannten Major/Minor- beziehungsweise der Gerätenummer fest. Danach erstellt er im Prozesskontext eine Datenstruktur vom Typ struct file, in der sämtliche Optionen des Dateizugriffs wie die Einstellung für blockierende oder nicht blockierende Ein-/Ausgabe oder natürlich auch die Informationen zur geöffneten Datei gespeichert werden.

Als Nächstes wird der in der file_operations-Struktur vermerkte Callback für den open()-Syscall aufgerufen, dem unter anderem eine Referenz dieser file-Struktur übergeben wird. Anhand dieser Referenz wird auch bei allen anderen Callbacks die Treiberinstanz referenziert. Eine Treiberinstanz ist notwendig, da ein Treiber die Möglichkeit haben muss, sitzungsspezifische Daten zu speichern.

Solche Daten könnten zum Beispiel einen Zeiger umfassen, der die aktuelle Position in einem Datenstrom anzeigt. Dieser Zeiger muss natürlich pro geöffneter Datei eindeutig sein, selbst wenn ein Prozess ein Gerät mehrfach geöffnet hat.

2.5.3    Ein-/Ausgabe für Benutzerprogramme

Für Benutzerprogramme spiegelt sich dieser Kontext im Deskriptor wider, der nach einem erfolgreichen open() als Rückgabewert an das aufrufende Programm übergeben wird:

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>

int main()
{
  // Ein Deskriptor ist nur eine Identifikationsnummer.
  int fd;
  char text[256];

  // Die Datei "test.c" lesend öffnen und den zurück-
  // gegebenen Deskriptor der Variable fd zuweisen
  fd = open( "test.c", O_RDONLY );

  // Aus der Datei unter Angabe des Deskriptors lesen
  read( fd, text, 256 );

  // "text" verarbeiten
  // Danach die Datei schließen
  close( fd );

  return 0;
}

Listing 2.23     Einen Deskriptor benutzen

Ein wichtiger Syscall im Zusammenhang mit der Ein-/Ausgabe auf Gerätedateien ist ioctl() (I/O-Control). Über diesen Syscall werden alle Funktionalitäten abgebildet, die sich nicht in das standardisierte Interface einbauen lassen.

2.5.4    Dateisysteme

Ein besonderer Fall der Ein-/Ausgabe ist das Dateisystem, das wir im Folgenden näher behandeln wollen. Eigentlich müssen wir zwischen »Dateisystem« und »Dateisystem« unterscheiden, da Unix mehrere Schichten für die Interaktion mit Dateien benutzt. Über dem physischen Dateisystem, also der Hardware, liegt ein virtuelles Dateisystem, das die Verarbeitung von Daten vereinfacht.

Der VFS-Layer

Die oberste Schicht des Dateisystems ist der sogenannte VFS-Layer (engl. virtual filesystem). Das virtuelle Dateisystem ist eine Schnittstelle, die die grundlegenden Funktionen beim Umgang mit Dateien von den physischen Dateisystemen abstrahiert:

Die Benutzer beziehungsweise ihre Programme greifen nun über solche uniformen Schnittstellen des VFS auf die Funktionen und Daten des physischen Dateisystems zu. Der Treiber des Dateisystems muss also entsprechende Schnittstellen anbieten, damit er in das VFS integriert werden kann.

Das Einbinden eines Dateisystems in das VFS nennt man Mounting. Eingebunden werden die Dateisysteme unterhalb von bestimmten Verzeichnissen, den sogenannten Mountpoints. Definiert wird das Ganze in einer Datei im Userspace, /etc/fstab:

# Proc-Verzeichnis
proc         /proc    proc    defaults                     0 0

# Festplatten-Partitionen
UUID=c5d055a1-8f36-41c3-9261-0399a905a7d5
             /        ext3    relatime,errors=remount-ro   0 1
UUID=c2ce32e7-38e4-4616-962e-8b824293537c
             /home    ext3    relatime                     0 2
# Swap
/dev/sda7    none     swap    sw                           0 0

# Wechseldatenträger
/dev/scd0    /mnt/dvd udf,iso9660 user,noauto,exec,utf8    0 0

Listing 2.24     Eine /etc/fstab-Datei

Interessant sind für uns im Moment dabei vor allem die ersten beiden Spalten dieser Tabelle: Dort werden das Ger"at sowie der Mountpoint angegeben, wo das darauf befindliche Dateisystem eingehängt werden wird.

Besonders interessant ist an dieser Stelle das Root-Dateisystem /. Die /etc/fstab befindet sich, wie gesagt, irgendwo auf dem Dateisystem, auf das man nur zugreifen kann, wenn man zumindest das Root-Dateisystem schon gemountet hat. Man hat also das klassische Henne-Ei-Problem, das nur gelöst werden kann, wenn der Kernel den Ort des Root-Dateisystems als Option beim Booten übergeben bekommt.

So kennen die Bootmanager (bspw. grub und der veraltete lilo) eine Option root, mit der man dem zu bootenden Kernel mitteilt, was sein Root-Dateisystem sein soll. Von diesem kann er dann die fstab lesen und alle weiteren Dateisysteme einbinden.