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 10 - Hardware-Sicht: Strukturen unter der Lupe von CPU und RAM

Willkommen im Maschinenraum! Nachdem wir uns im Hauptkapitel damit beschäftigt haben, wie wir Daten logisch in Strukturen (Structs) kapseln und mit Methoden versehen, werfen wir nun den Blaumann über und steigen hinab in die physikalische Reality.

Für den Compiler ist eine Struktur nämlich kein schickes Konzept zur Kapselung, sondern schlicht ein Rezept dafür, wie eine Reihe von Variablen hintereinander im Arbeitsspeicher (RAM) angeordnet werden soll. Wie genau dieses Rezept in Bytes übersetzt wird, hat drastische Auswirkungen auf den Speicherbedarf und die Ausführungsgeschwindigkeit deines Programms.

In diesem Abschnitt klären wir die Fragen, die Systemprogrammierer nachts wachhalten:

  • Wie liegen die Felder einer Struktur tatsächlich im RAM?
  • Warum verschwendet der Compiler absichtlich Speicherplatz mit Füllbytes (Padding)?
  • Wie spart uns Rust durch Field Reordering automatisch bares Geld (in Form von RAM)?
  • Wie zwingen wir Rust mit #[repr(C)] oder #[repr(packed)] zu einem bestimmten Speicherlayout?
  • Und warum verbrauchen manche Strukturen auf Prozessorebene exakt null Bytes?

1. Das Speicherlayout: Wie liegen Felder im RAM?

Wenn wir eine klassische Struktur definieren, könnte man naiv annehmen, dass die Felder einfach wie Perlen auf einer Schnur direkt hintereinander im Speicher abgelegt werden. Das stimmt – allerdings mit einer Einschränkung, die durch die physikalische Architektur moderner Prozessoren bedingt ist.

Die Analogie: Das Logistikzentrum und die Ladezonen

Stell dir ein riesiges Logistikzentrum vor. Die Ladebuchten für LKWs sind genau nummeriert, und der Gabelstapler kann Waren am effizientesten bewegen, wenn sie auf standardisierten Paletten liegen, die genau an den Rastergrenzen (z. B. alle 4 oder 8 Meter) ausgerichtet sind.

Wenn du nun eine Kiste hast, die 8 Meter lang ist, kann der Gabelstapler sie mit einem einzigen Hub aufladen, wenn sie exakt an einer 8-Meter-Markierung (z. B. bei Meter 0, 8, 16, 24) beginnt. Liegt die Kiste aber schief – sagen wir, sie beginnt bei Meter 3 und geht bis Meter 11 –, ragt sie über die Rastergrenzen hinaus. Der Gabelstaplerfahrer muss nun zweimal ansetzen: Einmal, um den Teil im ersten Rasterabschnitt anzuheben, und ein zweites Mal für den Rest im zweiten Abschnitt. Das kostet Zeit und nervt den Fahrer gewaltig.

Genau so arbeitet eine CPU! Sie liest Daten nicht byteweise aus dem RAM, sondern in sogenannten Wortbreiten (Word Size) – bei modernen 64-Bit-CPUs sind das meist Blöcke von 8 Bytes (64 Bit).

  • Ein ausgerichteter Speicherzugriff (aligned access) bedeutet, dass ein Wert von der Größe $N$ Bytes an einer Speicheradresse liegt, die ohne Rest durch $N$ teilbar ist. Ein 8-Byte-Pointer muss also an einer Adresse liegen, die durch 8 teilbar ist (z. B. 0x1000, 0x1008).
  • Ein nicht ausgerichteter Speicherzugriff (unaligned access) zwingt die CPU, zwei Speicherzyklen durchzuführen, um die Daten zusammenzusuchen. Auf manchen eingebetteten Systemen (z. B. älteren ARM-Prozessoren) führt ein unaligned access sogar zu einem sofortigen Programmabsturz (Bus Error).

Data Alignment und Padding (Füllbytes)

Um der CPU diese Mehrarbeit zu ersparen, sorgt der Compiler beim Übersetzen des Codes für das sogenannte Data Alignment (Daten-Ausrichtung). Wenn ein Feld nicht an einer für seinen Typ passenden Adresse starten kann, fügt der Compiler ungenutzte Füllbytes – das sogenannte Padding – ein.

Schauen wir uns das an einem konkreten Beispiel an. Angenommen, wir haben folgende Struktur:

#![allow(unused)]
fn main() {
struct SensorDaten {
    aktiv: bool,      // Typische Größe: 1 Byte
    temperatur: f64,  // Typische Größe: 8 Bytes
    id: u16,          // Typische Größe: 2 Bytes
}
}

Würde der Compiler die Felder starr in dieser Reihenfolge ablegen, sähe das Speicherlayout (ohne Optimierung) so aus:

  1. aktiv belegt das erste Byte (Offset 0).
  2. Das nächste Feld temperatur ist ein f64 (8 Bytes). Es erfordert ein Alignment von 8 Bytes. Die nächste freie Adresse ist jedoch Offset 1. Da 1 nicht durch 8 teilbar ist, muss der Compiler 7 Bytes Padding einfügen! temperatur beginnt erst bei Offset 8 und geht bis Offset 15.
  3. Das Feld id ist ein u16 (2 Bytes). Es erfordert ein Alignment von 2 Bytes. Offset 16 ist durch 2 teilbar, also kann id direkt bei Offset 16 abgelegt werden (bis Offset 17).
  4. Nun ist die Struktur eigentlich zu Ende. Allerdings muss die Gesamtgröße einer Struktur immer ein Vielfaches ihres größten Alignments sein (damit Arrays dieser Struktur ebenfalls korrekt ausgerichtet sind). Das größte Alignment ist das von f64 (8 Bytes). Die aktuelle Größe ist 18 Bytes. Das nächste Vielfache von 8 ist 24. Der Compiler muss also am Ende noch einmal 6 Bytes Padding anhängen.

Ohne Optimierung würde diese Struktur also 24 Bytes im RAM belegen, obwohl die eigentlichen Nutzdaten nur 11 Bytes ($1 + 8 + 2$) groß sind! Über 50 % des Speichers wären nutzlose Luftlöcher.


2. Field Reordering: Der schlaue Packmeister Rust

Im Gegensatz zu Programmiersprachen wie C oder C++ macht der Rust-Compiler standardmäßig keine Versprechen darüber, in welcher Reihenfolge die Felder einer Struktur im Arbeitsspeicher landen. Rust behält sich das Recht vor, die Felder im Speicher komplett umzusortieren (Field Reordering), um Padding-Bytes zu minimieren.

Bleiben wir bei unserer Analogie des Umzugskartons: Ein sturer Packmeister (der C-Compiler) packt die Gegenstände starr in der Reihenfolge ein, wie sie auf dem Zettel stehen. Ein cleverer Packmeister (der Rust-Compiler) sortiert die Gegenstände um, damit sie kompakter in den Karton passen.

Wenn wir unsere Struktur SensorDaten in Rust kompilieren, analysiert der Compiler die Typen und ordnet sie im Speicher so an, dass das Alignment gewahrt bleibt, aber möglichst wenig Füllbytes entstehen. Er sortiert die Felder nach abfallendem Alignment:

  1. Zuerst kommt das größte Feld: temperatur (f64, 8 Bytes) bei Offset 0 bis 7.
  2. Danach folgt id (u16, 2 Bytes) bei Offset 8 und 9.
  3. Zuletzt kommt aktiv (bool, 1 Byte) bei Offset 10.
  4. Nun sind wir bei 11 Bytes. Das maximale Alignment der Struktur ist weiterhin 8 Bytes (wegen f64). Die nächste durch 8 teilbare Zahl ist 16. Der Compiler fügt am Ende also 5 Bytes Padding hinzu.

Durch dieses einfache Umsortieren schrumpft der Speicherbedarf von 24 Bytes auf 16 Bytes! Rust spart uns hier völlig automatisch 33 % des RAM-Bedarfs ein.

Lass uns das in einem echten, ausführlich kommentierten und kompilierbaren Rust-Programm überprüfen. Wir nutzen dafür die Funktionen std::mem::size_of und std::mem::align_of aus der Standardbibliothek.

use std::mem::{align_of, size_of};

// Wir definieren unsere Struktur.
// Der Rust-Compiler wird die Felder im Speicher automatisch umsortieren.
struct SensorDaten {
    aktiv: bool,      // 1 Byte, Alignment 1
    temperatur: f64,  // 8 Bytes, Alignment 8
    id: u16,          // 2 Bytes, Alignment 2
}

fn main() {
    // Da wir spitze Klammern in Fließtext vermeiden wollen, nutzen wir den
    // Turbofisch-Operator ::<T> beim Aufruf der mem-Funktionen.
    let groese = size_of::<SensorDaten>();
    let ausrichtung = align_of::<SensorDaten>();

    println!("--- SensorDaten Layout-Analyse ---");
    println!("Gesamtgröße im RAM:    {} Bytes", groese);
    println!("Erforderliches Alignment: {} Bytes", ausrichtung);

    // Wir können auch die Größe der einzelnen Felder ausgeben
    println!("Nutzdaten-Größe:       {} Bytes (1 bool + 8 f64 + 2 u16)", 
             size_of::<bool>() + size_of::<f64>() + size_of::<u16>());
    println!("Verschwendeter Platz:  {} Bytes (Padding)", 
             groese - (size_of::<bool>() + size_of::<f64>() + size_of::<u16>()));
}

Wenn du dieses Programm ausführst, siehst du auf der Konsole:

--- SensorDaten Layout-Analyse ---
Gesamtgröße im RAM:    16 Bytes
Erforderliches Alignment: 8 Bytes
Nutzdaten-Größe:       11 Bytes (1 bool + 8 f64 + 2 u16)
Verschwendeter Platz:  5 Bytes (Padding)

3. Die Attribute #[repr(C)] und #[repr(packed)]

Obwohl die automatische Optimierung von Rust fantastisch ist, gibt es Situationen, in denen wir die volle Kontrolle über das Speicherlayout benötigen. Das ist vor allem dann der Fall, wenn:

  1. Wir über das Foreign Function Interface (FFI) mit C-Bibliotheken kommunizieren wollen. C-Bibliotheken erwarten, dass die Felder exakt in der Reihenfolge liegen, in der sie deklariert wurden.
  2. Wir Daten direkt über das Netzwerk senden oder aus einer Datei lesen wollen (Binärprotokolle), bei denen jedes Byte eine vordefinierte Bedeutung hat.

Das Attribut #[repr(C)] (C-Kompatibilität)

Mit dem Attribut #[repr(C)] zwingst du den Rust-Compiler, das standardisierte Layout der Sprache C zu verwenden. Das bedeutet:

  • Die Felder werden exakt in der Reihenfolge deklariert, in der sie im Quellcode stehen.
  • Es findet kein Field Reordering statt.
  • Padding-Bytes werden eingefügt, um die Alignment-Regeln der Zielarchitektur einzuhalten.

Lass uns eine Struktur mit #[repr(C)] ausstatten und den Unterschied sehen:

use std::mem::size_of;

#[repr(C)]
struct SensorDatenC {
    aktiv: bool,      // 1 Byte
    // Hier entstehen 7 Bytes Padding!
    temperatur: f64,  // 8 Bytes
    id: u16,          // 2 Bytes
    // Hier entstehen 6 Bytes Padding am Ende!
}

fn main() {
    println!("Größe der repr(C)-Struktur: {} Bytes", size_of::<SensorDatenC>());
}

Ausgabe dieses Programms:

Größe der repr(C)-Struktur: 24 Bytes

Wie vorhergesagt, wächst die Struktur auf 24 Bytes an, da der Compiler die Felder nicht mehr umsortieren darf.

Das Attribut #[repr(packed)] (Kompressions-Modus)

Was aber, wenn wir extremen Speichermangel haben (z. B. auf einem winzigen Mikrocontroller) und uns das Alignment der CPU völlig egal ist? Wir wollen einfach absolut kein Padding haben.

Dafür gibt es das Attribut #[repr(packed)]. Es weist den Compiler an:

  1. Ignoriere alle Alignment-Regeln der Felder.
  2. Füge absolut keine Padding-Bytes ein.
  3. Die Ausrichtung (Alignment) der gesamten Struktur sinkt auf 1 Byte.
use std::mem::{align_of, size_of};

#[repr(packed)]
struct SensorDatenPacked {
    aktiv: bool,      // 1 Byte
    temperatur: f64,  // 8 Bytes
    id: u16,          // 2 Bytes
}

fn main() {
    println!("--- SensorDaten Packed Analyse ---");
    println!("Gesamtgröße: {} Bytes", size_of::<SensorDatenPacked>());
    println!("Alignment:   {} Byte", align_of::<SensorDatenPacked>());
}

Ausgabe:

--- SensorDaten Packed Analyse ---
Gesamtgröße: 11 Bytes
Alignment:   1 Byte

Die Struktur belegt nun exakt 11 Bytes – kein einziges Byte geht verloren.

Caution

Die Gefahren von #[repr(packed)]

Das Eliminieren von Padding hat einen hohen Preis. Da die Felder nun an unaligned Speicheradressen liegen können, muss die CPU bei jedem Zugriff tief in die Trickkiste greifen, was die Performance deines Programms spürbar verschlechtert.

Noch gefährlicher ist das Erzeugen von Referenzen auf unaligned Felder. Rust verbietet es standardmäßig, eine normale Referenz (z. B. &sensor.temperatur) auf ein unaligned Feld einer gepackten Struktur zu erstellen, da Referenzen in Rust immer korrekt ausgerichtet sein müssen. Versuchst du es dennoch, wirft dir der Compiler einen Fehler an den Kopf oder warnt dich eindringlich vor undefiniertem Verhalten (Undefined Behavior).


4. Der Speicherbedarf der drei Struct-Arten

Rust bietet uns drei verschiedene Arten von Strukturen an. Auf logischer Ebene erfüllen sie unterschiedliche Zwecke – aber wie sieht es auf der Ebene der Hardware aus?

1. Classic Structs und Tuple Structs

Für die Hardware macht es absolut keinen Unterschied, ob du eine klassische Struktur mit benannten Feldern (struct Point { x: i32, y: i32 }) oder eine Tupel-Struktur (struct Point(i32, i32)) verwendest. Beide werden identisch im RAM abgelegt. Die Feldnamen sind reine syntaktische Hilfen für uns Programmierer und werden vom Compiler komplett wegradiert. Der Speicherbedarf ist in beiden Fällen die Summe der Feldgrößen plus das nötige Padding.

2. Unit-like Structs: Die 0-Byte-Magie (Zero Sized Types - ZST)

Jetzt wird es richtig faszinierend. Was passiert, wenn wir eine Struktur ohne Felder definieren?

#![allow(unused)]
fn main() {
struct EinheitsTyp; // Ein Unit-like Struct
}

Logisch betrachtet besitzt diese Struktur keine Daten. Und auf Hardware-Ebene?

  • Ihr Speicherbedarf beträgt exakt 0 Bytes!
  • Sie wird in der Fachsprache als Zero Sized Type (ZST) bezeichnet.

Vielleicht fragst du dich jetzt: „Wozu soll eine Struktur gut sein, die überhaupt keine Daten speichern kann? Ist das nicht nutzlos?“ Keineswegs! Rust nutzt ZSTs für extrem elegante Compilezeit-Garantien:

  1. Typ-Marker: Du kannst sie verwenden, um Zustände im Typ-System abzubilden (z. B. im State Pattern). Der Compiler prüft zur Compilezeit, ob deine Zustandsübergänge korrekt sind, erzeugt im finalen Maschinencode aber keinen einzigen Byte-Zugriff.
  2. Träger von Funktionalität: Du kannst Methoden auf einem Unit-like Struct implementieren. Das ist nützlich für mathematische Hilfsfunktionen oder zustandslose Schnittstellen.
  3. Optimierte Kollektionen: Ein HashSet<T> in Rust ist unter der Haube einfach eine HashMap<T, ()>. Da der Unit-Typ () ebenfalls ein Zero Sized Type mit 0 Bytes Größe ist, belegt das Set keinen zusätzlichen Speicherplatz für die Werte – nur für die Schlüssel. Das ist maximale Effizienz ohne Overhead!

Der Compiler optimiert Instanzen von ZSTs komplett weg. Wenn du eine Variable von einem Unit-like Struct erstellst, wird dafür auf Prozessorebene kein Speicher reserviert, kein Stack-Pointer verschoben und kein Register belegt.


5. Vollständiges Hardware-Demoprogramm

Zum Abschluss dieses Ausflugs in den Maschinenraum lassen wir ein umfassendes Demo-Programm laufen, das all diese Aspekte auf deinem Bildschirm sichtbar macht. Du kannst diesen Code direkt in eine Datei kopieren und mit cargo run ausführen.

use std::mem::{align_of, size_of};

// 1. Ein klassisches, vom Rust-Compiler optimiertes Struct
struct Optimiert {
    a: u8,
    b: u64,
    c: u16,
}

// 2. Das gleiche Struct im C-kompatiblen Layout (kein Reordering)
#[repr(C)]
struct KompatibelC {
    a: u8,
    b: u64,
    c: u16,
}

// 3. Das gleiche Struct komplett komprimiert (kein Padding)
#[repr(packed)]
struct Gepackt {
    a: u8,
    b: u64,
    c: u16,
}

// 4. Ein Unit-like Struct (Zero Sized Type)
struct Leer;

fn main() {
    println!("==================================================");
    println!("       RUST STRUCT LAYOUT INSPECTOR (CPU/RAM)     ");
    println!("==================================================");
    println!();

    println!("--- 1. Rust Default (Field Reordering aktiv) ---");
    println!("Größe:      {:>2} Bytes (Erwartet: 16)", size_of::<Optimiert>());
    println!("Alignment:  {:>2} Bytes", align_of::<Optimiert>());
    println!();

    println!("--- 2. C-Kompatibel (#[repr(C)]) ---");
    println!("Größe:      {:>2} Bytes (Erwartet: 24)", size_of::<KompatibelC>());
    println!("Alignment:  {:>2} Bytes", align_of::<KompatibelC>());
    println!();

    println!("--- 3. Gepackt (#[repr(packed)]) ---");
    println!("Größe:      {:>2} Bytes (Erwartet: 11)", size_of::<Gepackt>());
    println!("Alignment:  {:>2} Bytes (Erwartet:  1)", align_of::<Gepackt>());
    println!();

    println!("--- 4. Unit-like Struct (Zero Sized Type) ---");
    println!("Größe:      {:>2} Bytes (Erwartet:  0)", size_of::<Leer>());
    println!("Alignment:  {:>2} Bytes (Erwartet:  1)", align_of::<Leer>());
    println!();

    println!("==================================================");
    println!("Erkenntnis: Rust schützt dich standardmäßig vor ");
    println!("unnötigem Speicherverbrauch, gibt dir aber die ");
    println!("Kontrolle zurück, wenn du sie wirklich brauchst!");
    println!("==================================================");
}

Mit diesem Wissen im Hinterkopf bist du bestens gerüstet, um Strukturen zu schreiben, die nicht nur logisch elegant, sondern auch auf Hardware-Ebene blitzschnell und speichereffizient sind. Viel Spaß beim Optimieren!