zig-skills/references/c-interop.md

20 KiB

C Interoperability Reference (Zig 0.16.0)

Zig can export C-compatible APIs for use from any language that supports the C ABI: Swift, Objective-C, Python, Ruby, Rust, etc. This enables architectures like Ghostty (93% Zig business logic + 4% platform-native GUI).

Primary Zig 0.16 release-note source: https://ziglang.org/download/0.16.0/release-notes.html

Zig 0.16 translate-c is Aro/translate-c based rather than libclang based. @cImport is deprecated as the long-term API; prefer b.addTranslateC(...) in build scripts and import translate_c.createModule().

For ABI-sensitive bindings, keep comptime assertions for @sizeOf, @alignOf, field offsets, enum/flag values, and calling conventions. Treat generated binding differences as high-risk until checked.

Table of Contents

Quick Start

Minimal C-compatible library:

src/lib.zig:

const std = @import("std");

// Global state (opaque to C consumers). This minimal example is not
// thread-safe; init must be called exactly once before get/set/deinit.
var context: ?*Context = null;

const Context = struct {
    allocator: std.mem.Allocator,
    value: i32,
};

/// Initialize the library. Returns 0 on success, -1 on failure.
export fn mylib_init() c_int {
    if (context != null) return -1;
    const gpa = std.heap.c_allocator;
    context = gpa.create(Context) catch return -1;
    context.?.* = .{ .allocator = gpa, .value = 0 };
    return 0;
}

/// Clean up resources.
export fn mylib_deinit() void {
    if (context) |ctx| {
        ctx.allocator.destroy(ctx);
        context = null;
    }
}

/// Get the current value.
export fn mylib_get_value() c_int {
    return if (context) |ctx| ctx.value else 0;
}

/// Set the value.
export fn mylib_set_value(v: c_int) void {
    if (context) |ctx| ctx.value = v;
}

build.zig:

const std = @import("std");

pub fn build(b: *std.Build) void {
    const target = b.standardTargetOptions(.{});
    const optimize = b.standardOptimizeOption(.{});

    const lib = b.addLibrary(.{
        .name = "mylib",
        .linkage = .static,
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/lib.zig"),
            .target = target,
            .optimize = optimize,
            .link_libc = true,
        }),
    });

    b.installArtifact(lib);

    // Install header alongside library
    b.installFile("include/mylib.h", "include/mylib.h");
}

include/mylib.h:

#ifndef MYLIB_H
#define MYLIB_H

#ifdef __cplusplus
extern "C" {
#endif

int mylib_init(void);
void mylib_deinit(void);
int mylib_get_value(void);
void mylib_set_value(int v);

#ifdef __cplusplus
}
#endif

#endif /* MYLIB_H */

Exporting Functions

export Keyword

The export keyword creates a function with C ABI linkage:

// Creates symbol "add" with C calling convention
export fn add(a: c_int, b: c_int) c_int {
    return a + b;
}

Equivalent to:

fn add(a: c_int, b: c_int) callconv(.c) c_int {
    return a + b;
}
comptime {
    @export(&add, .{ .name = "add" });
}

Custom Symbol Names

Use @export for custom symbol names:

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

comptime {
    @export(&zigAdd, .{ .name = "mylib_add" });  // Symbol: mylib_add
}

Calling Convention

For internal C-callable functions (not exported):

// C calling convention, but not exported as symbol
fn internalCallback(data: ?*anyopaque) callconv(.c) void {
    // Called by C code via function pointer
}

Restrictions on Exported Functions

Exported function signatures are limited to C-compatible constructs:

Allowed:

  • C integer types: c_int, c_uint, c_long, c_ulong, c_char, etc.
  • Fixed-width integers matching C: i8, i16, i32, i64, u8, u16, u32, u64
  • Floating point: f32 (float), f64 (double)
  • Pointers: *T, [*]T, [*c]T, ?*T
  • bool (maps to C _Bool)
  • void
  • usize, isize (map to size_t, ptrdiff_t)

Not allowed in signatures:

  • Comptime parameters
  • Generic types (anytype)
  • Zig error unions (!T)
  • Zig optionals (except optional pointers ?*T)
  • Slices ([]T) - use pointer + length instead
  • Non-extern structs/unions/enums
  • Arbitrary bit-width integers (u3, i47)

Inside the function body, all Zig features work:

export fn process(data: [*]const u8, len: usize) c_int {
    // Inside: full Zig features
    const slice = data[0..len];

    for (slice) |byte| {
        if (byte == 0) return -1;
    }

    return @intCast(slice.len);
}

C-Compatible Types

Integer Type Mapping

Zig Type C Type Notes
c_char char Signed or unsigned (platform-dependent)
c_short short
c_int int
c_long long 32-bit on Windows, 64-bit elsewhere
c_longlong long long
c_uchar unsigned char
c_ushort unsigned short
c_uint unsigned int
c_ulong unsigned long
c_ulonglong unsigned long long
usize size_t
isize ptrdiff_t
i8/u8 int8_t/uint8_t
i16/u16 int16_t/uint16_t
i32/u32 int32_t/uint32_t
i64/u64 int64_t/uint64_t

Pointer Type Mapping

Zig Type C Equivalent Notes
*T T* Non-null pointer
?*T T* Nullable pointer
[*]T T* Many-item pointer
[*c]T T* C pointer (nullable, arithmetic allowed)
*const T const T* Const pointer

Extern Structs

For structs passed across FFI boundary:

// Extern struct: C-compatible layout
pub const Point = extern struct {
    x: f64,
    y: f64,
};

// Can be passed by value or pointer
export fn distance(a: Point, b: Point) f64 {
    const dx = a.x - b.x;
    const dy = a.y - b.y;
    return @sqrt(dx * dx + dy * dy);
}

Extern Unions

pub const Value = extern union {
    i: c_int,
    f: f32,
    p: ?*anyopaque,
};

Extern Enums

// Specify backing type for C compatibility
pub const Status = enum(c_int) {
    ok = 0,
    err_invalid = -1,
    err_nomem = -2,
};

export fn get_status() Status {
    return .ok;
}

Building C Libraries

Static Library

const lib = b.addLibrary(.{
    .name = "mylib",
    .linkage = .static,  // Creates libmylib.a
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/lib.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true, // Required by c_allocator or direct libc calls.
    }),
});
b.installArtifact(lib);

Dynamic/Shared Library

const lib = b.addLibrary(.{
    .name = "mylib",
    .linkage = .dynamic,  // Creates libmylib.so / libmylib.dylib / mylib.dll
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/lib.zig"),
        .target = target,
        .optimize = optimize,
        .link_libc = true,
    }),
    .version = .{ .major = 1, .minor = 0, .patch = 0 },
});

b.installArtifact(lib);

Cross-Compilation

Build for specific targets:

// Build for Apple Silicon Mac
const mac_arm = b.resolveTargetQuery(.{
    .cpu_arch = .aarch64,
    .os_tag = .macos,
});

const lib = b.addLibrary(.{
    .name = "mylib",
    .linkage = .static,
    .root_module = b.createModule(.{
        .root_source_file = b.path("src/lib.zig"),
        .target = mac_arm,
        .optimize = .ReleaseFast,
    }),
});

Multi-Target Build

const targets = [_]std.Target.Query{
    .{ .cpu_arch = .x86_64, .os_tag = .macos },
    .{ .cpu_arch = .aarch64, .os_tag = .macos },
    .{ .cpu_arch = .x86_64, .os_tag = .linux, .abi = .gnu },
    .{ .cpu_arch = .aarch64, .os_tag = .linux, .abi = .gnu },
};

for (targets) |t| {
    const resolved = b.resolveTargetQuery(t);
    const lib = b.addLibrary(.{
        .name = b.fmt("mylib-{s}-{s}", .{
            @tagName(t.cpu_arch.?),
            @tagName(t.os_tag.?),
        }),
        .linkage = .static,
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/lib.zig"),
            .target = resolved,
            .optimize = .ReleaseFast,
        }),
    });
    b.installArtifact(lib);
}

Creating Header Files

Zig does not auto-generate C headers. Write them manually to match exported symbols.

Header Template

#ifndef MYLIB_H
#define MYLIB_H

#include <stdint.h>
#include <stdbool.h>
#include <stddef.h>

#ifdef __cplusplus
extern "C" {
#endif

/* Opaque handle type */
typedef struct mylib_context mylib_context_t;

/* Lifecycle */
mylib_context_t* mylib_create(void);
void mylib_destroy(mylib_context_t* ctx);

/* Operations */
int mylib_process(mylib_context_t* ctx, const uint8_t* data, size_t len);
const char* mylib_get_error(mylib_context_t* ctx);

/* Callback type */
typedef void (*mylib_callback_t)(void* user_data, int result);
void mylib_set_callback(mylib_context_t* ctx, mylib_callback_t cb, void* user_data);

#ifdef __cplusplus
}
#endif

#endif /* MYLIB_H */

Matching Zig Implementation

const std = @import("std");

pub const Context = struct {
    allocator: std.mem.Allocator,
    // Storage is owned by this context and remains valid until the next
    // operation that changes the error or until mylib_destroy.
    error_msg: ?[:0]const u8 = null,
    callback: ?Callback = null,

    const Callback = struct {
        func: *const fn (?*anyopaque, c_int) callconv(.c) void,
        user_data: ?*anyopaque,
    };
};

export fn mylib_create() ?*Context {
    const allocator = std.heap.c_allocator;
    const ctx = allocator.create(Context) catch return null;
    ctx.* = .{ .allocator = allocator };
    return ctx;
}

export fn mylib_destroy(ctx: ?*Context) void {
    if (ctx) |c| {
        c.allocator.destroy(c);
    }
}

export fn mylib_process(ctx: ?*Context, data: [*]const u8, len: usize) c_int {
    const c = ctx orelse return -1;
    const slice = data[0..len];

    // Process data...
    _ = slice;

    if (c.callback) |cb| {
        cb.func(cb.user_data, 0);
    }

    return 0;
}

export fn mylib_get_error(ctx: ?*Context) [*:0]const u8 {
    const c = ctx orelse return "null context";
    return if (c.error_msg) |msg|
        msg.ptr
    else
        "no error";
}

export fn mylib_set_callback(
    ctx: ?*Context,
    cb: ?*const fn (?*anyopaque, c_int) callconv(.c) void,
    user_data: ?*anyopaque,
) void {
    if (ctx) |c| {
        c.callback = if (cb) |f| .{ .func = f, .user_data = user_data } else null;
    }
}

macOS Integration

Universal Binaries (Fat Binaries)

Build for both architectures and combine with lipo:

build.zig:

pub fn build(b: *std.Build) void {
    const optimize = b.standardOptimizeOption(.{});

    // Build for both architectures
    const arm64 = b.resolveTargetQuery(.{ .cpu_arch = .aarch64, .os_tag = .macos });
    const x86_64 = b.resolveTargetQuery(.{ .cpu_arch = .x86_64, .os_tag = .macos });

    const lib_arm64 = b.addLibrary(.{
        .name = "mylib",
        .linkage = .static,
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/lib.zig"),
            .target = arm64,
            .optimize = optimize,
        }),
    });

    const lib_x86_64 = b.addLibrary(.{
        .name = "mylib",
        .linkage = .static,
        .root_module = b.createModule(.{
            .root_source_file = b.path("src/lib.zig"),
            .target = x86_64,
            .optimize = optimize,
        }),
    });

    // Use lipo to create universal binary
    const lipo = b.addSystemCommand(&.{
        "lipo", "-create", "-output",
    });
    const universal_lib = lipo.addOutputFileArg("libmylib.a");
    lipo.addFileArg(lib_arm64.getEmittedBin());
    lipo.addFileArg(lib_x86_64.getEmittedBin());

    // Install universal binary
    const install = b.addInstallFile(universal_lib, "lib/libmylib.a");

    const universal_step = b.step("universal", "Build universal binary");
    universal_step.dependOn(&install.step);
}

Manual lipo usage:

# Build each architecture
zig build -Dtarget=aarch64-macos -Doptimize=ReleaseFast
mv zig-out/lib/libmylib.a libmylib-arm64.a

zig build -Dtarget=x86_64-macos -Doptimize=ReleaseFast
mv zig-out/lib/libmylib.a libmylib-x86_64.a

# Combine into universal binary
lipo -create -output libmylib.a libmylib-arm64.a libmylib-x86_64.a

# Verify architectures
lipo -info libmylib.a

XCFramework Creation

XCFrameworks are the modern way to distribute libraries for Apple platforms:

# 1. Build universal library (see above)

# 2. Create directory structure
mkdir -p MyLib.xcframework/macos-arm64_x86_64/Headers

# 3. Copy library and headers
cp libmylib.a MyLib.xcframework/macos-arm64_x86_64/
cp include/mylib.h MyLib.xcframework/macos-arm64_x86_64/Headers/

# 4. Create module map
cat > MyLib.xcframework/macos-arm64_x86_64/Headers/module.modulemap << 'EOF'
module MyLib {
    umbrella header "mylib.h"
    export *
}
EOF

# 5. Create Info.plist
cat > MyLib.xcframework/Info.plist << 'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>AvailableLibraries</key>
    <array>
        <dict>
            <key>HeadersPath</key>
            <string>Headers</string>
            <key>LibraryIdentifier</key>
            <string>macos-arm64_x86_64</string>
            <key>LibraryPath</key>
            <string>libmylib.a</string>
            <key>SupportedArchitectures</key>
            <array>
                <string>arm64</string>
                <string>x86_64</string>
            </array>
            <key>SupportedPlatform</key>
            <string>macos</string>
        </dict>
    </array>
    <key>CFBundlePackageType</key>
    <string>XFWK</string>
    <key>XCFrameworkFormatVersion</key>
    <string>1.0</string>
</dict>
</plist>
EOF

Using xcodebuild (simpler):

xcodebuild -create-xcframework \
    -library libmylib.a \
    -headers include/ \
    -output MyLib.xcframework

Swift Integration

Module Map

Create module.modulemap alongside your header:

module MyLib {
    umbrella header "mylib.h"
    export *
}

Using from Swift

import MyLib

// Use C functions directly
let result = mylib_init()
if result == 0 {
    mylib_set_value(42)
    print("Value: \(mylib_get_value())")
    mylib_deinit()
}

Swift-Friendly Wrapper

import MyLib

class MyLibWrapper {
    private var initialized = false

    init?() {
        guard mylib_init() == 0 else { return nil }
        initialized = true
    }

    deinit {
        if initialized {
            mylib_deinit()
        }
    }

    var value: Int32 {
        get { mylib_get_value() }
        set { mylib_set_value(newValue) }
    }
}

Xcode Project Integration

  1. Drag MyLib.xcframework into Xcode project
  2. Ensure "Embed & Sign" or "Do Not Embed" (for static libs)
  3. Import module: import MyLib

For static libraries without XCFramework:

  1. Add library to "Link Binary With Libraries"
  2. Add header path to "Header Search Paths"
  3. Create bridging header if not using module map

Improving Swift Interop (Advanced)

For better Swift projection, use API notes (.apinotes files):

MyLib.apinotes:

Name: MyLib
Functions:
  - Name: mylib_create
    SwiftName: "MyLibContext.create()"
    NullabilityOfRet: O  # Nullable C pointer; imported as Optional in Swift
  - Name: mylib_destroy
    SwiftName: "MyLibContext.destroy(self:)"
  - Name: mylib_get_error
    NullabilityOfRet: N
    ResultType: "const char * _Nonnull"

See Swift.org: Improving the Usability of C APIs for more.

Common Patterns

Opaque Pointers

Hide implementation details from C consumers:

const std = @import("std");

const InternalState = struct {
    allocator: std.mem.Allocator,
    data: std.ArrayList(u8),
    // Complex internal state...
};

// C sees: typedef struct handle handle_t;
// (opaque, can't access fields)

export fn handle_create() ?*InternalState {
    const allocator = std.heap.c_allocator;
    const state = allocator.create(InternalState) catch return null;
    state.* = .{
        .allocator = allocator,
        .data = std.ArrayList(u8).init(allocator),
    };
    return state;
}

export fn handle_destroy(h: ?*InternalState) void {
    if (h) |state| {
        state.data.deinit();
        state.allocator.destroy(state);
    }
}

Error Handling Across FFI

Zig errors can't cross FFI boundary. Use return codes or out parameters:

pub const ErrorCode = enum(c_int) {
    ok = 0,
    invalid_argument = -1,
    out_of_memory = -2,
    io_error = -3,
    unknown = -99,
};

export fn process_data(
    data: [*]const u8,
    len: usize,
    out_result: *c_int,
) ErrorCode {
    const slice = data[0..len];

    // Internal Zig code can use errors
    const result = processInternal(slice) catch |err| {
        return switch (err) {
            error.OutOfMemory => .out_of_memory,
            error.InvalidData => .invalid_argument,
            else => .unknown,
        };
    };

    out_result.* = result;
    return .ok;
}

fn processInternal(data: []const u8) !c_int {
    // Full Zig error handling here
    if (data.len == 0) return error.InvalidData;
    return @intCast(data.len);
}

Callbacks

C callbacks with user data:

const CallbackFn = *const fn (
    user_data: ?*anyopaque,
    event_type: c_int,
    event_data: ?*const anyopaque,
) callconv(.c) void;

var stored_callback: ?CallbackFn = null;
var stored_user_data: ?*anyopaque = null;

export fn register_callback(cb: ?CallbackFn, user_data: ?*anyopaque) void {
    stored_callback = cb;
    stored_user_data = user_data;
}

export fn trigger_event(event_type: c_int) void {
    if (stored_callback) |cb| {
        cb(stored_user_data, event_type, null);
    }
}

String Handling

Zig slices vs C strings:

const std = @import("std");

// Accept C string, return length
export fn string_length(s: [*:0]const u8) usize {
    return std.mem.len(s);
}

// Accept pointer + length (more efficient)
export fn process_string(s: [*]const u8, len: usize) c_int {
    const slice = s[0..len];
    // Process slice...
    _ = slice;
    return 0;
}

// Return C string (must be static or allocated)
const greeting: [:0]const u8 = "Hello from Zig!";

export fn get_greeting() [*:0]const u8 {
    return greeting.ptr;
}

// Allocate a sentinel-terminated string. The caller must pass the same
// payload length to free_string; the sentinel is stored at index len.
export fn alloc_string(len: usize) ?[*:0]u8 {
    const allocator = std.heap.c_allocator;
    const buf = allocator.allocSentinel(u8, len, 0) catch return null;
    return buf.ptr;
}

export fn free_string(s: ?[*:0]u8, len: usize) void {
    if (s) |ptr| {
        const allocator = std.heap.c_allocator;
        allocator.free(ptr[0..len :0]);
    }
}

Thread Safety

For simple exported counters, atomics avoid the need to expose an I/O context across the C ABI. More complex synchronization in Zig 0.16 uses std.Io.Mutex, whose lock operations take an std.Io value; design that runtime context into the library rather than using the removed std.Thread.Mutex API.

const std = @import("std");

var atomic_counter: std.atomic.Value(c_int) = .init(0);

export fn atomic_increment() c_int {
    return atomic_counter.fetchAdd(1, .seq_cst) + 1;
}

Versioning

Export version info for runtime checking:

pub const version_major: c_int = 1;
pub const version_minor: c_int = 2;
pub const version_patch: c_int = 3;

comptime {
    @export(&version_major, .{ .name = "mylib_version_major" });
    @export(&version_minor, .{ .name = "mylib_version_minor" });
    @export(&version_patch, .{ .name = "mylib_version_patch" });
}

export fn mylib_version_string() [*:0]const u8 {
    return "1.2.3";
}

Header:

extern const int mylib_version_major;
extern const int mylib_version_minor;
extern const int mylib_version_patch;
const char* mylib_version_string(void);