Skip to content

Packed Structs

A normal struct is laid out for ordinary program use. The compiler may add padding between fields so that each field has a suitable address.

A normal struct is laid out for ordinary program use. The compiler may add padding between fields so that each field has a suitable address.

A packed struct is different. Its fields are placed at exact bit positions.

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

This declares a packed type with three one-bit fields. Each bool field takes one bit.

A value is made in the usual way.

const flags = Flags{
    .read = true,
    .write = false,
    .execute = true,
};

A complete program:

const std = @import("std");

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

pub fn main() void {
    const flags = Flags{
        .read = true,
        .write = false,
        .execute = true,
    };

    std.debug.print("read = {}, write = {}, execute = {}\n", .{
        flags.read,
        flags.write,
        flags.execute,
    });
}

The output is:

read = true, write = false, execute = true

The syntax for field access does not change. A packed struct is still a struct. The difference is representation.

Packed structs are useful when the representation matters.

Common examples are bit flags, hardware registers, file formats, and network packet fields.

const Permissions = packed struct {
    owner_read: bool,
    owner_write: bool,
    owner_execute: bool,
    group_read: bool,
    group_write: bool,
    group_execute: bool,
    other_read: bool,
    other_write: bool,
    other_execute: bool,
};

This describes nine permission bits. A normal struct would usually use more space. A packed struct uses one bit for each bool.

A packed struct may use integer fields with explicit bit widths.

const Header = packed struct {
    version: u4,
    kind: u4,
    length: u16,
};

Here version uses 4 bits, kind uses 4 bits, and length uses 16 bits.

The total size is 24 bits.

const std = @import("std");

const Header = packed struct {
    version: u4,
    kind: u4,
    length: u16,
};

pub fn main() void {
    const h = Header{
        .version = 1,
        .kind = 3,
        .length = 1024,
    };

    std.debug.print("version = {d}\n", .{h.version});
    std.debug.print("kind = {d}\n", .{h.kind});
    std.debug.print("length = {d}\n", .{h.length});
}

A field of type u4 cannot hold a value larger than 15. The bit width is part of the type.

Packed structs can be converted to and from integers of the same bit size.

const Header = packed struct {
    version: u4,
    kind: u4,
    length: u16,
};

const raw: u24 = @bitCast(Header{
    .version = 1,
    .kind = 3,
    .length = 1024,
});

The reverse conversion is also a bit cast.

const h: Header = @bitCast(raw);

@bitCast keeps the bits and changes the type used to interpret them. It does not parse, validate, or reorder the data.

This is useful, but it should be used carefully. The meaning of the bits must already be known.

Packed structs may have methods.

const Header = packed struct {
    version: u4,
    kind: u4,
    length: u16,

    pub fn isLarge(self: Header) bool {
        return self.length > 1024;
    }
};

They may also have declarations.

const Header = packed struct {
    version: u4,
    kind: u4,
    length: u16,

    const default_version = 1;
};

A packed struct is not a general replacement for a normal struct. Use normal structs for ordinary data.

const User = struct {
    id: u64,
    name: []const u8,
    active: bool,
};

This is ordinary program data. The exact bit layout is not the point.

Use a packed struct when the exact layout is the point.

const StatusRegister = packed struct {
    carry: bool,
    zero: bool,
    interrupt_disable: bool,
    decimal: bool,
    break_flag: bool,
    unused: bool,
    overflow: bool,
    negative: bool,
};

This type describes an 8-bit register.

const raw: u8 = 0b1000_0011;
const status: StatusRegister = @bitCast(raw);

Now the fields may be read by name.

if (status.negative) {
    std.debug.print("negative\n", .{});
}

This is clearer than masking the integer by hand at every use site.

The hand-written version would look like this:

const negative = (raw & 0b1000_0000) != 0;

The packed struct puts those names in one place.

There are still limits. A packed struct describes a bit layout inside a value. It does not solve byte order by itself. When bytes come from a file or network, the program must still handle endianness correctly.

For example, reading two bytes into a u16 depends on the chosen byte order. Only after the integer has the intended value should it be bit-cast into a packed struct.

A packed struct may expose unaligned fields. Taking pointers to such fields is restricted because the field may not begin at an address boundary suitable for its type.

For most code, this rule has a simple consequence: read and write packed fields by value. Do not design APIs that require pointers to individual packed fields.

Packed structs are a low-level feature. They are precise, but they are also easy to misuse. They should appear near the boundary where the program touches hardware, binary formats, or external data.

Exercises.

7-21. Define a packed struct with three boolean fields: read, write, and execute.

7-22. Define a packed struct for one byte with fields low: u4 and high: u4.

7-23. Convert a u8 value to the packed byte struct with @bitCast, then print both fields.

7-24. Define a packed struct for an 8-bit status register. Give each bit a name.