⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1456+2b1c6633aOn an older Zig?

Recipe: Struct Memory Layout

The problem

You need to know how a struct sits in memory: how big it is, where each field lands, and whether you can hand it to C or to hardware. Guessing from the field types is a mistake, because the default layout is allowed to reorder fields and insert padding. Zig gives you builtins to read the real numbers and keywords to pin them down when it matters.

The plan

  1. Inspect any type with @sizeOf, @alignOf, and @offsetOf.
  2. Force a field boundary with align(N).
  3. Use packed struct for bit-level fields with no padding, then @bitCast it to an integer.
  4. Use extern struct when the layout must match the C ABI.
const std = @import("std");

// Default (auto) layout. The compiler picks offsets for this target; it is
// free to add padding so each field sits on its natural alignment.
const Padded = struct {
    a: u8,
    b: u64,
    c: u32,
};

// One field forced to a 16-byte boundary. A field's alignment raises the
// whole struct's alignment to the largest of its fields.
const Aligned = struct {
    a: u8 align(16),
    b: u8,
};

// Packed: no padding, fields in declaration order, sub-byte integers allowed.
// Bit-cast to an unsigned integer of the same width for hardware registers.
const Register = packed struct {
    enable: bool, // bit 0
    mode: u3, // bits 1..3
    reserved: u4, // bits 4..7
    value: u8, // bits 8..15
};

// Extern: follows the platform C ABI, so it can cross an FFI boundary.
const CStruct = extern struct {
    a: u8,
    b: u32,
};

pub fn main(init: std.process.Init) !void {
    var buf: [1024]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    try out.print("Padded:   size={d} align={d} offsets a={d} b={d} c={d}\n", .{
        @sizeOf(Padded),   @alignOf(Padded),
        @offsetOf(Padded, "a"), @offsetOf(Padded, "b"), @offsetOf(Padded, "c"),
    });

    try out.print("Aligned:  size={d} align={d} offset b={d}\n", .{
        @sizeOf(Aligned), @alignOf(Aligned), @offsetOf(Aligned, "b"),
    });

    const reg = Register{ .enable = true, .mode = 0b101, .reserved = 0, .value = 42 };
    const raw: u16 = @bitCast(reg);
    try out.print("Register: size={d} raw=0x{X:0>4}\n", .{ @sizeOf(Register), raw });

    try out.print("CStruct:  size={d} offset b={d}\n", .{
        @sizeOf(CStruct), @offsetOf(CStruct, "b"),
    });

    try out.flush();
}

The default layout reorders fields

Padded is declared a: u8, b: u64, c: u32. A C programmer expects a at offset 0, seven bytes of padding, b at 8, c at 16, for 24 bytes. Zig’s default layout is auto, which is explicitly unspecified, and the current compiler uses that freedom: it places b at 0, c at 8, and a at 12, for a total of 16 bytes. Putting the largest field first removes the padding a naive order would need.

The lesson is not the specific numbers, which belong to this compiler and this target. It is that you must not depend on the field order of an auto struct. If a byte offset matters, read it with @offsetOf, or use a layout that guarantees order.

align(N) raises the whole struct

Aligned forces a to a 16-byte boundary. A field’s alignment cannot lower the struct’s alignment, only raise it, so the whole struct becomes 16-aligned and 16 bytes. Reach for this when a buffer must be cache-line aligned or when a device expects a specific boundary.

Packed structs give you bits

packed struct drops padding, keeps fields in declaration order, and allows sub-byte integers. Register packs a bool, a u3, a u4, and a u8 into exactly two bytes. @bitCast reinterprets it as a u16 with no copy: the fields fill the integer from the least significant bit up, so enable is bit 0 and value occupies the high byte. The result is 0x2A0B, which is how you would build a hardware register value or decode one.

Extern structs match C

extern struct follows the platform C ABI: fields stay in order, padding follows C rules, so CStruct is 8 bytes with b at offset 4. This is the layout to use across an FFI boundary. The ABI chapter covers passing these to and from C in full.

Variations

  • std.debug.print("{}", .{@typeInfo(T)}) dumps a type’s fields and their declared types when you want the whole picture at comptime.
  • @sizeOf versus @bitSizeOf: the second reports bit width, which is what packed fields actually consume.
  • Round-tripping bytes: once a layout is fixed with packed or extern, the binary round-trip recipe writes and reads it as raw bytes with defined endianness.