GCC patchen, um Actually Portable Executables zu bauen
(ahgamut.github.io)- 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
SIGTERMundEINVALkeine Compile-Time-Konstanten sind und damit mit den Regeln von C fürswitch case-Labels sowie für Initialisierungen vonstatic/const structkollidierten - Versuche mit
sed, Python-Regulären Ausdrücken, GCC-Plugins und dem Abfangen von Präprozessor-Makros wurden unternommen, aber Fehler beicase-Labels entstehen bereits beim Parsen und ließen sich daher mit einem Plugin allein nur schwer verhindern - Der finale Patch wird mit dem Flag
-fportcosmoaktiviert; kurz bevor GCC einen Fehler ausgibt, wandelt erswitchinif/gotoum und ergänzt Strukturinitialisierungen durch das Befüllen von Werten zur Laufzeit - Es bleiben Grenzen wie anonyme Strukturen,
enum, einigeconst int-Fälle und Array-Indizes in Initialisierungsausdrücken, aber der Weg ist frei, Software wiebash,curl,git,ninjaundgccohne Ä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- odercmake-Build-Systemen folgende Softwarepakete bauenbashcurlgitninjagccselbst
- 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
redbeanAufmerksamkeit 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
quickjswiederholte sich dasselbe Muster - Werden Systemkonstanten wie
SIGTERMundEINVALalscase-Labels inswitch-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 EINVALodercase ENOSYSinnerhalb vonswitch(errno)keine Compile-Time-Konstanten sind, gibt GCC den Fehlercase label does not reduce to an integer constantaus - Der Patch wandelt
switch-Anweisungen automatisch in die folgende Struktur um- Für jeden
case-Wert wird ein Zweig der Formif (errno == EINVAL) goto caselabel_EINVAL;hinzugefügt - Ein
gotozumdefault-Label wird hinzugefügt - Die ursprünglichen
case-Blöcke werden mit Labels undgoto endofthis_switchneu aufgebaut
- Für jeden
- Diese Methode ist nicht die eleganteste, konnte aber die in mehreren Codebasen beobachteten Fälle behandeln
- Da auch
switch-Anweisungen mit gemischtenifdefs und Fallthrough behandelt werden mussten, war diegoto-basierte Umwandlung die einfache Option
Auch struct-Initialisierung unterliegt derselben Einschränkung
- Auch bei der Initialisierung von
staticoderconst structtritt das Problem mit Compile-Time-Konstanten auf - Wenn
EINVALkeine 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 structstatic struct- Globale
const struct - Globale
static struct
- Normale lokale
- 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
- Innerhalb einer Funktion wird eine
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
- Denn es ist im Voraus schwer zu wissen, welche
- Der nächste Versuch nutzte ein GCC-Plugin für AST-Transformationen
- GCC-Plugins können mit
-fplugin=your-plugin.sogeladen werden - Bei Events wie
PLUGIN_PRE_GENERICIZE,PLUGIN_FINISH_PARSE_FUNCTIONundPLUGIN_FINISH_DECList Zugriff auf den AST möglich - Mit
debug_treelässt sich der AST ausgeben, und mitwalk_tree_without_duplicateslässt sich der AST durchlaufen
- GCC-Plugins können mit
- 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_readeraus den GCC-Plugin-Headern konnten Callbacks registriert werden, wenn Makros per#definedefiniert, per#undefaufgehoben 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
- Beispiel:
- Das Plugin fing die Verwendung von
SYMBOLIC(SIGTERM)ab, ersetzte sie durch den temporären Wert und wandelte den AST später in der PhasePLUGIN_PRE_GENERICIZEwieder in die korrekteVAR_DECLum - Dieser Makro-Hack war erfolgreich darin, Fehler bei
switch- undstruct-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 ENOSYSumgewandelt werden muss, musste das Plugin wegen der Makroersetzung auch normale Verwendungen wieENOSYS + 1oderprintf("ENOSYS = %d", ENOSYS)behandeln - Beim GCC-Patch wird die Funktion statt über ein separates
-fpluginüber das Flag-fportcosmoaktiviert - Kurz bevor GCC einen
case is not constant-Fehler ausgibt, prüft es, obflag_portcosmoaktiv 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 ing++eincase constant-Fehler auftrat, ließ sich auchninjabauen - Beim Versuch, Python 3.11 mit
ncurseszu verknüpfen, wurde ein neues Problem entdeckt- In
ncursesgab es Code mit binären Ausdrücken in Strukturinitialisierungswerten, etwaDATA2(PARODD | PARENB, PARODD) - Der bisherige Patch behandelte einfache Konstanteninitialisierungen, aber keine Ausdrücke wie
PARODD | PARENB
- In
- 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 vong++11passt
- Nach dem C-Standard müssen
- Nach dieser Änderung ließ sich
ncursesproblemlos bauen
Verbleibende Einschränkungen und praktisches Fazit
- Der Patch ist nicht perfekt
- Einige anonyme Strukturen werden nicht behandelt
- Einige Fälle von
enumundconst intwerden nicht behandelt - Fälle, in denen
SIGTERMinnerhalb eines Initialisierungsausdrucks als Array-Index verwendet wird, werden nicht behandelt - Bei ungewöhnlichen
static-Initialisierungen oder wennSIGTERMin einstatic const int8_tgeschrieben 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 undstruct-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
muslbaut, besteht eine gute Chance, dass sie sich auch mit Cosmopolitan Libc bauen lässt
3 Kommentare
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...
Ach, dass es in einen Codeblock eingeschlossen wird, ist ein Bug ;_;
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-statichinzu.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 hoffe, du wirst auf HN besser behandelt.
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.
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
SIGHUPbei 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.
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-statichinzu.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?
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
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?“
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.
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
switchdurchifzu 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))undcase COSMO_EINVAL:verwenden, ohne den Code in einen Haufen gotos zu verwandeln.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.
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
Ich verstehe nicht, warum es so überraschend ist, Code zu sehen, der
SIGxxxals Index verwendet. Signalnummern sind wohldefiniert und gelten im Allgemeinen als zusammenhängend und bei 1 beginnend.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.
std::error_codeist keine Konstante, daher dürfte es nicht dasselbe Problem haben.