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.

10 Kapitel ca. 12 Minuten Beispiele in C und Python

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.

quadrat.cdein Text
Präprozessorfügt ein, ersetzt Makros
Compilerprüft, erzeugt Assembly
AssemblerAssembly zu Bytes
Linkerverbindet die Teile
quadratlauffähig
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.

C
Quelltext Compiler Maschinencode CPU führt aus

Einmal vorher übersetzt — auf dem Rechner des Entwicklers.

Python
Quelltext Bytecode .pyc VM liest Bytecode

Beim Start übersetzt — ausgeführt wird der Bytecode, nicht dein Text.

Java
Quelltext Bytecode .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

…2ca01E
…2ca100
…2ca200
…2ca300

Dieselben Bits, drei Deutungen

als int 30
als float 4,2 · 10-44
erstes Byte als char Steuerzeichen „Record Separator“
Cint alter = 30;

printf("%p",  (void*)&alter);  // 0x7ffd…2ca0 — wo es liegt
printf("%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

Stack lokale Variablen, Aufrufe
wächst nach unten
freier Raum zwischen beiden
Heap was du selbst anforderst
wächst nach oben
Statisch globale Variablen
feste Größe ab dem Start
Text die Maschinenbefehle selbst
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

  1. fakultaet n 1 zurück nach fakultaet(2)
  2. fakultaet n 2 zurück nach fakultaet(3)
  3. fakultaet n 3 zurück nach main
  4. main e ?
Cint fakultaet(int n) { if (n <= 1) return 1; return n * fakultaet(n - 1);} int main(void) { int e = fakultaet(3);}

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.

WÄHREND kaputt() LÄUFT Rahmen kaputt x = 42 Zeiger gültig NACH DEM return frei — überschreibt der nächste Aufruf Zeiger zeigt ins Leere
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.

Stackautomatisch

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
Heapvon Hand

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 return
int *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

a b [1, 2, 3] [1, 2, 3, 4]

c = a[:] — jetzt zwei Objekte

a [1, 2, 3, 4] c [1, 2, 3, 4] [1, 2, 3, 4, 5]
Pythona = [1, 2, 3]
b = a          # nur ein zweiter Name
b.append(4)
print(a)       # [1, 2, 3, 4] — a hat sich mitverändert

c = a[:]       # jetzt eine echte Kopie
c.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

Zugriff auf eine
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

  1. 01 Stacktraces werden lesbar. Was ein Absturz ausgibt, ist genau der Stapel aus Kapitel 05: oben der Ort des Fehlers, darunter der Weg dorthin.
  2. 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.
  3. 03 Entscheidungen lassen sich begründen. Ob etwas auf den Stack oder den Heap gehört, ist keine Geschmacksfrage, sondern eine Frage der Lebensdauer.
  4. 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.
Zurück zu den Demos Eric LubczynskiAusarbeitung im Rahmen der Ausbildung