Das #[repr(packed)]-Attribut stammt aus den Anforderungen der Systemprogrammierung, bei denen das Speicherlayout den externen Spezifikationen entsprechen muss – wie z. B. Hardware-Registern oder Netzwerkprotokollen – indem es die Auffüllbytes zwischen den Feldern eliminiert. Während Rust normalerweise garantiert, dass Verweise an die Anforderungen ihres Zieltyps ausgerichtet sind, zwingt das gepackte Struct die Felder dazu, sequentielle Byte-Offsets unabhängig von der Ausrichtung anzunehmen, was dazu führen kann, dass ein u32 an einer Adresse platziert wird, die nicht durch vier teilbar ist. Der Versuch, einen Verweis (& oder &mut) auf ein solches nicht ausgerichtetes Feld zu erstellen, stellt ein sofortiges undefiniertes Verhalten dar, da der Compiler und LLVM ausgerichtete Adressen für Optimierungen wie Vektorisierung oder atomare Operationen annehmen. Um sicher auf Daten zuzugreifen, muss der gesamte Zwischenverweis vermieden werden, indem stattdessen die Makros addr_of! und addr_of_mut! verwendet werden, um Rohzeiger direkt zu erhalten, und dann ptr::read_unaligned oder ptr::write_unaligned verwendet werden, um Daten ohne Ausrichtungsannahmen zu kopieren.
use std::ptr::{addr_of, read_unaligned}; #[repr(packed)] struct Packet { flags: u8, timestamp: u64, // Potenziell bei Offset 1, nicht ausgerichtet } fn get_timestamp(p: &Packet) -> u64 { // UB: &p.timestamp würde einen nicht ausgerichteten Verweis erzeugen let raw_ptr = addr_of!(p.timestamp); unsafe { read_unaligned(raw_ptr) } }
Bei der Entwicklung eines Zero-Copy-Parsers für ein binäres Finanzprotokoll (FIX) benötigte das Team eine Struktur, die genau dem drahtformat entspricht: ein u8-Nachrichtentyp, gefolgt von einem u64-Zeitstempel ohne Auffüllung. Die ursprüngliche Implementierung verwendete #[repr(packed)] mit direktem Feldzugriff, was zu intermittierenden Segmentierungsfehlern auf ARM-Architekturen führte, bei denen nicht ausgerichteter Zugriff in den Kernel führt.
Mehrere Lösungen wurden evaluiert. Zunächst die manuelle Rekonstruktion Byte für Byte mit Verschieben und ODER-Operationen: Dies beseitigte Ausrichtungsprobleme, führte jedoch zu erheblichem CPU-Overhead pro Paket und fehleranfälliger Bitbearbeitungslogik, die die Prüfung erschwerte. Zweitens die Verwendung von #[repr(C)] mit expliziten Auffüllfeldern, um die Ausrichtung zu erzwingen: Dies bewahrte die Sicherheit, brach jedoch die Protokollkompatibilität, indem die Byte-Offsets nachfolgender Felder geändert wurden, was kostspielige Speicherkopien erforderte, um die Daten vor der Übertragung neu anzuordnen. Drittens die Beibehaltung von #[repr(packed)], aber nur den Zugriff auf Felder über Rohzeiger mit nicht ausgerichteten Lesevorgängen: Dies bewahrte das genaue Speicher-Layout und vermied undefiniertes Verhalten, indem niemals ausgerichtete Verweise auf das Zeitstempelfeld erstellt wurden.
Das Team wählte den dritten Ansatz und implementierte eine Getter-Methode, die addr_of!(self.timestamp) gefolgt von ptr::read_unaligned verwendete, um den Zeitstempelwert zurückzugeben. Dadurch wurden Abbrüche auf ARM und x86_64 beseitigt und die Zero-Copy-Architektur erhalten, was die Latenz im Vergleich zum Byte-Rekonstruktionsansatz um 40 % reduzierte.
Warum stellt die Erstellung eines Verweises auf ein nicht ausgerichtetes Feld ein undefiniertes Verhalten dar, selbst auf Architekturen, die nicht ausgerichteten Zugriff unterstützen?
Während x86_64-Prozessoren nicht ausgerichtete Ladevorgänge in der Hardware tolerieren, sind die Regeln für undefiniertes Verhalten in Rust strenger als die Hardwarefähigkeiten, um aggressive Optimierungen zu ermöglichen. Wenn der Compiler &u32 sieht, nimmt er an, dass die Adresse vier Byte ausgerichtet ist, was es ihm ermöglicht, SIMD-Anweisungen zu erzeugen, nachfolgende Ausrichtungsprüfungen zu optimieren oder Speicheroperationen umzustellen. Diese Annahme zu verletzen – selbst auf nachsichtiger Hardware – ermöglicht es dem Compiler, den Code falsch zu kompilieren, was potenziell zu Abstürzen oder stiller Datenkorruption bei zukünftigen Compiler-Versionen oder anderen Architekturen führt.
Wie unterscheidet sich das addr_of!-Makro semantisch vom &-Operator, wenn es auf gepackte Strukturfelder angewendet wird?
Der &-Operator erstellt konzeptionell zuerst einen Verweis und wandelt ihn dann in einen Rohzeiger um, wenn er einem zugewiesen wird, wodurch die Überprüfung der Ausrichtungsvalidität sofort ausgelöst wird. Im Gegensatz dazu ist addr_of! ein eingebautes Makro, das die Adresse direkt berechnet, ohne einen Zwischenverweis zu erstellen, und somit die Anforderung an die Ausrichtung vollständig umgeht. Diese Unterscheidung ist entscheidend, da addr_of! einen *const T zurückgibt, der möglicherweise nicht ausgerichtet ist, während &field UB sein würde, wenn das Feld nicht ausgerichtet ist, selbst wenn es sofort in einen Zeiger umgewandelt wird.
Warum ist die Implementierung von Drop für ein gepacktes Struct, das nicht-Copy-Felder enthält, problematisch, und wie kann man die benutzerdefinierte Zerstörung sicher implementieren?
Die Methode Drop::drop erhält &mut self, das ausgerichtet ist (die Struktur selbst behält die Gesamtanpassung bei), jedoch erfordert das Löschen einzelner Felder, ihre Zerstörer mit &mut Field aufzurufen. Wenn ein Feld eine höhere Ausrichtung hat als der Anfang der Struktur und daher nicht ausgerichtet ist, ist das Erstellen von &mut Field, um Drop aufzurufen, undefiniertes Verhalten. Um solche Strukturen sicher zu löschen, müssen nicht-Copy-Felder in ManuallyDrop eingewickelt werden, und dann muss in der benutzerdefinierten Drop-Implementierung ptr::read_unaligned oder ptr::drop_in_place auf den Rohzeig angewendet werden, der über addr_of_mut! erhalten wurde, um sicherzustellen, dass der Zerstörer ausgeführt wird, ohne jemals einen ausgerichteten Verweis auf das nicht ausgerichtete Feld zu erstellen.