Zig Packed Structs

A packed struct places fields side by side in memory with no gaps between them. Regular structs insert padding bytes between fields to satisfy CPU alignment requirements. Packed structs skip that padding, giving you exact control over every bit — essential for network protocols, hardware registers, binary file formats, and any situation where the memory layout must match an external specification precisely.

Regular Struct vs Packed Struct

  Regular struct:
  struct { a: u8, b: u32, c: u8 }

  Memory layout (typical x86):
  [ a (1B) ][ pad (3B) ][ b (4B) ][ c (1B) ][ pad (3B) ]
  Total: 12 bytes (padding added for alignment)

  Packed struct:
  packed struct { a: u8, b: u32, c: u8 }

  Memory layout:
  [ a (1B) ][ b (4B) ][ c (1B) ]
  Total: 6 bytes (no padding, exact fit)
const Regular = struct { a: u8, b: u32, c: u8 };
const Packed  = packed struct { a: u8, b: u32, c: u8 };

std.debug.print("Regular size: {d}\n", .{@sizeOf(Regular)}); // 8 or 12
std.debug.print("Packed size:  {d}\n", .{@sizeOf(Packed)});  // 6

Bit Fields in Packed Structs

Packed structs support integer fields of any bit width — not just the standard 8, 16, 32, 64. This lets you pack multiple small values into a single byte or word:

const IPv4Header = packed struct {
    ihl:      u4,   // 4 bits: internet header length
    version:  u4,   // 4 bits: IP version (always 4)
    dscp:     u6,   // 6 bits: differentiated services
    ecn:      u2,   // 2 bits: explicit congestion notification
    length:   u16,  // 16 bits: total packet length
    id:       u16,  // 16 bits: identification
    flags:    u3,   // 3 bits: flags
    offset:   u13,  // 13 bits: fragment offset
    ttl:      u8,   // 8 bits: time to live
    protocol: u8,   // 8 bits: protocol (TCP=6, UDP=17)
    checksum: u16,  // 16 bits: header checksum
    src_ip:   u32,  // 32 bits: source IP
    dst_ip:   u32,  // 32 bits: destination IP
};
// Total: 4+4+6+2+16+16+3+13+8+8+16+32+32 = 160 bits = 20 bytes
// Exactly the IPv4 header size specified in RFC 791
  IPv4 Header (20 bytes):
  ┌────────┬────────┬──────────────────────────────────┐
  │version │  ihl   │     dscp (6)    │  ecn (2)       │  byte 0-1
  │ (4bit) │ (4bit) │                                  │
  ├────────────────────────────────────────────────────┤
  │              total length (16 bits)                │  byte 2-3
  ├────────────────────────────────────────────────────┤
  │         identification (16 bits)                   │  byte 4-5
  ├───────┬───────────────────────────────────────────-┤
  │ flags │          fragment offset (13 bits)         │  byte 6-7
  │ (3bit)│                                            │
  ├───────────────────┬────────────────────────────────┤
  │    ttl (8 bits)   │     protocol (8 bits)          │  byte 8-9
  ├────────────────────────────────────────────────────┤
  │              header checksum (16 bits)             │  byte 10-11
  ├────────────────────────────────────────────────────┤
  │              source IP address (32 bits)           │  byte 12-15
  ├────────────────────────────────────────────────────┤
  │           destination IP address (32 bits)         │  byte 16-19
  └────────────────────────────────────────────────────┘

Hardware Register Example

Embedded systems use memory-mapped registers — specific memory addresses that control hardware. Packed structs map perfectly to register layouts:

const UartControl = packed struct {
    enable:       bool, // bit 0: enable UART
    tx_enable:    bool, // bit 1: enable transmit
    rx_enable:    bool, // bit 2: enable receive
    parity_en:    bool, // bit 3: enable parity
    parity_odd:   bool, // bit 4: odd parity (else even)
    stop_bits_2:  bool, // bit 5: 2 stop bits (else 1)
    _reserved:    u2,   // bits 6-7: unused
};

// Point to the hardware register address (embedded systems)
const UART0_CTRL = @as(*volatile UartControl, @ptrFromInt(0x4000_0000));

// Enable UART with TX, RX, even parity, 1 stop bit
UART0_CTRL.* = UartControl{
    .enable      = true,
    .tx_enable   = true,
    .rx_enable   = true,
    .parity_en   = true,
    .parity_odd  = false,
    .stop_bits_2 = false,
    ._reserved   = 0,
};

Packed Struct as an Integer

A packed struct can be cast to and from an integer of the same bit width. This lets you serialize, compare, or transmit packed structs as raw numbers:

const Flags = packed struct {
    read:    bool,
    write:   bool,
    execute: bool,
    _pad:    u5,
};

const perms = Flags{ .read = true, .write = true, .execute = false, ._pad = 0 };

// Cast to u8 to inspect raw bits
const raw: u8 = @bitCast(perms);
std.debug.print("Permissions byte: 0b{b:0>8}\n", .{raw}); // 0b00000011
                                                            // read=1, write=1, exec=0

// Reconstruct from an integer
const from_byte: Flags = @bitCast(@as(u8, 0b00000101)); // read+execute
std.debug.print("Read: {}, Execute: {}\n", .{from_byte.read, from_byte.execute});
  Flags packed struct in memory (8 bits):
  bit: 7 6 5 4 3 2 | 1     | 0
       [  _pad    ] [write] [read]

  read=1, write=1 → 0b00000011 = 3
  read=1, exec=1  → 0b00000101 = 5

Bit Extraction and Manipulation

const Color = packed struct {
    r: u8,  // bits 0-7
    g: u8,  // bits 8-15
    b: u8,  // bits 16-23
    a: u8,  // bits 24-31
};

const red   = Color{ .r = 255, .g = 0,   .b = 0,   .a = 255 };
const green = Color{ .r = 0,   .g = 255, .b = 0,   .a = 255 };

// Cast to u32 to get the raw RGBA integer
const red_int:   u32 = @bitCast(red);
const green_int: u32 = @bitCast(green);

std.debug.print("Red   as u32: 0x{X:0>8}\n", .{red_int});   // 0xFF0000FF
std.debug.print("Green as u32: 0x{X:0>8}\n", .{green_int}); // 0xFF00FF00

Constraints on Packed Structs

  Allowed field types in packed structs:
  ✓ Integers of any bit width (u1, u3, u7, u13, u32, ...)
  ✓ bool (stored as 1 bit)
  ✓ Other packed structs
  ✓ Packed unions
  ✓ Arrays of the above

  NOT allowed:
  ✗ Regular structs (not packed)
  ✗ Slices ([]T) — they are fat pointers, not fixed-width
  ✗ Optional pointers ?*T
  ✗ Floats (f32, f64) — allowed but alignment may cause issues

extern struct vs packed struct

  packed struct:
  - Bits packed with no gaps
  - Supports sub-byte fields (u3, u5, bool)
  - Used for bit-level protocols and hardware registers

  extern struct:
  - Same layout as C would produce
  - Fields aligned to their natural alignment
  - Padding inserted between fields (same as C)
  - Used for C interop where field-level layout matches C struct

Choose packed struct when you need bit-level packing. Choose extern struct when you need to match a C struct layout for interoperability.

Leave a Comment

Your email address will not be published. Required fields are marked *