36 Punkte von GN⁺ 2025-08-29 | 1 Kommentare | Auf WhatsApp teilen
  • Objektorientierte Designmuster ermöglichen auch in in C geschriebenen Kerneln durch Polymorphismus und Modularität ein flexibles Systemdesign
  • Mit vtables (virtuellen Funktionstabellen) lassen sich Schnittstellen von Geräten und Diensten standardisieren; dynamische Änderungen zur Laufzeit unterstützen unterschiedliche Verhaltensweisen
  • Kernel-Dienste und Scheduler bieten über vtables konsistente Schnittstellen für Start, Stopp und Neustart und kapseln dabei Implementierungsdetails
  • In Verbindung mit Kernel-Modulen wird dynamisches Laden von Treibern unterstützt, wodurch sich das System ohne Neukompilierung erweitern lässt
  • Dieser Ansatz bietet Flexibilität und experimentelle Freiheit, hat aber den Nachteil einer umständlicheren Syntax und der expliziten Übergabe von Objekten

Freiheit und objektorientierte Muster in der OS-Entwicklung

  • Die Entwicklung eines eigenen OS erlaubt freies Experimentieren ohne die Zwänge von Zusammenarbeit oder realen Anwendungsfällen
    • Frei von Sicherheitslücken, Wartungsaufwand und Release-Druck
    • Genau das macht OS-Entwicklung attraktiv: unkonventionelle Programmiermuster lassen sich erkunden
  • Der LWN-Artikel „Object-oriented design patterns in the kernel“ zeigt Beispiele dafür, wie der Linux-Kernel objektorientierte Prinzipien in C umsetzt
    • Polymorphismus wird mit Strukturen umgesetzt, die Funktionszeiger enthalten
    • Durch Kapselung, Modularität und Erweiterbarkeit lassen sich die Vorteile objektorientierter Ansätze auch in Low-Level-Kerneln nutzen

Grundkonzept der vtable

  • Eine vtable ist eine Struktur mit Funktionszeigern, die die Schnittstelle eines Objekts definiert
    • Beispiel: eine Struktur für Geräteoperationen
      struct device_ops {  
          void (*start)(void);  
          void (*stop)(void);  
      };  
      struct device {  
          const char *name;  
          const struct device_ops *ops;  
      };  
      
  • Unterschiedliche Geräte, etwa netdev und disk, verwenden dieselbe API, haben aber verschiedene Implementierungen
    • netdev.ops->start() ruft das Verhalten des Netzwerkgeräts auf, disk.ops->start() das des Festplattengeräts
  • Änderungen zur Laufzeit: Durch den dynamischen Austausch der vtable kann Verhalten geändert werden, ohne den aufrufenden Code anzupassen
    • Mit geeigneter Synchronisierung ermöglicht das eine saubere dynamische Weiterentwicklung des Verhaltens

Anwendungsbeispiele im OS

Dienstverwaltung

  • Kernel-Dienste wie Netzwerkmanager, Worker-Pools oder Fenster-Server lassen sich über eine konsistente Schnittstelle verwalten
    • Dienststruktur:
      struct service_ops {  
          void (*start)(void);  
          void (*stop)(void);  
          void (*restart)(void);  
      };  
      struct service {  
          pid_t pid;  
          const struct service_ops *ops;  
      };  
      
  • Jeder Dienst implementiert sein eigenes Verhalten, lässt sich aber im Terminal auf standardisierte Weise starten/stoppen/neustarten
  • Die Kopplung zwischen Code und Diensten sinkt, die Verwaltung wird vereinfacht

Scheduler

  • Ein Scheduler kann unterschiedliche Strategien unterstützen, etwa Round Robin, Shortest Job First, FIFO oder Prioritätsplanung
    • Die Schnittstelle wird auf yield, block, add und next vereinfacht
    • Über eine vtable definiert, kann die Scheduling-Policy zur Laufzeit ausgetauscht werden
    • Die gesamte Policy lässt sich ändern, ohne den restlichen Kernel anzupassen

Dateiabstraktion

  • Die file_operations-Struktur von Linux setzt die Philosophie „Alles ist eine Datei“ um
  • Sockets, Geräte und Textdateien bieten alle dieselbe read/write-Schnittstelle
  • Code im Userspace kann konsistent arbeiten, ohne Implementierungsdetails kennen zu müssen

Verbindung mit Kernel-Modulen

  • Kernel-Module unterstützen durch den Austausch der vtable das dynamische Laden von Treibern oder Hooks
    • Wie bei Linux-Modulen lässt sich der Kernel ohne Neukompilierung oder Neustart erweitern
    • Beim Hinzufügen neuer Funktionen muss nur die vtable der bestehenden Struktur aktualisiert werden

Nachteile

  • Komplexe Syntax:
    • Objekte müssen explizit übergeben werden, etwa wie in object->ops->start(object)
    • Im Vergleich zur impliziten Übergabe in C++ ist das umständlicher
    • Auch Funktionssignaturen werden ausführlicher:
      static void object_start(struct object* this) {  
          this->id = ...  
      }  
      
  • Vorteil: Durch die explizite Übergabe werden Abhängigkeiten von Funktionen klarer und die Kopplung zwischen Objekt und Verhalten transparenter
    • Im Kernel-Code ist das ein sinnvoller tradeoff zwischen Komplexität und Klarheit

Erkenntnisse

  • vtables bieten eine einfache Möglichkeit, Komplexität zu verringern und zugleich Flexibilität zu erhalten
    • Verhalten zur Laufzeit austauschbar, konsistente Schnittstellen, einfaches Hinzufügen neuer Funktionen
  • Sie eröffnen neue Wege, objektorientiertes Design in C umzusetzen, und unterstreichen den experimentellen Reiz der OS-Entwicklung
  • Weiteres Material: Das xine-Projekt (https://xine.sourceforge.net/hackersguide#id324430) zeigt, wie sich mit vtables private Variablen verwalten lassen
  • OS-Entwicklung ist ein Feld für kreative Experimente, und objektorientierte Muster erweisen sich auch in Low-Level-Systemen als mächtiges Werkzeug

1 Kommentare

 
GN⁺ 2025-08-29
Hacker-News-Kommentare
  • Es geht um einen Beitrag darüber, dass der Linux-Kernel trotz seiner Implementierung in C objektorientierte Prinzipien übernommen hat, etwa durch die Nutzung von Funktionszeigern in Strukturen zur Umsetzung von Polymorphie. Solche Techniken gab es jedoch schon lange vor der objektorientierten Programmierung und sie werden als „abstrakte Datentypen (ADT)“ oder Datenabstraktion bezeichnet. Der zentrale Unterschied zwischen ADT und OOP besteht darin, dass man bei ADTs die Implementierung von Funktionen auslassen kann, während sie bei OOP immer vorhanden sein muss. Wenn man in OOP optionale Funktionen benötigt, muss man für jede optionale Funktion zusätzliche Klassen anlegen, diese bei jeder Implementierung per Mehrfachvererbung mit erben und zur Laufzeit prüfen, ob das Objekt eine Instanz dieser Zusatzklasse ist. Bei ADTs reicht es dagegen, einfach zu prüfen, ob der Funktionszeiger NULL ist
    • In Smalltalk und Objective-C ist es die traditionelle OOP-Art, zur Laufzeit einfach zu prüfen, ob ein Objekt auf eine Nachricht reagieren kann. Schade ist, dass das Wesen von OOP durch die übermäßig klassenzentrierten Entwurfsmuster von C++ und Java verfälscht wurde
    • Dem wird weitgehend zugestimmt; auch in C werden solche Muster verwendet. In klassischer OOP ist es üblich, in der Basisklasse eine Standard- oder Stub-Implementierung bereitzustellen. In moderner OOP oder in konzeptorientierten Sprachen gibt es auch den Ansatz, auf ein Interface zu casten, das nur die benötigte Teilmenge der API verwendet. Go ist dafür ein gutes Beispiel
    • Auf die Behauptung, dass diese Technik älter als objektorientierte Programmierung sei, wird erwidert, man würde OOP eher als Formalisierung bereits existierender Muster und Paradigmen beschreiben
    • Auch in den meisten OOP-Sprachen wie Java und C# kann man heute Lambdas verwenden und damit genau dasselbe wie in C implementieren. Lambdas sind letztlich nur Funktionszeiger und können daher direkt Instanzvariablen zugewiesen werden. (Dazu gibt es auch die absurde alte Anekdote, dass Java mehr als zehn Jahre brauchte, um Lambdas einzuführen, und Sun Microsystems früher sogar gegen Microsoft klagte, weil versucht wurde, Java um Lambdas zu erweitern)
    • Vererbung ist nicht zwingend nötig. Man kann das Composite-Muster verwenden. Auch Python ähnelt dem, weil dort der self-/this-/object-Zeiger explizit übergeben werden muss und es damit an Datenabstraktion im C-Stil erinnert
  • Vor einigen Jahren hat Peterpaul einmal ein leichtgewichtiges objektorientiertes System entwickelt, das sich angenehm auf C aufsetzen lässt (Repo). Objekte müssen nicht explizit übergeben werden, die Dokumentation ist zwar knapp, aber es gibt eine vollständige Test-Suite (Test 1, Test 2)
    • Wer sehen möchte, wie das ohne den syntaktischen Zucker von carbon aussieht, kann hier nachsehen. Parametrische Polymorphie scheint nicht unterstützt zu werden
    • Vala scheint ebenfalls ein passender Versuch für diese Nische zu sein
  • Die Person sagt, sie kenne sich in diesem Teil nicht gut aus, aber der OP scheint etwas anderes zu machen als die Kernel-Entwickler. Liest man den vom OP verlinkten Artikel, dann enthält die vtable dort typisierte Funktionszeiger, während der OP offenbar void-Zeiger benutzt. Außerdem war der im Beitrag der Kernel-Entwickler genannte Hauptvorteil, dass man Speicher spart, weil nicht jede Strukturinstanz mehrere Funktionszeiger enthalten muss, sondern nur einen einzigen vtable-Zeiger. Speicherersparnis ist also der zentrale Punkt, während der OP diese vtable als Indirektionsschicht für Methodenaustausch zur Laufzeit und für Polymorphie verwendet. Dieses Muster unterscheidet sich von dem, was die Kernel-Entwickler beschrieben haben
    • Der OP meinte nicht void-Zeiger, sondern void als Funktion ohne Argumente und ohne Rückgabewert. Eine vtable wird verwendet, um Polymorphie umzusetzen. Ohne Polymorphie würde man gar keine vtable einsetzen und damit noch mehr Speicher sparen
  • Auf die Meinung, dass es unbequem sei, das Objekt jedes Mal explizit zu übergeben, sagt jemand, er möge implizites this gerade nicht. Man übergibt die this-Instanz ohnehin ständig weiter, und ein explizites this macht klar, ob eine Variable zur Instanz gehört oder global beziehungsweise aus einer anderen Quelle stammt
    • In der OOP-Syntax von C++ (und Java) sei es einer der großen Fehler, dass beim Verweis auf Instanzmitglieder this nicht verpflichtend ist
    • Gemeint sei wohl, dass der Autor den Teil kritisiert, in dem bei object->ops->start(object) das Objekt zweimal explizit genannt werden muss: einmal für das Auflösen der vtable und einmal, um das Objekt an die C-Funktionsimplementierung zu übergeben
    • Um die Zugehörigkeit von Variablen klarzumachen, werden oft Benennungskonventionen wie mFoo, m_Foo, foo_ usw. verwendet. foo_ wird bevorzugt, weil es kürzer ist als this->foo. Natürlich kann man in C++ auch this explizit schreiben
    • Implizites this macht den Code knapper, und mit echten Methoden muss man den struct-Präfix nicht in jeder Funktion wiederholen. Statt mystruct_dosmth(s); wirkt etwa s->dosmth(); natürlicher
    • Mit Makros lässt sich das auch etwas geschickter lösen
  • Diese Muster wurden zum ersten Mal in einer Tmux-Präsentation gelernt (Folien). Es gibt auch einen eigenen Artikel dazu (Artikel über objektorientierte Befehle in tmux)
  • Während des Studiums wurde diese Art in einigen kleinen Projekten einmal umgesetzt. Es machte Spaß, in C ein OOP-ähnliches Gefühl zu erzeugen, aber wenn man nicht aufpasst, kann sehr schnell alles aus dem Ruder laufen
  • Man sollte beachten, dass dies kein Muster für das ganze Objekt ist, sondern eines, das das Interface nutzt, also die vtable beziehungsweise die Tabelle von Funktionszeigern. Andere objektorientierte Funktionen wie Klassen oder Vererbung sind eher teuer und in vieler Hinsicht schwer handhabbar
    • Vererbung ist letztlich nur eine Form von Komposition einer vtable. Auch eine Klasse ist nichts weiter als die Kombination aus vtable und Variablen im Scope
    • In C wirkt Feldvererbung überraschend natürlich, wenn man eine struct über ihr erstes Mitglied castet
    • In einer vtable stehen normalerweise Funktionen, die einen this-Zeiger annehmen. Das Beispiel struct file_operations enthält Funktionszeiger, die keinen this-Zeiger annehmen, und ist daher schwerlich eine echte vtable
  • Man kann Inline-Wrapper für vtable-Funktionen schreiben, sodass man statt thing->vtable->foo(thing, ...) einfach foo(thing, ...) verwenden kann
  • Es wurde sich immer gefragt, warum solche Muster nicht in den neuen C-Standard aufgenommen werden. Offensichtlich implementieren viele Leute immer wieder dasselbe Muster
    • Wenn man syntaktischen Zucker hinzufügt, braucht man gleichzeitig eine offiziell erlaubte Verwendungsweise und irgendeinen Fallback, der so wirkt, als fehle etwas. Eine Stärke von C ist, dass es dynamische Komplexität nicht verbirgt. Wenn dynamischer Dispatch passiert, ist das immer klar sichtbar. Viele Sprachen bieten bereits eine solche Formalisierung, aber die besondere Stärke von C besteht darin, dass Komplexität offenliegt. Deshalb verwendet man es nur dann, wenn man dynamischen Dispatch wirklich braucht. Außerdem ist die Syntax nicht besonders schwierig
    • Beim High C Compiler scheint es wohl zumindest teilweise Versuche in diese Richtung gegeben zu haben
  • Aus starker persönlicher Erfahrung kommt der Rat, dieses Muster niemals zu verwenden. Die Wartung großer Codebasen mit dieser Struktur sei ein Albtraum gewesen. Die Lesbarkeit sei schrecklich, der Compiler könne auf Zeiger basierende Aufrufe nicht optimieren, und Tooling-Unterstützung gebe es überhaupt nicht. Die Syntax sei unbeholfen, und Neueinsteiger könnten den Code erst lesen, wenn sie den C++-Compiler intern vollständig verstanden hätten. Vor allem könne es die Wartbarkeit langfristig ruinieren, gemessen an den fragwürdigen Vorteilen einer OOP-Einführung. Wenn es wirklich nötig sei, solle man einfach C++ verwenden
    • Auf die Nachfrage, was genau der Albtraum gewesen sei, wird gesagt, dass weniger syntaktischer Zucker im Gegenteil die Lesbarkeit verbessern könne, weil dann sofort sichtbar sei, ob ein Funktionsaufruf dynamischer Dispatch ist. So könne man den Einsatz auf die Stellen beschränken, an denen dynamischer Dispatch wirklich nötig ist. Außerdem habe man auch einmal einen Blogbeitrag gelesen, dem zufolge dynamischer Code in C wegen der geringeren Zahl an Funktionszeigern leichter zu optimieren sei. Niemand verlange, einen C++-Compiler vollständig nachzubauen; wenn man nur das Wesen von OOP versteht, könne man so etwas ganz natürlich umsetzen. Und auf die Aussage „Mach aus C kein schlechtes C++“ lautet die Erwiderung, dass dies im Gegenteil eine sehr C-typische Art sei und die Wahl gerade deshalb getroffen werde, weil sich Dynamik dort gezielt einbauen lässt, wo man sie haben will.