- Rust-Binärdateien durchlaufen vor
fn main()eine Laufzeit-Initialisierungsphase, in der unter anderem Panic-/Unwinding-Behandlung und die Umwandlung von Programmargumenten erfolgen - Wenn der Loader des Betriebssystems die Kontrolle an den Entry Point übergibt, führen die C-Laufzeit und die Rust-Laufzeit Initialisierungsfunktionen aus; mit
#[unsafe(link_section = "...")]und dem Konstruktor-Ansatz lässt sich Pre-Main-Code platzieren - Linker-Sektionen sammeln Daten, die von mehreren Crates beigesteuert werden, beim Erstellen der Binärdatei an einer Stelle;
link-sectionmacht sie wie Rust-Slices behandelbar - Mit
ctorundlink-sectionzusammen lassen sich Muster wie die Registrierung von CLI-Subcommands oder das Sortieren eines String-Interning-Pools schon vormainaufbauen und danach ohne Locks lesen - Dieser Ansatz bietet allokationsfreie Aggregation und Inversion of Control, aber wegen erschwerter Dead-Code-Eliminierung, Einschränkungen bei Konstruktoren, Plattformunterschieden und Grenzen bei der Miri-Kompatibilität sollte sein Einsatz sorgfältig gewählt werden
Die Phase vor main in einer Rust-Binärdatei
- Jede Rust-Binärdatei hat ein
fn main(), aber der tatsächliche Ausführungsfluss erreichtmainerst nach dem Durchlaufen des Betriebssystem-Loaders und der Laufzeit-Initialisierung - In C gibt es die als
libcbekannte C-Laufzeit, und Rust besitzt über die Standardbibliothek eine eigene Laufzeit, die auf der C-Laufzeit aufsetzt und darüber höherwertige Abstraktionen bereitstellt - Der Zweck der Laufzeit besteht darin, den Code der Entwickler mit dem Betriebssystem der Plattform zu integrieren
- Die C-Laufzeit richtet in der Phase vor
mainLaufzeitdienste wie Allokation, Dateizugriff und Thread-Local Storage ein - Rust bereitet zu diesem Zeitpunkt Panic- und Unwinding-Behandlung vor und wandelt C-artige Programmargumente in die Schnittstelle
std::env::argsum - Die Pre-Main-Phase läuft vor dem Benutzercode, ist Single-Threaded und findet in einer Umgebung mit vorhersagbarer Reihenfolge statt, was sie für deterministische Initialisierung geeignet macht
Entry Point
- Eine Binärdatei startet, wenn der Loader des Betriebssystems sie in den Speicher lädt, die Umgebung einrichtet und dann die Kontrolle übergibt
- Unter Linux wird der Entry Point im Feld
e_entrydes ELF-Headers gespeichert; standardmäßig platziert der Linker dort die Adresse eines Symbols namens_start - Auch Windows hat einen ähnlichen Hook; eine ausführbare Datei startet in der Funktion
_WinMainCRTStartup - Frühes Runtime-Bootstrapping war ein statischer Aufrufbaum von Funktionen wie der Initialisierung von Datei-I/O oder des Allokators
- Mit zunehmender Komplexität der Laufzeit wuchs auch dieser statische Initialisierungs-Aufrufbaum, und Binärdateien enthielten mehr Funktionen der C-Laufzeit, die womöglich gebraucht wurden oder auch nicht
- Als Linker ungenutzten Code schon vor dem Erstellen der Binärdatei entfernen konnten, wurde ein Weg benötigt, den statischen Initialisierungs-Aufrufbaum zu ersetzen
- Der GCC-Ansatz
__attribute__((constructor))legte eine Liste von Funktionszeigern für Initialisierungsfunktionen in einem zusammenhängenden Bereich der Binärdatei ab, den die C-Laufzeit beim Start durchlief und aufrief - Konstruktoren konnten Prioritäten erhalten; so kann zum Beispiel
mallocvor gepufferter Datei-I/O initialisiert werden müssen - Die moderne
glibc-Laufzeit unter Linux speichert Funktionszeiger in.init_array, und mit einem numerischen Suffix lässt sich die Ausführungsreihenfolge festlegen - Prioritätswerte von 100 oder darunter sind für die Laufzeit selbst reserviert, daher sollte Code mit C-Laufzeit 101 oder höher verwenden
- In Rust lassen sich mit Attributen wie
#[used]und#[unsafe(link_section = ".init_array.101")]Zeiger auf Initialisierungsfunktionen platzieren
linktime: ctor, link-section usw.
- Die Beispiele funktionieren unter Linux und mehreren BSDs, sind aber nicht als plattformübergreifende Beispiele konzipiert
- macOS unterstützt
start- undstop-Symbole, allerdings unter anderen Namen; Windows unterstützt keinestart-/stop-Symbole, besitzt aber praktisch äquivalente Regeln zur Sektionsanordnung ctorundlink-sectionsind Crates aus dem Projektlinktime, das plattformspezifische Unterschiede und die Komplexität der Linker-Arbeit abstrahiertinventoryundlinkmesind weit verbreitete Crates, die auf demselben Prinzip aufbauen, haben für die Beispiele hier aber Einschränkungen- Die Crate
ctorübernimmt den Boilerplate-Code, um Konstruktoren plattformübergreifend zu registrieren - Funktionen mit einem Attribut wie
#[ctor(unsafe, priority = 101)]werden von der C-Laufzeit aufgerufen, nachdem der Linker alles angeordnet hat, auch wenn sie im Code nie direkt aufgerufen werden
Sektionen und Linker-Skripte
- Compiler können Daten oder Code an bestimmten Stellen in einer Binärdatei platzieren, auf den meisten Plattformen in Bereichen, die Sektionen genannt werden
- Rust kann dieselbe Organisationsmöglichkeit über das Attribut
link_sectionnutzen - Viele Linker erlauben Entwicklern, Linker-Skripte bereitzustellen; diese Textdateien sagen dem Linker, wie Objektdateien zusammengesetzt werden sollen
- Mit Linker-Skripten kann eine einzelne C-Datei zu einer Linux-Executable werden oder zu einem rohen Assembler-Block, der in einem Bootsektor einer Festplatte liegt
- Linker-Skripte können virtuelle Symbole definieren, die nicht in den Quelldateien existieren, aber in C-Code verwendet werden können, um auf Basisdatenzeiger der geladenen Binärdatei zuzugreifen
- Im Beispiel-Linker-Skript sind
_TEXT_START_und_TEXT_END_so definiert, dass sie auf Anfang und Ende der Sektion.textzeigen - Der Punkt in
_TEXT_START_ = .;steht für den Location Counter, der als ein Wert nahe der aktuellen Ausgabeadresse der Binärdatei interpretiert wird
Linker-Symbole
- Der Linker setzt den Wert von Start-/End-Symbolen nicht auf einen Zeiger, sondern auf die Adresse, an der ein
staticmit demselben Namen liegen würde - Start-/End-Symbole sind keine Zeiger vom Typ
*const Type, sondern tragen nur über ihre Adresse Bedeutung und besitzen keine eigenen Daten - Eine Sektion besteht aus Daten im Bereich einschließlich des Startsymbols und ausschließlich des Endsymbols
- Viele Linker können die Grenzen aller Sektionen einer ausführbaren Datei automatisch definieren
- In der GNU-Toolchain werden für eine Sektion namens
MY_SECTIONautomatisch die Symbole__start_MY_SECTIONund__stop_MY_SECTIONdefiniert - macOS hat ein ähnliches Muster und erzeugt für jede Sektion die Symbole
section$startundsection$end - Beim GNU-Linker heißen Sektionen, die im Linker-Skript nicht ausdrücklich genannt werden, Orphan Sections
- Der Linker definiert Präfix-Symbole wie
_startund_stopnur dann automatisch, wenn der Sektionsname mit einem C-Symbolnamen kompatibel ist our_stringsfunktioniert,our.stringsoder.our_stringsdagegen nicht auf dieselbe Weise- Da Grenzsymbole keine Daten haben und nur ihre Adresse wichtig ist, werden sie im Beispiel als
MaybeUninit<()>dargestellt - Ein idealer „opaker externer Typ“ ist in Stable Rust noch nicht vorhanden, daher dient
MaybeUninitals Ersatz - Einen Zeiger mit
&raw constauf einstatic-Element zu bilden ist immer gültig, daher kann man die Adresse sicher erhalten, ohne den Wert zu lesen link-sectionabstrahiert diese Details von Linker-Sektionen und wandelt sie in Rust-Slices um, auf denen sich Standard-Slice-Operationen verwenden lassen- Die Stärke von Link-Sektionen liegt darin, dass jede Crate, die Code für die Binärdatei bereitstellt, Elemente in dieselbe Sektion eintragen kann, die der Linker kurz vor dem finalen Erstellen der Binärdatei zusammenführt
Dependency Injection
- Das sektionsbasierte Registrierungsmodell funktioniert nach demselben Prinzip wie Dependency Injection
- Frameworks wie Dagger und Spring basieren ebenfalls auf dem Prinzip, dass der Verbraucher registrierter Daten nicht an den Anbieter gekoppelt sein sollte
- Anbieter registrieren Daten dort, wo sie definiert werden, und Verbraucher lesen die Registry aus
- Bei klassischer Dependency Injection muss das Framework beim Start häufig den Modulgraphen durchlaufen oder geladene Klassen scannen, um Anbieter und Verbraucher zu finden
- Bei Linker-Sektionen sammelt der Linker die Daten der Anbieter beim Erstellen der Binärdatei und macht sie für Verbraucher leicht lesbar
- Das Beispiel zur Registrierung von CLI-Subcommands ist ein Fall dieses Musters, bei dem
link_section::sectionzur Registrierung von Subcommands verwendet wird - Turbopack nutzt dieses Muster für String-Pool-Konstanten, Registrierungsmechanismen für Serialisierung/Deserialisierung und die Registrierung von turbotask-Inkrementalkompilierungsfunktionen
- Auch ein hypothetischer Webserver könnte dieses Muster verwenden, um Routen und Middleware bereits beim Build automatisch einzusammeln
Sektionen für Registrierung nutzen
- Ein Vorteil von Arbeit vor
mainist, dass keine Threads laufen, solange man sie nicht ausdrücklich startet - In dieser Umgebung lässt sich die Komplexität von Locks oder Synchronisationsprimitive in vielen Fällen vermeiden
- Der Lebenszyklus der Daten kann klar in eine beschreibbare Phase vor
mainund eine unveränderliche Phase nachmaingetrennt werden - Wenn man beim Zugriff auf Daten in einem laufenden Programm das Erwerben und Freigeben von Locks vermeiden kann, wird die Struktur einfacher und oft effizienter
- Das Beispiel verwendet eine Struktur
CliSubcommand, eineconst-Konstruktorfunktion und#[section], um Subcommands einzusammeln - Subcommands wie
list,addundhelpkönnen an beliebigen Stellen im Code stehen - Die Funktion
mainkann dynamisch dispatchen, solange sie nur die Definition der SektionCLI_SUBCOMMANDSsieht, ohne die Namen oder Positionen registrierter Subcommands zu kennen - Wenn keine registrierten Subcommands vorhanden sind, wird auf ein Standard-Subcommand zurückgefallen; im Beispiel fungiert
helpals Standardwert
Über unveränderliche Daten hinaus
- Das vorangehende Beispiel setzt voraus, dass gelinkte Daten unveränderlich sind, aber linkerbasierte Datenorganisation kann auch für veränderliche Daten genutzt werden
- Die Veränderlichkeit globaler statischer Daten ist in Rust ein verbreitetes Problem und kann mit Werkzeugen für innere Veränderlichkeit wie Mutexen oder atomaren Typen gelöst werden
- Mutexe und atomare Typen sind ohne Konkurrenz nicht teuer, aber auch nicht kostenlos
- Um Daten in Rust sicher zu verändern, muss die Änderung thread-sicher erfolgen, und es darf keine anderen Referenzen auf dieselben Daten geben, solange eine veränderliche Referenz existiert
- Die Pre-Main-Umgebung ist Single-Threaded, solange keine Threads ausdrücklich gestartet werden, daher sind keine atomaren Operationen nötig
- In einer Single-Threaded-Umgebung gilt die happens-before-Beziehung automatisch: Änderungen geschehen vor späteren Lesezugriffen
- Änderungen an Daten in Link-Sektionen vor
mainkönnen danach von beliebigen Threads sicher ohne Locks gelesen werden - Wenn veränderliche Referenzen nur vor
mainerzeugt und wieder beendet werden, ist auch die Bedingung erfüllt, dass während ihrer Existenz keine anderen Referenzen vorhanden sind - Das Slice einer Link-Sektion ist ein Alias für statische Elemente innerhalb der Sektion, daher gelten Alias-Regeln sowohl für das Slice als auch für die statischen Elemente
- Um über ein Slice sicher zu verändern, müssen die statischen Elemente zwingend in
UnsafeCellliegen - Bei statischen Elementen ohne
UnsafeCelldarf LLVM Werte cachen, umordnen oder Annahmen über die Daten treffen UnsafeCellselbst implementiert nichtSync, daher ist ein separater Wrapper-Typ nötig- Das Beispiel verwendet
SyncUnsafeCellundMaybeUninit<SyncUnsafeCell<...>>, um Grenzsymbole und Elemente zu definieren - Beim Beispiel eines sortierbaren String-Interning-Pools wird der String-Pool zur Link-Zeit definiert und das Slice früh zur Laufzeit sortiert, damit Strings danach per binärer Suche gefunden werden können
- Die manuelle Implementierung enthält viel Boilerplate, aber mit
ctorundlink-sectionlässt sich dieselbe Struktur mitTypedMutableSectionund Konstruktoren deutlich kompakter aufbauen - Die Elemente von
TypedMutableSectionmüssenconstsein, weil intern Code verwendet wird, der der manuellen Implementierung ähnelt
Vorteile des Link-Sektions-Musters
- Dieses Muster aggregiert markierte Elemente auf garantierte Weise und platziert alle Daten in vorab allokiertem, zusammenhängendem Speicher
- Registrierungen können über den gesamten Code verstreut sein
- Die Anzahl der Elemente in einer Sektion lässt sich garantiert bestimmen
- Link-Sektionen benötigen keine separate Allokation
- Ohne Link-Sektionen müsste man für dieselbe Struktur ein
HashMap,Vecoder eine andere Datenstruktur allokieren und beim Sammeln der Elemente mehrfach vergrößern - Bei traditionellen Sammelansätzen sind die Abhängigkeiten zwischen gemeinsam genutztem Typmodul, beitragenden Modulen und Sammelmodul eng verflochten
- Mit Link-Sektionen kann der Sammler irgendwo liegen und muss sich nicht darum kümmern, welche Module Daten beisteuern
scattered-collectbietet mehrere datenstrukturanaloge Typen mit Unterstützung zur Link-ZeitScattered*Slicesind verschiedeneVec-ähnliche Strukturen, die ein Slice bereitstellen und optional Sortierung unterstützenScatteredMapundScatteredSetsindHashMap-/HashSet-ähnliche Strukturen, die mit minimaler Pre-Main-Initialisierung hashbasierte Key-Value-Lookups bereitstellen
Wann man diesen Ansatz nicht verwenden sollte
- Berechnungen zur Link-Zeit sind mächtig, aber nicht immer das richtige Werkzeug
- Anstelle eines Link-Time-Ansatzes kann man Daten auch manuell in einer Crate sammeln, die jede Crate sehen kann, die Daten beitragen möchte
- Manuelles Sammeln kann unbequem sein und erfordert statt eines einzigen Beitragspunkts in der Kern-Crate oft eine Sammel-Crate mit vielen Crate-Referenzen
- Dead-Code-Eliminierung wird schwieriger
link-sectionundlinkmemarkieren Elemente mit#[used], daher kann der Linker ungenutzte Daten nicht entfernen- Bei kleinen Daten wie interned String-Atomen ist das womöglich kein Problem, aber wenn rohe JSON-/JavaScript-Fragmente oder große Datenstrukturen interned werden, kann sich viel schwer erkennbarer Dead Code ansammeln
- Pre-Main-Konstruktorfunktionen unterliegen Einschränkungen
- Konstruktorfunktionen dürfen nicht panicen, und Rust garantiert nicht, dass alle Funktionen der Standardbibliothek verfügbar sind
- Die Aufrufreihenfolge von Initialisierungsfunktionen mit derselben Priorität ist nicht garantiert und stark plattformabhängig
- Diese Einschränkungen lassen sich mit sorgfältigem Design umgehen, aber der Pre-Main-Ansatz kann aus subtilen und schwer zu debuggenden Gründen dennoch falsch sein
- Miri unterstützt nicht alle Pre-Main-Konstruktoren und Konfigurationen von Link-Sektionen vollständig
- Derzeit betrachtet Miri die Pre-Main-Ausführung nur sehr grundlegend und modelliert Link-Sektionen nicht
- Für das Testen auf Undefined Behavior werden LLVM-Sanitizer wie ASan und TSan empfohlen
- Muster der Inversion of Control können es erschweren, alle Stellen zu auditieren, die Daten in Link-Sektionen eintragen
- Viele weit verbreitete und stark genutzte Rust-Programme sind bereits auf Pre-Main-Funktionen wie
ctor,link-section,inventoryundlinkmeangewiesen
Kurze Einordnung zu WASM
- WASM unterstützt Linker-Sektionen aufgrund früherer Designentscheidungen nicht nativ
- Der Hinweis
#[link_section]platziert Elemente nicht in echten Code-Sektionen, sondern in benutzerdefinierten WASM-Sektionen, auf die der WASM-Code selbst nicht zugreifen kann - Die Crate
linktimeunterstützt WASM und liefert einen emulierten Workaround, damit der Ansatz auch in WASM-Binärdateien funktioniert - Eine angemessene WASM-Unterstützung könnte künftig vorgeschlagen werden
Fazit
- Vor
mainlässt sich bereits viel Arbeit erledigen, die in bestimmten Fällen erhebliche Vorteile bringt - Die Pre-Main-Umgebung ist in ihrer Reihenfolge stark kontrolliert und gut steuerbar, sodass sich viele Aufgaben ohne Locks, atomare Typen oder andere Synchronisationsprimitive sicherer erledigen lassen
- Linker-Sektionen ermöglichen es, zusammengehörige Daten über die gesamte Binärdatei hinweg beliebig zu aggregieren und gemeinsam zu platzieren, wodurch ungünstige Reihenfolgen bei Crate-Abhängigkeiten vermieden werden
- In vielen Fällen lassen sich Allokationen vollständig vermeiden, was Abstand zu Problemen von Allokatoren wie Fragmentierung durch wiederholte Allokation schafft
- Relevante Crates sind
ctor,dtor,link-section,scattered-collect
1 Kommentare
Lobste.rs-Meinungen
Apple nutzt libSystem.dylib als ABI-Stabilitätsgrenze für Systemaufrufe, und Windows der NT-Familie verwendet dafür nicht Systemaufrufe, sondern
ntdll.dll: not syscallsAuf OpenBSD scheint Go einen Metadaten-Flag gesetzt zu haben, der die Erzwingung des NX-Bits deaktiviert, um die Richtlinie zu umgehen, nach der der Kernel den Prozess beendet, wenn Go Systemaufrufe außerhalb des vom Loader eingerichteten schreibgeschützten
libc-Mappings versuchtAllerdings libSystem.dylib contains the functionality which would normally be
libc.soplus other things, daher entspricht es in dieser Hinsicht dem BSD-Ansatz, bei dem „libc die Stabilitätsgrenze“ istAußerdem verwendet Go ab Go 1.16
libc, um OpenBSDs Richtlinie für Systemaufrufe einzuhaltenLinux ist relativ selten in dem Sinne, dass es stabile Systemaufrufnummern hat, weil es nicht wie andere Betriebssysteme so aufgebaut ist, dass „ein als dynamische Bibliothek in den Adressraum des Prozesses geladener Kernel-Teil sich instabile Systemaufruf-
enum-Definitionen mit dem Kernel-Mode-Code teilt“, und weil Linux und glibc nicht wie anderswo gemeinsam im selben Repository entwickelt werdenUnter Windows übernimmt die C-Laufzeit auch das Parsen einer aus CP/M stammenden Befehlszeichenkette, die über MS-DOS übernommen wurde und von Windows’ API zur Erzeugung von Unterprozessen weitergeführt wird, in ein POSIX-artiges
argv-ArrayDeshalb gibt es in der Python-
subprocess-Dokumentation den Abschnitt Converting an argument sequence to a string on Windows, der erklärt, wie einargv-Array nach den im MS-C-Runtime fest verdrahteten Anführungszeichenregeln in eine Zeichenkette umgewandelt wird. Der eigene Parser des aufgerufenen Unterprozesses kann auf Wunsch anders als diese Regeln arbeitenBei Linux bedeutet
_startauch nicht exakt, dass der Linker automatisch ein Symbol dieses Namens in die Binärdatei einfügt. Wenn eine Binärdatei im ELF-Format eine ausführbare Datei und keine Bibliothek ist, enthält das Felde_entryim Header, also an Offset0x18, die Adresse, zu der der Loader nach dem Einrichten des Speichers springt_startist die GCC-Konvention, um das Ziel anzugeben, auf dase_entryzeigt, wenn man nicht den vonlibcbereitgestellten Einstiegspunkt verwendet, und Werkzeuge wie NASM folgen dem meines Wissens ebenfallsUnter Windows wird
_WinMainCRTStartupvom Loader ebenfalls über denAddressOfEntryPointim PE-Header gefunden. Dieser liegt relativ zum Beginn des PE-Headers bei Offset0x0028, und der PE-Header kommt nach dem MZ-(DOS-EXE-)Header und dem DOS-StubUm die Details des PE-Headers zu lernen, sind Making the smallest Windows application und Tiny PE gut geeignet. Tiny PE verletzt die PE-Spezifikation teilweise auf eine Weise, die Windows akzeptiert, etwa indem Bereiche überlappt werden, die das Betriebssystem nicht liest, oder Code in ungenutzte Header-Felder gelegt wird. Geht man so weit, hängt die minimale Dateigröße, die Windows akzeptiert, von der jeweils ausgeführten Windows-Version ab
Zu sehr kleinen ELF-Ausführungsdateien unter Linux ist auch A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux lesenswert
_start: Auf a.out-Systemen war der Einstiegspunkt, an dem der Kernel in die ausführbare Datei sprang, traditionellstart, deklariert in csu/crt0. Beispiele sind 7th edition und VAX BSDDamals stellte der C-Compiler globalen Symbolen ein
_voran, daher deklariert V7_main, und bei BSD sieht man, dass der Assemblername für C-start()als undekoriertesstartdeklariert wurdeProgramme begannen damals am Dateianfang, und der Linker-Aufruf von
ccordnetecrt0so an, dass es ganz vorne stand.csusteht für C startup code,crt0für das nullte C-Runtime-UnterstützungsobjektWie genau das unter System V mit dem Aufkommen von ELF funktionierte, ist schwerer herauszufinden, aber
startoder_startblieb als Programmeinstiegspunkt in csu/crt0 in GebrauchIch habe nie ganz verstanden, wie ELF die Behandlung des
_-Präfixes verändert hat, aber vermutlich wurde aus einer Laune heraus noch eine weitere Ebene hinzugefügt, sodassstartaus irgendeinem Grund zu_startwurdeEin klares Gegenstück ist wohl, dass ELF
_endhinzufügte; das entspricht dem oberen Ende von BSS und der Position, diesbrk(0)zurückgeben würde, bevormalloc()den Heap anlegtmainin Rust interessiert und dachte, es wäre gut, in einem Artikel zusammenzufassen, was das ist und warum es nützlich istEs gibt auch Ideen für Folgeartikel, etwa wie man mithilfe der Linker-Aggregation schnellere Collections baut, aber zuerst würde ich gern Feedback zu diesem einsteigerorientierten Thema hören
mainin Umgebungen mitno_stdund manchmal auch ohneallocnur eine weitere Funktion, und die Initialisierung liegt größtenteils in der Verantwortung des EntwicklersIch habe einige selbstgeschriebene Boilerplate-Muster im Codebestand für ähnliche Zwecke, daher interessiert mich, wie solche Crates mit Embedded-Umgebungen zusammenspielen