Ach, der Mensch begnügt sich gern. Nimmt die Schale für den Kern. -- Albert Einstein (zugeschrieben)
1 Der Kernel
In diesem Kapitel wollen wir uns mit der Architektur eines Linux-Systems auseinandersetzen.
Auch wenn dieses Kapitel »Der Kernel« <Die Bezeichnung »Kernel« steht für den Kern des Betriebssystems. Wenn man das Wort »Betriebssystem« streng definiert, also alle Anwenderprogramme etc. ausschließt, ist dieses gerade der Kernel. Das klingt kompliziert, wird aber bald klarer.> heißt, macht es wenig Sinn, hier jede einzelne Quelldatei des Linux-Kernels wirklich durchzuexerzieren. Stattdessen wollen wir die Grundlagen der Systemarchitektur am Beispiel von Linux vorstellen, deren Probleme skizzieren und natürlich wichtige Zusammenhänge erläutern.
Dazu werden wir uns auf die Prinzipien konzentrieren, die Linux nutzt, um so zu funktionieren, wie es letztendlich funktioniert. Natürlich wird dabei die eine oder andere konkrete Verbindung zum Sourcecode gezogen werden. Schließlich kann man bei Linux auf die Kernel-Quellen zugreifen und sich selbst von der Korrektheit der in diesem Kapitel getroffenen Aussagen überzeugen.
Wider die Missverständnisse
Zum Aufbau und zur Ausrichtung dieses Kapitels hat uns folgende Motivation getrieben:
Selbst Leute, die viel auf ihr Fachwissen halten, disqualifizieren sich regelmäßig durch Aussagen wie:
- »Warum programmiert man nicht endlich mal ein OS in Java, das ist doch so genial objektorientiert?«
- »Benutzerprogramme haben keinen direkten Zugriff auf die Hardware; alles läuft über den Kernel.«
- »Benutzerprogramme können gar nicht auf den Kernel zugreifen, der ist geschützt.«
Solche Sätze sind entweder Halbwahrheiten oder sogar ausgemachter Blödsinn. Nach diesem Kapitel sollten Sie diese und andere gängige Aussagen und Internet-Mythen in den richtigen Zusammenhang bringen können. Außerdem wird erst durch dieses Kapitel eine wirkliche Grundlage für das Verständnis von Linux und damit für den Rest des Buches gelegt.
1.1 Grundlagen
Dazu müssen wir natürlich mit den wichtigsten Grundlagen beginnen; Grundlagen, die wir für das Verständnis des restlichen Kapitels benötigen werden. Viele dieser Angaben sind eigentlich absolut selbstverständlich – und 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 in den richtigen Kontext eingeordnet.
Fangen wir also beim Computer selbst an. Wenn man so davor sitzt, sieht man natürlich in erster Linie Dinge, die mit seiner Funktion herzlich wenig zu tun haben: Tastatur, Maus, 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.
Der »Rechner«
Was in einem Computer rechnet – und nichts anderes macht das Ding, <Jetzt dürfen Sie dreimal raten, wieso ein Computer auch oft »Rechner« genannt wird und was herauskommt, wenn man das ursprünglich englische Wort »Computer« ins Deutsche übersetzt.> selbst wenn wir Texte schreiben oder im Netz surfen –, ist mithin der Prozessor.
1.1.1 Der Prozessor
Was gibt es also über einen Prozessor zu sagen? In den meisten PCs steckt heutzutage ein zu Intels x86 kompatibler Prozessor. So ein auch CPU genannter Mikrochip hat nun im Wesentlichen drei Aufgaben:
- das Ausführen arithmetisch-logischer Operationen
- das Lesen und Schreiben von Daten im Arbeitsspeicher
- das Ausführen von Sprüngen im Programm
Der Maschinencode
Die letzte Aufgabe deutet schon an, dass ein Prozessor natürlich nicht gottgegebene Dinge macht. 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 fast jeden Prozessor einen eigenen. Ausnahmen bilden da 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 zu sein 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 Pentium-Prozessoren noch unterstützt wird, obwohl diese intern völlig anders aufgebaut sind und demzufolge auch anders arbeiten.
Die meisten Benutzer stellen sich nun vor, dass ihre Programme in eine solche Maschinensprache übersetzt und vom Prozessor ausgeführt werden. Das ist jetzt natürlich nur teilweise richtig: Dieses vom Prozessor ausgeführte Maschinencode-Programm, von dem eben gesprochen wurde, ist in Wahrheit natürlich nur eine Folge von Maschinenbefehlen.
Das Multitasking
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. Man braucht schließlich 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 natürlich später noch ausführlich beleuchten; im Augenblick benötigen wir nur das Verständnis dieser für den Endbenutzer so wichtigen Aktionen.
1.1.2 Der 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 <Der in diesem Wort beschriebene Fluss bezeichnet den Ablauf des Programms. Er kann durch bedingte Sprünge variiert und kontrolliert werden.> gesteuert werden kann. Eine Frage blieb 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 Kontext bringen.
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.
Spezialregister
Der Platz auf dem Prozessor ist jedoch beschränkt, und meistens werden einige Register auch für Spezialaufgaben gebraucht:
- Befehlsregister
- In diesem Register ist die Adresse des nächsten auszuführenden Befehls gespeichert. Sprungbefehle können dieses Register verändern und so die Ausführung des Programms an einer anderen Stelle fortsetzen lassen.
- Nullregister
- Die meisten Prozessorarchitekturen besitzen ein spezielles schreibgeschütztes Register, aus dem man nur die Null lesen kann. Dies ist sehr praktisch, da man so diese wichtige Konstante direkt nutzen kann und nicht erst aus dem Speicher zu laden braucht.
- Statusregister
- Im Statusregister stehen bestimmte Bits für diverse Statusinformationen: beispielsweise dafür, ob das letzte Ergebnis null war oder ob ein Überlauf oder ein Unterlauf bei der letzten Berechnung stattgefunden hat.
Jedes Register ist dabei 32 Bit groß und bei den neuen 64-Bit-Prozessoren natürlich entsprechend 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 gibt es im Moment aufgrund der noch üblichen Adressbreite von 32 Bit eine Begrenzung, die dem Hauptspeicher eine maximale Größe von 4 Gigabyte auferlegt. Man greift ja auf die ein Byte großen Speicherstellen über Adressen zu – und 232 Byte sind gerade 4 Gigabyte.
Mit verschiedenen Maschinenbefehlen kann man nun auf diese Adressen zugreifen und die dort gespeicherten Bytes lesen oder auch schreiben. Ein interessanter Effekt bei der byteweisen Adressierung auf einem 32-Bit-basierten System sind die zustande kommenden Adressen:
Beim Lesen ganzer Datenwörter <Ein »Wort« ist diesem Zusammenhang 32 Bit lang.> 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.
Zugriff auf die Festplatte
Interessanterweise sollte man festhalten, dass der Prozessor und damit indirekt auch Programme nicht direkt auf die Festplatte zugreifen können. Stattdessen wird der DMA-Controller <DMA = 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 nun vielleicht vor dem Ende des Transfers nicht weitermachen kann, wird wahrscheinlich ein anderes Programm ausgeführt. Das Betriebssystem sollte also das nächste abzuarbeitende Programm heraussuchen und schließlich 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 auf 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, sondern viel eher mit einem Arbeitsschritt beim Ausführen eines Befehls gleichzusetzen ist. <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 nun die Zugriffszeit auf häufig benutzte Datensätze und Variablen aus dem Hauptspeicher zu verkürzen, hat man Pufferspeicher, sogenannte Caches, eingeführt.
Transparenter Puffer
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 halben Prozessortakt angesprochen. Die aus dem Hauptspeicher stammenden gepufferten Werte können so für den Prozessor völlig transparent zwischengespeichert werden. Der Prozessor 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 völlig unerheblich.
Zusammenfassung: Die Speicherhierarchie
Die Speicherpyramide
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. Es gibt spezielle Register für festgelegte Aufgaben neben für den Programmierer frei benutzbaren Registern.
2. | Der L1-Cache des Prozessors |
- Der Level1-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 Level2-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 2048 Kilobyte groß.
4. | Der L3-Cache |
- Falls der L2-Cache auf dem Chip sitzt, kann durch einen zusätzlichen externen Level3-Cache noch eine weitere Beschleunigung erreicht werden.
5. | Der Hauptspeicher |
- Auf 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 erst aufwendig magnetisch oder optisch gelesen werden müssen, bevor sie schließlich in den Hauptspeicher übertragen werden können, sind diese Speicher am langsamsten. Aber von einigen wenigen bis einigen tausend Gigabyte sind hier die Speicherkapazitäten natürlich am größten. Zudem ist es oft der Fall, dass zum Beispiel Festplatten auch noch eigene Caches im jeweiligen Controller besitzen, 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, hat jedoch auch den wenigsten 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.
1.1.3 Fairness und Schutz
Denken wir also nun unseren ersten Gedanken bezüglich der »parallelen« Ausführung mehrerer Programme logisch weiter.
Wenn der Ablauf unterschiedlicher Programme quasiparallel, also eben 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. Das ist aber so ohne Weiteres nicht möglich: Schließlich läuft eben 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.
Unterbrechen des Prozessors
Unterbrochen werden kann ein Prozessor dabei durch Interrupts. <Eigentlich kennt der Prozessor Interrupts und Exceptions. Die Literatur trennt 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.> Über diese »Unterbrechungen« signalisieren zum Beispiel viele I/O-Geräte, dass sie angeforderte Daten nun bereitgestellt haben, oder ein Zeitgeber signalisiert eben den Ablauf einer bestimmten Zeitspanne. Wird so 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.
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. Das kann man durch das im Folgenden noch näher erläuterte Prinzip des virtuellen Speichers erreichen: Dies bedeutet nämlich für die Programme, dass sie nicht direkt auf die physikalischen 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 Hardware-Einheit, der MMU <Memory Management Unit>, wird dann die virtuelle Adresse bei einem Speicherzugriff in die physikalische ü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 auch auf die Festplatte ausgelagert werden können, ohne dass die betroffenen Programme davon etwas merken. Greifen diese dann auf die ausgelagerten Daten zu, wird der betroffene Speicherbereich von der Festplatte wieder in den RAM kopiert und die MMU aktualisiert. Wird dann das vor dieser Aktion unterbrochene Programm des Benutzers schließlich fortgesetzt, kann es nun wieder ganz normal auf die Daten des angeforderten Speicherbereichs zugreifen.
Virtueller Speicher
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 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 auch 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 also das Betriebssystem vor den Anwenderprogrammen, während diese selbst jeweils durch virtuelle Adressräume voneinander getrennt sind.
1.1.4 Die Programmierung
So viel zu einer kurzen Einführung in den Prozessor und dessen Implikationen für unsere Systeme. Der nächste größere Punkt ist dann natürlich die Programmierung: Wie kann man einem Prozessor sagen, was er machen soll? Bisher haben wir nur über Maschinencode gesprochen, also über Befehle, die der Prozessor direkt versteht. Die binäre Kodierung 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 ganzen Kolonnen von Nullen und Einsen hantieren, nicht einmal in der Betriebssystemprogrammierung. Aus diesem Grund wurde bereits in den Anfangsjahren der Informatik die Assemblersprache entworfen, in deren reinster Form ein Maschinenbefehl durch eine für einen Menschen lesbare Abkürzung – ein Mnemonic – repräsentiert wird.
Besser lesbar als Maschinencode
Neuere Assembler, also Programme, die einen in einer Assemblersprache geschriebenen Code in eine Maschinensprache übersetzen, bieten zusätzlich zu dieser Eins-zu-eins-Übersetzung noch Makros als Zusammenfassung häufig benötigter Befehlskombinationen zur Vereinfachung an.
Im Rahmen dieser Eins-zu-eins-Zuordnung von Assembler zu Maschinencode ist natürlich auch die umgekehrte Richtung möglich, was man dann Disassemblieren nennt.
Betrachten wir das folgende Beispielprogramm, das auf einem MIPS-2000-System <Ja, es gibt mehr als nur Intel & Co ... ;-)> 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 1.1 »Hello World«-Beispielcode in MIPS-Assembler
In diesem Beispiel wurde nun nichts gerechnet, obwohl wir eingangs erwähnten, dass dies das Einzige sei, was ein Prozessor kann. Stattdessen geben wir einen Text auf dem Bildschirm aus, wobei wir nun von den anderen Möglichkeiten eines Prozessors relativ ausgiebig Gebrauch machen.
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.
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 nun an, was das Betriebssystem weiter machen 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 macht jetzt nichts weiter, als diverse Register mit Werten zu füllen und ihm erlaubte Interrupts <Da Benutzerprogramme in einem eingeschränkten Berechtigungslevel laufen, können sie nicht wahllos alle Interrupts aufrufen.> aufzurufen.
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 irgendwie beendet. Diese Beendigung findet, wie leicht zu erkennen ist, nicht auf der Ebene des Prozessors statt, <Es gibt auch einen speziellen Befehl, um den Prozessor beim Herunterfahren des Systems richtig anzuhalten.> sondern es wird nur eine Nachricht an das Betriebssystem gesendet. Dieses wusste unser Programm irgendwie zu starten, und es wird sich jetzt wohl auch um dessen Ende kümmern können.
Aber betrachten wir doch erst einmal die definierten Einstiegspunkte in den Kernel: die Syscalls. In den meisten Büchern über Linux finden Sie bei der Erläuterung des Kernels meist so ein Bild:
Abbildung 1.1 Ein nicht ganz korrektes Schema
Ein solches Bild soll dann verdeutlichen, dass Benutzerprogramme nicht direkt auf die Hardware zugreifen, sondern irgendwie den Kernel für diese Aufgabe benutzen.
Das ist aber nicht vollkommen korrekt, und beim Leser würde ein falsches Bild entstehen. Im Assemblerbeispiel 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 dieser nach Belieben erfolgen könnte. Jedoch ist das genaue Gegenteil der Fall.
Abbildung 1.2 So sollte es sein.
Einstiegspunkte in den Kernel
In Abbildung 1.2 wird nämlich 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 überprüfen.
Linux kennt dabei knapp 300 Syscalls, die alle in der Datei include/asm/unistd.h Ihres Kernel-Sources verzeichnet sind.
#define __NR_exit 1 #define __NR_fork 2 #define __NR_read 3 #define __NR_write 4 #define __NR_open 5 #define __NR_close 6
Listing 1.2 Auszug aus der include/asm/unistd.h von Linux
Dem »exit«-Call ist also die Nummer 1 und dem »write«-Call die Nummer 4 zugeordnet, also leicht 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. <Beim MIPS sehen Funktionsaufrufe anders aus als beim Intel/x86. Im Gegensatz zum MIPS müssen beim Intel-Prozessor nämlich alle Funktionsargumente auf den Stack geschoben werden, was aber für ein einführendes Beispiel nicht so übersichtlich gewesen wäre.>
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 bleiben bei unserer 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
Abstraktere Programmierung
Dafür müssen wir jedoch kurz 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. Und unser etwas kompliziert anmutendes MIPS-Beispiel sieht in C doch auch gleich viel einfacher aus:
#include <stdio.h>
main()
{
printf("Hello World! \n");
}
Listing 1.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 nun 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 Assemblercode, zeigt es doch noch nicht alle Möglichkeiten und Verbesserungen, die eine Hochsprache bietet. Ganz abgesehen davon, dass Hochsprachen leicht zu lesen und leicht zu erlernen sind, bieten sie nämlich noch 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 solchen 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. <Wie gesagt sind Maschinensprache und Assemblernotation weitestgehend äquivalent. Eine Übersetzung in Assembler findet aber nur statt, wenn der Programmierer dies aus welchen Gründen auch immer explizit verlangt.>
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).
Java!
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 <Dieser Bytecode ist so eine Art maschinenunabhängige Maschinensprache.> übersetzt, der schließlich zur Laufzeit interpretiert wird.
Mehr zur Programmierung unter Unix finden Sie in Kapitel 21.
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 1.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 Shell-Kapitel dieses wertvolle und höchst effiziente Werkzeug nicht mehr missen wollen.
Mehr zur Shell finden Sie in den Kapiteln 3 bis 6.
Ausführbare Dateien
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 werden 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 stellten, war ja, ob und wie wir die Syscalls in unseren Hochsprachen nutzen können.
Libc: Die Standardbibliothek
Unter C ist die Sache einfach: Die Standardbibliothek (»libc«) beinhaltet entsprechende Funktionsdefinitionen. Nach außen hin kann man über die Datei unistd.h die von der Bibliothek exportierten Funktionssymbole einbinden und die Syscalls so direkt nutzen. Intern werden die Syscalls wieder in Assembler geschrieben. Dies geschieht teilweise durch ganze vollständig in Assembler geschriebene Quelldateien und teilweise auch durch Inline-Assembler. Die Programmiersprache C erlaubt es nämlich, zwischen den Anweisungen in der Hochsprache auch Assemblerdirektiven zu verwenden, die dann natürlich speziell gekennzeichnet werden.
Würde man das Beispielprogramm nicht mit printf, einem Befehl direkt aus dem C-Standard, sondern direkt mit dem Linux-Syscall »write« schreiben, würde das Programm wie folgt aussehen:
Listing 1.5 Das C-Beispiel mit dem write-Syscall
#include <unistd.h>
int main()
{
write(0, "Hello World! \n", 13);
}
Hier nutzen wir den Syscall direkt anstatt 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 alles Aufgaben für den Kernel.
In 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 ist normalerweise ein Byte).
1.1.5 Die Benutzung
Nachdem wir bisher betrachtet haben, welche Implikationen sich aus der Hardware für das Betriebssystem ergeben, wollen wir jetzt 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 den Benutzer äußert sich dieser Vorgang vor allem in einer kurzen Wartezeit, bis er sich am System anmelden kann. In dieser Zeit werden alle Dienste initialisiert, die das System erbringen soll.
Die Initialisierung des Systems
Bei Workstations gehört dazu in 90 % der Fälle eine grafische Oberfläche. Bei einer Vollinstallation eines Linux-Systems kann dazu auch schon mal ein Webserver- oder ein Fileserverdienst gehören. Werden solche komplexen Dienste beim Booten gestartet, dauert ein Systemboot natürlich länger als bei puren Desktopsystemen – mit anderen Worten lässt sich ein Windows XP 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 dem Benutzer eine Schnittstelle angeboten, mit der er arbeiten kann.
Mehr zum Bootvorgang finden Sie in Kapitel 25.
Im laufenden Betrieb
Im laufenden Betrieb möchten 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 nun, dass den Benutzer so etwas nicht interessiert – schließlich sollen die Programme ausgeführt und auch die 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, würde 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.
Korrektheit
Ebenfalls zu diesem Themenkreis gehört die Korrektheit des Systems. Es soll seine Aufgabe so erfüllen, wie es ihm zugedacht ist – 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 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, nimmt man jedoch plötzlich »den Strom weg«, 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 ziemlich sicher zu Problemen kommen wird.
Aber auch den Applikationen muss eine gewisse Zeit zum Beenden eingeräumt werden. Vielleicht müssen temporäre Daten gesichert oder andere Arbeiten noch korrekt beendet werden. Das Betriebssystem muss also eine Möglichkeit haben, den Anwendungen zu sagen: Jetzt beende dich bitte selbst – oder ich mach das.