⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.2122+3e15e99e6What's newOn an older Zig?

ABI

const std = @import("std");
const expect = std.testing.expect;

// `extern struct` guarantees C layout: declaration order, C padding rules.
const CPoint = extern struct {
    x: i32,
    y: i32,
};

// `packed struct` is bit-level and backed by an integer, with no padding.
const Flags = packed struct {
    a: bool,
    b: bool,
    rest: u6,
};

// A plain struct may be reordered and padded however the compiler likes.
const Loose = struct {
    small: u8,
    big: u64,
};

test "extern struct follows C layout" {
    try expect(@sizeOf(CPoint) == 8);
    try expect(@offsetOf(CPoint, "x") == 0);
    try expect(@offsetOf(CPoint, "y") == 4);
}

test "packed struct is exactly its bits" {
    try expect(@bitSizeOf(Flags) == 8);
    try expect(@sizeOf(Flags) == 1);

    const f = Flags{ .a = true, .b = false, .rest = 0 };
    // Packed structs convert to their backing integer.
    try expect(@as(u8, @bitCast(f)) == 1);
}

test "plain structs make no layout promise" {
    // Zig is free to order these for packing, so do not assume offsets.
    try expect(@sizeOf(Loose) >= 9);
}

test "extern union and enum" {
    const E = extern union { i: i32, f: f32 };
    try expect(@sizeOf(E) == 4);

    // An enum with a C ABI tag type.
    const Colour = enum(c_int) { red, green, blue };
    try expect(@intFromEnum(Colour.green) == 1);
}

// `callconv(.c)` makes a Zig function callable from C.
export fn addFromC(a: c_int, b: c_int) callconv(.c) c_int {
    return a + b;
}

test "exported function is callable from Zig too" {
    try expect(addFromC(2, 3) == 5);
}

A plain Zig struct makes no layout promise. Fields may be reordered and padded however the compiler prefers. That is a feature (it lets the optimiser pack things), but it means you cannot hand one to C.

The three layouts

DeclarationLayoutUse for
structunspecifiedordinary Zig code
extern structC ABI: declaration order, C paddingC interop
packed structexact bits, no padding, integer-backedwire formats, registers
const CPoint = extern struct { x: i32, y: i32 };   // offsets 0 and 4
const Flags = packed struct { a: bool, b: bool, rest: u6 };  // exactly 8 bits

A packed struct converts to and from its backing integer with @bitCast, which makes it the natural way to describe hardware registers and protocol headers.

What “ABI” means

The application binary interface is the set of agreements two separately compiled pieces of code need in order to call each other. Where a struct’s fields sit. Which registers hold which arguments. Who cleans up the stack. How a name appears in the symbol table. None of it is in the source language. All of it is decided by the platform, and both sides have to already agree, because by the time they meet there is no type information left to check.

That is why the mistakes here are quiet. A mismatched field offset is not a compile error in either language; it is a program that reads the wrong four bytes and keeps going.

Verifying, not assuming

@sizeOf, @offsetOf, and @bitSizeOf are comptime values, so a layout assumption can be asserted at compile time:

comptime {
    std.debug.assert(@offsetOf(CPoint, "y") == 4);
}

Worth doing at any real C boundary: it turns a silent mismatch into a build failure.

The stronger version of the same idea is to not hand-write the declaration at all. translate-c reads the header and produces the struct, so there is nothing to get out of step; see importing C. Hand-written extern declarations are for the cases where no header exists, and those are exactly the cases that need the assertions.

Bit order is not portable

packed struct gives you exact bit positions, which is what a hardware register or a protocol header needs. What it does not give you is agreement with a C compiler’s bitfields. C leaves the ordering of bits within a unit implementation-defined, so two compilers can lay out the same header differently.

For a wire format, that is fine and even an advantage, because you are defining the layout rather than matching someone else’s. For matching a C bitfield exactly, check it against the C compiler you care about rather than assuming.

Byte order is a separate question again, and packed struct says nothing about it. A protocol header read from a socket needs std.mem.bigToNative or an explicit byte-by-byte decode; @bitCast gives you the host’s order.

Calling conventions

callconv(.c) gives a function the C calling convention, and export gives it an unmangled symbol name:

export fn add(a: c_int, b: c_int) callconv(.c) c_int {
    return a + b;
}

That is all it takes for C to call Zig. Going the other way, declare the C function with extern:

extern "c" fn abs(n: c_int) c_int;

export does both jobs. It gives the function an unmangled symbol name and the C calling convention, so the callconv(.c) above spells out what export already implies. Writing it costs nothing and says what you meant. @export(&add, .{ .name = "add" }) is the case where you supply the convention yourself, because it exports a function that keeps whatever convention it declared.

One more thing does not cross the boundary: Zig errors. An export function returning !void has no C representation, so a function meant to be called from C returns a status code and keeps its error handling inside.

Do not extern everything

extern struct costs you the packing the optimiser would otherwise do. Use it at the boundary, and use ordinary structs inside.

It also freezes your layout as part of the interface. A plain struct can gain a field with no consequence beyond a recompile. An extern struct that C code allocates is a promise about size that you cannot change without breaking whatever was compiled against it.