Atrybut #[repr(packed)] pochodzi z wymagań programowania systemowego, gdzie układ pamięci musi odpowiadać zewnętrznym specyfikacjom—takim jak rejestry sprzętowe czy protokoły sieciowe—eliminuje dodatkowe bajty między polami. Chociaż Rust normalnie gwarantuje, że wskaźniki są wyrównane do wymagań ich typu, struktury pakowane zmuszają pola do zajmowania sekwencyjnych adresów bajtowych, niezależnie od wyrównania, co potencjalnie umieszcza u32 pod adresem, który nie jest podzielny przez cztery. Próba utworzenia wskaźnika (& lub &mut) do takiego niewyrównanego pola konsekwentnie stanowi nieokreślone zachowanie, ponieważ kompilator i LLVM zakładają wyrównane adresy dla optymalizacji, takich jak wektoryzacja czy operacje atomowe. Aby bezpiecznie uzyskać dostęp do danych, należy unikać tworzenia pośrednich wskaźników w pełni, zamiast tego korzystając z makr addr_of! i addr_of_mut! do uzyskania surowych wskaźników bezpośrednio, a następnie stosując ptr::read_unaligned lub ptr::write_unaligned do kopiowania danych bez założeń dotyczących wyrównania.
use std::ptr::{addr_of, read_unaligned}; #[repr(packed)] struct Packet { flags: u8, timestamp: u64, // Potencjalnie na przesunięciu 1, niewyrównane } fn get_timestamp(p: &Packet) -> u64 { // UB: &p.timestamp spowodowałoby utworzenie niewyrównanego wskaźnika let raw_ptr = addr_of!(p.timestamp); unsafe { read_unaligned(raw_ptr) } }
Podczas tworzenia parsera zero-copy dla binarnego protokołu finansowego (FIX), zespół potrzebował struktury odpowiadającej dokładnie formatowi transmisji: u8 typ wiadomości, a następnie bezpośrednio po nim u64 znacznik czasu bez dodatkowego wyrównania. Początkowa implementacja wykorzystywała #[repr(packed)] z bezpośrednim dostępem do pól, co powodowało sporadyczne awarie segmentacji na architekturach ARM, gdzie dostęp do niewyrównanych adresów prowadził do pułapek w jądrze.
Rozważono kilka rozwiązań. Po pierwsze, ręczna rekonstrukcja bajt po bajcie przy użyciu przesunięć i operacji OR: to wyeliminowało problemy z wyrównaniem, ale wprowadziło znaczną dodatkową moc obliczeniową na pakiet i logiczne manipulacje bitami, które utrudniły audyt. Po drugie, użycie #[repr(C)] z explicite dodanymi polami do wyrównania: to zachowało bezpieczeństwo, ale zepsuło zgodność protokołów poprzez zmianę przesunięć bajtów kolejnych pól, co wymagało kosztownych kopii pamięci, aby uporządkować dane przed transmisją. Po trzecie, zachowanie #[repr(packed)], ale dostęp do pól tylko za pomocą surowych wskaźników z niewyrównanym odczytem: to zachowało dokładny układ pamięci, unikając nieokreślonego zachowania poprzez nigdy nie tworzenie wyrównanych wskaźników do pola znacznika czasu.
Zespół wybrał trzecie podejście, implementując metodę gettera, która używała addr_of!(self.timestamp), a następnie ptr::read_unaligned, aby zwrócić wartość znacznika czasu. To wyeliminowało awarie na ARM i x86_64, jednocześnie zachowując architekturę zero-copy, co zmniejszyło opóźnienia o 40% w porównaniu z podejściem opartym na rekonstrukcji bajtów.
Dlaczego stworzenie wskaźnika do niewyrównanego pola stanowi nieokreślone zachowanie, nawet na architekturach, które wspierają dostęp niewyrównany?
Chociaż procesory x86_64 tolerują niewyrównane ładowania w hardware, zasady dotyczące nieokreślonego zachowania Rust są surowsze niż możliwości hardware, aby umożliwić agresywne optymalizacje. Kiedy kompilator widzi &u32, zakłada, że adres jest wyrównany do czterech bajtów, co pozwala mu na generowanie instrukcji SIMD i optymalizację przyszłych kontroli wyrównania albo reorganizację operacji pamięci. Naruszenie tego założenia—nawet na wybaczającej architekturze—pozwala kompilatorowi błędnie skompilować kod, co może prowadzić do awarii lub cichego uszkodzenia danych w przyszłych wersjach kompilatorów lub na innych architekturach.
Jak makro addr_of! różni się semantycznie od operatora &, gdy stosowane jest do pól struktur pakowanych?
Operator & koncepcyjnie tworzy pierwsze wskaźnik, a następnie przekształca go w surowy wskaźnik, jeśli przypisany do jednego, tym samym natychmiast wyzwalając kontrolę ważności wyrównania. W przeciwieństwie do tego, addr_of! jest wbudowanym makrem, które oblicza adres bezpośrednio, nie tworząc pośredniego wskaźnika, pomijając całkowicie wymagania wyrównania. To rozróżnienie ma kluczowe znaczenie, ponieważ addr_of! zwraca *const T, który może być niewyrównany, podczas gdy &field byłoby UB, jeśli pole jest niewyrównane, nawet jeśli natychmiast rzutowane na wskaźnik.
Dlaczego implementacja Drop dla struktury pakowanej, zawierającej pola, które nie są kopiowalne, jest problematyczna, i jak można bezpiecznie implementować niestandardową destrukcję?
Metoda Drop::drop otrzymuje &mut self, które jest wyrównane (strukturę sama wykonuje całkowite wyrównanie), ale zrzucenie pojedynczych pól wymaga wywołania ich destruktorów z &mut Field. Jeśli pole ma wyższe wyrównanie niż początek struktury i jest tym samym niewyrównane, stworzenie &mut Field do wywołania Drop jest nieokreślonym zachowaniem. Aby bezpiecznie zrzucić takie struktury, należy owinąć pola, które nie są kopiowalne, w ManuallyDrop, a następnie w niestandardowej implementacji Drop korzystać z ptr::read_unaligned lub ptr::drop_in_place na surowych wskaźnikach uzyskanych za pomocą addr_of_mut!, zapewniając, że destruktor uruchomi się bez nigdy nie tworząc wyrównanego wskaźnika do niewyrównanego pola.