Skip to lesson

learningzig.org / advanced / 14-c-interop · lesson 14 of 25

TL;DR

Call C from Zig with zero overhead — use @cImport to include C headers, link C libraries, and pass data between Zig and C seamlessly. Free tutorial with examples.

Key concepts

  • zig c interop
  • call c from zig
  • zig cImport
  • zig c library
  • zig ffi
  • zig c integration
  • zig c header import

C Interop

Zig has first-class C interoperability. You can call C functions, use C headers with @cImport, and link against C libraries — all without any bindings generator or FFI overhead.

C Types in Zig

Zig provides built-in C type equivalents for seamless interop.

const std = @import("std");

pub fn main() !void {
    // Zig's C-compatible types
    const types = .{
        .{ "c_char", @sizeOf(u8) },
        .{ "c_short", @sizeOf(c_short) },
        .{ "c_int", @sizeOf(c_int) },
        .{ "c_long", @sizeOf(c_long) },
        .{ "c_longlong", @sizeOf(c_longlong) },
        .{ "c_uint", @sizeOf(c_uint) },
        .{ "c_ulong", @sizeOf(c_ulong) },
    };

    std.debug.print("C Type Sizes (platform-dependent):\n\n", .{});
    inline for (types) |t| {
        std.debug.print("  {s:<14} = {} bytes\n", .{ t[0], t[1] });
    }

    // Pointer type mapping
    std.debug.print("\nPointer Mapping:\n", .{});
    std.debug.print("  C: const char*    -> Zig: [*c]const u8\n", .{});
    std.debug.print("  C: int*           -> Zig: [*c]c_int\n", .{});
    std.debug.print("  C: void*          -> Zig: ?*anyopaque\n", .{});
    std.debug.print("  C: NULL           -> Zig: null\n", .{});

    // C strings are null-terminated
    const c_string: [*:0]const u8 = "Hello from Zig";
    const zig_slice = std.mem.span(c_string);
    std.debug.print("\nC string: {s} (len={})\n", .{ zig_slice, zig_slice.len });
}

Working with C Strings

Convert between Zig slices and C null-terminated strings.

const std = @import("std");

fn cStringLength(s: [*:0]const u8) usize {
    var len: usize = 0;
    while (s[len] != 0) : (len += 1) {}
    return len;
}

fn compareCStrings(a: [*:0]const u8, b: [*:0]const u8) bool {
    const a_slice = std.mem.span(a);
    const b_slice = std.mem.span(b);
    return std.mem.eql(u8, a_slice, b_slice);
}

pub fn main() !void {
    // Zig string literals are null-terminated
    const greeting: [*:0]const u8 = "Hello, World!";

    // Get length (like C's strlen)
    const len = cStringLength(greeting);
    std.debug.print("String: {s}\n", .{std.mem.span(greeting)});
    std.debug.print("Length: {}\n", .{len});

    // Compare strings (like C's strcmp)
    const a: [*:0]const u8 = "hello";
    const b: [*:0]const u8 = "hello";
    const c: [*:0]const u8 = "world";

    std.debug.print("\n\"hello\" == \"hello\": {}\n", .{compareCStrings(a, b)});
    std.debug.print("\"hello\" == \"world\": {}\n", .{compareCStrings(a, c)});

    // Convert Zig slice to C string with allocator
    var gpa: std.heap.DebugAllocator(.{}) = .init;
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const zig_string = "Dynamic string";
    const c_copy = try allocator.dupeZ(u8, zig_string);
    defer allocator.free(c_copy);

    std.debug.print("\nDuped C string: {s}\n", .{c_copy});
    std.debug.print("Is null-terminated: {}\n", .{c_copy[c_copy.len] == 0});
}

Simulating C Library Calls

Zig can call C standard library functions directly.

const std = @import("std");

// Simulating what C interop looks like
// In real code: const c = @cImport(@cInclude("stdlib.h"));

// Manual implementations matching C signatures
fn c_abs(n: c_int) c_int {
    return if (n < 0) -n else n;
}

fn c_atoi(str: [*:0]const u8) c_int {
    const slice = std.mem.span(str);
    var result: c_int = 0;
    var negative = false;
    var i: usize = 0;

    if (i < slice.len and (slice[i] == '-' or slice[i] == '+')) {
        negative = slice[i] == '-';
        i += 1;
    }

    while (i < slice.len and slice[i] >= '0' and slice[i] <= '9') : (i += 1) {
        result = result * 10 + @as(c_int, slice[i] - '0');
    }

    return if (negative) -result else result;
}

fn c_isalpha(ch: u8) bool {
    return (ch >= 'A' and ch <= 'Z') or (ch >= 'a' and ch <= 'z');
}

fn c_toupper(ch: u8) u8 {
    return if (ch >= 'a' and ch <= 'z') ch - 32 else ch;
}

pub fn main() !void {
    // abs
    std.debug.print("abs(-42) = {}\n", .{c_abs(-42)});
    std.debug.print("abs(17) = {}\n", .{c_abs(17)});

    // atoi
    std.debug.print("\natoi(\"123\") = {}\n", .{c_atoi("123")});
    std.debug.print("atoi(\"-456\") = {}\n", .{c_atoi("-456")});
    std.debug.print("atoi(\"0\") = {}\n", .{c_atoi("0")});

    // Character functions
    const test_chars = "Hello, World! 123";
    std.debug.print("\nCharacter analysis of \"{s}\":\n", .{test_chars});

    var alpha_count: usize = 0;
    std.debug.print("  Uppercase: ", .{});
    for (test_chars) |ch| {
        if (c_isalpha(ch)) alpha_count += 1;
        std.debug.print("{c}", .{c_toupper(ch)});
    }
    std.debug.print("\n  Alpha chars: {}\n", .{alpha_count});

    // Real @cImport usage:
    // const c = @cImport({
    //     @cInclude("stdio.h");
    //     @cInclude("stdlib.h");
    //     @cInclude("string.h");
    // });
    // const result = c.abs(-42);
    // const len = c.strlen("hello");
}

Struct Layout Compatibility

Zig structs can match C struct layout with extern struct.

const std = @import("std");

// extern struct matches C layout exactly
const Point = extern struct {
    x: f64,
    y: f64,
};

const Color = extern struct {
    r: u8,
    g: u8,
    b: u8,
    a: u8,
};

const Rect = extern struct {
    origin: Point,
    size: Point,
    color: Color,
};

fn distance(a: Point, b: Point) f64 {
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    return @sqrt(dx * dx + dy * dy);
}

fn rectArea(r: Rect) f64 {
    return r.size.x * r.size.y;
}

pub fn main() !void {
    // Struct sizes match C
    std.debug.print("Struct sizes:\n", .{});
    std.debug.print("  Point: {} bytes\n", .{@sizeOf(Point)});
    std.debug.print("  Color: {} bytes\n", .{@sizeOf(Color)});
    std.debug.print("  Rect:  {} bytes\n", .{@sizeOf(Rect)});

    // Use like C structs
    const p1 = Point{ .x = 0, .y = 0 };
    const p2 = Point{ .x = 3, .y = 4 };
    std.debug.print("\nDistance: {d:.2}\n", .{distance(p1, p2)});

    const rect = Rect{
        .origin = .{ .x = 10, .y = 20 },
        .size = .{ .x = 100, .y = 50 },
        .color = .{ .r = 255, .g = 128, .b = 0, .a = 255 },
    };

    std.debug.print("Rect at ({d}, {d}), size {d}x{d}\n", .{
        rect.origin.x, rect.origin.y, rect.size.x, rect.size.y,
    });
    std.debug.print("Area: {d}\n", .{rectArea(rect)});
    std.debug.print("Color: rgb({}, {}, {})\n", .{ rect.color.r, rect.color.g, rect.color.b });

    // packed struct for bit-level control
    const Flags = packed struct {
        readable: bool,
        writable: bool,
        executable: bool,
        _padding: u5 = 0,
    };

    std.debug.print("\nFlags size: {} byte\n", .{@sizeOf(Flags)});
    const flags = Flags{ .readable = true, .writable = true, .executable = false };
    std.debug.print("r={}, w={}, x={}\n", .{ flags.readable, flags.writable, flags.executable });
}

Try It Yourself

const std = @import("std");

// Build a simple C-compatible string utility library

const CString = struct {
    ptr: [*:0]const u8,

    fn init(s: [*:0]const u8) CString {
        return .{ .ptr = s };
    }

    fn len(self: CString) usize {
        return std.mem.span(self.ptr).len;
    }

    fn slice(self: CString) []const u8 {
        return std.mem.span(self.ptr);
    }

    fn startsWith(self: CString, prefix: []const u8) bool {
        const s = self.slice();
        if (prefix.len > s.len) return false;
        return std.mem.eql(u8, s[0..prefix.len], prefix);
    }

    fn endsWith(self: CString, suffix: []const u8) bool {
        const s = self.slice();
        if (suffix.len > s.len) return false;
        return std.mem.eql(u8, s[s.len - suffix.len ..], suffix);
    }

    fn contains(self: CString, needle: []const u8) bool {
        return std.mem.indexOf(u8, self.slice(), needle) != null;
    }

    fn eql(self: CString, other: CString) bool {
        return std.mem.eql(u8, self.slice(), other.slice());
    }
};

pub fn main() !void {
    const greeting = CString.init("Hello, World!");
    const other = CString.init("Hello, World!");
    const different = CString.init("Goodbye!");

    std.debug.print("String: \"{s}\"\n", .{greeting.slice()});
    std.debug.print("Length: {}\n", .{greeting.len()});
    std.debug.print("Starts with 'Hello': {}\n", .{greeting.startsWith("Hello")});
    std.debug.print("Ends with '!': {}\n", .{greeting.endsWith("!")});
    std.debug.print("Contains 'World': {}\n", .{greeting.contains("World")});
    std.debug.print("Contains 'Zig': {}\n", .{greeting.contains("Zig")});
    std.debug.print("Equals other: {}\n", .{greeting.eql(other)});
    std.debug.print("Equals different: {}\n", .{greeting.eql(different)});
}

Key Takeaways

  • Zig can call C functions directly with @cImport and @cInclude — no bindings needed
  • C types (c_int, c_long, etc.) have platform-matching sizes
  • [*:0]const u8 is Zig's equivalent of const char* (null-terminated)
  • Use std.mem.span() to convert C strings to Zig slices
  • extern struct matches C struct layout; packed struct controls bit layout
  • allocator.dupeZ() creates null-terminated copies for C APIs
  • Zig can link C libraries in build.zig with exe.linkSystemLibrary("libname")

Pro Tip: When wrapping a C library, create a thin Zig wrapper that converts C idioms to Zig idioms: turn error codes into Zig errors, turn null pointers into optionals, and turn C strings into slices. This gives callers a natural Zig API while keeping the C interop contained in one place.

Next Steps

You've now covered the core tools in Zig's toolkit. It's time to put everything together in a hands-on capstone project that combines error handling, memory management, file I/O, and the build system into a real command-line utility.

Two-tier handoff: this document is the complete reading surface. Continue learning for stateful practice, progress, and real sandbox execution.