RustprogramowanieProgramista Systemów Rust

W jaki sposób **MaybeUninit<T>** izoluje surową pamięć od założeń o ważności kompilatora, a jaką konkretną niebezpieczną inwariantę musi zapewnić programista, gdy twierdzi, że ta pamięć zawiera żywą instancję **T**?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Przed Rust 1.36, programiści polegali na std::mem::uninitialized do alokacji pamięci stosu dla wartości, które miały być zainicjowane później. Ta funkcja była zasadniczo niezdatna, ponieważ informowała kompilator, że ważny T istnieje w tej lokalizacji pamięci, mimo że bity były losowe. Dla typów z inwariantami bezpieczeństwa — takich jak bool, char czy referencje — prowadziło to do natychmiastowego niedookreślonego zachowania, ponieważ kompilator optymalizował na podstawie założenia, że wartość jest ważna (np. bool jest 0 lub 1). RFC 1892 wprowadził MaybeUninit<T> jako abstrakcję przypominającą unię, aby wyraźnie oznaczać pamięć, która jeszcze nie zawiera ważnego T, rozwiązując ten problem z bezpieczeństwem.

Problem

Kluczowym problemem jest traktowanie pamięci niezainicjowanej przez LLVM jako undef lub poison, w połączeniu z automatyczną generacją kodu 'drop glue' w Rust. Kiedy kompilator uważa, że zmienna typu T jest aktywna, może generować wywołania destruktorów lub optymalizacje niszowe. Jeśli T jest bool, niezainicjowany bajt może mieć wartość 2, co narusza inwariant prawidłowości bitów. Odczyt tego podczas sprawdzania usuwania lub inspekcji dyskryminatorów stanowi niedookreślone zachowanie. Ponadto, jeżeli inicjalizacja nie powiedzie się w trakcie przesyłania tablicy, kod 'drop glue' dla typu tablicy spróbuje usunąć wszystkie elementy, interpretując niezainicjowane bajty stosu jako wskaźniki i powodując błędy użycia po zwolnieniu lub podwójnego zwolnienia.

Rozwiązanie

MaybeUninit<T> działa jako typowy pojemnik, który może lub nie może zawierać ważnego T. Zapobiega temu, aby kompilator zakładał inicjalizację, tym samym hamując emisję 'drop glue' oraz nieprawidłowe optymalizacje wzorców bitowych. Programista musi ręcznie śledzić, które instancje są zainicjowane, najczęściej za pomocą oddzielnego indeksu lub tablicy logicznej. Aby wyodrębnić wartość, należy użyć assume_init, assume_init_ref lub std::ptr::read, ale tylko po dowiedzeniu się, że napisano ważny T za pomocą write lub manipulacji wskaźnikami. Krytycznym inwariantem jest to, że assume_init nigdy nie może być wywołane na pamięci, która nie jest w pełni zainicjowana, a gdy porzucamy częściowo zainicjowaną strukturę, programista musi ręcznie usunąć tylko zainicjowane elementy, używając ptr::drop_in_place, aby uniknąć wycieków zasobów.

use std::mem::{self, MaybeUninit}; use std::ptr; fn init_array_fallible<T, E, const N: usize>( mut f: impl FnMut(usize) -> Result<T, E>, ) -> Result<[T; N], E> { let mut array: [MaybeUninit<T>; N] = unsafe { MaybeUninit::uninit().assume_init() }; let mut i = 0; while i < N { match f(i) { Ok(val) => { array[i].write(val); i += 1; } Err(e) => { for j in 0..i { unsafe { ptr::drop_in_place(array[j].as_mut_ptr()); } } return Err(e); } } } Ok(unsafe { mem::transmute::<[MaybeUninit<T>; N], [T; N]>(array) }) }

Sytuacja z życia

Opracowujesz sterownik no_std dla karty interfejsu sieciowego, gdzie alokacja na stercie jest zabroniona, a opóźnienia muszą być deterministyczne. Musisz przydzielić stałą tablicę 1024 obiektów Connection na stosie. Inicjalizacja każdego Connection związana jest z zapisaniem rejestru sprzętowego, co może się nie powieść, jeśli bufor NIC jest pełny. Wyzwanie polega na zapewnieniu, że jeśli inicjalizacja 500. połączenia się nie powiedzie, poprzednie 499 zostanie poprawnie zamkniętych (zrzucenie deskryptorów plików i zwolnienie mapowań DMA), podczas gdy pozostałe 524 miejsca pozostaną nietknięte, aby uniknąć wszelkiego niedookreślonego zachowania wynikającego z usunięcia niezainicjowanej pamięci.

Jednym z potencjalnych podejść jest wykorzystanie Default::default(), aby wstępnie-inicjować tablicę wartościami sentinela. Wymaga to, aby Connection zaimplementował Default, co stanowi problem, ponieważ „domyślne” połączenie wciąż uzyska zasoby jądra, które muszą być wyraźnie zwolnione, co komplikuje ścieżkę błędu. Dodatkowo, konstruowanie 1024 pustych połączeń, tylko po to, aby je nadpisać, marnuje cykle inicjalizacji i narusza surowe wymagania czasowe sterownika na uruchomienie interfejsu.

Drugą strategią jest użycie Vec<Connection> z with_capacity oraz dynamicznym dodawaniem, a następnie konwersja do tablicy o stałej wielkości. Jest to bezpieczne i idiomatyczne w kodzie użytkownika. Jednak Vec wymaga globalnego alokatora, który nie jest dostępny w tym kontekście jądra. Wprowadza również potencjalne ścieżki paniki i fragmentację pamięci, które są nieakceptowalne w przestrzeni jądra, a konwersja do tablicy o stałej wielkości wymaga kontroli w czasie działania, co komplikuje logikę obsługi błędów.

Trzecie podejście polega na wykorzystaniu MaybeUninit<[Connection; 1024]> do alokacji pamięci bez inicjalizacji. Prawidłowo zainicjowane połączenia są zapisywane za pomocą MaybeUninit::write, a jeśli wystąpi błąd pod indeksem i, ręcznie iterujemy od 0 do i-1 i wywołujemy ptr::drop_in_place na każdym zainicjowanym miejscu przed zwróceniem błędu. Po sukcesie transmutujemy całą tablicę do zainicjowanego typu. Wybraliśmy to rozwiązanie, ponieważ zapewnia alokację stosu o zerowej wartości kosztowej z deterministyczną wydajnością, spełnia ograniczenia no_std i zapewnia, że czyszczenie zasobów Occurs only for truly initialized objects. Rezultatem był solidny sterownik, który nigdy nie wywoływał niedookreślonego zachowania podczas odzyskiwania z częściowej awarii i utrzymywał spójne opóźnienie inicjalizacji na poziomie mikrosekundowym.

Co często umyka kandydatom


Dlaczego wywołanie assume_init na niezainicjowanym MaybeUninit<T> stanowi niedookreślone zachowanie, nawet jeśli wartość nigdy nie jest później bezpośrednio odczytywana?

Wielu kandydatów błędnie uważa, że niedookreślone zachowanie występuje tylko wtedy, gdy fizycznie uzyska się dostęp do danych, takich jak ich drukowanie lub nawigacja na ich podstawie. Jednak system typów Rust informuje kompilator, że ważny T istnieje natychmiast po wywołaniu assume_init. Dla typów z optymalizacjami niszowymi (takimi jak bool, char, Option<&T>, czy NonNull<T>) kompilator może wygenerować kod, który inspekcjonuje wzór bitowy w celu ustalenia wariantów enum lub ważności. Jeśli pamięć zawiera losowe bity (np. 0xFF dla bool), ta inspekcja wywołuje niedookreślone zachowanie w LLVM (ładowanie poison lub undef). Dodatkowo, gdy zakres się kończy, kompilator wstawia kod usunięcia dla T, który spróbuje uruchomić destruktory na nieprawidłowych danych, co prowadzi do awarii lub luk w zabezpieczeniach. Dlatego assume_init jest umową, w której programista zapewnia prawidłową inicjalizację; naruszenie tego psuje stan kompilatora niezależnie od jawnych odczytów.


Jaka jest różnica między używaniem MaybeUninit::write a std::ptr::write na wskaźniku zwróconym przez MaybeUninit::as_mut_ptr(), i kiedy każda z nich jest odpowiednia?

MaybeUninit::write jest bezpieczną metodą, która przejmuje własność T i zapisuje ją w niezainicjowanym miejscu, zwracając mutowalną referencję do teraz zainicjowanych danych. Jest preferowana, gdy masz wartość gotową i chcesz natychmiastowego bezpiecznego dostępu. Z kolei std::ptr::write jest funkcją unsafe, która zapisuje wartość do surowego wskaźnika bez odczytywania lub usuwania starej wartości (co jest krytyczne, ponieważ pamięć jest niezainicjowana). Musisz użyć ptr::write, gdy zapisujesz przez surowy wskaźnik uzyskany z as_mut_ptr() i musisz uniknąć ograniczeń kontrolera pożyczek write, lub przy implementowaniu niskopoziomowych abstrakcji, gdy masz tylko surowe wskaźniki. Kluczowa różnica polega na tym, że write zapewnia gwarancje bezpieczeństwa i śledzenia cyklu życia, podczas gdy ptr::write wymaga ręcznej weryfikacji, że docelowe miejsce jest ważne, prawidłowo wyrównane i niezainicjowane, aby uniknąć naruszeń aliasingowych lub przedwczesnych usunięć.


Jak poprawnie usunąć częściowo zainicjowaną tablicę MaybeUninit<T> bez wycieków zasobów lub wywoływania niedookreślonego zachowania, i dlaczego kolejność operacji jest krytyczna?

Gdy inicjalizacja nie powiedzie się pod indeksem i, musisz usunąć tylko elementy 0..i. Poprawna procedura polega na iteracji od 0 do i-1 i wywołaniu std::ptr::drop_in_place(array[j].as_mut_ptr()). To uruchamia destruktor dla T bez przeniesienia wartości na zewnątrz opakowania MaybeUninit (co pozostawiłoby miejsce w stanie przeniesienia, chociaż nadal technicznie niezainicjowane). Kluczowe jest natychmiastowe przeprowadzenie tej operacji czyszczenia w przypadku błędu, zanim zostanie zwrócony błąd, aby upewnić się, że ramka stosu jest prawidłowo odzwierciedlona. Gdybyś zamiast tego spróbował użyć mem::forget na tablicy lub po prostu zwrócił, opakowanie MaybeUninit zostałoby usunięte (co by nic nie zrobiło), ale aktywne instancje T wyciekałyby swoje zasoby (takie jak uchwyty do plików lub pamięć na stercie). Z drugiej strony, jeśli omyłkowo usuniesz elementy i..N, wywołasz niedookreślone zachowanie, traktując niepoprawną pamięć jako ważne instancje T.