StuBS
Betriebssystementwicklung im Allgemeinen

Bislang wurden viele Programme entwickelt, die auf einem Betriebssystem basieren, weil wir die Services des Betriebssystems bewusst und auch nicht-bewusst verwendet haben. Neben den Systemrufen wie mmap (für malloc) oder write (für printf) haben wir auch implizite Features des Betriebssystem verwendet. Beispielsweise müssen wir uns heute keine Gedanken mehr darüber machen, dass unser Programm hängen bleiben könnte, und den gesamten PC einfriert, weil das Betriebssystem Prozessumschaltung implementiert hat. Außerdem sind weitere Isolationstechniken und Policies eingebaut.

In Betriebssystembau, allerdings, wollen wir selbst ein Betriebssystem entwickeln, d.h. unser Code kann keine Services einer unter liegenden Software-Schicht verwenden, vielmehr müssen wir diese Schicht erst selbst aufbauen. Dabei ergeben sich einige Änderungen im Vergleich zur Anwendungenwicklung auf einem Betriebssystem, die hier erläutert werden sollen.

Die Programmierumgebung allerdings bleibt quasi identisch, es kann immernoch eine IDE verwendet werden. Lediglich die Abfolge beim Bauen des Betriebssystems muss angepasst werden, damit ein bootbares Speicherabbild entsteht. Für die Betriebssystementwicklung wird in BSB C++ verwendet, einige andere Sprachen wären ebenfalls möglich, solange man deren Voraussetzungen in tiefer liegenden Ebenen in C(++) erfüllen kann.

Laden und Booten des Betriebssystems

Ein Linux nimmt eine ELF-Datei (Executable and Linking Format), lädt bestimmte Bereiche davon in den Speicher und beginnt unser Programm in einem eigenen Prozess auszuführen. Das wird derart gemacht, dass andere Prozesse von unserem Prozess nicht gestört werden.

Für ein eigenes Betriebssystem gibt es allerdings kein Betriebssystem, was unseren Code laden kann. Allerdings gibt es den Bootloader. Der Bootloader ist eine Software-Schicht, die auf einigen bestimmten Sektoren der Festplatte vom BIOS (oder UEFI) geladen wird. Er hat die Aufgabe Speicherabbilder von Betriebssystem zu laden und dort hin zu springen, also die Ausführung des Betriebssystemcodes zu beginnen.

Der Bootloader versteht allerdings das ELF-Format nicht. Er lädt stur ein Speicherabbildung von einer der Festplatten und springt (nach ein paar anderen Aufgaben) dort hin.

Ein Speicherabbild ist eine vorbereitete flache Struktur eines Programms, also ein Abbild des initialen Speicherinhalts. Dabei sind auch uninitialisierte Daten (die normalerweise nicht in der ELF enthalten sind) als Speicher vorallokiert.

Allerdings gibt es in C++ dennoch einige Aufgaben, die der Code des Betriebssystems erledigen muss (den wir euch in BSB abgenommen haben). Dazu zählt, dass die Konstruktoren aller globalen Objekte ausgeführt werden müssen, damit die Member die richtigen Werte aufweisen. Außerdem werden vom Betriebssystem noch die anderen Kerne hochgefahren, denn bis hierhin ist nur ein Kern (der Boot-Prozessor) eines Mehrkernsystems aktiv.

Dynamische Speicherverwaltung für das Betriebssystem

Gewöhnliche Anwendungen für Betriebssysteme können in höheren Sprachen wie Go oder Python entwickelt werden. Diese Sprachen verstecken viele unschöne oder nervige Probleme vor dem Programmierer, eines davon ist die Speicherverwaltung. Doch wo kommt eigentlich der Speicher für ein neues Objekt her?

Der Python-Interpreter, der ebenfalls nur eine Anwendung auf bspw. Linux ist, fragt das Betriebssystem an, ihm weiteren Speicher zur Verfügung zu stellen. Das könnte bspw. mit dem mmap- oder brk-Systemruf passieren.

Wir wollen allerdings in Betriebssystembau ein Betriebssystem entwickeln, d.h. es gibt keine unterliegende Software-Schicht, die uns Speicher zur Verfügung stellen kann. Eine eigene Speicherverwaltung für das Betriebssystem zur Verfügung zu stellen ist viel Arbeit und wird erst in Betriebssystemtechnik gemacht.

D.h. der Speicher, den wir für unsere Datenstrukturen dennoch benötigen muss irgendwo anders her kommen. Wir haben schon früher festgestellt, dass der Bootloader unser erstelltes Speicherabbildung einfach in den Speicher lädt. Wir können also Bereiche vorallokieren und später zur Laufzeit mit Inhalten füllen. Dafür deklarieren wir global einige Variablen und Objekte, die der Compiler dann im Data-Segment unterbringt. Auf diese Objekte kann dann wie gewohnt zugegriffen werden. Bspw:

class Keyboard {
// Keyboard-Klasse hier
};
Keyboard kbrd;
int main () {
kbrd.plugin ();
}
Handles keystrokes.
Definition: keyboard.h:18
void plugin()
Initialization of the keyboard.
Definition: keyboard.cc:9
int main()
Kernels main function.
Definition: main.cc:56

Andere Objekte können einfach auf den Stack gelegt werden. Dieser ist allerdings ebenfalls vorallokiert und kann nicht wachsen. Er ist auf 4KiB begrenzt, d.h. wir können nur sehr begrenzt rekursive Funktionen verwenden. Objekte bzw. Variablen werden automatisch auf den Stack gelegt, wenn sie innerhalb einer Funktion deklariert werden. Dort werde die Variablen erst beim Rücksprung auf der Funktion abgeräumt. D.h. einen Zeiger auf ein im Stack liegendes Objekt sollte mit Vorsicht behandelt werden, nachdem die Funktion zugekehrt ist.

int foo () {
// bar wird auf den Stack angelegt
int bar = 42;
foorbar ( &bar );
}

Keine LibC

Die LibC ist eine sehr grundlegende Bibliothek in der C-Welt, die viele einfache Aufgaben erfüllt. Bspw. liefert sie string-Funktionen oder auch printf. Die LibCs, die es auf Betriebssystemen gibt, dazu zählt bspw. die GLibC, basiert auf der Speicherverwaltung eines Betriebssystems und bestimmer Systemrufe, wie bspw. write.

Es gibt Implementierungen der Kernfunktionen auch für Systeme ohne Betriebssystem. Bspw. erlaubt die Newlib, in einen Betriebssystemkern eingebaut zu werden, wenn einige Funktionen vom Betriebssystem zur Verfügung gestellt werden.

In Betriebssystembau gibt es keine LibC und auch keine Standard-Template Library (STL) von C++. Vielmehr liefern wir einige Klassen mit, die Benutzung finden können, andere Funktionalitäten der LibC werden für BSB nicht benötigt.

Debugging - oder "Warum hat sich die CPU zurückgesetzt?"

Debugging ist für Programme eine sehr wichtige Aktivität, die auf einem Betriebssystem leicht mithilfe von einigen Tools wie GDB oder valgrind durchgeführt werden kann. Es ist ein leichtes GDB zu benutzen um die Werte aller verwendeten Variablen zu sehen.

Das ist bei der Betriebssystementwicklung dank einiger guter Emulatoren mit guter Debugging-Unterstützung immernoch möglich, allerdings ist Debugging auf der echten Hardware sehr zeitaufwendig. Außerdem muss immer damit gerechnet werden, dass die echte Hardware sich leicht anders verhält als der Emulator.

Ohne weiteres ist allerdings ein Debuggen auf der echten Hardware nicht möglich. Theoretisch könnte ein GDB-Stub in Software implementiert werden, der bspw. über eine serielle Schnittstelle gesteuert wird, das wird aber in BSB nicht gemacht. Für die echte Hardware ist es also nur möglich die Ausgaben sinnvoll zu steuern, und daraus Schlüsse zu ziehen, warum der Prozessor sich zurücksetzt, oder warum sich Prozesse verklemmen.

Keine Isolation von Programmabläufen

Unter Linux sind alle Prozesse von allen anderen Prozessen isoliert, d.h. ein Prozess kann nicht dafür sorgen, dass ein anderer verklemmt und keinen Fortschritt mehr macht (abgesehen von Interaktionen zwischen den Prozessen, Kommunikation, etc). So können auch mehrere Kerne unfallfrei von verschiedenen Prozessen verwendet werden.

Wenn wir allerdings selbst ein Betriebssystem schreiben, gibt es die Isolation nicht. Es kann also problemlos vorkommen, dass ein Kern eine Datenstruktur verändert, während ein anderer darauf zugreifen will und es zu Inkonsistenzen komme muss. Das bedeutet, dass wir im Betriebssystem gut überlegt Locks verteilen müssen, um auszuschließen, dass sich mehrere Kerne in die Quere kommen.

Außerdem kann unser Programmablauf ständig durch Interrupts unterbrochen werden. Für eine Anwendung in Linux ist das irrelevant, weil Linux die Interrupts abarbeitet und zur Anwendung zurückkehrt. Allerdings muss Linux auch darauf achten im Interrupt keine Datenstrukturen zu zerstören, die gerade benutzt werden (könnten).