1 Punkte von GN⁺ 2025-10-26 | 1 Kommentare | Auf WhatsApp teilen
  • Eine technische Analyse untersucht den Prozess, mit dem der Kernel über den Systemaufruf execve einen Prozess erzeugt und initialisiert, bevor ein Programm ausgeführt wird
  • Dieser Aufruf übergibt den Pfad der ausführbaren Datei, Argumente und Umgebungsvariablen; auf dieser Basis lädt der Kernel eine ausführbare Datei im ELF-Format
  • Eine ELF-Datei enthält Code, Daten, Symbole und Informationen für das dynamische Linken; der Kernel interpretiert diese und führt Speichermapping und Stack-Initialisierung durch
  • Danach übergibt der Kernel die Kontrolle an den Entry-Point _start, und erst nachdem die sprachspezifische Runtime initialisiert wurde, wird die benutzerdefinierte main-Funktion aufgerufen
  • Dieser Ablauf zeigt die Zusammenarbeit von Betriebssystem, Compiler und Runtime und ist wichtig, um zu verstehen, wie Programmausführung auf Systemebene funktioniert

Der Startpunkt der Programmausführung: der execve-Aufruf

  • Unter Linux beginnt die Programmausführung mit dem Systemaufruf execve
    • In der Form execve(const char *filename, char *const argv[], char *const envp[]) werden Name der ausführbaren Datei, Argumentliste und Liste der Umgebungsvariablen übergeben
    • Der Kernel entscheidet damit, welches Programm in welcher Umgebung ausgeführt wird
  • In Hochsprachen ist dieser Aufruf durch die Prozessausführungs-API der Standardbibliothek gekapselt
    • Beispiel: Rusts std::process::Command ruft intern execve auf
    • Ähnlich wie bei der PATH-Suche einer Shell wird dabei ein Befehlsname in einen vollständigen Pfad umgewandelt
  • Bei Skripten mit Shebang (#!) führt der Kernel das Programm mit dem angegebenen Interpreter aus
    • Beispiel: #!/usr/bin/python3 → Ausführung mit dem Python-Interpreter

ELF: die Struktur der ausführbaren Datei

  • Ausführbare Dateien unter Linux verwenden das ELF-Format (Executable and Linkable Format)
    • ELF ist ein standardisiertes Format für ausführbare Dateien, das Code, Daten, Symbole und Relocation-Informationen enthält
    • Andere Betriebssysteme verwenden eigene Formate wie Mach-O (macOS) oder PE (Windows)
  • Der ELF-Header enthält Informationen über die Struktur der Datei und ihre Speicheranordnung
    • Beispielhafte Felder: ELF Magic, Class, Entry point address, Program headers, Section headers
    • Entry point address ist die Adresse der Instruktion, mit der das Programm startet
  • Im gezeigten Beispiel handelt es sich um eine ELF32-Datei für die RISC-V-Architektur, deren Entry-Point auf die Adresse 0x10358 gesetzt ist

Interne Bestandteile von ELF

  • Eine ELF-Datei besteht aus mehreren Sections
    • .text: ausführbarer Code
    • .data: initialisierte globale Variablen
    • .bss: nicht initialisierte globale Variablen
    • .plt: Tabelle für Aufrufe gemeinsam genutzter Bibliotheken
    • .symtab, .strtab: Symbol- und String-Tabellen
  • Die PLT (Procedure Linkage Table) unterstützt Aufrufe von Funktionen aus Shared Libraries
    • Beispiel: printf, malloc aus libc
    • Die PT_INTERP-Section in ELF gibt den dynamischen Linker (Interpreter) an
  • Der Kernel liest ELF, ordnet ladbare Sections im Speicher an und aktiviert bei Bedarf Sicherheitsfunktionen wie ASLR und NX-Bit

Symboltabelle und Runtime-Linking

  • Die Symboltabelle (symtab) von ELF enthält Adressinformationen zu Funktionen und Variablen
    • Beispielhaft gibt es Einträge wie _start, main, __libc_start_main
    • Selbst ein einfaches „Hello, World!“-Programm kann mehr als 2300 Symbole enthalten
  • Das stammt größtenteils aus der Standardbibliothek und dem Runtime-Initialisierungscode
    • Der Grund ist, dass libc-Implementierungen wie musl oder glibc eingebunden sind
  • Nachdem der Kernel die einzelnen ELF-Sections geladen hat, übergibt er die Kontrolle an den Interpreter (dynamischen Linker)
    • Der Interpreter verarbeitet Relocations, Address Space Layout Randomization (ASLR), das Setzen von Ausführungsrechten (NX-Bit) usw.

Der Ablauf der Stack-Initialisierung

  • Vor der Programmausführung muss der Kernel den Stack direkt aufbauen
    • Der Stack wird für lokale Variablen, Funktions-Call-Frames und die Übergabe von Argumenten verwendet
  • Die beim Aufruf von execve übergebenen argv und envp werden auf dem Stack abgelegt
    • Darüber greift das Programm auf Kommandozeilenargumente und Umgebungsvariablen zu
  • Der Kernel legt außerdem den ELF Auxiliary Vector (auxv) auf dem Stack ab
    • Er enthält rund 30 Einträge, darunter Seitengröße, ELF-Metadaten und Systeminformationen
    • Beispiel: AT_PAGESZ gibt die Größe einer Speicherseite an, etwa 4 KiB
  • Im Beispiel eines RISC-V-Emulators beginnt der Stack-Pointer (sp) an einer hohen Adresse, und Argumente, Umgebungsvariablen sowie Auxiliary Vector werden in umgekehrter Reihenfolge abgelegt

Der Entry-Point und die Funktion _start

  • Der Entry-Point von ELF ist auf die Adresse der Funktion _start gesetzt
    • _start ist der erste User-Space-Code, an den der Kernel die Kontrolle übergibt
  • Die meisten Sprachen führen in _start zunächst eine Runtime-Initialisierung durch und rufen danach main auf
    • Beispiel: Rusts std::rt::lang_start, Cs __libc_start_main
  • Im Rust-Beispiel lässt sich mit den Attributen #![no_std] und #![no_main] _start auch direkt ohne Runtime definieren
    • Innerhalb von _start werden argc, argv und envp vom Stack gelesen und anschließend der main-Pointer aufgerufen
  • Die sprachspezifische Runtime übernimmt sprachspezifische Initialisierungsschritte wie globale Konstruktoren, Thread-Local Storage oder Exception-Handling

Der vollständige Ablauf bis zum Aufruf von main()

  • Der Gesamtprozess lässt sich wie folgt zusammenfassen
    1. Aufruf von execve → der Kernel lädt die ELF-Datei
    2. Interpretation von ELF → Mapping der Code-/Daten-Sections, Festlegung des Interpreters
    3. Aufbau des Stacks → Speicherung von Argumenten, Umgebungsvariablen und Auxiliary Vector
    4. Ausführung des Entry-Points _start
    5. Aufruf von main() nach der Runtime-Initialisierung
  • Diese Abfolge zeigt die Kooperationsstruktur von Betriebssystem-Kernel, ELF-Format und Sprach-Runtime
  • Der reale Linux-Kernel enthält zusätzlich interne Logik für Adressräume, Prozesstabellen, Gruppenverwaltung usw., doch dieser Beitrag erklärt den Kernablauf davor

Fazit und Korrektur

  • Der Ablauf vor main() ist eine Kombination aus Initialisierung auf Kernel-Ebene und Runtime-Setup
  • Selbst ein einfaches „Hello, World!“-Programm wird erst nach einer komplexen ELF-Struktur und Runtime-Initialisierung ausgeführt
  • In einer früheren Version des Artikels wurde ein Teil der Section-Ladelogik dem Kernel zugeschrieben; dies wurde korrigiert, da es sich tatsächlich um die Aufgabe des ELF-Interpreters handelt
  • Diese Analyse ist eine nützliche Grundlage für das Verständnis von Systemprogrammierung, Compilern und OS-Architektur

1 Kommentare

 
GN⁺ 2025-10-26
Hacker-News-Kommentare
  • Es wird der dynamische Link-Prozess von ELF-Dateien erklärt.
    Der Kernel mappt die PT_LOAD-Segmente eines ELF, lädt anschließend den durch PT_INTERP angegebenen dynamischen Linker (ld.so) und übergibt ihm die Kontrolle.
    Danach reloziert sich der dynamische Linker selbst und lädt die benötigten Shared Objects per mmap/mprotect.
    Diese Struktur wird als vergleichbar mit dem shebang(#!)-Mechanismus von Skripten beschrieben.

    • Der Kernel interessiert sich überhaupt nicht für Abschnittsinformationen, sondern verarbeitet ausschließlich PT_LOAD-Segmente.
      Jemand berichtet von einer früheren Erfahrung, bei der er mit objcopy eine beliebige Datei in ein ELF einfügen wollte und verwirrt war, dass der Kernel sie nicht lud.
      Am Ende baute er selbst ein Patch-Tool für die Program Header Table, und diese Funktion wurde später auch dem Linker mold hinzugefügt.
      Zugehöriger Artikel: Self-contained Lone Lisp Applications
    • Der Autor räumt ein, den Inhalt zuvor versehentlich falsch bearbeitet und veröffentlicht zu haben, und sagt eine Korrektur zu.
    • Unter Linux läuft der Loader im User Space; deshalb habe man sich immer gefragt, warum es nicht mehr verschiedene Loader gebe.
  • Es wird berichtet, dass man experimentiert habe, den gesamten Code vor main() oder ganz ohne main() zu packen.
    Zugehöriger Artikel: Packing a codebase into a single function

    • Beim Lesen sei es überraschend einfach und nicht besonders fragil erschienen, was interessant gewesen sei.
      Scherzhaft heißt es, man müsse nur alle Funktionen in die Form main(100+n, ...) bringen.
  • Wer sich für das Thema interessiert, solle sich das eigene Projekt cpu.land ansehen.
    Dort gehe es weniger um Speicherlayout als um Multitasking und den Code-Ladeprozess.

    • Jemand bedankt sich und sagt, er möge cpu.land wirklich sehr.
  • Es wird gefragt, wie viele C-Projekte die Standardbibliothek meiden und ausschließlich direkt Linux-Syscalls aufrufen.
    Auf diese Weise zu programmieren fühle sich deutlich unterhaltsamer an.

    • Es wird dagegengehalten, dass die direkte Nutzung von Syscalls eher ineffizient sei.
      Auf Funktionen wie ALSA oder DRM greife man besser über Systembibliotheken statt über Kernel-Syscalls zu, was viele Vorteile habe.
      Das sei hinsichtlich Portabilität und Wartbarkeit besser als ein Windows-artiger Ansatz.
    • Unter Windows könne man, so wird ergänzt, nur mit der Win32 API arbeiten, ohne die C-Runtime zu linken.
    • Jemand berichtet, früher selbst ein liblinux-Projekt erstellt zu haben, um Programme nur mit Syscalls zu schreiben.
      Inzwischen habe er es eingestellt, weil die nolibc-Header von Linux inzwischen gut seien,
      arbeite derzeit aber an einer syscall-basierten Lisp-Interpretersprache.
      Das Experiment, den Linux-User-Space direkt über Systemaufrufe aufzubauen, sei eine sehr spannende Reise gewesen.
    • Man versuche zwar, portabel zu bleiben, aber Dateideskriptoren seien so praktisch, dass man nur schwer darauf verzichten könne.
    • Außerdem wird ergänzt, dass viel Treibercode tatsächlich nur Syscalls verwende.
  • Es wird erklärt, dass der ELF-Interpreter (ld.so) nach dem Mapping der initialen ELF-Segmente das gesamte weitere Laden übernimmt.
    execve mappt die PT_LOAD-Segmente, füllt den aux vector auf dem Stack
    und springt dann zum Entry Point des ELF-Interpreters.
    Der Kernel weiß nichts über PLT/GOT.

  • Jemand, der dieses Thema an der Universität unterrichtet, sagt, dass Studierende wegen Speicherdiagrammen oft verwirrt seien.
    In Lehrbüchern werden höhere Adressen weiter oben gezeichnet, aber bei echten Linux-Prozessen
    wird eine niedrige Adresse oben und eine hohe unten ausgegeben.
    In /proc/<pid>/maps werden die Adressen größer, je weiter man nach unten scrollt.
    Die Aussage „der Heap wächst nach oben (und der Stack nach unten)“ beschreibt also nur eine numerische Richtung;
    visuell ist es eher umgekehrt.
    Es wird vorgeschlagen, es wie in einer IDE zu zeichnen, sodass die Adressen nach unten hin größer werden, weil das viel intuitiver sei.

    • Der Stack wachse trotzdem, indem sich der Stack Pointer verringert, daher sei die Formulierung „wächst nach unten“ weiterhin korrekt.
      Allerdings wird vorgeschlagen, Visualisierungen eher horizontal darzustellen.
    • Jemand erinnert sich, früher dieselbe Verwirrung erlebt zu haben, und dass auch die Little-Endian-Adressnotation irritierend gewesen sei.
    • Dem wird entgegengehalten, dass die Formulierung „der Stack wächst nach unten“ wenig intuitiv sei, wenn man daran denke, wie reale Gegenstände gestapelt werden.
  • Jemand sagt, er experimentiere gern auf alten PIC16-Mikrocontrollern mit solchen Dingen.
    Es mache Spaß, Stack Pointer, Timer und Variableneinstellungen direkt zu handhaben.

  • Es wird eine Erfahrung im Zusammenhang mit shebang(#!) geteilt.
    Eine Java-Anwendung habe den Fehler ausgegeben, ein Ausführungsskript nicht finden zu können,
    tatsächlich sei aber der shebang-Pfad des Skripts falsch gewesen.
    Lokal habe es funktioniert, doch auf dem Remote-Server sei der Interpreter-Pfad anders gewesen.

    • Das sei kein reines Java-Problem, sondern könne bei jedem Programm mit ENOENT-Fehler auftreten.
      Der Rat lautet, es mit strace auszuführen, weil man dann sofort sehe, bei welchem Syscall der Fehler auftrat.
    • Es wird ein Artikel geteilt, der die Struktur von shebang analysiert: What the #! means
    • Zusätzlich wird angemerkt, dass für shebang-Unterstützung im Kernel die Einstellung CONFIG_BINFMT_SCRIPT=y nötig sei.
  • Beim Debugging sei man immer wieder unsicher, wann genau die Relocation-Reihenfolge des Hauptbinaries angewendet werde.
    Ob das vor oder nach der Auflösung der eigenen Symbole des Linkers geschehe, wirke wie schwarze Magie.

  • Es wird darauf hingewiesen, dass der Link im Markdown beim Abschnitt „lang_start function (defined here)“ defekt ist.