2    So funktioniert Linux

»Ach, der Mensch begnügt sich gern.
Nimmt die Schale für den Kern.«
– Albert Einstein (zugeschrieben)

Wie bereits in der Einleitung erwähnt, bezeichnet der Begriff Linux eigentlich nur den Kern des Betriebssystems, kurz »Kernel« genannt. In diesem Kapitel wollen wir nun erklären, wie Linux unter der Oberfläche funktioniert. Dazu werden wir am Beispiel des Linux-Kernels die Grundlagen einer typischen Betriebssystemarchitektur vorstellen und dabei die in diesem Kontext zu lösenden Probleme sowie zentrale Zusammenhänge erläutern.

Zwar ist es wenig sinnvoll, im Rahmen dieses Buches jede einzelne Quelldatei des Linux-Kernels zu besprechen, jedoch werden wir an geeigneten Stellen die eine oder andere konkrete Verbindung zum Quellcode herstellen. Schließlich kann man bei Linux auf die Kernelquellen zugreifen und sich selbst von der Korrektheit der folgenden Aussagen überzeugen.

Zum Aufbau und zur Ausrichtung dieses Kapitels hat uns folgende Erfahrung angeregt: Selbst Leute, die viel auf ihr Fachwissen halten, disqualifizieren sich regelmäßig durch Aussagen wie:

Solche Sätze sind entweder Halbwahrheiten oder sogar ausgemachter Unsinn. Nach diesem Kapitel sollten Sie diese und andere gängige Aussagen und Internetmythen in den richtigen Zusammenhang bringen können. Außerdem legt dieses Kapitel eine Grundlage für das Verständnis von Linux und damit für den Rest des Buches.

2.1    Grundlagen

Beginnen wir mit den wichtigsten Grundlagen, die Sie für das Verständnis des restlichen Kapitels benötigen werden. Viele dieser Informationen erscheinen Ihnen vielleicht selbstverständlich – trotzdem empfehlen wir Ihnen, diesen Abschnitt sorgfältig zu lesen. Vielleicht wird doch noch der eine oder andere mutmaßlich bekannte Fakt etwas deutlicher oder lässt sich in den richtigen Kontext einordnen.

Fangen wir also beim Computer selbst an. Wenn man so davorsitzt, sieht man in erster Linie Dinge, die mit seiner Funktion herzlich wenig zu tun haben: Tastatur, Maus und Bildschirm. Diese Geräte braucht der Mensch, um irgendwie mit dem Rechner in Kontakt zu treten – das allgegenwärtige »Brain-Interface« ist ja schließlich noch Science-Fiction.

Was in einem Computer rechnet (und nichts anderes tut das Ding, selbst wenn wir Texte schreiben oder im Netz surfen)[ 8 ], ist der Prozessor.

2.1.1    Prozessor

In den meisten PCs steckt heutzutage ein mit Intel x86 kompatibler Prozessor, wobei mittlerweile die ARM-Architektur zunehmende Verbreitung findet. So ein auch CPU (Central Processing Unit) genannter Mikrochip hat im Wesentlichen drei Aufgaben:

Die letzte Aufgabe deutet schon an, dass ein Prozessor keine »gottgegebenen« Dinge tut. Vielmehr führt er ein in Maschinencode vorliegendes Programm aus.

Wie dieser Maschinencode nun aussieht, bestimmt der Befehlssatz des Prozessors. Mit anderen Worten gibt es nicht den Maschinencode, sondern viele Maschinencodes – so ziemlich für jeden Prozessor einen eigenen. Ausnahmen bilden nur Fabrikate wie die Prozessoren von AMD, die im Wesentlichen Intels x86-Befehlscode ausführen. Allerdings ist diese Einschränkung in Bezug auf die Kompatibilität nicht so erheblich, wie sie auf den ersten Blick scheint. Die meisten Hersteller moderner Prozessoren achten nämlich auf Abwärtskompatibilität, um mit den jeweiligen Vorgängermodellen noch kompatibel zu sein. In letzter Konsequenz führte genau dieser Fakt – also die Abwärtskompatibilität der Befehlssätze neuer Prozessoren – zum unglaublichen Erfolg der Intel-Prozessoren.

Als klassisches Beispiel bietet sich hier der 16-Bit-Code des 80386-Prozessors von Intel an, der auch von aktuellen Many-Core-Prozessoren noch unterstützt wird, obwohl diese intern völlig anders aufgebaut sind und demzufolge auch anders arbeiten.

Die meisten Benutzerinnen und Benutzer stellen sich nun vor, dass ihre Programme in eine solche Maschinensprache übersetzt und vom Prozessor ausgeführt werden. Dies ist natürlich nur teilweise richtig: Das vom Prozessor ausgeführte Maschinencode-Programm ist nur eine Folge von Maschinenbefehlen.

Damit man nun von mehreren »parallel« laufenden Programmen auf diese lose Folge von Befehlen abstrahieren kann, braucht man zum ersten Mal den Begriff des Betriebssystems – eine vertrauenswürdige Instanz, die die zu verarbeitenden Programme in kleine Häppchen aufteilt und diese dann nacheinander zur Ausführung bringt. Diese Multitasking genannte Vorgehensweise werden wir später noch ausführlich beleuchten; im Augenblick benötigen wir nur das Verständnis dieser für Endbenutzerinnen und -benutzer so wichtigen Aktionen.

2.1.2    Speicher

Bevor wir diesen Gedanken weiterdenken, soll kurz der Speicheraspekt betrachtet werden. Bisher haben wir nämlich nur gesagt, dass der Prozessor irgendwie rechnen und mit seiner Maschinensprache bezüglich dieser Berechnungen und der Flusskontrolle gesteuert werden kann. Fluss bezeichnet hier den Ablauf des Programms. Dieser kann durch bedingte Sprünge variiert und gesteuert werden.

Eine Frage ist aber noch offen: Woher kommen überhaupt die Ausgangswerte für die Berechnungen? Wir haben zwar bei der Beschreibung der Aufgaben eines Prozessors schon den ominösen Punkt »Das Lesen und Schreiben von Daten im Arbeitsspeicher« erwähnt, jedoch wollen wir diesen Fakt nun in den richtigen Zusammenhang setzen.

Die Register des Prozessors

Jeder Prozessor besitzt eine gewisse Anzahl von Registern, auf die im Maschinencode direkt zugegriffen werden kann. Diese Register sind hardwaremäßig auf dem Prozessorchip selbst integriert und können damit ohne Zeitverzug noch im selben Takt angesprochen werden.

Der Platz auf dem Prozessor ist jedoch beschränkt, und meistens werden einige Register auch für Spezialaufgaben gebraucht:

Jedes dieser Register ist entweder 32 Bit groß oder bei aktuellen 64-Bit-Prozessoren 64 Bit groß. Der Speicherplatz in den Registern ist also sehr stark begrenzt und höchstens für kleinste Programme ausreichend.

Der Hauptspeicher

Der Großteil des benötigten Platzes wird daher im Hauptspeicher zur Verfügung gestellt. Auch hier gab es früher aufgrund der damals noch üblichen Adressbreite von 32 Bit eine Begrenzung, die dem Hauptspeicher eine maximale Größe von 4 Gigabyte auferlegte. Man greift ja auf die 1 Byte großen Speicherstellen über Adressen zu – und 232 Byte sind gerade 4 Gigabyte. Bei 64-Bit-Betriebssystemen k"onnen dagegen theoretisch bis zu 264 Byte Speicher (entspricht 16 Exabyte RAM) adressiert werden.

Mit verschiedenen Maschinenbefehlen kann man nun auf diese Adressen zugreifen und die dort gespeicherten Bytes lesen oder schreiben. Ein interessanter Effekt bei der byteweisen Adressierung auf einem 32-Bit-basierten System sind die zustande kommenden Adressen: Beim Lesen ganzer Datenwörter[ 9 ] wird man nämlich nur Vielfache von 4 als Adressen nutzen. Schließlich ist ein Byte 8 Bit lang, und 4 mal 8 Bit sind gerade 32 Bit.

Interessanterweise können der Prozessor und damit indirekt auch Programme nicht direkt auf die Festplatte zugreifen. Stattdessen wird der DMA-Controller (Direct Memory Access) so programmiert, dass die betreffenden Datenblöcke in vorher festgelegte Bereiche des Hauptspeichers kopiert werden. Während der DMA-Controller die Daten von der sehr langsamen Festplatte in den im Vergleich zum Prozessor auch nicht gerade schnellen Hauptspeicher kopiert, kann die CPU nun weiterrechnen.

Da das eben noch ausgeführte Programm vielleicht vor dem Ende des Transfers nicht weiterlaufen kann, wird wahrscheinlich ein anderes Programm ausgeführt. Das Betriebssystem sollte also das nächste abzuarbeitende Programm heraussuchen und zur Ausführung bringen. Ist der Transfer dann abgeschlossen, kann der Prozessor die Daten von der Platte ganz normal aus dem Hauptspeicher lesen.

Im Übrigen wird so auch mit ausführbaren Programmen verfahren, die vor der Ausführung intern natürlich ebenfalls in den Hauptspeicher kopiert werden. Das Befehlsregister referenziert also den nächsten auszuführenden Befehl, indem es dessen Hauptspeicheradresse speichert.

Caches

Auch der Hauptspeicher ist also langsamer als der Prozessor. Konkret bedeutet das, dass der Takt ein anderer ist. In jedem Takt kann ein Arbeitsschritt erledigt werden, der beim Prozessor im Übrigen nicht unbedingt mit einem abgearbeiteten Befehl gleichzusetzen ist, sondern viel eher mit einem Arbeitsschritt beim Ausführen eines Befehls. (Tatsächlich nutzen fast alle aktuellen Prozessoren intern Pipelines oder andere Formen der Parallelisierung, um in einem Takt mehr als einen Befehl ausführen und so im Idealfall alle Komponenten des Chips auslasten zu können.) Um die Zugriffszeit auf häufig benutzte Datensätze und Variablen aus dem Hauptspeicher zu verkürzen, hat man Pufferspeicher, sogenannte Caches, eingeführt.

Diese Caches befinden sich entweder direkt auf dem Prozessor-Chip (L1-Cache) oder »direkt daneben«. Caches können je nach Bauart zwischen ein paar Kilobytes und wenigen Megabytes groß sein und werden bei meist vollem oder halbem Prozessortakt angesprochen. Die aus dem Hauptspeicher stammenden gepufferten Werte können so für den Prozessor transparent zwischengespeichert werden. Dieser nämlich greift weiterhin auf Adressen im Hauptspeicher zu – ob ein Cache dabei den Zugriff beschleunigt oder nicht, ist für den Prozessor nicht ersichtlich und auch unerheblich.

Zusammenfassung: Die Speicherhierarchie

Der Computer besitzt also eine Speicherhierarchie, die absteigend mehr Speicherplatz bei längeren Zugriffszeiten bietet:

  1. Die Register des Prozessors
    Die Register bieten einen direkten Zugriff bei vollem Prozessortakt. Neben speziellen Registern für festgelegte Aufgaben gibt es auch solche, die frei für Programmiererinnen und Programmierer benutzbar sind.

  2. Der L1-Cache des Prozessors
    Der Level-1-Cache sitzt direkt auf dem Prozessor und ist in der Regel 8 bis 256 Kilobyte groß.

  3. Der L2-Cache
    Je
    nach Modell kann der Level-2-Cache entweder auf dem Prozessor (on-die) oder direkt neben dem Prozessor auf einer anderen Platine untergebracht sein. Der L2-Cache ist normalerweise zwischen 512 und 2.048 Kilobyte groß.

  4. Der L3-Cache
    Falls der L2-Cache
    auf dem Chip sitzt, kann durch einen zusätzlichen externen Level-3-Cache noch eine weitere Beschleunigung erreicht werden.

  5. Der Hauptspeicher
    Auf das RAM kann der Prozessor nur mit einer gewissen Zeitverzögerung zugreifen. Dafür kann dieser Speicher bei einer 32-Bit-Architektur bis zu 4 Gigabyte groß werden.

  6. Die Festplatte oder anderer Hintergrundspeicher
    Da Daten vom Hintergrundspeicher oft erst aufwendig gelesen werden müssen, bevor sie schließlich in den Hauptspeicher übertragen werden können, sind diese Speicher i.d.R. am langsamsten. Aber von einigen wenigen bis einigen Tausend Gigabyte sind hier die Speicherkapazitäten am größten. Zudem besitzen zum Beispiel Festplatten oft noch eigene Caches im jeweiligen Controller, um auch selbst den Zugriff durch Zwischenspeicherung oder vorausschauendes Lesen etwas beschleunigen zu können.

  7. Fehlende Daten
    Es kann natürlich auch vorkommen, dass der Prozessor beziehungsweise ein Programm auf Daten wartet, die erst noch eingegeben werden müssen. Ob dies über die Tastatur, die Maus oder einen Scanner passiert, soll hier nicht weiter interessieren.

Ein L1-Cache bietet also die kürzesten Zugriffszeiten und den geringsten Platz. Weiter unten bietet in der Regel die Festplatte den meisten Platz an, ist aber im Vergleich zum Cache oder auch zum Hauptspeicher extrem langsam.

Die Speicherpyramide

Abbildung 2.1     Die Speicherpyramide

2.1.3    Fairness und Schutz

Führen wir also nun unseren ersten Gedanken bezüglich der »parallelen« Ausführung mehrerer Programme logisch weiter. Wenn der Ablauf unterschiedlicher Programme quasiparallel, also abwechselnd in jeweils sehr kurzen Zeitabschnitten erfolgen soll, muss eine gewisse Fairness gewährleistet werden. Rein intuitiv denkt man da eigentlich sofort an zwei benötigte Zusicherungen:

  1. Gerechte Zeiteinteilung

    Selbstverständlich darf jedes Programm nur einen kurzen Zeitabschnitt lang auf dem Prozessor rechnen. Mit anderen Worten: Es muss eine Möglichkeit für das Betriebssystem geben, ein laufendes Programm zu unterbrechen. Dies aber ist so nicht ohne Weiteres möglich: Schließlich läuft gerade das Programm und nicht das Betriebssystem. Es bleiben also zwei Möglichkeiten, um doch noch für das Scheduling, also das Umschalten zwischen zwei Benutzerprogrammen, zu sorgen: Entweder geben die Programme freiwillig wieder Rechenzeit ab, oder der Prozessor wird nach einer gewissen Zeitspanne in seiner aktuellen Berechnung unterbrochen.

    Unterbrochen werden kann ein Prozessor dabei durch Interrupts. Über diese »Unterbrechungen« signalisieren zum Beispiel viele I/O-Geräte, dass sie angeforderte Daten nun bereitgestellt haben, oder ein Zeitgeber signalisiert den Ablauf einer bestimmten Zeitspanne. Wird solch ein Interrupt nun aktiv, unterbricht der Prozessor seine Ausführung und startet eine für diesen Interrupt spezielle Interrupt Service Routine. Diese Routine ist immer ein Teil des Betriebssystems und könnte nun zum Beispiel entscheiden, welches andere Programm als Nächstes laufen soll.

    Eigentlich kennt der Prozessor Interrupts und Exceptions. Die Literatur unterscheidet beide Begriffe gewöhnlich unter dem Aspekt, dass Interrupts asynchron auftreten und von anderen aktiven Elementen des Systems geschickt werden, während Exceptions schlichte Ausnahmen im Sinne eines aufgetretenen Fehlers sind und damit immer synchron auftreten. Für uns hier ist dieser feine Unterschied jedoch nicht relevant, daher möchten wir im Folgenden ausschließlich von Interrupts sprechen – auch wenn es sich bei manchen Ereignissen eigentlich um Exceptions handelt.

  2. Speicherschutz

    Die einzelnen Programme sollen sich natürlich nicht gegenseitig beeinflussen. Das heißt vor allem, dass die Speicherbereiche der einzelnen Programme voreinander geschützt werden. Man erreicht dies durch das im Folgenden noch näher erläuterte Prinzip des virtuellen Speichers: Dies bedeutet für die Programme, dass sie nicht direkt auf die physischen Adressen des RAMs zugreifen können. Die Programme merken davon aber nichts – sie haben in ihren Augen den gesamten Speicherbereich für sich allein. In einer speziellen Hardwareeinheit, der MMU (Memory Management Unit), wird dann die virtuelle Adresse bei einem Speicherzugriff in die physische übersetzt.

    Dieses Konzept hat auch den nützlichen Nebeneffekt, dass bei einer hohen Speicherauslastung – also wenn die gestarteten Programme zusammen mehr Speicher benötigen, als der PC RAM besitzt – einige Speicherbereiche auf die Festplatte ausgelagert werden können, ohne dass die Programme davon etwas merken. Greifen diese dann auf die ausgelagerten Daten zu, wird der betroffene Speicherbereich von der Festplatte wieder ins RAM kopiert und die MMU aktualisiert. Wird das vor dieser Aktion unterbrochene Programm des Benutzers fortgesetzt, kann es wieder ganz normal auf die Daten des angeforderten Speicherbereichs zugreifen.

Außer dem Schutz des Speichers durch das Konzept des Virtual Memory gibt es noch die unter anderem vom x86-Standard unterstützten Berechtigungslevel (auch Ringe genannt). Diese vier Level oder Ringe schränken dabei den jeweils verfügbaren Befehlssatz für alle Programme ein, die im jeweiligen Ring beziehungsweise Berechtigungslevel laufen. Die gängigen Betriebssysteme wie Linux oder Windows nutzen dabei jeweils nur zwei der vier bei x86 verfügbaren Ringe: Im Ring 0 wird das Betriebssystem samt Treibern ausgeführt, während alle Benutzerprogramme im eingeschränktesten Ring 3 ablaufen. So schützt man das Betriebssystem vor den Anwenderprogrammen, während diese selbst durch virtuelle Adressräume voneinander getrennt sind.

2.1.4    Programmierung

So viel zu einer kurzen Einführung in den Prozessor und seinen Implikationen für unsere Systeme. Der nächste wichtige Punkt ist die Programmierung: Wie kann man einem Prozessor sagen, was er tun soll? Bisher haben wir nur über Maschinencode gesprochen, also über Befehle, die der Prozessor direkt versteht. Die binäre Codierung dieser Befehle wird dann mehr oder weniger direkt benutzt, um die Spannungswerte auf den entsprechenden Leitungen zu setzen.

Assembler

Nun möchte aber niemand mit Kolonnen von Nullen und Einsen hantieren, nicht einmal in der Betriebssystemprogrammierung. Aus diesem Grund wurde bereits in den Anfangsjahren der Informatik die Assembler-Sprache entworfen, in deren reinster Form ein Maschinenbefehl durch eine für einen Menschen lesbare Abkürzung – ein Mnemonic – repräsentiert wird.

Neuere Assembler, also Programme, die einen in einer Assembler-Sprache geschriebenen Code in eine Maschinensprache übersetzen, bieten zusätzlich zu dieser 1:1-Übersetzung Makros als Zusammenfassung häufig benötigter Befehlskombinationen zur Vereinfachung an. Im Rahmen dieser 1:1-Zuordnung von Assembler zu Maschinencode ist natürlich auch die umgekehrte Richtung möglich, was man Disassemblieren nennt.

Betrachten wir das folgende Beispielprogramm, das auf einem MIPS-2000System[ 10 ] den Text »Hello World!« ausgeben würde:

        .data                     # Datensegment
str:    .asciiz "Hello World!
n" # String ablegen .text # Codesegment main: li $v0, 4 # 4 = Print_string la $a0, str # Adresse des # Strings übergeben syscall # Systemfunktion # aufrufen li $v0, 10 # 10 = Quit syscall # Programm beenden

Listing 2.1     »Hello World«-Beispielcode in MIPS-Assembler

Wenn Sie das klassische Hello-World-Beispiel in anderen Sprachen wie etwa Python kennen, wird Ihnen schon allein durch den Umfang auffallen, dass Assembler-Code deutlich komplexer ist, da viele Komfortfunktionen entfallen.

Zunächst einmal legen wir nämlich die Zeichenfolge Hello World!, gefolgt von einem Zeichen für den Zeilenumbruch (\n), im Hauptspeicher ab und bezeichnen diese Stelle für den späteren Gebrauch im Programm kurz mit str. Im Hauptprogramm (gekennzeichnet durch das Label main) laden wir eine bestimmte Nummer in ein Register des Prozessors und die Adresse der Zeichenkette in ein anderes Register.

Anschließend lösen wir durch den syscall-Befehl einen Interrupt aus, bei dessen Bearbeitung das Betriebssystem die im Register $v0 angegebene Nummer auswertet. Diese Nummer gibt an, was das Betriebssystem weiter tun soll: In unserem Fall soll es den Text auf dem Bildschirm ausgeben. Dazu holt es sich noch die Adresse der Zeichenkette aus dem zweiten Register und erledigt seine Arbeit. Zurück im Programm wollen wir dieses jetzt beenden, wozu die Nummer 10, gefolgt vom bekannten Interrupt, genügt.

Zugriff auf das Betriebssystem

In diesem Beispiel haben wir nun schon das große Mysterium gesehen: den Zugriff auf das Betriebssystem, den Kernel. Das Beispielprogramm tut nichts weiter, als diverse Register mit Werten zu füllen und ihm erlaubte Interrupts aufzurufen. Da Benutzerprogramme in einem eingeschränkten Berechtigungslevel laufen, können sie nicht wahllos alle Interrupts aufrufen.

Das Betriebssystem erledigt in diesem Beispiel die ganze Arbeit: Der Text wird aus dem Speicher ausgelesen, auf dem Bildschirm ausgegeben, und das Programm wird schließlich beendet. Diese Beendigung findet, wie leicht zu erkennen ist, nicht auf der Ebene des Prozessors statt (es gibt auch einen speziellen Befehl, der den Prozessor beim Herunterfahren des Systems richtig anhält), sondern es wird nur eine Nachricht an das Betriebssystem gesendet. Das System wusste unser Programm irgendwie zu starten, und es wird sich jetzt wohl auch um dessen Ende kümmern können.

Aber betrachten wir zunächst die definierten Einstiegspunkte in den Kernel: die Syscalls. In den meisten Büchern über Linux finden Sie bei der Erläuterung des Kernels ein Bild wie das folgende:

Ein nicht ganz korrektes Schema

Abbildung 2.2     Ein nicht ganz korrektes Schema

Ein solches Bild soll verdeutlichen, dass Benutzerprogramme nicht direkt auf die Hardware zugreifen, sondern den Kernel für diese Aufgabe benutzen.

Diese Darstellung ist aber nicht vollkommen korrekt und lässt ein falsches Bild entstehen. Im Assembler-Beispiel haben wir gesehen, dass ein Benutzerprogramm sehr wohl auf die Hardware zugreifen kann: Es kann zum Beispiel Daten aus dem Hauptspeicher in Register laden, alle möglichen arithmetischen und logischen Operationen ausführen sowie bestimmte Interrupts auslösen. Außerdem ist in der obigen Grafik der Zugriff auf den Kernel nicht visualisiert; man könnte also annehmen, dass er nach Belieben erfolgen kann. Jedoch ist das genaue Gegenteil der Fall.

So sollte es sein.

Abbildung 2.3     So sollte es sein.

In Abbildung 2.3 wird schon eher deutlich, dass ein Benutzerprogramm nur über ausgewiesene Schnittstellen mit dem Kernel kommunizieren kann. Diese ausgewiesenen Systemaufrufe (engl. system calls, daher auch die Bezeichnung Syscalls) stellen einem Programm die Funktionalität des Betriebssystems zur Verfügung.

So kann man über Syscalls zum Beispiel, wie Sie gesehen haben, einen Text auf den Bildschirm schreiben oder das aktuelle Programm beenden. Entsprechend kann man natürlich Eingaben der Tastatur lesen und neue Programme starten. Außerdem kann man auf Dateien zugreifen, externe Geräte ansteuern oder die Rechte des Benutzers bzw. der Benutzerin überprüfen.

Linux kennt einige Hundert Syscalls, die alle in der Datei unistd_32.h (bzw. unistd_64.h) der Kernel-Sources verzeichnet sind. Um sich die Datei anzuschauen, installieren Sie in Ihrer Distribution die Kernel-Header-Dateien. Unter Ubuntu heißt das Paket bspw. linux-headers-VERSION, die Datei findet sich dann unter /usr/src/linux-headers-VERSION-generic/arch/x86/include/generated/uapi/asm/unistd_32.h.

#define __NR_exit                 1
#define __NR_fork                 2
#define __NR_read                 3
#define __NR_write                4
#define __NR_open                 5
#define __NR_close                6
#define __NR_waitpid              7
#define __NR_creat                8

Listing 2.2     Auszug aus der unistd_32.h des Linux-Kernels

Dem exit-Call ist in diesem Fall die Nummer 1 und dem write-Call die Nummer 4 zugeordnet, also etwas andere Nummern als in unserem Beispiel für das MIPS-System. Auch muss unter Linux/x86 ein Syscall anders initialisiert werden als in unserem Beispiel.[ 11 ] Das Prinzip ist jedoch gleich: Wir bereiten die Datenstruktur für den Syscall vor und bitten das Betriebssystem anschließend per Interrupt, unseren Wunsch zu bearbeiten.

Für ein Benutzerprogramm sind Syscalls die einzige Möglichkeit, direkt eine bestimmte Funktionalität des Kernels zu nutzen.

Natürlich lässt unsere Definition der Syscalls noch viele Fragen offen. Bisher wissen wir ja nur, dass wir die Daten irgendwie vorbereiten müssen, damit das Betriebssystem nach einem Interrupt diesen Systemaufruf verarbeiten kann. Was uns noch fehlt, ist die Verbindung zu den verschiedenen Hochsprachen, in denen ja fast alle Programme geschrieben werden.

Hochsprachen

Im Folgenden müssen wir zunächst klären, was eine Hochsprache überhaupt ist. Als Hochsprache bezeichnet man eine abstrakte höhere Programmiersprache, die es erlaubt, Programme problemorientierter und unabhängig von der Prozessorarchitektur zu schreiben. Bekannte und wichtige Hochsprachen sind zum Beispiel C/C++, Java oder auch PHP. Unser etwas kompliziert anmutendes MIPS-Beispiel sieht in C auch gleich viel einfacher aus:

#include <stdio.h>
main()
{
  printf("Hello, World!
n");
}

Listing 2.3     »Hello, World« in C

In der ersten Zeile binden wir eine Datei ein, in der der einzige Befehl in unserem Programm definiert wird: printf(). Dieser Befehl gibt wie zu erwarten einen Text auf dem Bildschirm aus, in unserem Fall das bekannte »Hello, World!«.

Auch wenn dieses Beispiel schon einfacher zu lesen ist als der Assembler-Code, zeigt es doch noch nicht alle Möglichkeiten und Verbesserungen, die eine Hochsprache bietet. Abgesehen davon, dass Hochsprachen leicht zu lesen und zu erlernen sind, bieten sie nämlich komplexe Daten- und Kontrollstrukturen, die es so in Assembler nicht gibt. Außerdem ist eine automatische Syntax- und Typüberprüfung möglich.

Dumm ist nur, dass der Prozessor solch einen schön geschriebenen Text nicht versteht. Die Textdateien mit dem Quellcode, die man im Allgemeinen auch als Source bezeichnet, müssen erst in Assembler beziehungsweise gleich in Maschinensprache übersetzt werden.[ 12 ] Eine solche Übersetzung (auch Kompilierung genannt) wird von einem Compiler vorgenommen. Wird ein Programm jedoch nicht nur einmal übersetzt, sondern während der Analyse der Quelldatei gleich Schritt für Schritt ausgeführt, so spricht man von interpretierten Sprachen und nennt das interpretierende Programm einen Interpreter. Die meisten Sprachen sind entweder pure Compiler- oder pure Interpretersprachen (auch Skriptsprachen genannt).

Eine interessante Ausnahme von dieser Regel ist Java. Diese Sprache wurde von Sun Microsystems entwickelt, um möglichst portabel und objektorientiert Anwendungen schreiben zu können. Ganz davon abgesehen, dass jede Sprache portabel ist, sofern ein entsprechender Compiler/Interpreter und alle benötigten Bibliotheken – das sind Sammlungen von häufig benutztem Code, beispielsweise Funktionen, die den Zugriff auf eine Datenbank abstrahieren – auf der Zielplattform vorhanden sind, wollte Sun dies mit dem folgenden Konzept erreichen: Ein fertig geschriebenes Java-Programm wird zuerst von einem Compiler in einen Bytecode übersetzt, der schließlich zur Laufzeit interpretiert wird. Dieser Bytecode ist eine Art maschinenunabhängige Maschinensprache.

Mehr zur Programmierung unter Unix finden Sie in Kapitel 12.

Für unser kleines C-Beispiel reicht dagegen der einmalige Aufruf des GNU-C-Compilers, des gcc, aus:

$ gcc -o hello hello.c
$ ./hello
Hello, World!
$

Listing 2.4     Das Beispiel übersetzen und ausführen

In diesem Beispiel wird die Quelldatei hello.c mit unserem kleinen Beispielprogramm vom gcc in die ausführbare Datei hello übersetzt, die wir anschließend mit dem gewünschten Ergebnis ausführen. In diesem Beispiel haben Sie auch zum ersten Mal die Shell gesehen. Diese interaktive Kommandozeile wirkt auf viele Leute, die sich zum ersten Mal mit Unix auseinandersetzen, recht anachronistisch und überhaupt nicht komfortabel. Man möchte nur klicken müssen und am liebsten alles bunt haben. Sie werden jedoch spätestens nach unserem Shellkapitel dieses wertvolle und höchst effiziente Werkzeug nicht mehr missen wollen.

Die Datei hello ist zwar eine ausführbare Datei, enthält aber keinen reinen Maschinencode. Vielmehr wird unter Linux/BSD das ELF-Format für ausführbare Dateien genutzt. In diesem Format ist zum Beispiel noch angegeben, welche Bibliotheken benötigt oder welche Variablen im Speicher angelegt werden müssen. (Auch wenn Sie in Assembler programmieren, wird eine ausführbare Datei in einem solchen Format erzeugt – das Betriebssystem könnte sie sonst nicht starten.)

Doch zurück zu unseren Syscalls, die wir in den letzten Abschnitten etwas aus den Augen verloren haben. Die Frage, die wir uns zu Beginn gestellt haben, war ja, ob und wie wir die Syscalls in unseren Programmen nutzen können, die in Hochsprachen geschrieben sind.

Unter C ist die Sache einfach: Die Standardbibliothek (libc) enthält entsprechende Funktionsdefinitionen. Nach außen hin kann man über die Datei unistd.h die von der Bibliothek exportierten Funktionssymbole einbinden und Syscalls auf diese Weise direkt nutzen. Intern werden die Syscalls wieder in Assembler geschrieben. Dies geschieht teils durch vollständig in Assembler geschriebene Quelldateien und teils auch durch Inline-Assembler. Die Programmiersprache C erlaubt es nämlich, zwischen den Anweisungen in der Hochsprache auch Assembler-Direktiven zu verwenden, die dann natürlich speziell gekennzeichnet werden.

Würde man das Beispielprogramm nicht mit printf schreiben, einem Befehl direkt aus dem C-Standard, sondern direkt mit dem Linux-Syscall write, so sähe es wie folgt aus:

#include <unistd.h>
int main() {
  write(0, "Hello, World!
n", 13);
}

Listing 2.5     Das C-Beispiel mit dem write-Syscall

Hier nutzen wir den Syscall direkt statt indirekt wie über printf. Der Aufruf sieht auch schon etwas komplizierter aus, da mehr Argumente benötigt werden. Doch diese steigern nur die Flexibilität des Syscalls, der auch zum Schreiben in Dateien oder zum Senden von Daten über eine Netzwerkverbindung genutzt werden kann – wohlgemerkt: Im Endeffekt sind dies alles Aufgaben für den Kernel.

In welche Datei beziehungsweise auf welches Gerät geschrieben werden soll, gibt das erste Argument an. Dieser Deskriptor ist in unserem Fall die standardmäßig mit dem Wert 0 belegte normale Ausgabe: der Bildschirm. Danach folgen der zu schreibende Text sowie die letztendlich davon wirklich zu schreibende Anzahl Zeichen (eigentlich Bytes, aber ein Zeichen entspricht normalerweise einem Byte.

2.1.5    Benutzung

Nachdem wir bisher betrachtet haben, welche Implikationen sich aus der Hardware für das Betriebssystem ergeben, wollen wir im Folgenden die Eigenschaften des Systems aus Benutzersicht erläutern. Dazu betrachten wir zuerst ein beliebiges Betriebssystem beim Start.

Der Bootvorgang

Wenn man den PC anschaltet, bootet nach einer kurzen Initialisierung des BIOS das Betriebssystem. Für die Benutzer äußert sich dieser Vorgang vor allem in einer kurzen Wartezeit, bis sie sich am System anmelden können. In dieser Zeit werden alle Dienste initialisiert, die das System erbringen soll.

Bei Arbeitsplatzrechnern gehört dazu in 90 % der Fälle eine grafische Oberfläche. Bei einer Vollinstallation eines Linux-Systems kann dazu auch schon einmal ein Webserver- oder Fileserver-Dienst gehören. Werden solche komplexen Dienste beim Booten gestartet, dauert ein Systemboot natürlich länger als bei puren Desktop-Systemen – insofern lässt sich ein Windows 11 Home nicht mit einer Unix-Workstation vergleichen.

Für das System selbst heißt das, dass alle für die Arbeit benötigten Datenstrukturen zu initialisieren sind. Am Ende des Bootvorgangs wird den Benutzern eine Schnittstelle angeboten, mit der sie arbeiten können.

Im laufenden Betrieb

Im laufenden Betrieb möchten Benutzerinnen und Benutzer ihre Programme starten, auf ein Netzwerk zugreifen oder spezielle Hardware wie Webcams nutzen. Das Betriebssystem hat nun die Aufgabe, diese Betriebsmittel zu verwalten. Der Zwiespalt ist dabei, dass die Benutzer so etwas nicht interessiert – schließlich sollen die Programme ausgeführt und auch ihre restlichen Wünsche möglichst mit der vollen Leistung des Systems erfüllt werden.

Würde der Kernel also zur Erfüllung dieser Aufgaben den halben Speicher oder 50%% der Rechenzeit benötigen, könnte er diesen indirekten Anforderungen nicht gerecht werden.

Tatsächlich stellt es für jeden Betriebssystemprogrammierer die größte Herausforderung dar, den eigenen Ressourcenverbrauch möglichst gering zu halten und trotzdem alle Wünsche zu erfüllen.

Ebenfalls zu diesem Themenkreis gehört die Korrektheit des Systems. Es soll seine Aufgabe nach Plan erfüllen – grundlose Abstürze, vollständige Systemausfälle beim Ausfall einzelner kleiner Komponenten oder nicht vorhersagbares Verhalten sind nicht zu akzeptieren. Daher wollen wir die Korrektheit im Folgenden als gegeben annehmen, auch wenn sie in der Realität nicht unbedingt selbstverständlich ist.

Das Herunterfahren

Das Herunterfahren dient zum Verlassen des Systems in einem korrekten Zustand, damit die Systemintegrität beim nächsten Start gewahrt bleibt. Vor allem beim Dateisystem zeigt sich die Wichtigkeit eines solchen Vorgehens: Puffer und Caches erhöhen die Performance beim Zugriff auf die Platte extrem, dreht man jedoch plötzlich den Strom ab, sind alle gepufferten und noch nicht auf die Platte zurückgeschriebenen Daten weg. Dabei wird das Dateisystem mit ziemlicher Sicherheit in einem inkonsistenten Zustand zurückgelassen, sodass es beim nächsten Zugriff sehr wahrscheinlich zu Problemen kommen wird.

Aber auch den Applikationen muss eine gewisse Zeit zum Beenden eingeräumt werden. Vielleicht sind temporäre Daten zu sichern oder andere Arbeiten noch korrekt zu beenden. Das Betriebssystem muss also eine Möglichkeit haben, den Anwendungen zu sagen: Jetzt beende dich bitte selbst – oder ich tue es.