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
- Inspect any type with
@sizeOf,@alignOf, and@offsetOf. - Force a field boundary with
align(N). - Use
packed structfor bit-level fields with no padding, then@bitCastit to an integer. - Use
extern structwhen 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.@sizeOfversus@bitSizeOf: the second reports bit width, which is what packed fields actually consume.- Round-tripping bytes: once a layout is fixed with
packedorextern, the binary round-trip recipe writes and reads it as raw bytes with defined endianness.