Skip to content

← Performance and Data Layout step 7 of 20

Hard Primitives

The other reprs: transparent, packed, align, integer enums

A representation attribute, written #[repr(...)], tells the compiler how to lay out a type in memory. Without one, Rust uses its default representation, whose field order and padding the compiler may choose. #[repr(C)] asks for the field order, padding and alignment that the C rules for the target platform would give. Matching an existing C header sometimes needs more than that. This problem covers three more attributes: packed, transparent and an integer type such as u8 on an enum.

Two terms are used throughout. A type's alignment is the number its address must be a multiple of: a u32 usually has alignment 4, so it must start at an address divisible by 4. Padding is the unused bytes the compiler inserts between fields so that each field starts at a correctly aligned address.

The header you are matching

#pragma pack(push, 1)
struct wire_header {
    uint8_t  version;
    uint32_t length;
    uint8_t  flags;
    uint16_t checksum;
};
#pragma pack(pop)

typedef int32_t handle_t;

enum status : uint8_t { STATUS_OK = 0, STATUS_RETRY = 1, STATUS_FATAL = 2 };

#pragma pack(push, 1) is a compiler extension supported by GCC, Clang and MSVC that removes padding from the structs between it and pack(pop). The enum status : uint8_t form, which fixes the enum's storage type, is C23 and C++ syntax.

#[repr(C, packed)]

packed removes all padding: each field starts at the byte right after the previous one, and the struct's alignment becomes 1. Under ordinary C rules, wire_header would take 12 bytes: version at offset 0, three padding bytes, length at offset 4, flags at offset 8, one padding byte, and checksum at offset 10. Packed, it takes exactly 8 bytes, the sum of its field sizes, with the fields at offsets 0, 1, 5 and 6. That is the layout a wire format with no gaps needs.

Write #[repr(C, packed)], not #[repr(packed)]. packed on its own removes the padding but keeps Rust's default representation, so the field order is not guaranteed to match the declaration and the struct is not guaranteed to match the header. Clippy's repr_packed_without_abi lint, which warns by default, reports this, and the grade treats Clippy warnings as errors.

References to packed fields are rejected

let r = &h.length; // error[E0793]

With Rust 1.98.1 the error reads:

error[E0793]: reference to field of packed struct is unaligned
  = note: this struct is 1-byte aligned, but the type of this field may require higher alignment
  = note: creating a misaligned reference is undefined behavior (even if that reference is never dereferenced)

Every &T in Rust must point to an address that is correctly aligned for T, and the compiler is allowed to generate code that relies on it. In a packed struct, length sits at offset 1, so &h.length would usually be misaligned. Creating such a reference is undefined behavior even if it is never read, so the compiler rejects it.

This also catches code that does not mention &. println!("{}", h.length) is rejected with the same error, because the formatting macros take a reference to each argument, and so is a method call such as h.length.to_string() that borrows the field because the method takes &self. Writing the argument as a block, println!("{}", { h.length }), copies the value first and compiles.

There are two ways to read a packed field:

  1. Copy the value out. let n = h.length; reads the field by value and creates no reference. For a Copy field such as an integer, this is the usual fix and needs no unsafe. When you access a field by name, the compiler knows it may be misaligned and generates a load that is correct for the target.

  2. Take a raw pointer and read it unaligned.

    unsafe { (&raw const h.checksum).read_unaligned() }
    

    &raw const place creates a raw pointer (*const u16 here) without creating a reference first. It has been stable since Rust 1.82, and the older std::ptr::addr_of! macro does the same thing. read_unaligned reads a value through the pointer without requiring the address to be aligned. Reading with *ptr instead would still require alignment. This form is useful when you need the field's address, for example to pass it to other code. For a field that is not Copy, such as a String, read_unaligned makes a bitwise duplicate, and you become responsible for making sure only one of the two copies is ever dropped.

This problem uses both: read_length copies the field out, and read_checksum uses the raw-pointer form.

Packed structs have costs. On targets that do not support unaligned loads, or support them slowly, the compiler may read a misaligned field in several smaller pieces. Use packed when an external format requires the exact byte layout, not as a general way to save memory.

#[repr(transparent)]

#[repr(transparent)]
pub struct Handle(pub i32);

A wrapper with a single field is called a newtype. #[repr(transparent)] guarantees that the newtype has the same size, alignment and calling convention (ABI) as its field. Handle can therefore stand in for a C int32_t such as handle_t in an extern "C" function signature, while Rust code still cannot mix a Handle up with a plain i32 or another newtype around i32. On the grading platform, i32 and therefore Handle have size 4 and alignment 4.

If the C side instead declared a struct, such as typedef struct { int32_t raw; } handle_t;, the matching Rust type would be #[repr(C)]. Some platforms pass a one-field struct differently from a plain integer, and transparent promises the integer's calling convention.

The rule is that a transparent type may contain at most one field that is not trivial. A trivial field has size 0 and alignment 1, such as () or PhantomData<T>; any number of those may sit next to the real field, which is how phantom-typed handles carry a type marker at no cost. A second real field, or a zero-size field with larger alignment such as [u16; 0], is rejected with E0690.

#[repr(u8)] on an enum

#[repr(u8)]
pub enum Status { Ok = 0, Retry = 1, Fatal = 2 }

Each variant of an enum has an integer tag called its discriminant; the = 0, = 1 and = 2 set them. #[repr(u8)] stores the discriminant as a u8, so Status has size 1 and alignment 1, matching the C enum. Without the attribute, the size is chosen by the compiler and is not guaranteed. s as u8 converts a variant to its discriminant, so Status::Retry as u8 is 1.

Option<Status> also has size 1 with the current compiler. A Status byte can only hold 0, 1 or 2, so the compiler can use one of the other 253 byte values to represent None. Which value it picks is not specified. An unused value that the compiler borrows this way is called a niche. The standard library guarantees that Option<T> has the same size as T only for specific types such as references, Box and NonZero integers, not for an enum like Status, so do not rely on this size in an FFI signature.

What to write

Give the three types the representations above and implement:

pub fn read_length(h: &WireHeader) -> u32       // copy the field out
pub fn read_checksum(h: &WireHeader) -> u16     // &raw const + read_unaligned
pub fn status_code(s: Status) -> u8             // the discriminant
pub fn handle_raw(h: Handle) -> i32             // the wrapped value
pub fn describe() -> Vec<(String, usize, usize)>

The test builds a WireHeader from four values (version and flags in 0 to 255, length in 0 to 4294967295, checksum in 0 to 65535) and a Handle from any i32, including i32::MIN and i32::MAX. read_length, read_checksum and handle_raw must return those values unchanged, and status_code must return 0, 1 and 2 for Ok, Retry and Fatal.

describe returns exactly four (name, size, align) rows, in this order and with these names: "WireHeader", "Handle", "Status" and "Option<Status>". Use std::mem::size_of and std::mem::align_of to compute the numbers. The expected rows are 8/1, 4/4, 1/1 and 1/1. The first three follow from the representations on the grading platform. The last is the niche result described above, which the test records for the compiler it runs.

The tests cannot detect every mistake. Rust's default representation also gives Handle size 4 and Status size 1 with the current compiler, so the tests pass without transparent or u8. The attributes are what turn those numbers into guarantees that the C side can depend on.

Related errors and lints

  • E0793: a reference to a field of a packed struct, covered above.
  • E0587: a type has both packed and align(N). packed lowers the alignment to 1 and align raises it, so the compiler rejects the combination: "type has conflicting packed and align representation hints".
  • E0690: a transparent type with more than one non-trivial field.
  • E0084: an integer representation such as #[repr(u8)] on an enum with no variants: "unsupported representation for zero-variant enum".
  • Putting #[repr(C)] on something that is not a data type, such as a function, type alias, use or statement, is also an error. With Rust 1.98.1 the message is, for example, "#[repr(C)] attribute cannot be used on functions"; older compilers reported this as E0517.
  • Clippy's enum_clike_unportable_variant lint is denied by default. It reports a fieldless enum whose discriminant values would not fit on a 32-bit target, for example 0x1_0000_0000 in a #[repr(isize)] enum.
  • Clippy's default_union_representation (in the opt-in restriction group) flags a union without a repr attribute, and trailing_empty_array (in the opt-in nursery group) flags a struct ending in a zero-length array without a repr attribute. Both shapes usually mean an FFI type is missing #[repr(C)].

The grade requires the code to compile, pass the tests, and produce no warnings under cargo clippy -- -D warnings.