Kapitel 05 - Hardware-Sicht: Die Physik der Zeichenketten
Willkommen im Maschinenraum! Wenn du aus der C-, C++- oder Assembler-Ecke kommst, hast du dich wahrscheinlich schon gefragt: „Warum macht Rust es mir mit Zeichenketten so schwer? Warum gibt es String und &str? Kann ein String nicht einfach ein simples Null-terminiertes Byte-Array sein wie in C?“
Die Antwort lautet: Ja, könnte er. Aber dann hätten wir wieder die gleichen Sicherheitslücken, Pufferüberläufe und Performance-Fallen, die die IT-Welt seit Jahrzehnten plagen. Rust geht einen anderen Weg, der maximale Speichersicherheit bei gleichzeitig kompromissloser Hardware-Effizienz garantiert.
In diesem Abschnitt legen wir die Samthandschuhe beiseite. Wir schnappen uns das virtuelle Oszilloskop und schauen uns an, wie Zeichenketten physikalisch im Arbeitsspeicher (RAM) liegen, wie der Prozessor (CPU) sie verarbeitet und was unter der Haube passiert, wenn du Text manipulierst. Schnall dich an, wir gehen auf Byte-Ebene!
1. Das Speicherlayout von String im Detail
Ein dynamischer String ist in Rust ein sogenannter Smart Pointer (intelligenter Zeiger), der die Eigentumsrechte (Ownership) über einen auf dem Heap allokierten Speicherbereich besitzt.
1.1 Die Stack-Komponente (Der Zettel auf dem Schreibtisch)
Wenn du eine Variable vom Typ String deklarierst, reserviert Rust dafür auf dem Stack exakt 24 Bytes (auf einer modernen 64-Bit-Architektur). Diese 24 Bytes sind in drei exakt gleich große Felder von jeweils 8 Bytes (64 Bits) unterteilt:
- Pointer (
ptr): Eine 64-Bit-Speicheradresse, die auf den Anfang des Speicherbereichs im Heap zeigt, in dem die eigentlichen Textdaten liegen. - Capacity (
cap): Eine 64-Bit-Ganzzahl ohne Vorzeichen (usize), die angibt, wie viel Heap-Speicher (in Bytes) der Allocator für diesen String reserviert hat. - Length (
len): Eine 64-Bit-Ganzzahl ohne Vorzeichen (usize), die angibt, wie viele Bytes des reservierten Heap-Speichers aktuell tatsächlich mit gültigen UTF-8-Zeichen gefüllt sind.
Die Alltagsanalogie: Der Büro-Zettel und der Lagerraum
Note
Alltagsanalogie: Stell dir vor, du sitzt an deinem Schreibtisch (Stack). Auf deinem Schreibtisch liegt ein kleiner Notizzettel (die 24 Bytes des
String). Auf diesem Zettel stehen drei Informationen:
- Lagerplatz-Adresse: „Halle B, Regal 4“ (der Zeiger
ptr).- Kapazität: „Maximal 10 Kisten passen in dieses Regal“ (die Kapazität
cap).- Aktueller Bestand: „Es stehen dort gerade 4 Kisten“ (die Länge
len).Das Regal selbst steht weit entfernt im riesigen Zentrallager (dem Heap). Wenn du nun eine neue Kiste ins Regal stellst, musst du nicht deinen Schreibtisch vergrößern. Der Notizzettel bleibt exakt gleich groß. Du streichst lediglich den aktuellen Bestand „4“ durch und schreibst eine „5“ hin.
1.2 Die Heap-Komponente (Der Lagerplatz)
Auf dem Heap liegen die eigentlichen Textzeichen als kontinuierliche Folge von Bytes. Wichtig ist: Diese Bytes sind nicht Null-terminiert wie in C (wo ein \0-Byte das Ende markiert). Rust benötigt kein Null-Byte, da die genaue Länge (len) direkt im Stack-Zettel gespeichert ist. Das verhindert die berüchtigten „Buffer Overreads“, bei denen ein Programm über das Ende des Strings hinausliest, weil das Null-Byte überschrieben wurde.
Hier ist die visuelle Repräsentation des Speicherlayouts für let s = String::from("Rust");:
graph TD
subgraph Stack [Stack - Feste 24 Bytes]
direction LR
ptr["Pointer (ptr) <br> 8 Bytes <br> zeigt auf Heap"]
cap["Capacity (cap) <br> 8 Bytes <br> Wert: 4"]
len["Length (len) <br> 8 Bytes <br> Wert: 4"]
end
subgraph Heap [Heap - Dynamischer Speicher]
data["'R' (0x52) | 'u' (0x75) | 's' (0x73) | 't' (0x74)"]
end
ptr --> data
1.3 Speicher-Inspektion mit kompilierbarem Code
Lass uns die Theorie in der Praxis überprüfen! Wir schreiben ein kleines Programm, das die Größe der Stack-Daten misst und die genauen Adressen ausgibt.
// Dieser Code ist voll funktionsfähig und kann direkt ausgeführt werden.
use std::mem::size_of;
fn main() {
// Wir erstellen einen veränderlichen String auf dem Heap.
let s = String::from("Rust");
// 1. Wir messen die Größe der String-Struktur auf dem Stack.
// Da wir auf einer 64-Bit-Architektur arbeiten (8 Bytes pro Zeiger/usize),
// erwarten wir hier exakt 24 Bytes (3 * 8 Bytes).
println!("Größe des String-Objekts auf dem Stack: {} Bytes", size_of::<String>());
// 2. Wir lassen uns die Speicheradresse des Stack-Objekts anzeigen.
// Das ist der Ort, an dem unser 'Notizzettel' liegt.
println!("Speicheradresse auf dem Stack: {:p}", &s);
// 3. Wir lassen uns den Zeiger auf die echten Heap-Daten anzeigen.
// Die Methode .as_ptr() gibt uns den rohen Zeiger (Pointer) aus der Struktur.
println!("Speicheradresse der Daten auf dem Heap: {:p}", s.as_ptr());
// 4. Länge und Kapazität auslesen.
println!("Länge (len): {}", s.len());
println!("Kapazität (cap): {}", s.capacity());
}
Erklärung der Code-Zeilen:
- In Zeile 2 importieren wir
size_ofaus dem Modulstd::mem. Diese Funktion verrät uns, wie viel Speicher ein Typ zur Kompilierzeit auf dem Stack einnimmt. - In Zeile 6 erzeugen wir den String
"Rust". Auf dem Heap werden dafür 4 Bytes allokiert (da “Rust” aus 4 ASCII-Zeichen besteht, die in UTF-8 jeweils 1 Byte groß sind). - In Zeile 11 nutzen wir
size_of::<String>(), um die Stack-Größe zu ermitteln. Sie wird auf 64-Bit-Systemen immer24sein. - In Zeile 15 gibt uns
{:p}die Speicheradresse der Stack-Variablesselbst aus. - In Zeile 19 nutzen wir
s.as_ptr(). Das greift direkt auf das erste Feld (ptr) unserer Stack-Struktur zu und gibt die Adresse auf dem Heap aus. Wenn du die Ausgabe mit der Stack-Adresse vergleichst, wirst du sehen, dass die Heap-Adresse in einem völlig anderen Adressbereich liegt (oft viel weiter „oben“ oder „unten“ im virtuellen Adressraum).
2. Der Fat Pointer: &str (String-Slice)
Jetzt wird es spannend. Was ist ein &str? Oft wird er als „Referenz auf einen String“ bezeichnet, aber das greift zu kurz. Ein &str ist ein Fat Pointer (breiter Zeiger).
2.1 Warum ein nacktes str nicht existieren kann
In Rust ist str ein Typ unbestimmter Größe (Dynamically Sized Type, DST). Da Text beliebig lang sein kann, kann der Compiler zur Kompilierzeit nicht wissen, wie viele Bytes er auf dem Stack für ein nacktes str reservieren müsste. Ein Typ, dessen Größe zur Kompilierzeit unbekannt ist, darf in Rust nicht direkt auf dem Stack liegen.
Die Lösung: Wir nutzen immer eine Referenz darauf, also &str. Und diese Referenz ist kein normaler, einfacher Zeiger (der nur 8 Bytes groß wäre), sondern ein Fat Pointer von exakt 16 Bytes (auf 64-Bit-Systemen).
2.2 Die Anatomie des Fat Pointers
Die 16 Bytes von &str teilen sich in zwei Felder auf:
- Pointer (
ptr) (8 Bytes): Zeigt auf die Startadresse des Textes im Speicher (das kann im Heap einesStringsein, oder im statischen Speicher, wie wir gleich sehen werden). - Length (
len) (8 Bytes): Gibt an, wie viele Bytes ab dieser Startadresse zu diesem Slice gehören.
Beachte: Ein &str besitzt keine Kapazität (cap). Warum? Weil ein Slice nur eine Sicht (View) auf bereits existierenden Speicher ist. Er darf diesen Speicher nicht vergrößern oder freigeben. Er ist ein reiner Beobachter.
Die Alltagsanalogie: Der Lieferschein
Note
Alltagsanalogie: Stell dir vor, du hast keinen eigenen Lagerraum gemietet. Stattdessen gibt dir dein Kollege einen Lieferschein (den Fat Pointer
&str). Auf diesem Zettel steht:
- „Gehe zu Halle B, Regal 4, Kiste Nr. 2“ (der Zeiger
ptr).- „Du darfst von dort an genau 3 Kisten inspizieren“ (die Länge
len).Du darfst keine neuen Kisten anbauen und du darfst das Regal nicht wegwerfen. Du hast nur eine zeitlich begrenzte Sicht auf einen Ausschnitt des Regals.
Hier ist die visuelle Darstellung eines Slices let slice: &str = &s[1..3];, der aus unserem vorherigen String s (“Rust”) erzeugt wurde:
graph TD
subgraph String_s [String s - Stack]
s_ptr["ptr"]
s_cap["cap: 4"]
s_len["len: 4"]
end
subgraph Slice_slice [Slice &str - Stack]
slice_ptr["ptr"]
slice_len["len: 2"]
end
subgraph Heap [Heap]
char_R["'R'"]
char_u["'u'"]
char_s["'s'"]
char_t["'t'"]
end
s_ptr --> char_R
slice_ptr --> char_u
Wie du siehst, zeigt slice_ptr direkt auf das Zeichen 'u' (das zweite Byte im Heap) und hat eine Länge von 2. Damit repräsentiert der Slice den Text "us".
2.3 Speicher-Inspektion des Fat Pointers
Schreiben wir auch hierfür ein verständliches Testprogramm:
use std::mem::size_of;
fn main() {
let s = String::from("Rust-Lehrbuch");
// Wir erzeugen einen Slice, der das Wort "Lehrbuch" ausschneidet.
// "Rust-Lehrbuch" -> "Rust-" sind 5 Bytes (Indizes 0 bis 4).
// Ab Index 5 ("L") bis Index 13 ("h") liegt "Lehrbuch" (8 Bytes).
let slice: &str = &s[5..13];
println!("Größe des &str auf dem Stack: {} Bytes", size_of::<&str>());
// Die Startadresse des ursprünglichen Strings auf dem Heap:
println!("Startadresse des Strings s: {:p}", s.as_ptr());
// Die Startadresse des Slices:
// Da "Lehrbuch" bei Byte-Index 5 beginnt, sollte diese Adresse
// exakt um 5 Bytes nach der Adresse von s liegen!
println!("Startadresse des Slices slice: {:p}", slice.as_ptr());
println!("Länge des Slices: {}", slice.len());
}
Erklärung der Ausgabe:
Wenn du dieses Programm ausführst, wirst du sehen, dass die Adresse des Slices im Hexadezimalsystem exakt 5 höher ist als die des ursprünglichen Strings. Aus 0x55d...0a0 wird 0x55d...0a5. Der Fat Pointer verweist also direkt mitten in die Heap-Allokation des String!
3. String-Literale im statischen Programmspeicher (.rodata)
Was passiert eigentlich, wenn wir im Code schreiben: let literal = "Hallo Welt";? Wo kommt dieser Text her? Er liegt weder auf dem Stack (außer dem Fat Pointer selbst), noch wird er zur Laufzeit dynamisch auf dem Heap allokiert.
3.1 Das .rodata-Segment
Wenn der Rust-Compiler dein Programm in eine ausführbare Datei übersetzt, sammelt er alle im Quellcode hartkodierten String-Literale und packt sie gesammelt in ein spezielles Segment der Binärdatei: das .rodata-Segment (Read-Only Data, schreibgeschützte Daten).
Wenn dein Betriebssystem das Programm startet, lädt es diese Binärdatei in den Arbeitsspeicher. Der Bereich, in dem das .rodata-Segment landet, wird vom Betriebssystem und der MMU (Memory Management Unit) der CPU als schreibgeschützt markiert.
3.2 Die Lebensdauer 'static
Ein String-Literal hat in Rust den Typ &'static str. Das Lebensdauer-Annotation 'static ist das Versprechen an den Compiler, dass diese Daten für die gesamte Laufzeit des Programms im Speicher existieren. Sie können niemals ungültig werden, weil sie fest im Binärcode eingebrannt sind.
Caution
Weil das
.rodata-Segment schreibgeschützt ist, würde jeder Versuch, diese Daten direkt im Speicher zu verändern, zu einem sofortigen Programmabsturz durch das Betriebssystem führen (ein klassischer Segmentation Fault). Rust verhindert dies elegant, indem der Typ&strgenerell keine Schreibzugriffe erlaubt.
Die Alltagsanalogie: Die Inschrift im Museum
Note
Alltagsanalogie: Ein String-Literal ist wie eine in Stein gemeißelte Inschrift an der Wand eines historischen Museums. Jeder Besucher kann sie lesen (schreibgeschützt). Die Inschrift ist immer da, solange das Museum existiert (Lebensdauer
'static). Du kannst sie nicht mitnehmen oder verändern. Wenn du den Text ändern willst, musst du ihn auf einen Zettel abschreiben und dort bearbeiten (was dem Kopieren in einenStringauf dem Heap entspricht).
4. UTF-8 unter der Haube: Die CPU-Perspektive
Rust-Strings sind standardmäßig immer als UTF-8 kodiert. Das ist ein fantastischer Standard für Internationalisierung, bringt aber aus Sicht der CPU einige drastische Konsequenzen mit sich.
4.1 Variable Byte-Breite
UTF-8 ist eine Unicode-Kodierung mit variabler Breite. Das bedeutet, dass ein einzelnes logisches Zeichen (char) im Speicher zwischen 1 und 4 Bytes groß sein kann:
- Englische Standardbuchstaben (ASCII): 1 Byte (z. B.
'a'->0x61) - Deutsche Umlaute und Akzente: 2 Bytes (z. B.
'ä'->0xC3 0xA4) - Asiatische Schriftzeichen und Symbole: 3 Bytes (z. B.
'€'->0xE2 0x82 0xAC) - Emojis: 4 Bytes (z. B.
'🦀'->0xF0 0x9F 0xA6 0x80)
4.2 Warum die O(1)-Indexierung s[0] verboten ist
In Sprachen wie C++ oder Java kannst du oft über s[0] auf das erste Zeichen zugreifen. Viele Programmierer nehmen an, dass das eine extrem billige Operation ist, die in konstanter Zeit $\mathcal{O}(1)$ abläuft. Das ist aber nur der Fall, wenn jedes Zeichen exakt gleich groß ist!
Wenn ein String Zeichen unterschiedlicher Breite enthält, kann die CPU nicht im Voraus wissen, an welcher Byte-Adresse das n-te Zeichen beginnt.
Lass uns ein Beispiel anschauen: let s = String::from("äpfel");
'ä'benötigt 2 Bytes (0xC3 0xA4).'p'benötigt 1 Byte (0x70).- Wenn du das Zeichen bei Index 1 haben willst, ist das
'p'. Aber im Speicher liegt es an Byte-Offset 2, nicht an Offset 1! An Offset 1 liegt das zweite Byte des Umlauts'ä', was für sich genommen ungültiger Zeichensalat is.
Um das n-te Zeichen zu ermitteln, müsste Rust den String von Anfang an Byte für Byte durchlaufen und die UTF-8-Längenindikatoren analysieren. Das wäre eine Schleife mit einer Laufzeit von $\mathcal{O}(N)$ (linear zur Länge des Strings).
Da Rust eine Systemsprache ist und dem Prinzip „Keine versteckten Performance-Kosten“ folgt, verbietet der Compiler den direkten Indexzugriff mit Zahlen.
4.3 Der Compilerfehler im Rampenlicht
Versuchen wir trotzdem, einen String zu indexieren, um zu sehen, wie uns der Compiler sanft (aber bestimmt) zurückweist:
fn main() {
let s = String::from("Rust");
// Wir versuchen, das erste Zeichen über den Index 0 zu holen.
let c = s[0];
}
Wenn wir versuchen, diesen Code zu kompilieren, bricht der Compiler mit folgendem Fehler ab:
error[E0277]: the type `String` cannot be indexed by `{integer}`
--> src/main.rs:4:13
|
4 | let c = s[0];
| ^^^^ `String` cannot be indexed by `{integer}`
|
= help: the trait `Index<{integer}>` is not implemented for `String`
Was sagt uns dieser Fehler?
Der Compiler teilt uns mit, dass der Trait Index für den Typ String mit einer Ganzzahl als Parameter nicht implementiert ist. Das ist eine bewusste Designentscheidung der Entwickler der Standardbibliothek, um uns vor Performance-Fallen zu schützen.
Wie reparieren wir das?
Wenn wir das erste Zeichen wollen, müssen wir explizit über den Iterator .chars() gehen:
fn main() {
let s = String::from("Rust");
// .chars() gibt uns einen Iterator über die echten Unicode-Zeichen (char).
// .next() holt das erste Element (vom Typ Option<char>).
if let Some(first_char) = s.chars().next() {
println!("Das erste Zeichen ist: {}", first_char);
}
}
4.4 .chars() vs. .bytes() auf Prozessorebene
Wie verhalten sich diese beiden Methoden im Inneren des Prozessors? Hier zeigt sich ein gigantischer Unterschied in der CPU-Auslastung.
.bytes(): Der Turbolader
Wenn du .bytes() aufrufst, gibt Rust einen Iterator zurück, der stur Byte für Byte durch den Speicher wandert.
- CPU-Ablauf: Die CPU muss lediglich den Wert des Zeigers um 1 erhöhen (
ptr = ptr + 1) und das Byte an der Adresse auslesen. - Hardware-Sicht: Das ist eine simple Speicherleseoperation ohne jegliches Branching (Verzweigungen). Die CPU-Pipeline kann perfekt vorausarbeiten (Instruction Prefetching) und der Code läuft mit maximaler Geschwindigkeit.
.chars(): Der Schwerstarbeiter
Wenn du .chars() aufrufst, muss Rust den UTF-8-Datenstrom dekodieren.
- CPU-Ablauf: Der Iterator liest das erste Byte. Dann prüft er die Bitmaske dieses Bytes, um herauszufinden, wie viele Folgebytes gelesen werden müssen:
- Fängt das Byte mit Bit
0an? (ASCII, 1 Byte) - Fängt es mit
110an? (2 Bytes) - Fängt es mit
1110an? (3 Bytes) - Fängt es mit
11110an? (4 Bytes)
- Fängt das Byte mit Bit
- Hardware-Sicht: Auf Assembly-Ebene bedeutet das zahlreiche Bitverschiebungen (
SHR), logische Und-Verknüpfungen (AND) und vor allem bedingte Sprünge (CMPundJNZ). Wenn du einen Text mit vielen verschiedenen Zeichenbreiten verarbeitest, kann die Sprungvorhersage (Branch Prediction) der CPU fehlschlagen (Branch Misprediction), was die CPU-Pipeline leert und den Prozessor spürbar ausbremst.
5. Der Allocator und das dynamische Wachstum von String
Was passiert auf Betriebssystem- und Hardwareebene, wenn wir einen String wachsen lassen, zum Beispiel mit s.push_str("mehr text")?
5.1 Das Speicherwachstum (Reallokation)
Wenn du einen neuen String erstellst, reserviert der Speicher-Allocator (z. B. jemalloc oder der System-Allocator) einen bestimmten Speicherblock auf dem Heap (die Kapazität cap). Solange du Zeichen hinzufügst und die Länge len die Kapazität cap nicht überschreitet, ist alles wunderbar: Rust schreibt die Daten einfach in die bereits reservierten Bytes und erhöht len auf dem Stack.
Sobald aber len + neue_bytes > cap eintritt, ist das Regal voll. Da der Speicher direkt hinter unserem Heap-Block von anderen Variablen belegt sein könnte, können wir unseren Speicherbereich nicht einfach nach rechts vergrößern.
Nun läuft folgender Prozess ab:
- Neue Kapazität berechnen: Rust verdoppelt in der Regel die bisherige Kapazität (Wachstumsfaktor 2). Wenn die Kapazität vorher 4 Bytes war, wird nach einer neuen Allokation für 8 Bytes angefragt.
- Speicher anfordern: Der Allocator wird aufgerufen, um einen neuen freien Speicherblock auf dem Heap mit der neuen Größe zu finden.
- Daten kopieren: Die bisherigen Daten werden byteweise vom alten Speicherort an den neuen Speicherort kopiert (
memcpyauf CPU-Ebene). - Alten Speicher freigeben: Der alte Speicherblock wird dem Allocator wieder als frei gemeldet.
- Stack-Informationen aktualisieren: In der Stack-Struktur des
Stringwird derptrauf die neue Heap-Adresse umgebogen undcapauf den neuen Wert gesetzt.
Die Alltagsanalogie: Der Umzug
Note
Alltagsanalogie: Stell dir vor, du wohnst in einer WG mit 4 Zimmern (Kapazität 4) und alle Zimmer sind belegt (Länge 4). Jetzt will ein 5. Mitbewohner einziehen. Du kannst nicht einfach ein Zimmer an das Haus anbauen, da das Grundstück daneben dem Nachbarn gehört. Also musst du eine neue, größere Wohnung mit 8 Zimmern suchen. Du packst alle deine Sachen in Kartons, fährst mit dem Möbelwagen zur neuen Wohnung, lädst alles aus und der 5. Mitbewohner zieht mit ein. Die alte Wohnung gibst du an den Vermieter zurück.
Dieser Umzug (Reallokation) ist extrem teuer! Er erfordert Betriebssystem-Aufrufe (Syscalls) und blockiert die CPU mit Kopierarbeiten. Zudem führt es zu Cache-Misses, da die Daten plötzlich an einer ganz anderen Adresse liegen.
5.2 Das Wachstum im Code beobachten
Hier ist ein praktisches Programm, das zeigt, wie sich die Heap-Adresse und die Kapazität ändern, wenn wir Zeichen anhängen:
fn main() {
let mut s = String::new();
println!("Start: Kapazität = {}, Adresse = {:p}", s.capacity(), s.as_ptr());
// Wir fügen in einer Schleife 20 Zeichen einzeln hinzu
for i in 1..=20 {
s.push('A');
println!(
"Nach {} Zeichen: Länge = {}, Kapazität = {}, Adresse = {:p}",
i,
s.len(),
s.capacity(),
s.as_ptr()
);
}
}
Was wir in der Ausgabe beobachten:
- Zu Beginn ist die Kapazität
0und der Zeiger zeigt ins Nirgendwo (ein spezieller Sentinel-Zeiger0x1oder0x0, da noch kein Heap-Speicher allokiert wurde). - Beim ersten
pushwird Speicher allokiert (z. B. Kapazität 4 oder 8, je nach OS-Implementierung). - Sobald die Anzahl der Zeichen die Kapazität übersteigt, springt die Kapazität auf das Doppelte an (z. B. von 8 auf 16).
- Achte auf die ausgegebene Speicheradresse: Bei fast jeder Kapazitätsänderung ändert sich die Hexadezimaladresse komplett! Das ist der Beweis, dass der String auf dem Heap physisch umgezogen ist.
5.3 Optimierung: with_capacity
Wenn du im Voraus weißt, wie groß dein String ungefähr wird, kannst du die teuren Reallokationen komplett vermeiden, indem du den Speicher direkt im Voraus reservierst:
fn main() {
// Wir reservieren sofort Platz für 20 Bytes auf dem Heap.
let mut s = String::with_capacity(20);
let start_ptr = s.as_ptr();
println!("Start-Adresse: {:p}", start_ptr);
for _ in 0..20 {
s.push('A');
}
let end_ptr = s.as_ptr();
println!("End-Adresse: {:p}", end_ptr);
// Da wir im Voraus genug Platz reserviert haben,
// sollte sich die Speicheradresse kein einziges Mal geändert haben!
assert_eq!(start_ptr, end_ptr);
println!("Erfolg: Kein Speicherumzug notwendig!");
}
Mit String::with_capacity(20) sparen wir uns alle Zwischenschritte. Die CPU dankt es uns mit maximaler Performance und null Kopier-Overhead.