Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Kapitel 08 (Hardware-Sicht): Anweisungen, Ausdrücke und Pattern Matching unter der CPU-Lupe

Willkommen zurück, Kollege! Nachdem wir im Hauptkapitel die Konzepte von Ausdrücken und dem eleganten Pattern Matching aus der Sicht des Softwareentwicklers betrachtet haben, wird es Zeit, den Schraubenschlüssel in die Hand zu nehmen. Wir steigen hinab in den Maschinenraum der CPU.

Wenn du aus der Welt von Sprachen wie C++, C oder gar Assembler kommst, weißt du, dass am Ende des Tages alles aus Bytes, Registern und Sprungadressen besteht. In diesem Hardware-Abschnitt lüften wir den Schleier der Abstraktion und schauen uns an, was der Rust-Compiler (in enger Zusammenarbeit mit dem Optimierungsschwergewicht LLVM) aus deinen Ausdrücken und match-Verzweigungen auf der nackten Hardware zaubert.

Schnapp dir einen Kaffee, wir fangen an!


1. Stack- und Register-Verhalten bei Block-Ausdrücken

Rust zeichnet sich als eine ausdrucksbasierte (expression-based) Sprache aus. Das bedeutet, dass fast jedes Konstrukt einen Wert zurückliefert – sogar ein simpler Code-Block, der in geschweifte Klammern {} gefasst ist.

Doch was bedeutet das für die Hardware? Legt der Prozessor für jeden Block einen neuen Stack-Frame an? Werden Daten im Arbeitsspeicher (RAM) hin- und herkopiert, nur weil wir einen Block verlassen? Die beruhigende Antwort lautet: Nein, absolut nicht.

Die Schmierzettel- und Taschenrechner-Analogie

Stell dir vor, du sitzt an deinem Schreibtisch und musst eine komplexe Steuererklärung ausfüllen. Du hast ein großes, schweres Archivbuch vor dir liegen – das ist unser Arbeitsspeicher (RAM). Jeder Eintrag dort dauert und erfordert ordentliches Aufschreiben.

Wenn du nun eine Zwischenrechnung anstellst, zum Beispiel:

#![allow(unused)]
fn main() {
let summe = {
    let a = 10;
    let b = 20;
    a + b
};
}

dann gehst du ja nicht hin, schlägst eine neue leere Seite im Archivbuch auf, schreibst dort mühsam 10 hin, auf die nächste Seite 20, rechnest das im Kopf zusammen, schreibst das Ergebnis 30 auf eine dritte Seite und radierst die ersten beiden Seiten danach wieder aus. Das wäre absurd und extrem zeitaufwendig.

Stattdessen nutzt du deinen Taschenrechner (die CPU-Register) für die direkte Addition oder machst dir eine flüchtige Notiz auf einem kleinen Schmierzettel (dem CPU-Stack), den du nach der Rechnung sofort zerknüllst und in den Papierkorb wirfst.

Genau so arbeitet Rust:

  1. Die Register-Optimierung: Wenn der Compiler sieht, dass die Variablen a und b nur innerhalb des Blocks existieren und danach nie wieder gebraucht werden, reserviert er für sie meist überhaupt keinen Platz im Arbeitsspeicher (RAM). Er lädt die Werte direkt in die ultraschnellen CPU-Register.
  2. Die SSA-Form (Single Static Assignment): Der Rust-Compiler übersetzt den Code intern in eine Form, bei der jede Variable nur genau einmal zugewiesen wird. LLVM erkennt dadurch sofort, dass a + b eigentlich nur 10 + 20 ist, führt diese Addition bereits während des Kompilierens aus (Constant Folding) und ersetzt den gesamten Block im fertigen Maschinencode durch die simple Zuweisung des fertigen Werts 30.

Ein kompilierbares Beispiel zur Demonstration

Schreiben wir ein kleines, aber vollständiges Programm, das wir theoretisch unter die Lupe nehmen können:

fn main() {
    // Ein Block, der einen Wert berechnet
    let ergebnis = {
        let x = 5;
        let y = 10;
        
        // Die Berechnung am Ende des Blocks ohne Semikolon!
        // Der Wert wird direkt an 'ergebnis' zurückgegeben.
        x * y + 3
    };

    println!("Das Ergebnis des Blocks ist: {}", ergebnis);
}

Was der Compiler auf Assembler-Ebene daraus macht

Wenn wir diesen Code mit Optimierungen übersetzen (z. B. via cargo build --release), sieht der generierte Maschinencode für die CPU (hier in x86_64-Assembler-Syntax) verblüffend einfach aus:

; Der gesamte Berechnungsblock wurde auf eine einzige Instruktion reduziert!
mov edx, 53      ; Schreibt direkt den fertigen Wert (5 * 10 + 3 = 53) in das Register edx

Wie kam es dazu?

  • Der Compiler hat erkannt, dass x und y zur Compilezeit Konstanten sind.
  • Er hat die mathematische Operation 5 * 10 + 3 im Kopf ausgerechnet.
  • Anstatt Maschinencode für die Multiplikation (imul) und Addition (add) zu erzeugen, hat er das Ergebnis direkt als konstanten Wert (ein sogenanntes Immediate) in die Register-Pipeline eingespeist.
  • Es wurden keine Stack-Adressen für x oder y reserviert. Es gab keinen Speicher-Overhead.

Auch wenn die Variablen keine Compilezeit-Konstanten sind, sondern beispielsweise von einer Benutzereingabe stammen, sorgt der Compiler dafür, dass die Berechnungen in Registern stattfinden:

#![allow(unused)]
fn main() {
// Angenommen, diese Werte kommen von außen
fn berechne(a: i32, b: i32) -> i32 {
    {
        let temp = a * 2;
        temp + b
    }
}
}

Auf Assembler-Ebene wird dies typischerweise so übersetzt:

; a befindet sich im Register edi (x86_64 Calling Convention)
; b befindet sich im Register esi
lea eax, [rsi + rdi*2]  ; Berechnet direkt (a * 2) + b und legt das Ergebnis in eax ab
ret                     ; Rücksprung, das Ergebnis ist bereits im Rückgaberegister eax!

Hier siehst du die pure Effizienz: Der Block hat keinerlei Spuren im RAM hinterlassen. Kein Stack-Zugriff war nötig, alles passierte direkt in den Registern edi, esi und eax.


2. Lvalues und Rvalues auf Assembler-Ebene

Im Hauptkapitel haben wir die Begriffe Lvalue und Rvalue kennengelernt. Lass uns diese Konzepte auf Hardware-Ebene übersetzen. In Rust nennen wir sie offiziell Place Expressions (Ort-Ausdrücke) und Value Expressions (Wert-Ausdrücke).

  • Lvalue / Place Expression: Repräsentiert einen dauerhaften Speicherort im RAM oder auf dem Stack. Er besitzt eine feste Speicheradresse.
  • Rvalue / Value Expression: Repräsentiert einen flüchtigen Datenwert, der in der CPU verarbeitet wird. Er besitzt im Moment der Auswertung keine zugängliche Speicheradresse, sondern lebt oft nur temporär in einem CPU-Register oder als direkter Teil eines CPU-Befehls.

Die Postfach-Analogie

Stell dir ein Postamt vor.

  • Ein Lvalue ist ein physisches Postfach mit einer festen Nummer, z. B. “Postfach 42”. Dieses Postfach existiert dauerhaft an einer Wand. Du kannst dort hingehen, Briefe hineinlegen (Schreiben / Zuweisung) und Briefe herausholen (Lesen).
  • Ein Rvalue ist der Inhalt des Briefes selbst, oder ein Blatt Papier, das im Wind fliegt. Wenn dir jemand im Vorübergehen die Zahl “5” zuruft, existiert diese Zahl kurz in der Luft (oder im Gehörgang). Sie hat aber keine Postfachnummer. Du kannst an die vorbeifliegende Zahl “5” keinen Brief schicken, weil sie keine Adresse hat. Sie ist ein reiner Wert.

Hardware-Repräsentation im Detail

Schauen wir uns an, wie sich diese Ausdrücke in Assembler-Befehlen ausdrücken:

  • Lvalues werden im Assemblercode meist über Speicheradressen angesprochen. In x86_64-Assembler erkennst du sie an den eckigen Klammern [...], die eine Adressierung des Stack-Speichers (relativ zum Base-Pointer rbp oder Stack-Pointer rsp) oder des Heaps signalisieren. Beispiel: [rbp - 8] zeigt auf den Speicherplatz einer lokalen Variable auf dem Stack.
  • Rvalues sind entweder Registerwerte (wie rax, rdx) oder unmittelbare Zahlenkonstanten im Befehl selbst (wie $10 oder $0x2f).

Der Lvalue-zu-Rvalue-Zerfall (Lvalue-to-Rvalue Coercion)

Wenn du im Code schreibst:

#![allow(unused)]
fn main() {
let mut x = 5;
let y = x;
}
  1. x ist links ein Lvalue (der Speicherort, an dem 5 abgelegt wird).
  2. In der zweiten Zeile steht x auf der rechten Seite. Hier verhält es sich wie ein Rvalue: Die CPU greift auf den Speicherort von x zu, liest den Wert 5 heraus, legt ihn kurz in ein Register und schreibt ihn dann an den Speicherort von y (einem anderen Lvalue).

Compilerfehler unter der Lupe: Zuweisung an einen Rvalue

Was passiert, wenn wir versuchen, die Logik auf den Kopf zu stellen? Betrachten wir folgendes fehlerhafte Programm:

fn main() {
    let mut x = 5;
    
    // Autsch! Das wird wehtun.
    // Wir versuchen, dem Ergebnis der Addition einen neuen Wert zuzuweisen.
    x + 1 = 10;
}

Wenn wir versuchen, diesen Code zu kompilieren, schlägt uns der Rust-Compiler diesen Fehler um die Ohren:

error[E0070]: invalid left-hand side of assignment
 --> src/main.rs:6:5
  |
6 |     x + 1 = 10;
  |     ^^^^^ cannot assign to this expression

Didaktische Fehleranalyse: Warum schlägt der Compiler Alarm?

Die Zuweisung = erwartet auf ihrer linken Seite zwingend einen Speicherort (Lvalue / Place Expression), in den sie den Wert hineinschreiben kann.

Der Ausdruck x + 1 ist jedoch ein reiner Rvalue / Value Expression. Bei der Berechnung von x + 1 holt die CPU den Wert von x aus dem Speicher, lädt ihn in ein Register (z. B. eax), addiert 1 dazu und lässt das Ergebnis 6 im Register eax liegen.

Dieses Register eax ist flüchtig. Es hat keine permanente Adresse im Arbeitsspeicher des Programms. Wenn der Compiler den Befehl eax = 10 zulassen würde, wohin sollte dieser Wert geschrieben werden? In das temporäre Register, das beim nächsten CPU-Befehl sowieso überschrieben wird? Das macht keinen Sinn. Daher verhindert das Typsystem von Rust solche logischen Fehler bereits im Keim.


3. Die Kompilierung von match auf Assembler-Ebene

Das match-Konstrukt gehört zu den mächtigsten Werkzeugen in Rust. Aber wie wird eine so komplexe Musterprüfung in einfachen Maschinencode übersetzt? Viele Entwickler befürchten, dass ein riesiges match zu einer langsamen Kette von nacheinander ausgeführten Vergleichen führt, ähnlich wie eine endlose Kette von if-else-Bedingungen in anderen Sprachen.

Glücklicherweise ist der Rust-Compiler extrem clever. Er wählt je nach Dichte und Anzahl der Muster eine von drei hardwarenahen Strategien.

Strategie 1: Einfache Vergleiche (Compare & Jump)

Wenn du nur sehr wenige Match-Arme hast (z. B. 2 oder 3), übersetzt der Compiler das match in simple Vergleiche und bedingte Sprünge.

Die Wachmann-Analogie

Ein Wachmann steht an der Tür und prüft nacheinander die Ausweise: “Bist du Thorsten? Nein? Bist du Anja? Nein? Dann bist du jemand anderes (Wildcard _).”

Rust-Code:

#![allow(unused)]
fn main() {
fn vergleiche(wert: i32) -> &'static str {
    match wert {
        1 => "Eins",
        2 => "Zwei",
        _ => "Andere",
    }
}
}

Assembler-Gegenstück:

; wert befindet sich im Register edi
cmp edi, 1              ; Vergleiche den Wert mit 1
je .Larm_eins           ; Wenn gleich (Jump if Equal), springe zum Code für "Eins"
cmp edi, 2              ; Vergleiche den Wert mit 2
je .Larm_zwei           ; Wenn gleich, springe zu "Zwei"
; Fallback für den Wildcard-Arm (_)
mov rax, .Lstr_andere   ; Lade Adresse von "Andere" in das Rückgaberegister rax
ret

.Larm_eins:
mov rax, .Lstr_eins     ; Lade Adresse von "Eins"
ret

.Larm_zwei:
mov rax, .Lstr_zwei     ; Lade Adresse von "Zwei"
ret

Strategie 2: Sprungtabellen (Jump Tables / Branch Tables)

Wenn du viele Match-Arme hast, deren Werte relativ dicht beieinander liegen (z. B. 0, 1, 2, 3, 4, 5), erzeugt der Compiler eine Sprungtabelle.

Die Fahrstuhl-Analogie

Stell dir vor, du stehst in einem Hochhaus im Erdgeschoss. Du möchtest in den 4. Stock. Du gehst in den Fahrstuhl und drückst den Knopf “4”. Der Fahrstuhl bringt dich direkt dorthin. Du musst nicht an jedem einzelnen Stockwerk anhalten, die Tür öffnen und fragen: “Ist das der 4. Stock? Nein? Weiter.”

Rust-Code:

#![allow(unused)]
fn main() {
fn waehle_aktion(code: u32) {
    match code {
        0 => println!("Initialisieren"),
        1 => println!("Starten"),
        2 => println!("Stoppen"),
        3 => println!("Pause"),
        4 => println!("Beenden"),
        _ => println!("Unbekannt"),
    }
}
}

Was auf Hardware-Ebene passiert:

Der Compiler legt im schreibgeschützten Datensegment des Programms eine Tabelle mit den Speicheradressen der verschiedenen Code-Blöcke an:

Sprungtabelle:
[0] -> Adresse von Block_0
[1] -> Adresse von Block_1
[2] -> Adresse von Block_2
[3] -> Adresse von Block_3
[4] -> Adresse von Block_4

Wenn die Funktion aufgerufen wird, prüft die CPU zuerst, ob der Wert im gültigen Bereich (0 bis 4) liegt. Wenn ja, nutzt sie den Wert direkt als Index in dieser Tabelle, holt sich die Zieladresse und springt mit einem einzigen Befehl dorthin:

; code befindet sich in edi
cmp edi, 4              ; Ist der Code größer als 4?
ja .Lfall_unbekannt     ; Wenn ja (Jump if Above), springe zum Wildcard-Zweig

; Indirekter Sprung über die Sprungtabelle
jmp [rax + rdi*8]       ; Berechne Adresse: Tabellenanfang + (code * 8 Byte)
                        ; Springe direkt zum entsprechenden Codeblock!

Laufzeitkomplexität: $O(1)$. Egal, ob du 5 oder 500 dicht beieinanderliegende Fälle hast – der Sprung zum richtigen Code-Block dauert immer exakt gleich lang!


Strategie 3: Binäre Suche (Binary Search Trees)

Was passiert, wenn die Werte weit verstreut sind, z. B. 12, 5000 und 999999? Eine Sprungtabelle wäre hier eine katastrophale RAM-Verschwendung, da sie fast eine Million leere Einträge enthalten müsste. In diesem Fall baut der Compiler im Maschinencode einen logischen Entscheidungsbaum auf (binäre Suche).

Die “Höher/Tiefer”-Ratespiel-Analogie

Du sollst eine Zahl zwischen 1 und 1000 erraten. Du fragst nicht: “Ist es 1? Ist es 2?”, sondern du fängst in der Mitte an: “Ist es größer als 500?” Basierend auf der Antwort halbierst du den Suchraum und fragst als nächstes nach 250 oder 750.

Rust-Code:

#![allow(unused)]
fn main() {
fn verarbeite_id(id: u32) -> &'static str {
    match id {
        10 => "Benutzer",
        2500 => "Administrator",
        80000 => "System-Dienst",
        _ => "Gast",
    }
}
}

Assembler-Ablauf:

Die CPU vergleicht den Wert zuerst mit dem mittleren Element (z. B. 2500):

cmp edi, 2500
je .Ladmin              ; Direkt Treffer!
jl .Lsuche_kleiner      ; Wenn kleiner (Jump if Less), prüfe die Werte darunter (10)
jg .Lsuche_groesser     ; Wenn größer (Jump if Greater), prüfe Werte darüber (80000)

Laufzeitkomplexität: $O(\log n)$. Selbst bei Hunderten weit verteilten Mustern benötigt die CPU nur eine Handvoll Vergleiche, um den richtigen Pfad zu finden.


4. CPU-Branch-Prediction und die Kosten von conditional jumps

Jetzt wird es richtig spannend. Wir schauen uns an, warum Verzweigungen (if und match) moderne CPUs vor große Herausforderungen stellen und wie sich das auf die Performance deines Codes auswirkt.

Die CPU-Pipeline und das Problem mit der Zukunft

Moderne Prozessoren arbeiten extrem schnell, weil sie Befehle wie auf einem Fließband verarbeiten – der sogenannten CPU-Pipeline. Ein Befehl wird eingelesen (Fetch), dekodiert (Decode), ausgeführt (Execute) und das Ergebnis zurückgeschrieben (Writeback).

Damit das Fließband niemals stillsteht, wartet die CPU nicht, bis ein Befehl komplett fertig ist, bevor sie den nächsten einliest. Sie zieht bereits die nächsten 10 bis 20 Befehle auf das Band.

Das Problem entsteht bei bedingten Sprüngen (conditional jumps). Wenn die CPU auf einen Vergleich stößt, weiß sie erst am Ende der Ausführungsstufe, ob sie links oder rechts abbiegen muss. Zu diesem Zeitpunkt befinden sich aber schon etliche Befehle des vermuteten Pfads auf dem Fließband!

Die Analogie des voreiligen Postboten

Stell dir einen extrem schnellen Postboten vor, der eine Straße entlangrennt. Die Straße gabelt sich. Rechts geht es zum Schloss, links zum Bauernhof. Welchen Weg soll er nehmen?

Wenn er an der Gabelung stehenbleibt, um auf die Karte zu schauen, verliert er wertvolle Sekunden. Also rät er! Er erinnert sich, dass er die letzten fünf Male zum Schloss musste, also rennt er einfach spekulativ nach rechts.

  • Treffer! (Branch Prediction Success): Er kommt direkt am Schloss an. Er hat keine Sekunde verloren.
  • Fehlschlag! (Branch Misprediction): Auf halbem Weg merkt er, dass der Brief für den Bauernhof war. Er muss abbremsen, den gesamten Weg zurück zur Gabelung rennen und den linken Pfad neu starten.

Für die CPU bedeutet ein solcher Fehlschlag (ein sogenannter Pipeline Flush), dass sie alle spekulativ eingelesenen Befehle wegschmeißen und die Pipeline komplett neu befüllen muss. Das kostet auf modernen CPUs etwa 10 bis 20 Taktzyklen. Für eine CPU, die Milliarden Operationen pro Sekunde ausführt, ist das eine Ewigkeit!

Das berühmte Rätsel der sortierten Daten

Um diesen Effekt zu verdeutlichen, schauen wir uns ein klassisches Experiment an. Wir haben eine Schleife, die Werte aus einem Vektor aufsummiert, aber nur, wenn sie größer als ein bestimmter Schwellenwert sind.

use std::time::Instant;
use rand::Rng;

fn main() {
    let mut rng = rand::thread_rng();
    
    // Wir erzeugen einen Vektor mit 32.768 Zufallszahlen zwischen 0 und 255
    let mut daten: Vec<i32> = (0..32768).map(|_| rng.gen_range(0..256)).collect();

    // --- TEST 1: Unsortierte Daten ---
    let start_unsortiert = Instant::now();
    let mut summe_unsortiert: i64 = 0;
    for &x in &daten {
        if x >= 128 { // Hier ist unsere Verzweigung!
            summe_unsortiert += x as i64;
        }
    }
    let dauer_unsortiert = start_unsortiert.elapsed();

    // Jetzt sortieren wir die Daten!
    daten.sort();

    // --- TEST 2: Sortierte Daten ---
    let start_sortiert = Instant::now();
    let mut summe_sortiert: i64 = 0;
    for &x in &daten {
        if x >= 128 { // Dieselbe Verzweigung wie oben!
            summe_sortiert += x as i64;
        }
    }
    let dauer_sortiert = start_sortiert.elapsed();

    println!("Unsortiert: {:?}", dauer_unsortiert);
    println!("Sortiert:   {:?}", dauer_sortiert);
    assert_eq!(summe_unsortiert, summe_sortiert);
}

Das überraschende Ergebnis:

Obwohl in beiden Schleifen exakt dieselben Berechnungen durchgeführt werden und das mathematische Arbeitsvolumen identisch ist, läuft der Test mit den sortierten Daten oft um ein Vielfaches (Faktor 2 bis 3) schneller!

Warum ist das so?

  • Bei den unsortierten Daten sind die Werte völlig zufällig verteilt. Die Bedingung x >= 128 ist mal wahr, mal falsch. Der Branch Predictor der CPU hat keine Chance, ein Muster zu erkennen. Er rät wie beim Münzwurf. Die CPU leidet ständig unter Fehlvorhersagen und muss die Pipeline entleeren.
  • Bei den sortierten Daten kommen zuerst alle Werte unter 128 (Bedingung ist dauerhaft falsch). Der Branch Predictor stellt sich schnell darauf ein. Nach der Hälfte des Vektors kommen nur noch Werte über 128 (Bedingung ist dauerhaft wahr). Auch darauf stellt sich der Predictor ein. Die Trefferquote liegt bei nahezu 100%. Die CPU-Pipeline läuft unter Volldampf!

Wie Rust dir hilft: Branchless Programming

Gute Compiler (und LLVM ist einer der besten) versuchen, solche Leistungseinbußen zu verhindern, indem sie bedingte Sprünge vermeiden, wo es nur geht. Sie nutzen stattdessen sogenannte Branchless (verzweigungsfreie) CPU-Instruktionen.

Ein hervorragendes Beispiel auf x86_64-CPUs ist der Befehl cmov (Conditional Move).

Anstatt Code zu erzeugen wie: “Wenn der Wert größer ist, springe zu Codeblock A, andernfalls zu B”, übersetzt der Compiler die Logik in: “Berechne beide Pfade (oder lade beide Werte) und nutze am Ende den cmov-Befehl, um das Ergebnis basierend auf dem CPU-Statusregister im Zielregister zu überschreiben.”

Da cmov kein Sprungbefehl ist, gibt es auch keine Branch Misprediction! Die CPU-Pipeline läuft stur und stabil weiter.

Wenn du also in Rust Code schreibst wie:

#![allow(unused)]
fn main() {
let x = if bedingung { a } else { b };
}

wird das vom Compiler sehr häufig in einen einzigen cmov-Befehl übersetzt. Rusts ausdrucksbasierte Natur macht es dem Optimierer besonders leicht, solche Muster zu erkennen und in hocheffizienten, branchless Maschinencode zu gießen.


5. Die Kompilierung von Schleifen (loop, while, for) auf Maschinenebene

Auf Hardware-Ebene kennt die CPU keine Konzepte wie for, while oder loop. Sie kennt lediglich Befehlszähler (Instruction Pointers) und Sprungbefehle.

Der Rust-Compiler übersetzt deine strukturierten Schleifen in einfache Blöcke mit bedingten und unbedingten Sprüngen:

1. Die loop-Kompilierung: Der unbedingte Sprung

Da loop eine Endlosschleife ist, wird sie in Assembler über einen einfachen, unbedingten Sprung (JMP) abgebildet.

#![allow(unused)]
fn main() {
loop {
    mach_etwas();
}
}

Kompiliert zu:

.Lschleifen_start:
    call mach_etwas
    jmp .Lschleifen_start  ; Unbedingter Sprung zurück zum Start

Wenn du ein break mit einem Wert nutzt (z. B. break 42;), legt der Compiler den Wert 42 in das Zielregister (z. B. eax) und springt über einen JMP-Befehl direkt hinter die Schleife.

2. Die while-Kompilierung: Die bedingte Schleife

Eine while-Schleife muss vor jedem Durchlauf prüfen, ob sie fortgesetzt werden soll.

#![allow(unused)]
fn main() {
while x < 10 {
    x += 1;
}
}

Wird auf Assembler-Ebene meist in eine sogenannte Loop-Header-Kompilierung oder Loop-Inversion übersetzt:

    jmp .Lpruefung
.Lschleifen_koerper:
    add edi, 1              ; x += 1 (edi hält x)
.Lpruefung:
    cmp edi, 10             ; Vergleiche x mit 10
    jl .Lschleifen_koerper  ; Jump if Less: Wenn x < 10, springe zum Körper

Loop Inversion: Der Compiler verlagert die Prüfung an das Ende der Schleife und springt vor dem ersten Durchlauf dorthin. Das spart im Schleifenkörper einen unbedingten Sprungbefehl ein, was das Pipelining der CPU beschleunigt!

3. Die for-Schleife und Iteratoren

In Rust ist eine for-Schleife syntaktischer Zucker für eine Schleife über einen Iterator. Wenn du for x in 0..5 schreibst, erzeugt der Compiler im Hintergrund eine Struktur, die den aktuellen Zähler speichert, inkrementiert und prüft. Da der Compiler diese Abstraktionen dank Inlining und Register-Optimierung auflöst, wird eine for-Schleife im finalen Maschinencode exakt genauso schnell übersetzt wie eine manuelle while-Zählschleife:

    xor eax, eax            ; Setze Zähler (eax) auf 0
.Lschleife:
    ; ... Schleifenkörper ...
    add eax, 1              ; Inkrementiere Zähler
    cmp eax, 5              ; Vergleiche mit 5
    jne .Lschleife          ; Jump if Not Equal: Wenn Zähler != 5, weiter

6. Operatoren und Zuweisungen auf System- und Hardwareebene

Wie reagiert die Hardware auf Operatoren und Zuweisungen?

1. Zuweisung von Place Expressions

Wenn Sie einer Variablen einen Wert zuweisen, wird dies auf Systemebene in Speicher- oder Registerkopien übersetzt.

  • Handelt es sich um eine lokale Variable, die in einem CPU-Register liegt, ist die Zuweisung ein einfacher Registertransfer: MOV EAX, EBX.
  • Liegt die Variable im RAM (z. B. auf dem Stack), schreibt die CPU den Wert direkt an das entsprechende Stack-Offset: MOV [RSP + 8], EAX.

2. Arithmetische Operatoren und CPU-Befehle

Die grundlegenden Operatoren entsprechen direkten Rechenbefehlen des Prozessors:

  • + kompiliert zu ADD (Addition)
  • - kompiliert zu SUB (Subtraktion)
  • * kompiliert zu IMUL (Multiplikation)
  • / und % kompilieren zu IDIV (Ganzzahldivision). Auf x86-CPUs legt der IDIV-Befehl den Quotienten (das Ergebnis von /) im Register eax und den Rest (das Ergebnis von %) im Register edx ab. Rust erhält also beide Werte durch eine einzige CPU-Operation!

3. Überlauf-Prüfung auf Hardwareebene (Overflows)

Wenn Sie zwei 8-Bit-Zahlen addieren (200u8 + 100u8 = 300), passt das Ergebnis nicht mehr in ein 8-Bit-Register (Maximum 255).

  • Auf Hardware-Ebene: Die CPU führt die Addition aus. Da der Wert überläuft, setzt das Rechenwerk (ALU) das Overflow-Flag (OF) im CPU-Statusregister (FLAGS).
  • Im Debug-Modus: Der Rust-Compiler fügt nach jeder mathematischen Operation einen bedingten Sprungbefehl ein, der das Overflow-Flag prüft:
    add al, bl
    jo .Loverflow_panic    ; Jump on Overflow: Wenn das OF gesetzt ist, stürze ab!
    
  • Im Release-Modus: Der Compiler lässt den jo-Befehl weg. Die CPU addiert die Zahlen, und der Wert läuft laut Zweierkomplement geräuschlos über (aus 256 wird 0). Dies spart pro Rechenoperation einen Sprungbefehl und ermöglicht der CPU-Pipeline maximale Geschwindigkeit.

7. Fazit

Wenn du das nächste Mal einen Codeblock { ... } schreibst oder ein komplexes match entwirfst, denke daran, was im Hintergrund geschieht:

  • Deine Blöcke werden vom Compiler analysiert und dank SSA direkt in Registern gehalten, anstatt unnötig auf dem Stack herumzudocken.
  • Rvalues sind flüchtige Registerbewohner ohne feste Adresse, während Lvalues feste Postfächer im Arbeitsspeicher sind.
  • match ist kein einfaches “if-else-Monstrum”, sondern wird über hochoptimierte Sprungtabellen oder binäre Suchbäume in rasante Hardware-Sprünge übersetzt.
  • Die CPU versucht ständig, deine Entscheidungen vorherzusehen. Durch cleveren Code und Compiler-Optimierungen wie cmov bleibt die CPU-Pipeline optimal gefüllt.

Mit diesem hardwarenahen Verständnis bist du bestens gerüstet, um Code zu schreiben, der nicht nur sicher ist, sondern auch die volle Power moderner CPUs entfesselt!