3 Punkte von GN⁺ 2024-04-09 | 1 Kommentare | Auf WhatsApp teilen
  • Selbst eine einzelne in C geschriebene Zeile Hello World muss nacheinander durch die kompilierte ausführbare Datei, die C-Standardbibliothek, Systemaufrufe, den Kernel und das Terminal laufen, bevor sie auf dem Bildschirm erscheint
  • Das mit gcc hello.c -o hello erzeugte Ergebnis ist eine ELF-64-bit-x86-64-Ausführungsdatei; zuerst wird am Einstiegspunkt 0x1060 im ELF-Header der _start-Code ausgeführt
  • Das vom Nutzer geschriebene main() startet nicht direkt, sondern läuft über _start und __libc_start_main; printf("Hello World!\n") wird durch Optimierung zu einem einfacheren puts()-Aufruf
  • Der String wird in .rodata an 0x2004 als Bytefolge gespeichert; C-Strings bestimmen ihr Ende nicht über Längeninformationen, sondern über einen NULL-Terminator
  • Der tatsächliche Ausgabepfad führt über libc-Buffering und Locks, den Systemaufruf write oder writev, den Linux-Kernel, ein Pseudo-Terminal und das Rendering im Terminal-Emulator und kann je nach Ausführungsumgebung variieren

Ausgangspunkt: C Hello World

  • Das Beispielprogramm ist der folgende in C geschriebene Code
#include <stdio.h>

int main() {
    printf("Hello World!\n");
    return 0;
}
  • Es liefert dasselbe Ergebnis wie print('Hello World!') in Python, aber ein C-Programm wird nicht direkt von einem Interpreter ausgeführt, sondern muss zuerst kompiliert werden
gcc hello.c -o hello
./hello
  • Das Ergebnis der Ausführung sieht so aus
Hello World!
  • Mit Grundkenntnissen in C oder Assembly lässt sich der Ablauf gut nachvollziehen

Was die ausführbare Datei ist

  • Entscheidend an der Ausgabe von file hello ist ELF executable, x86-64
    • Eine ELF-Ausführungsdatei ist ein unter Linux ausführbares Programmformat
    • x86-64 bedeutet ein Maschinenprogramm für 64-bit-x86-Prozessoren
  • Betrachtet man den ELF-Header mit readelf -h hello, erscheint Entry point address: 0x1060
    • Diese Adresse ist die Stelle, an der die CPU nach dem Laden des Programms mit der Ausführung beginnt

_start und der Einstieg in die C-Bibliothek

  • Disassembliert man mit objdump -D hello, befindet sich an Position 0x1060 _start
  • _start ist kein vom Nutzer direkt geschriebener Code, sondern Code, der vom Compiler – genauer vom Linker – automatisch hinzugefügt wurde
  • Dieser Code führt Initialisierung aus und führt anschließend den folgenden Aufruf aus
call *0x2f53(%rip)        # 3fd8 <__libc_start_main@GLIBC_2.34>
  • Diese Funktion ist nicht direkt im Programm definiert, sondern liegt in der Standard-C-Bibliothek
  • Im dynamischen Abschnitt von readelf -d hello wird die Abhängigkeit von libc.so.6 angezeigt
Shared library: [libc.so.6]
  • libc.so.6 ist die Standard-C-Bibliothek des Systems; .so-Dateien unter Linux enthalten Code, der wie .dll-Dateien unter Windows von mehreren Programmen gemeinsam genutzt werden kann
  • Die C-Bibliothek übernimmt Initialisierung wie die Verarbeitung von Kommandozeilenargumenten und Umgebungsvariablen, ruft main() auf und beendet das Programm anschließend mit dessen Rückgabewert

Was in main() tatsächlich passiert

  • In der Disassemblierung liegt main() bei 0x1149
  • Der Ablauf von main() ist folgender
    • Stack-Frame einrichten
    • Argumente für den Funktionsaufruf vorbereiten
    • Die Funktion zur Ausgabe von Hello World aufrufen
    • Stack-Frame aufräumen
    • Mit Exit-Code 0 zurückkehren
  • Entscheidend ist der Teil, der die Adresse des Strings als Argument vorbereitet und puts@plt aufruft
lea    0xeac(%rip),%rax
call   1050 <puts@plt>
  • Im Quellcode stand printf(), aber der Compiler optimiert das zu puts()
    • printf() ist eine komplexe Funktion mit formatierter Ausgabe
    • Das Beispiel nutzt keine Formatierungsfunktionen wie das Einsetzen von Variablen und wird daher durch das einfachere puts() ersetzt
    • Da puts() selbst einen Zeilenumbruch an den String anhängt, wird auch das \n aus dem ursprünglichen String entfernt

Wie der String gespeichert wird

  • Der String liegt im Abschnitt .rodata an der Adresse 0x2004
  • Die Bytes an dieser Stelle sehen so aus
48 65 6c 6c 6f 20 57 6f 72 6c 64 21 00
  • Diese Bytefolge wird als "Hello World!" plus abschließendem 0x00 interpretiert
  • 0x00 ist der NULL-Terminator und markiert das Ende eines C-Strings
  • Da C-Strings keine Längeninformation mitführen, verarbeitet eine Funktion, die einen String erhält, Byte für Byte, bis sie auf den NULL-Terminator trifft
  • Fehlt zwischen Strings ein NULL-Terminator, kann eine C-Funktion mehrere Strings hintereinander verarbeiten oder nicht erlaubten Speicher lesen und mit einem Segmentation Fault beendet werden

Der puts()-Pfad in Glibc

  • puts@plt führt letztlich in die Standardbibliothek
  • In Glibc ist puts() mit _IO_puts verbunden
  • _IO_puts führt folgende Arbeiten aus
    • Stringlänge ermitteln
    • Einen Lock für den Ausgabestream stdout erwerben
    • Bedingungen prüfen und _IO_sputn aufrufen
    • Zeilenumbruch ausgeben
    • Lock freigeben und Anzahl der ausgegebenen Zeichen zurückgeben
  • Die interne Implementierung von Glibc ist groß und komplex; daher geht der weitere Ablauf über die kleinere C-Bibliothek musl libc

Wie die Ausgabe in musl libc nach unten wandert

  • puts() in musl erwirbt den stdout-Lock, ruft fputs() und putc_unlocked('\n', stdout) auf und gibt den Lock wieder frei
  • fputs() ermittelt die Stringlänge und ruft fwrite() auf
  • fwrite() erwirbt erneut den Lock und ruft __fwritex() auf
  • __fwritex() prüft den Buffer-Zustand und ruft bei Bedarf den write-Funktionszeiger des Ausgabestreams auf
  • stdout ist mit fd = 1 definiert, und die write-Funktion ist zunächst auf __stdout_write gesetzt
  • __stdout_write() führt ein TIOCGWINSZ-ioctl aus und ruft danach __stdio_write() auf
  • __stdio_write() führt mit SYS_writev einen Systemaufruf aus

Systemaufruf und Kernel

  • Die C-Bibliothek allein kann nicht direkt mit der Hardware kommunizieren; Hardwarezugriff ist Aufgabe des Betriebssystem-Kernels
  • Die Ausgabeanforderung endet letztlich in einem Systemaufruf, der das Betriebssystem bittet, Text in den Ausgabestream zu schreiben
  • Typische Ausgabe erfolgt über den Systemaufruf write; musl verwendet writev, mit dem mehrere Buffer als Array geschrieben werden können
  • Die x86-64-Systemaufruf-Implementierung von musl ist je nach Anzahl der Argumente in __syscall0 bis __syscall6 aufgeteilt
  • Jede Funktion setzt die Argumente in CPU-Register und führt die Instruktion syscall aus
    • Die Kontrolle geht an den Kernel über
    • Der Kernel liest die Parameter aus den Registern und führt den angeforderten Systemaufruf aus

Nach dem Kernel: bis es auf dem Bildschirm sichtbar wird

  • Der Linux-Kernel nimmt den Systemaufruf write entgegen und schreibt Daten in eine geöffnete Datei oder einen Stream
  • Der Systemaufruf write erhält als Argumente einen Dateideskriptor, den zu schreibenden Buffer und die Anzahl der zu schreibenden Bytes
  • In der Beispielumgebung wird das Programm hello in einem GNOME-Terminal-Emulator ausgeführt, und stdout ist mit dem Pseudo-Terminal /dev/pts/0 verbunden
  • Der Kernel speichert die Hello-World-Nachricht in einem Buffer, und der Terminal-Emulator liest sie aus und zeigt sie auf dem Bildschirm an
  • Der Terminal-Emulator rendert den Text zu Frames; der X-Server oder Compositor setzt diese mit den Fenstern anderer Apps zusammen und zeigt sie anschließend über den Kernel auf dem Display an
  • Je nach Ausführungsumgebung kann der weitere Pfad variieren
    • Bei einem Remote-Login sendet der Kernel den Text an sshd, und sshd übergibt ihn als verschlüsselte Pakete wieder an den Kernel, der sie über das Internet verschickt
    • Bei einem physischen Terminal mit Serial-to-USB-Adapter sendet der Kernel den Text als USB-Pakete
    • Bei einer framebuffer console rendert der Kernel den Text zu Frames und gibt sie auf dem Display aus

Die Komplexität hinter einer kleinen Ausgabe

  • Die Übertragung der Hello-World-Nachricht ist nur ein einzelner Systemaufruf, der in einem einzelnen Programm entsteht
  • Moderne Software und Hardware bestehen aus so komplexen und feingliedrigen Schichten, dass sich selbst eine kleine Aktion kaum vollständig nachverfolgen lässt
  • Diese Erklärung lässt viele Details, Ausnahmen und interne Kernel-Abläufe aus und folgt nur dem Hauptpfad

1 Kommentare

 
GN⁺ 2024-04-09
Hacker-News-Kommentare
  • Ich habe aus Langeweile unter macOS etwas Ähnliches in Rust ausprobiert: Ein „Hello, world!“ mit #![no_std], #![no_main] und direkten WRITE/EXIT-Systemaufrufen kam in Ghidra, egal wie, auf rund 16 KB Ausgabe
    Weiteres Code-Golfing zum Verkleinern wäre sicher möglich, aber es wirkt sehr wahrscheinlich, dass das schon jemand ausprobiert und dokumentiert hat

    • Unter Windows war ein ähnliches Programm 3072 Byte groß und wurde mit rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:main kompiliert
      Es rief direkt ExitProcess, GetStdHandle und WriteFile aus kernel32 auf; weil es nur Hello World ist, habe ich den Panic-Handler eher grob gelassen. In der ausführbaren Datei steckt noch einiges an Padding, sodass man ohne Größenwachstum noch mehr unterbringen könnte, und mit noch „kriminelleren“ Methoden ließe sie sich weiter verkleinern, aber besonders sinnvoll scheint das nicht.
      Die zugehörige PDB-Debug-Datenbank war übrigens 208.896 Byte groß
    • Wenn man es so klein wie möglich machen will, muss man main komplett weglassen und _start verwenden; außerdem muss man dem Linker Flags übergeben, damit die Sections nicht ausgerichtet werden
      Unter https://darkcoding.net/software/a-very-small-rust-binary-ind... sieht man, dass man damit leicht in die Nähe von 500 Byte kommt
    • Code-Golfing macht Spaß, aber man sollte auch berücksichtigen, wie groß heutzutage die Page Size ist
      Bei einer Sprache mit Stack werden für die ausführbare Datei am Ende wahrscheinlich mindestens zwei Pages gemappt: eine nur lesbare und eine les-/schreibbare
    • Im Projekt min-sized-rust sind viele Optimierungen zusammengestellt, um die Größe von Rust-Binaries zu reduzieren
      Soweit ich mich erinnere, landete Hello World mit allen Optimierungen am Ende bei etwa 8 KB: https://github.com/johnthagen/min-sized-rust
    • XNU lädt keine Mach-O-Dateien, die kleiner als eine Page sind; auf dieser Plattform gibt es daher leider nicht viel Spielraum für Spielereien mit kleinen Binaries
  • Es gibt noch ein weiteres Kaninchenloch, das musl übersprungen hat. Unter Linux bedeutet der Aufruf von Systemfunktionen nicht nur, direkt syscall zu verwenden
    Die „höflichere“ Methode ist der Aufruf der vDSO. Das ist eine kleine, magische Bibliothek, die der Kernel automatisch in den Adressraum mappt, sodass der Kernel den optimalen Code zur Ausführung von Systemaufrufen bereitstellen kann
    Manche Systemaufrufe können im Userspace ausgeführt werden, sodass syscall selbst gar nicht mehr nötig ist; früher wählte die vDSO auch zwischen Kernel-Aufrufmechanismen wie int 0x80 oder sysenter
    https://man7.org/linux/man-pages/man7/vdso.7.html

    • Nur auf 32-Bit-x86 enthält die vDSO einen allgemeinen Shim für schnelle Systemaufrufe
      Unter x86-64 ist die standardmäßige Systemaufrufmethode die SYSCALL-Instruktion; in der vDSO stecken nur zeitbezogene Funktionen und ein paar SGX-bezogene Funktionen
  • Lesenswert ist auch ein Artikel, der den Overhead von „Hello World“-Programmen in verschiedenen Sprachen vergleicht: https://drewdevault.com/2020/01/04/Slow.html
    Folgeartikel: https://drewdevault.com/2020/01/08/Re-Slow.html
    Es gibt auch den legendären Artikel über das kleinste Programm unter Linux. Das Programm beendet sich schlicht mit Statuscode 42: https://www.muppetlabs.com/~breadbox/software/tiny/teensy.ht...
    Auf derselben Site findet man auch das kleinste „Hello World“-Programm

  • Der Artikel überspringt weitgehend die Rolle des dynamischen Linkers, der praktisch als der eigentliche Einstiegspunkt eines Programms betrachtet werden kann
    Wer diese Perspektive interessant findet, kann sich https://gist.github.com/kenballus/c7eff5db56aa8e4810d39021b2... ansehen

  • Für DOS-Fans: Ein in Assembly/Maschinencode geschriebenes „hello, world“ unter DOS konnte auf 23 Byte schrumpfen: https://github.com/susam/hello
    Von diesen 23 Byte entfallen 15 Byte auf den String selbst, der mit einem Dollarzeichen endet; der tatsächliche Maschinencode besteht also nur aus 8 Byte in vier x86-Instruktionen

  • Der Artikel war gut, aber ich hätte mir zwei zusätzliche Dinge gewünscht. Entweder die Optimierung und das Inlining, durch die printf zu puts wird, abschalten, oder gleich ein Hello World schreiben, das direkt puts verwendet
    Außerdem wäre es gut, die Kompilierung in die vier Schritte Präprozessierung, Kompilierung, Assemblierung und Linking aufzuteilen oder cc mit --save-temps zu verwenden und die dabei erzeugten Dateien zu erklären. Wenn man sich die Pipeline direkt ansieht, wirkt deutlich weniger daran wie Magie

  • Das erinnert mich an eine Aufgabe aus einer Systemprogrammierungs-Vorlesung an der Uni, die ich mochte: „Hier ist ein C++-Hello-World-Schnipsel; reicht das kleinstmögliche kompilierte Binary ein.“
    Ich erinnere mich, wie wir das Programm mit Tools wie readelf und objdump untersucht und Schicht für Schicht sowie Compiler-Optimierungen abgetragen haben, bis nur noch das kleinste Binary übrig war, das trotzdem „hello world“ ausgab.
    Natürlich habe ich danach gesucht, und es gab jemanden, der es viel besser gemacht hatte als die Studierenden: https://www.muppetlabs.com/%7Ebreadbox/software/tiny/teensy....

    • Ich frage mich, ob es überhaupt eine Bedeutung hatte, dass dieser Schnipsel C++ war.
      Man könnte doch einfach das kleinste Binary bauen, das hello world ausgibt, und behaupten, es sei semantisch äquivalent. Selbst inklusive String-Daten sollten ungefähr zehn x86-Instruktionen reichen.
    • Wenn das eine beliebte Aufgabe ist, frage ich mich, warum man nicht mehr Leute sieht, die auch bei Programmen jenseits von „hello world“ das kleinstmögliche Binary bauen.
      Mir persönlich macht das Spaß, weil ich gern Speicherplatz auf meinem Rechner spare, aber heutzutage werden viele Programme mit 10 MiB, 20 MiB, 50 MiB oder über 100 MiB geschrieben. Manche entstehen in kommerziellen Umfeldern für kommerzielle Zwecke, aber viele Programme werden auch aus reiner Freude geschrieben. Gibt es denn keine Freude daran, kleine Programme zu verwenden?
  • Ein Schluss wie „Es ist nach Mitternacht, also sollte ich schlafen gehen“ war für diesen Text im Gegenteil das perfekte Ende.

  • Leider bleibt dieser Text, wie viele tiefgehende „hello world“-Erkundungen, beim write-Systemaufruf stehen und übergeht den Rest eher grob.
    Bis zum Systemaufruf ist es im Wesentlichen nur eine Kette von Funktionsaufrufen, bei der printf puts aufruft, puts write aufruft, einen char const* übergibt und ein wenig Buchhaltung erledigt; das ist für mich persönlich nicht der interessanteste Teil.
    Wirklich interessant und komplex wird es erst nach dem Systemaufruf. Der Kernel verbindet das stdout des Prozesses mit der Eingabe des Terminal-Emulators, und das Terminal bereitet mit Font-Rendering-Bibliothek und GPU-Treiber den Framebuffer vor. Die zu den Zeichenbytes passenden Glyphenumrisse werden von der Platte gelesen, an den Viewport angepasst, Skalierung, Kerning und Font-Metriken werden angewendet, und anschließend übernimmt die GPU Rasterisierung und Antialiasing.
    Danach komponiert der Window-Manager den Rahmen des Terminalfensters und den Desktop; gibt es Transparenz oder Milchglas-Effekte, werden diese per Shader verarbeitet. Der resultierende Framebuffer wird passend zu Monitorauflösung und Farbtiefe als HDMI- oder DisplayPort-Signal paketiert und über Kabel und Eingangslogik des Displays in Pixel-Adressierungssignale umgewandelt. Je nach LCD, OLED, Plasma oder CRT unterscheidet sich die Art der Aktualisierung; ein 3840×2400-WRGB-OLED muss zum Beispiel etwa 36,86 Millionen Subpixel ansteuern.
    All das passiert innerhalb von 16,67 ms, der Dauer eines Frames bei 60 Hz.

    • Die Erklärung ist gut, aber letztlich bleibt sie beim menschlichen visuellen System stehen, und genau dort wird es ebenfalls wirklich interessant und komplex :)
      https://en.wikipedia.org/wiki/Visual_system
    • Wenn dir Untersuchungen in dieser Tiefe gefallen, dürfte dir auch Gynvael Coldwinds Artikel darüber gefallen, wie ein Python-hello-world unter Windows ausgeführt wird.
      Er behandelt CPython-Interna, Windows-conhost, Font-Rasterisierung, GPU-Rendering und mehr: https://gynvael.coldwind.pl/?id=754
    • Das meiste davon hat mit dem Programm selbst nichts zu tun. Wenn man die Ausgabe zum Beispiel nach /dev/null gepiped hätte, würde all das nicht passieren.
    • Auch das, was vor _start passiert, fehlt. Zum Beispiel, wie unter Linux ein Prozess entsteht, insbesondere das ziemlich seltsame execve, wie das Programm in den Speicher geladen wird, binfmt_* und das mächtige binfmt_misc, Relocations, Exception-Handling-Frames, Sections, der ELF-Loader insgesamt sowie die Zuweisung von Betriebssystemressourcen einschließlich des benötigten malloc.
  • Die Aussage „Anders als bei Python kann man keinen Interpreter aufrufen, um dieses Programm auszuführen“ ist nicht ganz richtig.
    Mit tcc -run hello.c geht das. Streng genommen ist das kein Interpreter, sondern eher ein In-Memory-Compiler.
    Zusätzliche Nerd-Punkte gibt es, wenn man das Programm dazu bringt, statt „Hello world“ „Hellorld“ zu sagen.