3 Punkte von GN⁺ 2023-07-15 | 3 Kommentare | Auf WhatsApp teilen
  • Um mit Cosmopolitan Libc mehr bestehende C-Software bauen zu können, reichte es nicht, nur das Build-System auszutricksen: Der Compiler musste mit einem rund 2.000 Zeilen großen GCC-Patch problematischen Code automatisch umwandeln
  • Das Hindernis war, dass Systemwerte wie SIGTERM und EINVAL keine Compile-Time-Konstanten sind und damit mit den Regeln von C für switch case-Labels sowie für Initialisierungen von static/const struct kollidierten
  • Versuche mit sed, Python-Regulären Ausdrücken, GCC-Plugins und dem Abfangen von Präprozessor-Makros wurden unternommen, aber Fehler bei case-Labels entstehen bereits beim Parsen und ließen sich daher mit einem Plugin allein nur schwer verhindern
  • Der finale Patch wird mit dem Flag -fportcosmo aktiviert; kurz bevor GCC einen Fehler ausgibt, wandelt er switch in if/goto um und ergänzt Strukturinitialisierungen durch das Befüllen von Werten zur Laufzeit
  • Es bleiben Grenzen wie anonyme Strukturen, enum, einige const int-Fälle und Array-Indizes in Initialisierungsausdrücken, aber der Weg ist frei, Software wie bash, curl, git, ninja und gcc ohne Änderungen am Quellcode zu bauen

Ziel des GCC-Patches

  • Um Actually Portable Executables mit Cosmopolitan Libc einfacher erstellen zu können, wurde ein rund 2.000 Zeilen großer gcc-Patch geschrieben
  • Nach Anwendung des Patches lassen sich mit ./configure- oder cmake-Build-Systemen folgende Softwarepakete bauen
    • bash
    • curl
    • git
    • ninja
    • gcc selbst
  • Die gebauten ausführbaren Dateien sollten auch unter Linux, FreeBSD, MacOS, OpenBSD, NetBSD und Windows laufen können
    • Mit dem Hinweis, dass die Ausführung unter Windows nicht direkt getestet wurde
  • Über GitHub Actions gebaute Binärdateien sind bei den superconfigure releases erhältlich

Blockaden beim Portieren auf Cosmopolitan Libc

  • Nachdem Cosmopolitan Libc 2021 durch den Webserver redbean Aufmerksamkeit bekam, wurde die wichtige Frage, wie gut bestehende C-Software auf dieser libc läuft
  • Beim Portieren von Sprach-Runtimes wie Lua, Wren, Janet und quickjs wiederholte sich dasselbe Muster
  • Werden Systemkonstanten wie SIGTERM und EINVAL als case-Labels in switch-Anweisungen verwendet, kollidiert das mit den C-Kompilierregeln
  • Der C-Standard verlangt, dass case-Labels Compile-Time-Konstanten sind
    • In Cosmopolitan Libc mussten C-Präprozessor-Makros für Systeminterfaces symbolisch sein
    • Dadurch konnte verbreiteter Code wie switch(errno) { case EINVAL: ... } unter Umständen nicht kompilieren

switch in if und goto umwandeln

  • Wenn Labels wie case EINVAL oder case ENOSYS innerhalb von switch(errno) keine Compile-Time-Konstanten sind, gibt GCC den Fehler case label does not reduce to an integer constant aus
  • Der Patch wandelt switch-Anweisungen automatisch in die folgende Struktur um
    • Für jeden case-Wert wird ein Zweig der Form if (errno == EINVAL) goto caselabel_EINVAL; hinzugefügt
    • Ein goto zum default-Label wird hinzugefügt
    • Die ursprünglichen case-Blöcke werden mit Labels und goto endofthis_switch neu aufgebaut
  • Diese Methode ist nicht die eleganteste, konnte aber die in mehreren Codebasen beobachteten Fälle behandeln
  • Da auch switch-Anweisungen mit gemischten ifdefs und Fallthrough behandelt werden mussten, war die goto-basierte Umwandlung die einfache Option

Auch struct-Initialisierung unterliegt derselben Einschränkung

  • Auch bei der Initialisierung von static oder const struct tritt das Problem mit Compile-Time-Konstanten auf
  • Wenn EINVAL keine Compile-Time-Konstante ist, sind einige der folgenden Initialisierungen in C nicht gültig
    • Normale lokale struct-Initialisierung innerhalb einer Funktion ist möglich
    • const struct
    • static struct
    • Globale const struct
    • Globale static struct
  • Ein reales Beispiel ist das faulthandler-Modul von CPython
  • Die Lösung besteht darin, die Struktur mit Dummy-Werten zu initialisieren und anschließend vor der Verwendung Code zum Einsetzen der korrekten Werte hinzuzufügen
    • Innerhalb einer Funktion wird eine if-Anweisung ergänzt
    • Für globale oder statische Initialisierung wird __attribute__((constructor)) verwendet

Grenzen von Textersetzung und GCC-Plugins

  • Zunächst sollte die Umwandlung mit einem sed-Shellskript automatisiert werden, später mit Python-Skripten und Regulären Ausdrücken, um auch Fallthrough zu behandeln
  • Wegen des C-Präprozessors und ifdefs konnte Textersetzung jedoch nur schwer vollständig funktionieren
    • Denn es ist im Voraus schwer zu wissen, welche ifdefs beim Kompilieren aktiv sein werden
  • Der nächste Versuch nutzte ein GCC-Plugin für AST-Transformationen
    • GCC-Plugins können mit -fplugin=your-plugin.so geladen werden
    • Bei Events wie PLUGIN_PRE_GENERICIZE, PLUGIN_FINISH_PARSE_FUNCTION und PLUGIN_FINISH_DECL ist Zugriff auf den AST möglich
    • Mit debug_tree lässt sich der AST ausgeben, und mit walk_tree_without_duplicates lässt sich der AST durchlaufen
  • Das Problem war, dass die Validierung von case-Labels während des Parsens durch GCC erfolgt
    • Plugins greifen erst nach Abschluss des Parsens auf den AST zu
    • Ungültige Labels wie case SIGTERM: bleiben nicht im AST erhalten
    • Da GCC bereits einen Fehler ausgegeben hat, ist es schwer, das im Plugin zu korrigieren

Experiment zum Abfangen von Präprozessor-Makros

  • Da sich Fehler vor dem Parsen mit einem Plugin allein nur schwer verhindern ließen, wurde ein Ansatz ausprobiert, der mit dem C-Präprozessor interagiert
  • Über die Struktur cpp_reader aus den GCC-Plugin-Headern konnten Callbacks registriert werden, wenn Makros per #define definiert, per #undef aufgehoben oder verwendet werden
  • Unter Ausnutzung der Makrostruktur für Systemwerte in Cosmopolitan Libc wurden temporäre Konstanten eingefügt
    • Beispiel: extern const int SIGTERM;
    • Beispiel: #define SIGTERM SYMBOLIC(SIGTERM)
    • Für das Plugin wurden temporäre Werte wie static const int __tmpcosmo_SIGTERM = ... angelegt
  • Das Plugin fing die Verwendung von SYMBOLIC(SIGTERM) ab, ersetzte sie durch den temporären Wert und wandelte den AST später in der Phase PLUGIN_PRE_GENERICIZE wieder in die korrekte VAR_DECL um
  • Dieser Makro-Hack war erfolgreich darin, Fehler bei switch- und struct-Initialisierungen zu umgehen
  • Damit wurde mindestens auch ein Build von CPython 3.11 möglich

Warum statt eines Plugins GCC selbst geändert wurde

  • Nach Gesprächen mit Justine Tunney und der Bestätigung, dass das Problem lösbar war, wurde ein einfacherer Ansatz vorgeschlagen: die GCC-Codebasis direkt zu ändern
  • Plugin- und Makro-Hacks verursachten viel unnötige Arbeit, und die Behandlung von Edge Cases und Crashes war kompliziert
  • Wenn beispielsweise nur case ENOSYS umgewandelt werden muss, musste das Plugin wegen der Makroersetzung auch normale Verwendungen wie ENOSYS + 1 oder printf("ENOSYS = %d", ENOSYS) behandeln
  • Beim GCC-Patch wird die Funktion statt über ein separates -fplugin über das Flag -fportcosmo aktiviert
  • Kurz bevor GCC einen case is not constant-Fehler ausgibt, prüft es, ob flag_portcosmo aktiv ist, und führt dann die nötige Ersetzung durch
  • Der AST-Transformationscode des bisherigen Plugins wurde so verschoben, dass er innerhalb von GCC ausgeführt wird, bevor andere Plugin-Callbacks aufgerufen werden
  • Durch diese Änderung konnte der makrobezogene Hack-Code entfernt werden

Reale Builds und Erweiterung des Patches

  • Am 5. Juni 2023 bestand der gepatchte GCC alle früheren Plugin-Testfälle, und dem Cosmopolitan-Libc-Monorepo wurde ein neues Binary hinzugefügt
  • Beim Kompilieren von mehr Code folgten weitere Korrekturen und Verbesserungen
  • Der lua-Build wurde einfacher, und nachdem die Stelle gefunden war, an der in g++ ein case constant-Fehler auftrat, ließ sich auch ninja bauen
  • Beim Versuch, Python 3.11 mit ncurses zu verknüpfen, wurde ein neues Problem entdeckt
    • In ncurses gab es Code mit binären Ausdrücken in Strukturinitialisierungswerten, etwa DATA2(PARODD | PARENB, PARODD)
    • Der bisherige Patch behandelte einfache Konstanteninitialisierungen, aber keine Ausdrücke wie PARODD | PARENB
  • Anschließend wurde der Patch erweitert, sodass case-Labels und Strukturinitialisierungselemente in C beliebige Ausdrücke sein können
    • Nach dem C-Standard müssen case-Labels weiterhin Compile-Time-Konstanten sein
    • Der gepatchte Compiler gibt für solche Verwendungen eine Warnung aus, setzt die Kompilierung aber fort
    • C++ wurde von der Ersetzung beliebiger Ausdrücke ausgenommen, da sie nicht zur constexpr-Behandlung von g++11 passt
  • Nach dieser Änderung ließ sich ncurses problemlos bauen

Verbleibende Einschränkungen und praktisches Fazit

  • Der Patch ist nicht perfekt
    • Einige anonyme Strukturen werden nicht behandelt
    • Einige Fälle von enum und const int werden nicht behandelt
    • Fälle, in denen SIGTERM innerhalb eines Initialisierungsausdrucks als Array-Index verwendet wird, werden nicht behandelt
    • Bei ungewöhnlichen static-Initialisierungen oder wenn SIGTERM in ein static const int8_t geschrieben wird, können seltene Compiler-Crashes verbleiben
  • Dennoch wurden viele offensichtliche Gegenbeispiele beseitigt, und viele populäre Softwarepakete lassen sich reibungslos bauen
  • Strengere Testumgebungen könnten weitere Verbesserungsmöglichkeiten aufdecken
  • Wer möchte, kann switch-Anweisungen und struct-Initialisierungsausdrücke auch von Hand korrigieren, aber in vielen Fällen kann der Compiler das automatisch erledigen
  • Wenn C-Software statisch gebaut werden kann und insbesondere auch mit musl baut, besteht eine gute Chance, dass sie sich auch mit Cosmopolitan Libc bauen lässt

3 Kommentare

 
ryuheechul 2023-07-15

Verwendet neo jetzt ein Format, bei dem Nachrichten in Codeblöcke eingeschlossen werden? Dass man horizontal scrollen muss und die rötliche Textfarbe sind ein bisschen schade...

 
xguru 2023-07-16

Ach, dass es in einen Codeblock eingeschlossen wird, ist ein Bug ;_;

 
GN⁺ 2023-07-15
Hacker-News-Kommentare
  • Ich habe diesen Beitrag geschrieben; der Titel sollte „Patching GCC to build Actually Portable Executables“ lauten. Denn er bezieht sich auf Cosmopolitan Libc und jarts Actually-Portable-Executable-Format.
    Mit meinem GCC-Patch lassen sich nun Programme wie vim, emacs, ninja, bash, git und gcc mit Cosmopolitan Libc über die üblichen Build-Systeme im Stil von autotools/cmake bauen. Die erzeugten Executables sollten unter Linux, FreeBSD, MacOS, OpenBSD, NetBSD und Windows laufen können. Windows habe ich allerdings noch nicht getestet.
    Eine Liste der Software, die ich mit dieser Technik gebaut habe, gibt es hier: https://github.com/ahgamut/superconfigure
    Das superconfigure-Skript ist lediglich ein Wrapper um die normalen configure-Skripte, die zum Bauen von Software verwendet werden, und fügt Flags wie --enable-static hinzu.
    Wenn ihr GCC mit Cosmopolitan Libc bauen möchtet, könnt ihr dieses Repository verwenden: https://github.com/ahgamut/musl-cross-make/tree/gccbuild

    • Ich frage mich, ob dieser Patch eine Chance hat, in GCC upstream zu landen. APE-Executables und cosmopolitan libc sind extrem coole Technik; schön wäre, wenn sie leichter nutzbar würde.
    • Schön. Ich frage mich, ob dabei Probleme auftreten könnten, wenn man das hinter transpilierten Sprachen wie Nim verwendet.
    • Ich frage mich, ob du auch versucht hast, busybox zu bauen.
    • Ich habe kürzlich auf lobste.rs gesehen, dass du gesperrt wurdest, weil du angeblich ein Sockpuppet von jart seist, und erst jetzt verstanden, dass du tatsächlich jemand anderes bist.
      Ich hoffe, du wirst auf HN besser behandelt.
    • Dass es baut, ist für sich genommen noch kein so großer Erfolg; interessanter ist, ob es tatsächlich funktioniert.
      Mich würde zum Beispiel interessieren, ob es genügend Tests gab, um nachzuweisen, dass es im Vergleich zur glibc-Version keine Regressionen gibt.
  • Im Go-Repository gibt es dazu ein Issue: https://github.com/golang/go/issues/51900

  • Insgesamt ziemlich cool, aber mich interessiert die theoretische Grundlage. Wenn man ein neues Betriebssystem-Target erstellt, scheint es logisch, neue Konstanten zu definieren.
    Statt für jedes darunterliegende Betriebssystem dessen Konstanten unverändert durchzureichen, könnte man die Konstanten der einzelnen Betriebssysteme so übersetzen, dass sie überall gleich sind. Dann würde auch Code korrekt funktionieren, der Konstanten als Array-Indizes oder in case-Anweisungen verwendet.
    Das könnte mehr Overhead verursachen als der im Artikel beschriebene Ansatz, wirkt aber deutlich einfacher und auch für Leute, die neue Programme gegen Cosmopolitan libc kompilieren wollen, simpler zu verwenden.

    • Neue Konstanten zu erstellen würde das Kompilieren deutlich erleichtern, aber persönlich halte ich den Overhead zur Laufzeit für die Übersetzung zwischen bestehenden und neuen Konstanten für zu groß.
      Eine Liste der Konstanten gibt es hier: https://github.com/jart/cosmopolitan/blob/master/libc/sysv/c...
      Jedes Mal, wenn eine dieser Konstanten verwendet wird, müsste man viele Konstanten über große Lookup-Tabellen in das Binary aufnehmen, und jedes Mal, wenn das Programm sie prüfen muss, durch diese Tabellen gehen. Das wäre vielleicht nicht extrem langsam, aber meiner Ansicht nach definitiv spürbar.
      Ziel war es, das Portieren zu erleichtern, ohne den Quellcode der libc oder der zu portierenden Software stark zu verändern, und dabei Binaries zu erzeugen, deren Performance ähnlich gut oder besser ist. Unter diesen Einschränkungen wirkte dieser GCC-Patch wie der beste Weg, den Prozess zu vereinfachen.
      Wenn ich auf genügend Codebasen stoße, die SIGHUP bei der Initialisierung von Array-Indizes verwenden, werde ich den vorgeschlagenen Ansatz ausprobieren und die Kompromisse messen. Oder du kannst es selbst versuchen und mir sagen, ob ein eigener Konstantensatz besser ist.
  • Wenn „ziemlich viel Zeit darauf verwendet wurde, offensichtliche Gegenbeispiele zu entfernen, und viele populäre Programme reibungslos bauen“, wäre es hilfreich, eine Liste erfolgreich kompilierbarer Software zu veröffentlichen. Vielleicht gibt es sie ja schon.

    • Man kann Software wie vim, emacs, ninja, bash, git und gcc bauen. Die Liste der Programme, die ich mit dieser Technik gebaut habe, gibt es hier: https://github.com/ahgamut/superconfigure
      Das superconfigure-Skript ist lediglich ein Wrapper um die normalen configure-Skripte, die zum Bauen von Software verwendet werden, und fügt Flags wie --enable-static hinzu.
  • Ich frage mich, ob APE einen praktischen Zweck hat. Gibt es tatsächlich Stellen, die eine einzelne ausführbare Datei ausliefern, die auf mehreren Betriebssystemen laufen muss?

    • Logfile Navigator (https://lnav.org) verwendet eine APE-Executable als Agent, der mit entfernten Hosts kommuniziert.
      lnav selbst wird nicht als APE gebaut, aber der darin eingebettete Agent schon. Wenn ein Nutzer die Logs auf diesem Host lesen möchte, wird dieser Agent remote übertragen.
      Dadurch ist kein zusätzlicher Schritt nötig, um den Betriebssystemtyp separat zu erkennen oder mehrere Versionen der Executable mitzuliefern. Einen kurzen Beitrag dazu gibt es hier: https://lnav.org/2021/05/03/tailing-remote-files.html
    • Mir fallen Java-/Clojure-/Kotlin-Programmierer, .NET-Programmierer, JS, Python, Ruby, Erlang und Programmierer aller auf virtuellen Maschinen basierenden Sprachen ein.
      Statisch kompilierte Programme liegen zwar unter allem, sind auf Anwendungsebene aber in der Minderheit.
      APE stellt die große Frage: „Wer braucht noch eine virtuelle Maschine, wenn eine so Low-Level-nahe Ahead-of-Time(AOT)-Sprache wie C einmal kompiliert und überall ausgeführt werden kann?“
    • Zum Beispiel könnte es die Auslieferung von Executables an Stellen etwas einfacher machen, an denen es umständlich ist, mehrere Artefakte unter derselben id/version-Kombination zu haben, etwa in Maven-Repositories. PyPI dürfte ein ähnliches Problem haben.
      Außerdem ist Cross-Compiling schmerzhaft. Kleine Open-Source-Projekte ohne Build-Farm mit allen möglichen BSDs liefern für solche Plattformen oft gar keine Binaries aus.
      Selbst kubectl hat Binaries für Linux, aber keine für BSD – wenn man OSX nicht als BSD zählt. Für solche Projekte wirkt das daher wie ein einfacher Gewinn.
    • Künftige Side-Projects möchte ich auf diese Weise releasen. Selbst für eine clientseitige SPA baue ich eine Redbean-Executable. Die Vorstellung, dass solche Dinge auf mehreren Systemen einfach funktionieren, ist erstaunlich.
    • Es gibt einen Shader-Compiler, bei dem ein plattformunabhängiges, direkt ausführbares Executable-Format helfen würde.
      Er ist zu groß, um ihn spontan auf der Maschine des Nutzers zu bauen. Er enthält mehrere große C++-Abhängigkeiten von Drittanbietern, und der Build dauert je nach Hardware zwischen 2 und 20 Minuten.
      Er muss auf macOS x86-64+ARM, Linux x86-64 und Windows x86-64 laufen. ARM Linux wäre ebenfalls schön.
      Derzeit prüfen wir WASM, aber dafür braucht man eine installierte WASI-Runtime.
  • Für alle, die verwirrt sind: „Actually Portable Executables“ https://justine.lol/ape.html

    • Ich habe einen Kommentar hinzugefügt, der das erwähnt, aber es wurde noch nicht geändert. Ich denke, das passiert bald. Darf man hier @dang rufen?
  • switch durch if zu ersetzen, ist ziemlich grob. Man könnte das wohl beheben, indem man auf eine plattformneutrale Menge von Konstanten mappt und das Runtime-Symbol per Funktion dereferenziert.
    Zum Beispiel könnte man switch(CosmoMappErrno(EINVAL)) und case COSMO_EINVAL: verwenden, ohne den Code in einen Haufen gotos zu verwandeln.

    • Das hilft aber nicht dabei, unveränderten Code zu kompilieren. Um gotos, die intern vom Compiler erzeugt werden, muss man sich keine Sorgen machen; Compiler machen so etwas ohnehin überall.
      Etwas anderes wäre es natürlich, wenn gemeint ist, dass der Compiler solche switches erkennt und statt sie in einen if-Baum umzuschreiben, die Labels durch cosmo-spezifische Spezialkonstanten für die jeweiligen Werte ersetzt und den switch-Eingabewert in einen Funktionsaufruf kapselt, der den aktuellen Runtime-Plattformwert auf die entsprechende cosmo-spezifische Konstante mappt, während andere Werte unverändert durchgereicht werden.
      Das könnte tatsächlich eine einfachere Compiler-Transformation sein. Allerdings entsteht zusätzliche Komplexität, weil erkannt werden muss, welche von mehreren Konstantenmengen verwendet wird, und der passende Mapping-Aufruf auf den switch-Eingabewert angewendet werden muss. Auch auf Library-Seite müssten solche Mappings erstellt werden, was ebenfalls Komplexität erfordert.
      Da es bereits eine Implementierung gibt, die mit einer anderen Transformationsmethode funktioniert, weiß ich nicht, ob der Autor das implementieren möchte.
      Schließlich birgt so ein Mapping-Ansatz das Risiko falscher Zuordnungen, und bei Code, der negative errno-Werte verwendet, gibt es zusätzliche Tücken, etwa dass der Eingabewert vor dem Mapping invertiert werden müsste.
    • Die Idee, Runtime-Symbole per Funktion zu dereferenzieren, klingt interessant. Wenn du ein funktionierendes Beispiel zeigst, probiere ich es gern aus.
      Der Grund, warum mir das if-else-goto-Array gefällt, ist, dass es genau zu anderen Teilen von GCC passte. Wenn du dir meinen Patch[1] ansiehst, siehst du, dass ich nur sehr wenig am bestehenden GCC-Code ändern musste, um diese Funktion hinzuzufügen.
      [1]: https://github.com/ahgamut/gcc/tree/portcosmo-11.2
    • Muss man sich überhaupt darum kümmern, ob die Ersetzung automatisch erfolgt?
  • Ich verstehe nicht, warum es so überraschend ist, Code zu sehen, der SIGxxx als Index verwendet. Signalnummern sind wohldefiniert und gelten im Allgemeinen als zusammenhängend und bei 1 beginnend.

    • Bevor ich busybox ausprobiert habe, habe ich mehrere Codebasen wie git, bash, gcc und curl gebaut, und so einen Code hatte ich nicht gesehen.
      Außerdem trat dieser Fehler zusammen mit einem Compilerfehler in meinem Patch auf, dessen Debugging lange dauerte, deshalb blieb er mir als Überraschung im Gedächtnis.
      Ich frage mich, ob du dieses Codemuster für häufig hältst. Ich würde auch gern wissen, in welchen Codebasen außer busybox es vorkommt. Wenn es viele Beispiele gibt, könnte ich Zeit investieren, den Patch so zu aktualisieren, dass er mit solchen Mustern umgehen kann.
  • Wenn errno ein so großes Problem ist, weiß ich nicht, welche Hoffnung es gibt, die C++-Standardbibliothek und std::error_code zum Laufen zu bekommen.
    Dort gibt es keine Möglichkeit, magische Makros zu verwenden.

    • Das Problem waren wohl genau diese magischen Makros. Normalerweise werden Makros zu Konstanten erweitert, aber jetzt ist das nicht mehr so, wodurch einiges kaputtgeht.
      std::error_code ist keine Konstante, daher dürfte es nicht dasselbe Problem haben.