Makro std::ptr::addr_of! odgrywa kluczową rolę w niebezpiecznym Rust, umożliwiając tworzenie surowych wskaźników do pól bez pośredniego kroku tworzenia odniesienia. W przypadku struktur #[repr(packed)] pola mogą znajdować się w niezalignowanych przesunięciach pamięci, naruszając wymagania dotyczące wyrównania, które są inherentne dla typów odniesienia. Próba utworzenia odniesienia za pomocą operatora & do takich niezalignowanych danych stanowi natychmiastowe niezdefiniowane zachowanie, niezależnie od tego, czy odniesienie jest później używane. Makro addr_of! omija to, bezpośrednio materializując surowy wskaźnik z adresu pola, omijając przy tym prawa dotyczące wyrównania i ważności narzucane przez odniesienia. Ta różnica jest kluczowa dla poprawnych interakcji FFI i niskopoziomowej manipulacji pamięcią, gdzie powszechnie występują zapakowane układy danych.
Podczas opracowywania parsera o wysokiej wydajności dla legacy protokołu binarnego, zespół inżynieryjny napotkał strukturę #[repr(packed)], w której pole u32 zostało celowo umieszczone w przesunięciu wynoszącym 1 bajt, aby dopasować je do mapy rejestrów zewnętrznego sprzętu. Początkowa implementacja próbowała pożyczyć to pole za pomocą &packet.status_register, aby przekazać je do funkcji walidacyjnej, nieświadoma, że stworzono tym samym niezalignowane odniesienie, co spowodowało natychmiastowe niezdefiniowane zachowanie.
Pierwsze rozważane rozwiązanie polegało na usunięciu atrybutu packed i ręcznym dodaniu bajtów paddingowych, aby wymusić wyrównanie. Podejście to zapewniło bezpieczeństwo, pozwalając na naturalne tworzenie odniesień, ale naruszyło kompatybilność binarną ze specyfikacją sprzętową i marnowało przepustowość pamięci podczas przesyłania dużych tablic tych struktur.
Drugie podejście zaproponowało użycie arytmetyki wskaźników z unsafe { &*(base_ptr.add(1) as *const u32) }, aby ręcznie obliczyć adres pola. Choć to unikało bezpośredniej składni dostępu do pola, wciąż materializowało odniesienie poprzez operator dereferencji &*, co stanowi niezdefiniowane zachowanie, jeśli wynikowy wskaźnik nie jest odpowiednio wyrównany, nie oferując poprawy bezpieczeństwa w porównaniu z pierwotnym naiwnością pożyczką i potencjalnie wprowadzając w błąd przyszłych konserwatorów.
Zespół ostatecznie wybrał trzecie rozwiązanie, wykorzystując std::ptr::addr_of!, aby uzyskać surowy wskaźnik do niewyrównanego pola bez tworzenia pośredniego odniesienia. Ten wskaźnik został następnie przekazany do std::ptr::read_unaligned, aby bezpiecznie skopiować wartość do odpowiednio wyrównanej zmiennej lokalnej. Ta strategia zachowała wymagany układ pamięci, jednocześnie ściśle przestrzegając modelu pamięci Rust, co zaowocowało kodem, który przeszedł rygorystyczne testy z Miri i funkcjonował poprawnie na kilku architekturach docelowych, w tym ARM i x86_64.
Dlaczego stworzenie odniesienia do niezalignowanych danych stanowi niezdefiniowane zachowanie, nawet jeśli odniesienie jest natychmiast rzutowane na surowy wskaźnik?
W Rust czynność tworzenia odniesienia — na przykład &packed.field — nie jest jedynie obliczeniem wskaźnika, lecz asercją dla kompilatora, że docelowa pamięć spełnia wszystkie warunki tej klasy odniesienia, w tym wyrównanie i ważność dla odczytów. Backend LLVM i optymalizator Rust zakładają, że te invarianto są spełnione natychmiast po utworzeniu odniesienia, co umożliwia agresywne optymalizacje, takie jak przeładunek-load lub spekulatywne ładowania. Nawet jeśli odniesienie zostanie natychmiast rzutowane na *const T, optymalizator mógł wcześniej emitować instrukcje zakładające wyrównany dostęp lub może oznaczać wartość odniesienia jako dereferenceable w metadanych LLVM, co prowadzi do błędnej kompilacji na architekturach z rygorystycznymi wymaganiami co do wyrównania. Dlatego niezdefiniowane zachowanie występuje w momencie tworzenia odniesienia, a nie w momencie dereferencji, co sprawia, że sama obecność niezalignowanego odniesienia jest toksyczna dla poprawności programu.
W jaki sposób addr_of! różni się od użycia as *const _ z istniejącym odniesieniem i dlaczego makro jest konieczne?
Gdy piszesz &packed.field as *const T, kompilator Rust najpierw tworzy odniesienie (wywołując kontrole wyrównania i potencjalną UB), a dopiero potem konwertuje tę ważną odniesienie na surowy wskaźnik. Natomiast std::ptr::addr_of! działa bezpośrednio na wyrażeniu miejsca (na polu), generując surowy wskaźnik bez nigdy nie konstruowania pośredniego odniesienia. To jest kluczowe, ponieważ kompilator traktuje wnętrze addr_of! jako specjalną konstrukcję, która pomija kontrole ważności odniesienia, podczas gdy słowo kluczowe as wykonuje konwersję wartości na wartość, co wymaga, aby wartość źródłowa (odniesienie) była ważna. Użycie makra zapewnia, że sama derivacja wskaźnika nie może wprowadzić niezdefiniowanego zachowania związanego z naruszeniem wyrównania, zapewniając jedyną poprawną ścieżkę do uzyskania adresów potencjalnie niezalignowanych danych.
Jakie dodatkowe rozważania dotyczą użycia addr_of_mut! do uzyskiwania wskaźników do pól w strukturze zawierającej UnsafeCell?
Gdy struktura #[repr(packed)] zawiera UnsafeCell<T>, uzyskanie wskaźnika mutującego do wnętrza wymaga ostrożnego przestrzegania zasad aliasowania w Rust. UnsafeCell zapewnia mutowalność wewnętrzną, ale tworzenie mutowalnego odniesienia (&mut) do niezalignowanego pola UnsafeCell wciąż narusza wymagania dotyczące wyrównania i jest niezdefiniowanym zachowaniem. Kandydaci często zakładają, że UnsafeCell w jakiś sposób zwalnia wskaźnik z zasad dotyczących wyrównania, ale zwalnia on tylko z gwarancji dotyczącej aliasowania wyłącznych odniesień (noalias), a nie z wyrównania. Użycie addr_of_mut! daje *mut T, które nadal musi respektować wyrównanie typu podstawowego, gdy zostanie w końcu zdereferencowane lub przekazane do UnsafeCell::raw_get, co wymusza użycie read_unaligned lub write_unaligned do rzeczywistego dostępu do danych.