De #[repr(packed)] eigenschap komt voort uit de eisen van systems programming waarbij de geheugensamenstelling moet overeenkomen met externe specificaties, zoals hardware-registers of netwerkprotocollen, door het elimineren van opvulkruimten tussen velden. Terwijl Rust normaal gesproken garandeert dat referenties zijn uitgelijnd op basis van de vereisten van hun puntentype, dwingen packed structs velden om op sequentiële byte-offsets te staan ongeacht de uitlijning, wat mogelijk een u32 plaatst op een adres dat niet deelbaar is door vier. Proberen een referentie (& of &mut) naar zo'n niet-uitgelijnd veld te maken, vormt onmiddellijk ongedefinieerd gedrag, omdat de compiler en LLVM veronderstellen dat adressen zijn uitgelijnd voor optimalisaties zoals vectorisatie of atomische operaties. Om veilig toegang te krijgen tot gegevens, moet men het maken van tussenliggende referenties volledig vermijden en in plaats daarvan de addr_of! en addr_of_mut! macro's gebruiken om raw pointers direct te verkrijgen, en vervolgens ptr::read_unaligned of ptr::write_unaligned gebruiken om gegevens te kopiëren zonder aanname van uitlijning.
use std::ptr::{addr_of, read_unaligned}; #[repr(packed)] struct Packet { flags: u8, timestamp: u64, // Potentieel op offset 1, niet-uitgelijnd } fn get_timestamp(p: &Packet) -> u64 { // UB: &p.timestamp zou een niet-uitgelijnde referentie creëren let raw_ptr = addr_of!(p.timestamp); unsafe { read_unaligned(raw_ptr) } }
Tijdens de ontwikkeling van een zero-copy parser voor een binaire financiële protocol (FIX), had het team een struct nodig die exact overeenkwam met het wire-formaat: een u8 berichttype onmiddellijk gevolgd door een u64 timestamp zonder opvulling. De initiële implementatie gebruikte #[repr(packed)] met directe veldtoegang, wat leidde tot intermitterende segmentatiefouten op ARM architecturen waar niet-uitgelijnde toegang de kernel invalt.
Er werden verschillende oplossingen geëvalueerd. Ten eerste, handmatige reconstructie byte voor byte met behulp van verschuiving en OR-bewerkingen: dit elimineerde uitlijningsproblemen maar introduceerde aanzienlijke CPU-overhead per pakket en foutgevoelige bitverschillende logica die het auditen complex maakte. Ten tweede, gebruik van #[repr(C)] met expliciete opvulvelden om uitlijning af te dwingen: dit behield de veiligheid maar verbrak de protocolcompatibiliteit door de byte-offsets van daaropvolgende velden te wijzigen, wat dure geheugenkopiën vereiste om gegevens voor transmissie te herschikken. Ten derde, behoud van #[repr(packed)] maar toegang tot velden alleen via raw pointers met niet-uitgelijnde reads: dit behield de exacte geheugensamenstelling terwijl het ongedefinieerd gedrag vermeden werd door nooit uitgelijnde referenties naar het timestamp-veld te creëren.
Het team koos de derde benadering, waarbij zij een getter-methode implementeerden die addr_of!(self.timestamp) gebruikte, gevolgd door ptr::read_unaligned om de timestamp-waarde terug te geven. Dit elimineerde crashes op ARM en x86_64 terwijl het de zero-copy architectuur behield, wat de latentie met 40% verlaagde vergeleken met de byte-reconstructiebenadering.
Waarom vormt het creëren van een referentie naar een niet-uitgelijnd veld ongedefinieerd gedrag, zelfs op architecturen die niet-uitgelijnde toegang ondersteunen?
Hoewel x86_64 processors niet-uitgelijnde laden in hardware tolereren, zijn de regels voor ongedefinieerd gedrag in Rust strikter dan de hardwarecapaciteiten om agressieve optimalisaties mogelijk te maken. Wanneer de compiler &u32 ziet, veronderstelt het dat het adres vier-byte uitgelijnd is, waardoor het kan worden gecompileerd naar SIMD instructies, de daaropvolgende uitlijningscontroles kan optimaliseren of geheugenoperaties kan herschikken. Het schenden van deze aanname—zelfs op vergevingsgezinde hardware—stelt de compiler in staat onjuiste code te genereren, wat kan leiden tot crashes of stille datacorruptie in toekomstige compiler-versies of op andere architecturen.
Hoe verschilt de addr_of! macro semantisch van de & operator wanneer deze wordt toegepast op packed struct-velden?
De & operator creëert conceptueel eerst een referentie, en zet deze vervolgens om in een raw pointer als deze aan een raw pointer wordt toegewezen, waardoor de controle op uitlijningsgeldigheid onmiddellijk wordt geactiveerd. In tegenstelling hiermee is addr_of! een ingebouwde macro die het adres direct berekent zonder een tussenliggende referentie te creëren, wat de uitlijningsvereiste volledig omzeilt. Dit onderscheid is cruciaal omdat addr_of! een *const T retourneert dat mogelijk niet-uitgelijnd is, terwijl &field UB zou zijn als het veld niet-uitgelijnd is, zelfs als het onmiddellijk naar een pointer gecast wordt.
Waarom is het problematisch om Drop te implementeren voor een packed struct met niet-Copy velden, en hoe kan men een aangepaste vernietiging veilig implementeren?
De Drop::drop methode ontvangt &mut self, dat is uitgelijnd (de struct zelf behoudt de algemene uitlijning), maar het verwijderen van individuele velden vereist het aanroepen van hun destructors met &mut Field. Als een veld een hogere uitlijning heeft dan het begin van de struct en daardoor niet-uitgelijnd is, is het creëren van &mut Field om Drop aan te roepen ongedefinieerd gedrag. Om dergelijke structs veilig te verwijderen, moet men niet-Copy velden verpakken in ManuallyDrop, en in de aangepaste Drop implementatie, ptr::read_unaligned of ptr::drop_in_place gebruiken op raw pointers verkregen via addr_of_mut!, waarmee ervoor gezorgd wordt dat de destructor wordt uitgevoerd zonder ooit een uitgelijnde referentie naar het niet-uitgelijnde veld te creëren.