Fomos: Ein experimentelles Betriebssystem, das in Rust gebaut wurde
(github.com/Ruddle)- Fomos ist ein experimentelles OS, das in Rust gebaut wurde, und ein Projekt, um Ideen für Non-Unix-OS sowie die Herausforderungen von Exo-Kernel-Mustern zu verstehen
- Es bietet grafische Ausgabe, dynamische Allokation, paralleles Laden und Ausführen von Apps, Virtio-Maus- und -Tastaturunterstützung sowie kooperatives Scheduling
- Apps werden als einzelne Funktion der Form
pub extern "C" fn _start(ctx: &mut Context) -> i32behandelt; ohne Standardbibliothek erhalten sie OS-Funktionen überContext Contextist eine Struktur, die Funktionen und Zustände wie Logs, PID, Framebuffer,calloc,cdalloc,storeund Eingaben enthält; neue Funktionen werden hinten angefügt, um die Kompatibilität mit älteren Apps zu erhalten- Es gibt keine System Calls; Apps geben die Kontrolle per
returnan das OS zurück, danach wird die Funktionstarterneut aufgerufen — ein kooperatives Ausführungsmodell - App-Zustand kann in
Context.storegespeichert werden, und die Kernel-Loop ist vereinfacht so aufgebaut, dass sie die App-Liste durchläuft und für jede App_start(Context::new(...))aufruft - Da alle Funktionen und Nebeneffekte über
Contextweitergegeben werden, ist die Grundannahme, dass sich Sandboxing, Instrumentierung und Debugging durch Ersetzen oder Wrappen von Funktionen inContextumsetzen lassen - Sicherheit ist derzeit nicht implementiert; Apps können den RAM anderer Apps einsehen. Geplant ist, Datensicherheit ohne Context Switches und ohne app-spezifische virtuelle Speicher-Stacks zu implementieren
- Fehlende Punkte sind persistenter Speicher, GPU-Unterstützung, Networking sowie Abstraktionen zum Teilen von Daten und Funktionen zwischen Apps; an Virgl wird gearbeitet
- Der Build wird mit
./build.shausgeführt; erforderlich sein könnenrust nightly,gccsowieqemumit Virgl- und SDL-Flags
1 Kommentare
Hacker-News-Kommentare
Was mir nicht gefällt: In einem präemptiven System kann
while (true)das System zwar verlangsamen, aber in einem kooperativen System steht die Maschine praktisch still, sobald die Kontrolle nicht zurückgegeben wird.Auch aus Sicherheitssicht macht ein solches System Denial-of-Service-Angriffe viel zu einfach, und ein einzelner Bug in irgendeiner App kann sich auf das gesamte System ausweiten.
Ich bin kein Betriebssystementwickler, also korrigiert mich bitte, falls ich falschliege.
Betriebssysteme haben kooperatives Multitasking nicht aufgegeben, weil sie damit alle Probleme „ausufernder Ressourcennutzung“ für immer gelöst hätten, sondern weil einfache Fehler auf App-Ebene wie ein blockierter UI-Thread oder eine versehentliche Endlosschleife den Zustand des gesamten Systems zerstören.
Bei einem System, das darauf ausgelegt ist, beliebige Programme auszuführen, ist das ziemlich fatal.
Man kann Apps erlauben, kooperativ geplant zu werden, und trotzdem verhindern, dass
while(true){}das System endlos festhält.Zum Beispiel mit einem Zeitlimit pro App oder, experimenteller, durch Erkennung von Schleifen und großzügige Zeitlimits.
Für Programmierer ist es bequem, wenn die Isolation zwischen nicht zusammengehörigen Programmen maximal und zwischen zusammengehörigen Programmen minimal ist; für Nutzer ist es bequem, wenn Rechenressourcen stark isoliert sind, Speicher oder Berechtigungen aber weniger stark abgeschottet werden.
In der Praxis braucht man ein komplexes Scheduling, das eher präemptiv als kooperativ ist, aber doch ein wenig kooperativ.
Auch unter Linux ist es für ein bösartiges Programm wie eine Fork-Bomb nicht schwer, das System zum Stillstand zu bringen, besonders wenn Swap aktiviert ist; und selbst mit präemptivem Scheduler läuft, wenn ein Programm 99 % der System-Threads belegt, faktisch meistens dieses Programm.
Scheduling ist ein Spektrum, und heutige Betriebssysteme sind zwar präemptiv, aber bis zu einem gewissen Grad auch kooperativ.
Apps können entscheiden, ob sie die Kontrolle abgeben.
Umgekehrt könnte das Betriebssystem kooperativ bleiben, aber bei Überschreiten von Ressourcenschwellen oder bei Timer-Interrupts selten in einen Fehlermodus mit Kontextwechsel in präemptives Verhalten wechseln, die App beenden und danach wieder in den kooperativen Modus zurückkehren.
Man könnte das optimistisch kooperativ und pessimistisch präemptiv nennen.
Eine
while(true)-Schleife legt ein Single-Core-System lahm, aber bei einem Multi-Core-System nicht unbedingt.Die Kompromisse könnten heute andere sein als damals, als die Grundstruktur der Betriebssysteme entstand, die wir heute verwenden.
Besonders gut fand ich den Teil „In Fomos ist eine App einfach nur eine Funktion“.
Unix- oder Windows-Executables sind im Vergleich zu eigenständigen Funktionen sehr komplex; es ist kaum vorstellbar, wie elegant ein auf diese Weise geschriebener Kernel sein könnte.
Ich frage mich, ob Smalltalk/Squeak auch so funktioniert, und hoffe, dass der Autor mit Dateisystem, Task-Manager, sicherem Speicher-Stack und Ressourcenfreigabe weitermacht.
Natürlich gehört zum Minimalanforderungsprofil eines Proof of Concept auch, DOOM auszuführen.
Näher dran sind Lisp-Machine-Betriebssysteme: Anfangs riefen dort eigenständige Funktionen einander ohne Objektsystem auf, später wurden daraus generische Funktionen, die auf Argumentklassen spezialisiert waren.
Es wirkt, als hätten die Designer noch nicht entdeckt, warum andere Betriebssysteme die Dinge brauchten, die hier derzeit fehlen.
int main() { … }-Funktion ist.Ein anderes Beispiel in Rust ist https://github.com/hermit-os/hermit-rs.
Die Idee ist in Ordnung, aber ständig neue Funktionen an die Context-Struktur anzuhängen, um mit alten Einträgen kompatibel zu bleiben, ist der Weg in die Hölle der Abwärtskompatibilität.
Man sperrt sich damit selbst ein, weil man alte oder verworfene Einträge nicht mehr aus der Context-Struktur entfernen kann.
Ein besserer Ansatz scheint zu sein, Semantic Versioning zwischen Betriebssystem und App einzuführen.
Wenn eine App deklariert, für welche Betriebssystemversion sie gebaut wurde oder von welcher sie abhängt, kann das Betriebssystem die Kompatibilität prüfen und die passende Version der Context-Struktur übergeben.
Die meisten Abwärtskompatibilitätsprobleme bleiben zwar bestehen, aber man kann die Context-Struktur sauber halten, indem man im Kernel mehrere Strukturen nach Major-/Minor-Versionen vorhält.
Gute Idee, aber mir gefällt auch die Einfachheit, nur ein einziges Runtime-Interface zu haben.
Wenn das Betriebssystem ohnehin alle Versionen behandeln muss, könnten zukünftige Apps Padding verwenden, damit es sich „sauber“ anfühlt.
struct Context{ padding: [u8;256], // old stuff ctx: ContextV42 }Allerdings fühlt sich das, wenn ich es so hinschreibe, auch irgendwie falsch an.
Dass eine App ihre Version deklariert, wirkt wie ein Problem, das Executable-Formate wie ELF bereits lösen; deshalb probiere ich Alternativen aus.
Der Teil „Wie schläft man oder wartet asynchron? Man returnt einfach“ wirkt etwas seltsam.
Asynchrone Ein-/Ausgabe im Stil von
io_uringwäre großartig, aber dieses Modell scheint so etwas auszuschließen, was es erschweren könnte, angemessene Performance zu erreichen.Auch keine Unterstützung für Async zu haben, wirkt merkwürdig, weil sich das mit natürlichen Unterbrechungspunkten verbinden ließe.
Dafür müsste man aber wohl viel von dem Design aufgeben, den Anwendungszustand explizit auf die Festplatte zu speichern und wieder zu laden, und das scheint teuer zu werden.
Networking dürfte aus ähnlichen Gründen ebenfalls schwierig werden, zumindest wenn es effizient sein soll.
Diese Funktion wirkt wie das Ende einer Event-Loop, die beliebigen Zustand als Parameter erhält; daher ließe sich das, was eine Event-Loop tut, vermutlich weitgehend verallgemeinern.
Allerdings gibt man damit Sprachunterstützung für Coroutinen und Async auf.
Das angeführte Beispiel ist zu konstruiert
In einem präemptiven Betriebssystem bleiben Apps normalerweise auf eine Weise hängen, die nicht das gesamte System kooperativ macht, etwa durch Thread-Deadlocks oder Endlosschleifen
Außerdem kann ein präemptives System eine App beenden, wenn sie zu viele Threads oder Dateien anlegt oder zu viel Speicher verbraucht, lange bevor es faktisch kooperativ würde
Unser System ist einfach nur nachsichtiger
Zudem heißt es einerseits: „Sandboxing ist kostenlos, wenn man nur die Prämissen akzeptiert“, andererseits aber: „Jede App kann leicht den RAM anderer Apps einsehen, und das ist ein schwer zu lösendes Problem“ – Sandboxing ist also nicht kostenlos
Trotzdem ist es eine coole Idee, und ich hoffe, dass der Autor Erfolg damit hat
Eine Lösung für dieses Problem könnte darin bestehen, Funktionen zu verwenden, also Closures zu erzeugen, die Anwendungen kapseln und wie der eigene Context der App wirken
Ich frage mich, was wäre, wenn eine App andere Apps öffnen könnte – oder wenn eine App für eine andere App zum Betriebssystem werden könnte
Ein System, das korrekt für robustes Sandboxing entworfen ist, setzt Limits für alle Ressourcen und lehnt Anfragen ab, wenn ein Limit erreicht ist
Ich frage mich, wie Fomos zwischen Prozessen und ausführbaren Dateien unterscheidet
Unter Linux ist ein Prozess ein virtueller Adressraum mit argv/envp-Zeigern, Stack, Heap, Signalmaske, File-Handle-Tabelle, Signal-Handlern und ausführbarem Speicher sowie kernelinternen Daten wie uid und gid
Eine ausführbare Datei ist eine Datei, die genug Bits enthält, damit der Loader diesen Adressraum beim Systemaufruf
execvefüllen kannAuch ohne ausführbare Datei kann man mit
clone3oderforkProzesse erstellen; der Kernel verwendet ELF, und der Großteil des Userspace nutzt den RTLD-Loader von GLIBC, aber keines von beidem ist zwingend nötig, um Prozesse aus einem bestimmten ausführbaren Dateiformat zu erzeugenStatisch gelinkte ausführbare Dateien ohne position-independent code sind aus Sicht eines Assemblers eher „einfach Funktionen“, aber wenn man Laufzeitsymbole ohne ASLR auflöst, wird man anfällig für Buffer-Overflow-Angriffe, sobald die Adressen abhängiger Funktionen bekannt sind
Ich wünsche mir Alternativen zu den Schwächen von glibc und zum Posix-Prozessmodell, aber ich denke, ein erheblicher Teil der Komplexität von Unix-Executables ist inhärent
Laufzeit-Symbolauflösung ist schwierig, aber nützlich; beliebige Interpreter zuzulassen ist lästig, aber eine Stärke von Linux gegenüber Windows und MacOS; und eine Kernel-Schnittstelle über stabile Systemaufrufe bereitzustellen, ist eine Stärke von Linux
Beim Mac ist es Mach-O, bei Windows PE, bei Linux ELF; es gibt aber keinen Grund, warum es kein vielfältiges Ökosystem von Ausführungs- und Link-Formaten geben sollte
Ein Betriebssystem mit einem sehr einfachen Modell zum Laden von Code ist ein guter Ort für solche Experimente
Ein stabiles ABI ist weder etwas Einzigartiges an Linux, noch ist der Nutzen dieser Entscheidung besonders zweifelsfrei, aber die Treiberunterstützung ist großartig und schwer zu bestreiten
Das ist ziemlich interessant
Ich sehe nicht, wie man mit nicht vertrauenswürdigen kooperativen Apps ein gewisses Maß an Security und Safety erreichen kann
Jede App kann die CPU unbegrenzt festhalten und damit den Kernel und andere Apps anhalten
Der Grund, warum wir Betriebssysteme mit präemptivem Scheduling verwenden, ist, dass man eine fehlfunktionierende App stoppen kann, ohne den Rest des Systems zu beschädigen
Es verwendete zwar präemptives Multitasking, erzwang aber keinen Speicherschutz; stattdessen gab es den Compiler als Systemdienst, sodass nur ausführbare Dateien laufen konnten, die vom System-Compiler erzeugt und signiert worden waren
Weil der Compiler den Speicherschutz zur Build-Zeit garantierte, wurden Systemaufrufe und Interprozesskommunikation sehr billig
Ein etwas ausgefeilterer Compiler könnte auch kooperatives Multitasking auf ähnliche Weise erzwingen, indem er an den nötigen Stellen
yield-Aufrufe einfügtDas allgemeine Halteproblem lässt sich nicht lösen, aber es gibt trotzdem Klassen von Programmen, bei denen sich durch statische Analyse beweisen lässt, dass sie terminieren oder yielden
Nur nicht beweisbare Programme müssten gesondert behandelt werden, und mit einem Watchdog-Timer könnte man fehlfunktionierende Programme auch automatisch stoppen
Nicht vertrauenswürdiger Code läuft nicht im „Haupt“-Image, sondern in einer wegwerfbaren VM
Auch hier könnte man mit einem Hypervisor dasselbe Modell anwenden, aber niemand nutzt ein Smalltalk-System völlig allein, und man braucht ein gewisses Maß an Infrastruktur
Ich frage mich, ob sich in diesem Betriebssystem Sicherheit umsetzen lässt, ohne es komplett neu zu entwerfen – also ohne im Grunde all das noch einmal zu tun, was bestehende Betriebssysteme bereits getan haben.
Ich kenne zwei Wege, Sicherheit für Anwendungen zu erzwingen, die auf derselben Hardware laufen.
Der eine besteht darin, Prozesse zur Laufzeit per virtuellem Speicher zu isolieren; der andere darin, dass der Loader beim Laden überprüft, ob der Code beliebige Speicherzugriffe durchführen kann.
Letzteres wird üblicherweise durch eine virtuelle Maschine erzwungen, die nur Bytecode mit einem eingeschränkten Befehlssatz ohne Pointer-Arithmetik zulässt, wie bei der JVM oder Smalltalk.
Der Autor von Fomos möchte keine Kontextwechsel und Speicherisolation usw., und der Rust-Compiler erzeugt keinen Bytecode – gibt es da einen anderen Weg?
Soweit ich es verstehe, erzwingt ein zertifizierter Compiler Regeln wie das Verbot von
unsafe; der Quellcode entspricht hier also praktisch dem Bytecode.Auf den ersten Blick wirkt es Midori sehr ähnlich, aber die Details der Implementierung unterscheiden sich ziemlich.
In Theseus sind Treiber, Anwendungen usw. ELF-Objekte, und alle werden dynamisch zu einer einzigen ausführbaren Datei gelinkt, nämlich dem Kernel; außerdem gibt es interessante Techniken wie Hot Upgrades.
https://github.com/theseus-os/Theseus
https://www.theseus-os.com/
Wenn zur Laufzeit ein Segmentierungsfehler auftritt, würde man etwa prüfen, ob der Aufrufer über ein Sicherheitstoken oder Ähnliches die Berechtigung hat, auf diese Page zuzugreifen und sie aufzurufen.
Ich weiß nicht, wie praktikabel das tatsächlich wäre.
Selbst bei kooperativem Multitasking ist es heute vermutlich nicht wie zu Zeiten von Classic MacOS, weil es sehr viele Kerne gibt.
Ein oder zwei Prozesse, die nicht freiwillig abgeben, müssen nicht zwangsläufig das ganze System blockieren.
Wenn eine Funktion fehlerhaft läuft und nicht zurückkehrt, könnte das System sie beenden, sobald alle Kerne ausgelastet sind.
Kooperatives Multitasking bedeutet nicht zwingend schlechte Performance.
Timesharing war ursprünglich eine Methode, eine riesige einzelne CPU auf mehrere Nutzer aufzuteilen; da heute Single-User-Multicore-CPUs üblich sind, ist es längst an der Zeit, über andere Arten der Kernnutzung nachzudenken.
Dass dieses Projekt existiert, finde ich wirklich spannend.
In diesem Modell gibt es keine Kontextwechsel, daher könnte die Performance sogar besser werden.
Deshalb frage ich mich, was passieren würde, wenn man die Timeslices von Linux auf einen absurden Wert wie 10 Sekunden erhöht.
Ich würde gern mehr Details zum Sicherheitsplan hören.
Insgesamt zeigen solche Experimente meiner Ansicht nach, dass Betriebssysteme durch Greenfield-Design verbessert werden können.
Es erinnert mich ein wenig an Mirage OS: https://mirage.io/