⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.18.0-dev.624+b7f2c6d8fWhat's newOn an older Zig?Blograylib-zig

ABI

Updated · Code verified against Zig 0.18.0-dev.624+b7f2c6d8f

To pass a struct from Zig to C, declare it as extern struct. An extern struct uses the field order and padding of the C ABI. A packed struct gives exact bits with no padding, for registers and wire formats.

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

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

// `packed struct` has exact bits and no padding. An integer holds the bits.
const Flags = packed struct {
    a: bool,
    b: bool,
    rest: u6,
};

// The compiler can change the order of the fields of a plain struct, and add
// padding in any way that it selects.
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 can put these fields in any order for packing. 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)` lets C call a Zig function.
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. The compiler can put the fields in a different order and add padding in any way that it prefers. This freedom is a feature, because it lets the optimiser pack the fields. But you cannot give a plain struct 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. Thus a packed struct is the natural way to describe hardware registers and protocol headers.

What “ABI” means

The application binary interface is a set of agreements. Two pieces of code that are compiled separately need these agreements to call each other:

  • The position of the fields of a struct.
  • The registers that hold each argument.
  • The side that cleans the stack.
  • The form of a name in the symbol table.

The source language does not specify these items. The platform decides them. The two sides must agree before they meet, because at that time no type information remains to check.

Because nothing remains to check, a mistake here makes no noise. An incorrect field offset is not a compile error in either language. The result is a program that reads the wrong four bytes and continues.

Verify the layout

@sizeOf, @offsetOf and @bitSizeOf are comptime values. Thus you can assert a layout assumption at compile time:

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

Add these assertions at each real C boundary. An assertion changes a silent mismatch into a build failure.

A stronger version of the same idea is to not write the declaration by hand. translate-c reads the header and makes the struct. Thus the struct and the header cannot become different. See import C. Write an extern declaration by hand only when no header exists. Those cases are exactly the cases that need the assertions.

Bit order is not portable

packed struct gives you exact bit positions. A hardware register or a protocol header needs exact bit positions. But a packed struct does not always agree with the bitfields of a C compiler. In C, the order of bits in a unit is implementation-defined. Thus two compilers can put the bits of the same header in different positions.

For a wire format, this is not a problem. It is even an advantage, because you define the layout. You do not copy the layout of a different program. If you must match a C bitfield exactly, do not assume. Compare the layout with the C compiler that you use.

Byte order is a different question, and packed struct says nothing about it. A protocol header that you read from a socket needs std.mem.bigToNative or an explicit decode, one byte at a time. @bitCast gives you the byte order of the host.

Calling conventions

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

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

C needs nothing more to call Zig. To call C from Zig, declare the C function with extern:

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

export does the two jobs. It gives the function an unmangled symbol name and the C calling convention. Thus the callconv(.c) above only states what export already gives. The extra callconv(.c) costs nothing, and it shows what you intend.

With @export(&add, .{ .name = "add" }), you must supply the convention yourself. @export exports a function, and the function keeps the convention that it declares.

Zig errors also do not cross the boundary. If an export function returns !void, C has no representation for the return type. Thus a function for C callers returns a status code, and keeps its error handling inside.

Do not extern everything

An extern struct stops the packing that the optimiser can do for a plain struct. Use extern struct at the boundary, and use ordinary structs inside.

An extern struct also makes your layout fixed, as part of the interface. You can add a field to a plain struct, and the only result is a recompile. An extern struct that C code allocates is a promise about size. If you change the size, all code that was compiled against the old size breaks.