- 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
Hacker-News-Kommentare
Ich habe aus Langeweile unter macOS etwas Ähnliches in Rust ausprobiert: Ein „Hello, world!“ mit
#![no_std],#![no_main]und direktenWRITE/EXIT-Systemaufrufen kam in Ghidra, egal wie, auf rund 16 KB AusgabeWeiteres Code-Golfing zum Verkleinern wäre sicher möglich, aber es wirkt sehr wahrscheinlich, dass das schon jemand ausprobiert und dokumentiert hat
rustc hello.rs -C panic=abort -C opt-level=3 -C link-arg=/entry:mainkompiliertEs rief direkt
ExitProcess,GetStdHandleundWriteFileauskernel32auf; 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ß
mainkomplett weglassen und_startverwenden; außerdem muss man dem Linker Flags übergeben, damit die Sections nicht ausgerichtet werdenUnter https://darkcoding.net/software/a-very-small-rust-binary-ind... sieht man, dass man damit leicht in die Nähe von 500 Byte kommt
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
Soweit ich mich erinnere, landete Hello World mit allen Optimierungen am Ende bei etwa 8 KB: https://github.com/johnthagen/min-sized-rust
Es gibt noch ein weiteres Kaninchenloch, das musl übersprungen hat. Unter Linux bedeutet der Aufruf von Systemfunktionen nicht nur, direkt
syscallzu verwendenDie „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
syscallselbst gar nicht mehr nötig ist; früher wählte die vDSO auch zwischen Kernel-Aufrufmechanismen wieint 0x80odersysenterhttps://man7.org/linux/man-pages/man7/vdso.7.html
Unter x86-64 ist die standardmäßige Systemaufrufmethode die
SYSCALL-Instruktion; in der vDSO stecken nur zeitbezogene Funktionen und ein paar SGX-bezogene FunktionenLesenswert 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
printfzuputswird, abschalten, oder gleich ein Hello World schreiben, das direktputsverwendetAußerdem wäre es gut, die Kompilierung in die vier Schritte Präprozessierung, Kompilierung, Assemblierung und Linking aufzuteilen oder
ccmit--save-tempszu verwenden und die dabei erzeugten Dateien zu erklären. Wenn man sich die Pipeline direkt ansieht, wirkt deutlich weniger daran wie MagieDas 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
readelfundobjdumpuntersucht 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....
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.
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
printfputsaufruft,putswriteaufruft, einenchar 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
stdoutdes 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.
https://en.wikipedia.org/wiki/Visual_system
Er behandelt CPython-Interna, Windows-
conhost, Font-Rasterisierung, GPU-Rendering und mehr: https://gynvael.coldwind.pl/?id=754/dev/nullgepiped hätte, würde all das nicht passieren._startpassiert, fehlt. Zum Beispiel, wie unter Linux ein Prozess entsteht, insbesondere das ziemlich seltsameexecve, wie das Programm in den Speicher geladen wird,binfmt_*und das mächtigebinfmt_misc, Relocations, Exception-Handling-Frames, Sections, der ELF-Loader insgesamt sowie die Zuweisung von Betriebssystemressourcen einschließlich des benötigtenmalloc.Die Aussage „Anders als bei Python kann man keinen Interpreter aufrufen, um dieses Programm auszuführen“ ist nicht ganz richtig.
Mit
tcc -run hello.cgeht 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.