Ausarbeitung Programmier-Basics
Unter der Haube.
Was wirklich passiert,
wenn dein Code läuft.
Nicht Variablen, Schleifen und Verzweigungen — sondern der Weg dahinter: von der Textdatei über den Übersetzer bis zu dem Stapel Rahmen, den dein Programm im Speicher aufbaut, während es arbeitet.
Scrollen — oder ↓ ↑
01 Übersetzung
Der Prozessor kennt kein if
Er kennt Zahlen. Zwischen deiner Textdatei und der CPU stehen vier Werkzeuge, die nacheinander übersetzen — jedes mit genau einer Aufgabe.
1 — was du schreibst · Cint quadrat(int n) { return n * n; }
2 — was der Compiler daraus macht · Assemblyquadrat:
endbr64
imul %edi, %edi
mov %edi, %eax
ret
3 — was die CPU liest · Maschinencodef3 0f 1e fa 0f af ff 89 f8 c3
Jede Zeile hier ist dasselbe Programm. Nur die Sprache wechselt — von etwas, das ein Mensch liest, zu zehn Bytes, die ein Prozessor ausführt.
Der Zwischenschritt ist der interessante. Assembly ist
noch lesbar, steht aber schon 1 : 1 für die Bytes darunter:
c3 ist genau ret.
Das Argument kommt im Register edi an, das Ergebnis
verlässt die Funktion in eax. Es gibt hier keine
Variable n mehr — der Name existierte nur in deiner
Textdatei.
[i]Nicht ausgedacht: erzeugt mit gcc -O2 -c quadrat.c und
objdump -d auf x86-64. endbr64 ist eine
Sicherheitsmarke moderner Intel-CPUs. Ohne Optimierung baut der Compiler
zusätzlich einen Stack Frame auf (Kapitel 05); auf einem ARM-Prozessor
stehen dort völlig andere Bytes.
02 Ausführung
Drei Wege, dasselbe Ziel
„Kompiliert oder interpretiert“ ist eine Vereinfachung. Der eigentliche Unterschied ist, wann übersetzt wird.
Einmal vorher übersetzt — auf dem Rechner des Entwicklers.
.pyc
VM liest Bytecode
Beim Start übersetzt — ausgeführt wird der Bytecode, nicht dein Text.
.class
JVM führt aus
JIT übersetzt heiße Stellen
Beides — und der zweite Teil passiert, während das Programm schon läuft.
Python liest deinen Code nicht Zeile für Zeile vor. Das ist der hartnäckigste Mythos der Anfängerliteratur. Auch Python übersetzt zuerst — nur eben in Bytecode statt in Maschinencode, und erst beim Start statt vorher.
Der JIT-Compiler ist der interessante Sonderfall: Er beobachtet das laufende Programm und übersetzt die Stellen, die oft ausgeführt werden, doch noch in echten Maschinencode. Deshalb wird eine Java-Anwendung nach ein paar Sekunden schneller.
[i]Vereinfacht: Diese Wege gehören nicht zur Sprache, sondern zur Implementierung. Es gibt C-Interpreter und Java-Compiler, die direkt Maschinencode erzeugen (GraalVM Native Image). „Python ist interpretiert“ beschreibt genau genommen CPython, nicht Python.
03 Variable
Ein Name für eine Adresse
Der Speicher ist ein durchnummeriertes Regal aus Bytes. Eine Variable ist ein Name für ein Fach — und der Typ sagt, wie viele Fächer und wie man sie liest.
int alter = 30; — vier Bytes ab einer Adresse
Dieselben Bits, drei Deutungen
Cint alter = 30; printf("%p", (void*)&alter);// 0x7ffd…2ca0 — wo es liegtprintf("%zu", sizeof alter);// 4 — wie viele Fächer
In allen drei Zeilen darüber stehen dieselben 32 Bit. Kein einziges davon hat sich geändert. Nur die Frage, wie man sie liest, hat eine andere Antwort bekommen.
Deshalb ist ein Typ kein Verwaltungskram, den die Sprache verlangt, um dich zu ärgern. Er ist die Leseanweisung für ein Stück Speicher, das aus sich heraus nichts bedeutet.
[i]Vereinfacht: Bytereihenfolge „little-endian“ (üblich auf x86 und ARM),
int vier Byte groß — beides ist plattformabhängig, nicht
garantiert. Der Float-Wert ist eine sogenannte denormale Zahl dicht an null —
absurd weit von 30 entfernt, obwohl kein Bit anders steht. Sauber liest man Bits in C mit
memcpy oder einer union um, nicht mit einem
Zeiger-Cast.
04 Speicherbild
Vier Bereiche, zwei Richtungen
Jedes laufende Programm bekommt einen eigenen Adressraum. Er zerfällt in Bereiche mit sehr unterschiedlichen Regeln — und zwei davon wachsen aufeinander zu.
hohe Adressen
wächst nach unten
wächst nach oben
feste Größe ab dem Start
schreibgeschützt
niedrige Adressen
Stack — ab hier im ganzen Dokument amber Heap — ab hier cyan
Text und Statisch stehen schon fest, bevor das Programm startet. Ihre Größe steht in der ausführbaren Datei; das Betriebssystem legt sie beim Start einfach hin.
Stack und Heap sind die beweglichen. Der Stack wächst und schrumpft bei jedem Funktionsaufruf, ohne dass du etwas tust. Der Heap wächst nur, wenn du ihn ausdrücklich darum bittest.
Der freie Raum dazwischen ist der Grund, warum beide in entgegengesetzte Richtungen wachsen: So teilen sie sich denselben Vorrat, ohne dass man vorher festlegen muss, wer wie viel bekommt.
[i]Vereinfacht: Das ist virtueller Speicher — jeder Prozess sieht dieses Bild für sich allein, das Betriebssystem verteilt den echten Arbeitsspeicher dahinter. Die genaue Anordnung ist plattformabhängig, und ASLR verschiebt die Bereiche bei jedem Start absichtlich, damit Angreifer keine festen Adressen vorfinden.
05 Stack Frame
Ein Rahmen pro Aufruf
Jeder Funktionsaufruf legt einen Rahmen auf den Stapel: Parameter, lokale
Variablen und die Adresse, an der es danach weitergeht.
Das return räumt ihn wieder ab.
Aufrufstapel — der neueste Rahmen liegt oben
- fakultaet n 1 zurück nach fakultaet(2) gibt 1 zurück
- fakultaet n 2 zurück nach fakultaet(3) gibt 2 zurück
- fakultaet n 3 zurück nach main gibt 6 zurück
- main e ?
Drei Rahmen liegen übereinander — einer für jeden noch nicht beendeten Aufruf. Jeder hat sein eigenes n, und keiner weiß etwas vom anderen.
[i]Vereinfacht: Auf x86-64 werden die ersten Argumente in Registern übergeben und landen gar nicht im Rahmen, und ein Compiler mit Optimierung macht aus dieser Rekursion womöglich eine Schleife ganz ohne neue Rahmen. Das Bild hier ist das Lehrbuchmodell — und genau das, was dir ein Debugger als Aufrufliste anzeigt.
06 Lebensdauer
Der Rahmen geht, die Adresse bleibt
Eine lokale Variable lebt genau so lange wie der Rahmen, in dem sie liegt. Danach ist ihre Adresse nur noch eine Zahl, die irgendwohin zeigt.
C — so nichtint* kaputt(void) { int x = 42; return &x;// die Adresse überlebt — x nicht}
Solange kaputt läuft, ist &x eine
völlig gültige Adresse. Beim return wird der Rahmen
freigegeben — und der nächste Funktionsaufruf legt seinen eigenen
Rahmen genau dort hin.
Das Tückische: Oft steht dort im ersten Moment noch 42.
Der Fehler äußert sich dann erst Wochen später an einer ganz anderen
Stelle. Der Compiler warnt zum Glück:
function returns address of local variable.
[i]Das ist undefiniertes Verhalten: Der C-Standard legt nicht fest, was passiert. Ein sofortiger Absturz wäre der freundliche Fall — der unfreundliche ist ein Programm, das jahrelang fast richtig rechnet.
07 Stack gegen Heap
Zwei Speicher, zwei Verträge
Beide geben dir Platz. Der Unterschied ist, wer aufräumt — und was das kostet.
ein Sprung der Marke — fertig
- Vergebeneinen Zeiger verschieben, ein Befehl
- Aufräumenpassiert beim return, ohne dein Zutun
- Lebensdauerendet mit der Funktion
- Größebegrenzt, oft 8 MB
- Kostenpraktisch null
suchen, prüfen, weiter — bis ein Block passt
- Vergebender Allokator sucht einen freien Block
- Aufräumenfree() — oder ein Garbage Collector
- Lebensdauerso lange du willst
- Größepraktisch der freie Arbeitsspeicher
- Kostenspürbar, mit Verwaltungsaufwand
Cint a[3];// Stack — weg beim returnint *b = malloc(3 * sizeof *b);// Heap — bleibt bis free(b)
Faustregel: Wenn die Daten die Funktion nicht überleben müssen und ihre Größe schon beim Übersetzen feststeht, gehören sie auf den Stack. Alles andere auf den Heap.
Der Stack ist deshalb so schnell, weil er gar keine Verwaltung hat. Er kennt nur eine Marke, die vor- und zurückrutscht. Der Heap muss dagegen buchführen, welcher Block frei ist und welcher nicht.
[i]Vereinfacht: 8 MB ist der übliche Linux-Standardwert
(ulimit -s zeigt ihn an); zusätzliche Threads bekommen oft
weniger. In Sprachen mit Garbage Collector liegen fast alle Objekte auf
dem Heap — die Unterscheidung verschwindet dadurch nicht, sie wird nur
unsichtbar.
08 Kopie oder nicht
b = a kopiert nicht immer
Der häufigste Anfängerfehler ist kein Tippfehler. Er kommt daher, dass zwei Namen auf dasselbe Objekt zeigen — und man nur einen davon anfasst.
b = a — ein Objekt, zwei Namen
c = a[:] — jetzt zwei Objekte
Pythona = [1, 2, 3] b = a# nur ein zweiter Nameb.append(4) print(a)# [1, 2, 3, 4] — a hat sich mitverändertc = a[:]# jetzt eine echte Kopiec.append(5) print(a)# [1, 2, 3, 4] — unverändert
In Python liegt jedes Objekt auf dem Heap; ein Name ist nur ein
Schildchen daran. b = a hängt ein zweites Schildchen an
denselben Gegenstand — es entsteht nichts Neues.
C kennt ausschließlich Wertübergabe. Auch bei
f(int *p) wird kopiert — nur eben der Wert einer
Adresse. Genau deshalb kann die Funktion trotzdem am Original
arbeiten: Die Kopie zeigt auf dasselbe Fach.
[i]Vereinfacht: a[:] erzeugt eine flache Kopie. Enthält
die Liste selbst wieder Listen, teilen sich beide Kopien deren innere
Objekte weiterhin — dafür gibt es copy.deepcopy. Kleine
Zahlen und kurze Zeichenketten hält CPython außerdem als gemeinsame
Objekte vor, was beim Ausprobieren mit is verwirrt.
09 Fehlerbilder
Drei Abstürze, die jetzt Sinn ergeben
Die bekanntesten Laufzeitfehler sind keine Magie. Sie sind die direkte Folge der letzten fünf Kapitel — und man sieht ihnen an, wo sie herkommen.
Stack Overflow
Eine Rekursion ohne Abbruchbedingung legt Rahmen auf Rahmen. Der Stack ist begrenzt — irgendwann stößt er an eine Schutzseite, die das Betriebssystem darunter gelegt hat. Programm aus.
folgt aus 05 und 07
Memory Leak
cyan benutzt · rot vergessen · dunkel frei
Jedes malloc will sein free. Bleibt es
aus, bleibt der Block belegt, obwohl ihn niemand mehr kennt. Das
Programm wird über Stunden langsam und stirbt am Ende an
Speichermangel.
folgt aus 07
Segmentation Fault
Adresse, die nicht
mehr dir gehört
Ein Zeiger auf etwas, das es nicht mehr gibt: Der Rahmen ist abgeräumt oder der Block wurde freigegeben. Beim Zugriff greift das Programm ins Leere — und das Betriebssystem beendet es.
folgt aus 06
[i]In Sprachen mit Garbage Collector gibt es keine hängenden Zeiger — Lecks aber sehr wohl: Eine vergessene Referenz in einer langlebigen Liste hält ihr Objekt am Leben, obwohl niemand es mehr braucht. Der Aufräumer kann nicht wissen, dass du es nicht mehr willst.
10 Fazit
Warum sich das rechnet
- 01 Stacktraces werden lesbar. Was ein Absturz ausgibt, ist genau der Stapel aus Kapitel 05: oben der Ort des Fehlers, darunter der Weg dorthin.
- 02 Fehler bekommen eine Schublade. „Warum ändert sich meine Liste von selbst?“ ist Kapitel 08. „Warum wird der Dienst über Nacht langsam?“ ist Kapitel 07.
- 03 Entscheidungen lassen sich begründen. Ob etwas auf den Stack oder den Heap gehört, ist keine Geschmacksfrage, sondern eine Frage der Lebensdauer.
- 04 Neue Sprachen gehen schneller. Wer das Modell einmal hat, sucht in einer fremden Sprache nur noch die Antwort auf eine Frage: Wie verwaltet sie diese vier Bereiche?
Begriffe in einem Satz
- Stack Frame
- Der Speicherblock eines einzelnen Funktionsaufrufs.
- Call Stack
- Alle Rahmen übereinander — die Aufrufkette.
- Heap
- Der Bereich für Daten, die Funktionen überleben.
- Zeiger
- Eine Variable, deren Wert eine Adresse ist.
- Bytecode
- Zwischensprache für eine virtuelle Maschine.
- JIT
- Übersetzung in Maschinencode während der Laufzeit.
- Allokator
- Die Buchhaltung, die Heap-Blöcke vergibt.
- Undefiniert
- Der Standard sagt nicht, was passiert — alles ist erlaubt.