Learn Zig Series (#28) - C Interop: Exposing Zig to C

Learn Zig Series (#28) - C Interop: Exposing Zig to C

zig.png

What will I learn

  • You will learn the export keyword for making Zig functions callable from C;
  • You will learn the C calling convention (callconv(.c)) and why it matters;
  • You will learn building Zig as a static or shared library for C consumers;
  • You will learn struct layout compatibility between C and Zig using extern struct;
  • You will learn callback functions: passing Zig function pointers to C code;
  • You will learn ABI considerations: alignment, padding, and platform differences;
  • You will learn a practical example: writing a Zig library usable from Python via ctypes.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • An installed Zig 0.14+ distribution (download from ziglang.org);
  • The ambition to learn Zig programming.

Difficulty

  • Intermediate

Curriculum (of the Learn Zig Series):

Learn Zig Series (#28) - C Interop: Exposing Zig to C

Solutions to Episode 27 Exercises

Exercise 1 - Zig-friendly wrapper around C's localtime and strftime:

const std = @import("std");
const c = @cImport({
    @cInclude("time.h");
    @cInclude("stdio.h");
});

const DateTime = struct {
    raw: c.time_t,

    pub fn now() DateTime {
        var t: c.time_t = undefined;
        _ = c.time(&t);
        return .{ .raw = t };
    }

    pub fn format(self: DateTime, buf: []u8, fmt: [:0]const u8) []const u8 {
        const tm = c.localtime(&self.raw);
        if (tm == null) return buf[0..0];
        const written = c.strftime(buf.ptr, buf.len, fmt.ptr, tm);
        return buf[0..written];
    }
};

pub fn main() void {
    const dt = DateTime.now();

    var buf1: [64]u8 = undefined;
    var buf2: [64]u8 = undefined;
    var buf3: [64]u8 = undefined;

    const iso = dt.format(&buf1, "%Y-%m-%d");
    const full = dt.format(&buf2, "%d/%m/%Y %H:%M:%S");
    const day_name = dt.format(&buf3, "%A %B %d");

    std.debug.print("ISO:      {s}\n", .{iso});
    std.debug.print("Full:     {s}\n", .{full});
    std.debug.print("Day name: {s}\n", .{day_name});
}

The key insight: localtime returns a ?*c.struct_tm -- a nullable pointer to a static internal buffer. We check for null before passing it to strftime. The wrapper hides all the C pointer mechanics behind a clean Zig interface: call now(), then format() with a buffer and a format string.

Exercise 2 - Wrapping C's qsort for f64 slices:

const std = @import("std");
const c = @cImport({
    @cInclude("stdlib.h");
});

fn compareF64(a_ptr: ?*const anyopaque, b_ptr: ?*const anyopaque) callconv(.c) c_int {
    const a: *const f64 = @ptrCast(@alignCast(a_ptr));
    const b: *const f64 = @ptrCast(@alignCast(b_ptr));

    if (a.* < b.*) return -1;
    if (a.* > b.*) return 1;
    return 0;
}

fn sortF64(slice: []f64) void {
    c.qsort(
        @ptrCast(slice.ptr),
        slice.len,
        @sizeOf(f64),
        &compareF64,
    );
}

pub fn main() void {
    var data = [_]f64{ 3.14, 1.0, 2.718, 0.5, 9.81, 1.414, 6.28 };

    std.debug.print("Before qsort: ", .{});
    for (data) |v| std.debug.print("{d:.3} ", .{v});
    std.debug.print("\n", .{});

    sortF64(&data);

    std.debug.print("After qsort:  ", .{});
    for (data) |v| std.debug.print("{d:.3} ", .{v});
    std.debug.print("\n", .{});

    // Verify against Zig's own sort
    var check = [_]f64{ 3.14, 1.0, 2.718, 0.5, 9.81, 1.414, 6.28 };
    std.mem.sort(f64, &check, {}, std.sort.asc(f64));

    var match = true;
    for (data, check) |a, b| {
        if (a != b) {
            match = false;
            break;
        }
    }
    std.debug.print("Matches Zig sort: {}\n", .{match});
}

The critical part is callconv(.c) on the comparison function. Without it, Zig uses its own calling convention, and qsort (which expects the C calling convention) would pass arguments in the wrong registers. The ?*const anyopaque parameter types match C's const void*. We cast them to *const f64 inside.

Exercise 3 - Mini key-value store with qsort and bsearch:

const std = @import("std");
const c = @cImport({
    @cInclude("stdlib.h");
    @cInclude("string.h");
});

const Entry = extern struct {
    key: [32]u8,
    value: [64]u8,
};

fn compareEntry(a_ptr: ?*const anyopaque, b_ptr: ?*const anyopaque) callconv(.c) c_int {
    const a: *const Entry = @ptrCast(@alignCast(a_ptr));
    const b: *const Entry = @ptrCast(@alignCast(b_ptr));
    return c.strcmp(&a.key, &b.key);
}

fn makeEntry(key: []const u8, value: []const u8) Entry {
    var e: Entry = .{ .key = [_]u8{0} ** 32, .value = [_]u8{0} ** 64 };
    const klen = @min(key.len, 31);
    const vlen = @min(value.len, 63);
    @memcpy(e.key[0..klen], key[0..klen]);
    @memcpy(e.value[0..vlen], value[0..vlen]);
    return e;
}

fn lookup(store: []Entry, key: []const u8) ?[]const u8 {
    var search_key: Entry = .{ .key = [_]u8{0} ** 32, .value = [_]u8{0} ** 64 };
    const klen = @min(key.len, 31);
    @memcpy(search_key.key[0..klen], key[0..klen]);

    const result = c.bsearch(
        @ptrCast(&search_key),
        @ptrCast(store.ptr),
        store.len,
        @sizeOf(Entry),
        &compareEntry,
    );

    if (result) |found_ptr| {
        const entry: *const Entry = @ptrCast(@alignCast(found_ptr));
        return std.mem.sliceTo(&entry.value, 0);
    }
    return null;
}

pub fn main() void {
    var store = [_]Entry{
        makeEntry("apple", "A round fruit"),
        makeEntry("banana", "A yellow fruit"),
        makeEntry("cherry", "A small red fruit"),
        makeEntry("date", "A sweet desert fruit"),
        makeEntry("elderberry", "A dark purple berry"),
        makeEntry("fig", "A soft pear-shaped fruit"),
        makeEntry("grape", "Grows in clusters"),
        makeEntry("honeydew", "A type of melon"),
        makeEntry("kiwi", "Brown outside green inside"),
        makeEntry("lemon", "Very sour citrus"),
    };

    // Sort by key first (bsearch requires sorted data)
    c.qsort(@ptrCast(&store), store.len, @sizeOf(Entry), &compareEntry);

    // Successful lookups
    for ([_][]const u8{ "apple", "fig", "lemon" }) |key| {
        if (lookup(&store, key)) |val| {
            std.debug.print("{s}: {s}\n", .{ key, val });
        }
    }

    // Failed lookup
    if (lookup(&store, "mango")) |_| {
        std.debug.print("Should not reach\n", .{});
    } else {
        std.debug.print("mango: not found\n", .{});
    }
}

The extern struct declaration is critical here. Regular Zig structs can reorder fields and add padding however the compiler likes. extern struct guarantees C-compatible layout -- fields in declaration order, with C-standard padding rules. Without extern, bsearch and qsort would read memory at wrong offsets and produce garbage.

Here we go! Last time in episode 27 we covered calling C code from Zig -- importing headers with @cImport, type mappings, linking libraries, wrapping C APIs into safe Zig interfaces. That's one direction of the interop story. Today we flip it around: making your Zig code callable FROM C.

Why would you want this? Think about it. You've got a massive existing C codebase -- maybe a game engine, an embedded system firmware, a database, whatever -- and you want to write a new module in Zig. Better safety, better ergonomics, comptime, all the good stuff. But the rest of the system is C and it needs to call your new code. Or maybe you're writing a library in Zig that you want other languages to use. Python, Ruby, Lua, Node.js -- they all have C FFI interfaces. If your Zig library looks like a C library from the outside, every language with a C FFI can use it. That's a HUGE reach.

Zig makes this remarkably straightforward. A few keywords, a couple of build system flags, and your Zig functions are callable from C (or anything that speaks the C ABI) with zero overhead ;-)

The export keyword

The export keyword is how you tell Zig "make this symbol visible in the compiled output with a stable, C-compatible name". Without it, Zig mangles function names, optimizes things away, and generally does whatever it wants with your symbols. With export, the function gets a predictable name that C linkers can find:

// mathlib.zig
export fn add(a: i32, b: i32) i32 {
    return a + b;
}

export fn multiply(a: i32, b: i32) i32 {
    return a * b;
}

export fn factorial(n: u32) u64 {
    if (n <= 1) return 1;
    var result: u64 = 1;
    var i: u32 = 2;
    while (i <= n) : (i += 1) {
        result *= @as(u64, i);
    }
    return result;
}

// This function is NOT exported -- internal only
fn helperFunction() void {
    // C can't see this
}

When you compile this into a library, the symbols add, multiply, and factorial appear in the symbol table with exactly those names. No mangling, no prefixes, no suffixes. A C program can link against the library and call add(3, 4) as if it was written in C.

The export keyword also implies callconv(.c) -- the C calling convention. You don't need to specify it separately. This means the function uses the platform's standard calling convention (System V AMD64 ABI on Linux/macOS x86_64, Microsoft x64 on Windows, AAPCS on ARM, etc.). Arguments go in the right registers, return values come back in the right place, the stack gets cleaned up correctly. Everything the C side expects.

The C calling convention and callconv(.c)

Sometimes you need the C calling convention without exporting. This comes up when you're writing callback functions -- Zig functions that get called by C code through function pointers. We saw this briefly in the exercise solutions above with qsort. Let me show you the full picture:

const std = @import("std");

// An exported function -- visible to C, uses C calling convention
export fn processData(data: [*]const u8, len: usize) i32 {
    var sum: i32 = 0;
    for (data[0..len]) |byte| {
        sum += @as(i32, byte);
    }
    return sum;
}

// A callback function -- not exported, but uses C calling convention
// so C code can call it through a function pointer
fn onProgress(current: c_int, total: c_int, user_data: ?*anyopaque) callconv(.c) void {
    _ = user_data;
    std.debug.print("Progress: {d}/{d}\n", .{ current, total });
}

// You can take the address of a callconv(.c) function
// and pass it wherever C expects a function pointer
export fn getProgressCallback() *const fn (c_int, c_int, ?*anyopaque) callconv(.c) void {
    return &onProgress;
}

The difference between export fn and fn ... callconv(.c):

  • export fn makes the symbol visible in the binary AND uses the C calling convention. Other code can link to it by name.
  • fn ... callconv(.c) uses the C calling convention but does NOT export the symbol. You pass it as a function pointer, not by name.

If you call export fn from other Zig code in the same compilation unit, the compiler can still inline it and optimize normally. The export is for the linker's benefit, not a performance penalty.

There's also callconv(.naked) for functions with no prologue/epilogue (useful for interrupt handlers or trampolines in OS dev), and callconv(.inline) which forces inlining. But for C interop, .c is what you need practically always.

Building Zig as a static or shared library

Alright, knowing the keywords is one thing. Actually building a usable library is another. Here's a complete build.zig that produces both a static library (.a on Linux/macOS, .lib on Windows) and a shared library (.so, .dylib, .dll):

// build.zig
const std = @import("std");

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

    // Static library
    const static_lib = b.addStaticLibrary(.{
        .name = "zigmath",
        .root_source_file = b.path("src/mathlib.zig"),
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(static_lib);

    // Shared library
    const shared_lib = b.addSharedLibrary(.{
        .name = "zigmath",
        .root_source_file = b.path("src/mathlib.zig"),
        .target = target,
        .optimize = optimize,
    });
    b.installArtifact(shared_lib);

    // Also install the auto-generated C header
    static_lib.installHeader(b.path("src/mathlib.zig"), "zigmath.h");
}

After zig build, you'll find:

  • zig-out/lib/libzigmath.a -- the static library
  • zig-out/lib/libzigmath.so (or .dylib on macOS) -- the shared library

The static library gets linked directly into the final executable -- the linker copies the object code. The shared library gets loaded at runtime -- smaller executables, but you need to distribute the .so alongside your program.

Which one should you use? Static is simpler (one binary, no DLL hell, no LD_LIBRARY_PATH nonsense). Shared is better when multiple programs use the same library (shared memory, smaller total disk usage) or when you want to update the library without recompiling every consumer.

For the C side, you'd use the library like this:

// main.c
#include <stdio.h>

// Declare the Zig functions
extern int add(int a, int b);
extern int multiply(int a, int b);
extern unsigned long long factorial(unsigned int n);

int main() {
    printf("add(3, 4) = %d\n", add(3, 4));
    printf("multiply(6, 7) = %d\n", multiply(6, 7));
    printf("factorial(10) = %llu\n", factorial(10));
    return 0;
}

Compile and link: gcc main.c -L./zig-out/lib -lzigmath -o demo. That's it. The C compiler doesn't know or care that those functions were written in Zig. They look exactly like C functions from the linker's perspective.

Struct layout compatibility: extern struct

This is a topic I've seen trip up a lot of people. Regular Zig structs and C structs have different layout rules. Zig's compiler is free to reorder fields, insert arbitrary padding, or pack things however it wants for performance. C structs follow strict rules: fields appear in declaration order, padding follows the platform's alignment requirements.

If you're passing structs across the C boundary, you MUST use extern struct:

const std = @import("std");

// Regular Zig struct -- field order and padding are compiler's choice
const ZigPoint = struct {
    x: f64,
    y: f64,
    label: u8,
};

// C-compatible struct -- fields in declaration order, C padding rules
const CPoint = extern struct {
    x: f64,
    y: f64,
    label: u8,
};

// Export functions that use C-compatible structs
export fn createPoint(x: f64, y: f64, label: u8) CPoint {
    return .{ .x = x, .y = y, .label = label };
}

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

pub fn main() void {
    std.debug.print("ZigPoint size: {d}, align: {d}\n", .{
        @sizeOf(ZigPoint),
        @alignOf(ZigPoint),
    });
    std.debug.print("CPoint size:   {d}, align: {d}\n", .{
        @sizeOf(CPoint),
        @alignOf(CPoint),
    });

    const a = createPoint(0.0, 0.0, 'A');
    const b = createPoint(3.0, 4.0, 'B');
    const dist = distanceBetween(&a, &b);
    std.debug.print("Distance: {d:.2}\n", .{dist});
}

On a typical 64-bit system, CPoint will be 24 bytes: 8 for x, 8 for y, 1 for label, then 7 bytes of padding to align the struct to 8 bytes (since the largest field is f64 which requires 8-byte alignment). This matches exactly what a C compiler would produce for the equivalent struct CPoint { double x; double y; unsigned char label; };.

If you used a regular Zig struct instead, the compiler might (and often does) rearrange fields or use different padding. The C side would read x where label is, and you'd get garbage. This is not a theoretical concern -- it will absolutely happen in practice and produce bugs that are incredibly hard to track down.

Rule of thumb: if the struct crosses the Zig-C boundary in either direction, make it extern struct. No exceptions. Even if the layouts happen to match today, the Zig compiler makes no promises about regular struct layout between versions.

Callback functions: passing Zig function pointers to C

We touched on this earlier, but callbacks deserve a dedicated section because they're one of the most common patterns in C library design. The C library defines a function pointer type, you provide a Zig function that matches, and C calls it at the appropriate time. Event handlers, comparators, iterators, progress callbacks -- they're everywhere.

const std = @import("std");
const c = @cImport({
    @cInclude("stdlib.h");
});

// A C library might define something like:
// typedef int (*transform_fn)(int value, void* user_data);
// void apply_transform(int* arr, size_t len, transform_fn fn, void* user_data);

// We can implement that pattern entirely in Zig:
const TransformFn = *const fn (c_int, ?*anyopaque) callconv(.c) c_int;

export fn applyTransform(
    arr: [*]c_int,
    len: usize,
    transform: TransformFn,
    user_data: ?*anyopaque,
) void {
    for (0..len) |i| {
        arr[i] = transform(arr[i], user_data);
    }
}

// Example callback: multiply by a factor stored in user_data
fn multiplyCallback(value: c_int, user_data: ?*anyopaque) callconv(.c) c_int {
    const factor: *const c_int = @ptrCast(@alignCast(user_data.?));
    return value * factor.*;
}

// Example callback: clamp to a range
const ClampRange = extern struct {
    min_val: c_int,
    max_val: c_int,
};

fn clampCallback(value: c_int, user_data: ?*anyopaque) callconv(.c) c_int {
    const range: *const ClampRange = @ptrCast(@alignCast(user_data.?));
    if (value < range.min_val) return range.min_val;
    if (value > range.max_val) return range.max_val;
    return value;
}

pub fn main() void {
    var data = [_]c_int{ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };

    // Multiply everything by 3
    var factor: c_int = 3;
    applyTransform(&data, data.len, &multiplyCallback, @ptrCast(&factor));

    std.debug.print("After multiply: ", .{});
    for (data) |v| std.debug.print("{d} ", .{v});
    std.debug.print("\n", .{});

    // Clamp to [5, 20]
    var range = ClampRange{ .min_val = 5, .max_val = 20 };
    applyTransform(&data, data.len, &clampCallback, @ptrCast(&range));

    std.debug.print("After clamp:    ", .{});
    for (data) |v| std.debug.print("{d} ", .{v});
    std.debug.print("\n", .{});
}

The ?*anyopaque parameter is the classic C void* pattern for passing arbitrary context to callbacks. The callback casts it to whatever type it actually is. This is inherently unsafe -- if you pass the wrong type, you get undefined behavior. In pure Zig code you'd use generics and comptime to avoid this (as we covered in episode 14). But when talking to C, void* is the lingua franca.

One pitfall: make sure the data that user_data points to outlives the callback. If you create a local variable and pass its address, then the callback gets called after the variable goes out of scope, you're reading garbage memory. In the example above, factor and range live on main's stack for the entire duration of applyTransform, so it's safe.

ABI considerations: alignment, padding, platform differences

ABI stands for Application Binary Interface. It defines how functions are called at the machine level: which registers hold which arguments, how the stack is managed, how structs are passed and returned, alignment requirements, and more. When Zig and C code talk to each other, they must agree on the ABI. Here's what can go wrong and how to handle it:

const std = @import("std");

// Alignment demonstration
const AlignedData = extern struct {
    a: u8,     // offset 0, size 1
    // 3 bytes padding (b needs 4-byte alignment)
    b: u32,    // offset 4, size 4
    c: u8,     // offset 8, size 1
    // 1 byte padding (d needs 2-byte alignment)
    d: u16,    // offset 10, size 2
    // 0 bytes padding (e starts at offset 12, already 4-byte aligned)
    e: u32,    // offset 12, size 4
    // total: 16 bytes
};

// If you need tighter packing, use packed struct
const PackedData = packed struct {
    a: u8,
    b: u32,
    c: u8,
    d: u16,
    e: u32,
    // total: 12 bytes, no padding -- but NOT safe for C interop
};

// Export a function that demonstrates struct sizes
export fn getAlignedSize() usize {
    return @sizeOf(AlignedData);
}

pub fn main() void {
    std.debug.print("AlignedData:\n", .{});
    std.debug.print("  size:  {d} bytes\n", .{@sizeOf(AlignedData)});
    std.debug.print("  align: {d} bytes\n", .{@alignOf(AlignedData)});
    std.debug.print("  a offset: {d}\n", .{@offsetOf(AlignedData, "a")});
    std.debug.print("  b offset: {d}\n", .{@offsetOf(AlignedData, "b")});
    std.debug.print("  c offset: {d}\n", .{@offsetOf(AlignedData, "c")});
    std.debug.print("  d offset: {d}\n", .{@offsetOf(AlignedData, "d")});
    std.debug.print("  e offset: {d}\n", .{@offsetOf(AlignedData, "e")});

    std.debug.print("\nPackedData:\n", .{});
    std.debug.print("  size:  {d} bytes\n", .{@sizeOf(PackedData)});

    // Verify: these offsets must match what a C compiler produces
    // for: struct { uint8_t a; uint32_t b; uint8_t c; uint16_t d; uint32_t e; };
    std.debug.print("\nLayout matches C? offsets a=0 b=4 c=8 d=10 e=12: {}\n", .{
        @offsetOf(AlignedData, "a") == 0 and
            @offsetOf(AlignedData, "b") == 4 and
            @offsetOf(AlignedData, "c") == 8 and
            @offsetOf(AlignedData, "d") == 10 and
            @offsetOf(AlignedData, "e") == 12,
    });
}

The padding rules for extern struct follow the C standard: each field is aligned to its natural alignment (the size of the type, usually), and the struct's total size is a multiple of the largest field's alignment. This is why AlignedData is 16 bytes, not 12 -- there are gaps between a and b, and between c and d.

A few things that commonly bite people on different platforms:

  • long is 4 bytes on Windows x64 but 8 bytes on Linux x64. Use c_long instead of i64 or i32 -- Zig picks the right size for the target.
  • Struct return values: some ABIs return small structs in registers, others on the stack. extern struct + export fn handles this correctly -- Zig follows the same ABI rules as the target's C compiler.
  • Floating point: x86_64 passes the first 8 float/double args in XMM registers (System V) or first 4 in XMM (Windows). Again, callconv(.c) handles this automaticaly.
  • ARM vs x86: completely different register conventions, stack growth direction, and alignment requirements. Zig's cross-compilation handles it, but be aware that a struct that works on your x86_64 dev machine might have different padding on ARM.

Practical example: a Zig library usable from Python via ctypes

Let's bring everything together with something genuinely useful. We'll write a small string processing library in Zig and call it from Python using ctypes. No C compiler involved at all -- Zig produces the shared library, Python loads it:

// src/stringlib.zig
const std = @import("std");

const StringStats = extern struct {
    length: u32,
    words: u32,
    lines: u32,
    uppercase: u32,
    lowercase: u32,
    digits: u32,
};

export fn analyzeString(ptr: [*]const u8, len: u32) StringStats {
    const data = ptr[0..len];
    var stats = StringStats{
        .length = len,
        .words = 0,
        .lines = if (len > 0) @as(u32, 1) else 0,
        .uppercase = 0,
        .lowercase = 0,
        .digits = 0,
    };

    var in_word = false;
    for (data) |ch| {
        if (ch >= 'A' and ch <= 'Z') {
            stats.uppercase += 1;
            if (!in_word) {
                stats.words += 1;
                in_word = true;
            }
        } else if (ch >= 'a' and ch <= 'z') {
            stats.lowercase += 1;
            if (!in_word) {
                stats.words += 1;
                in_word = true;
            }
        } else if (ch >= '0' and ch <= '9') {
            stats.digits += 1;
            if (!in_word) {
                stats.words += 1;
                in_word = true;
            }
        } else {
            in_word = false;
            if (ch == '\n') stats.lines += 1;
        }
    }

    return stats;
}

export fn rot13(ptr: [*]u8, len: u32) void {
    for (ptr[0..len]) |*ch| {
        if (ch.* >= 'A' and ch.* <= 'Z') {
            ch.* = 'A' + (ch.* - 'A' + 13) % 26;
        } else if (ch.* >= 'a' and ch.* <= 'z') {
            ch.* = 'a' + (ch.* - 'a' + 13) % 26;
        }
    }
}

export fn countSubstring(
    haystack_ptr: [*]const u8,
    haystack_len: u32,
    needle_ptr: [*]const u8,
    needle_len: u32,
) u32 {
    if (needle_len == 0 or needle_len > haystack_len) return 0;
    const haystack = haystack_ptr[0..haystack_len];
    const needle = needle_ptr[0..needle_len];

    var count: u32 = 0;
    var i: u32 = 0;
    while (i + needle_len <= haystack_len) : (i += 1) {
        if (std.mem.eql(u8, haystack[i .. i + needle_len], needle)) {
            count += 1;
        }
    }
    return count;
}

Build it as a shared library with zig build (using the build.zig pattern from earlier, with addSharedLibrary). Then from Python:

# test_stringlib.py
import ctypes
import os

# Load the shared library
lib = ctypes.CDLL("./zig-out/lib/libstringlib.so")

# Define the StringStats struct
class StringStats(ctypes.Structure):
    _fields_ = [
        ("length", ctypes.c_uint32),
        ("words", ctypes.c_uint32),
        ("lines", ctypes.c_uint32),
        ("uppercase", ctypes.c_uint32),
        ("lowercase", ctypes.c_uint32),
        ("digits", ctypes.c_uint32),
    ]

# Set up function signatures
lib.analyzeString.argtypes = [ctypes.c_char_p, ctypes.c_uint32]
lib.analyzeString.restype = StringStats

lib.rot13.argtypes = [ctypes.c_char_p, ctypes.c_uint32]
lib.rot13.restype = None

lib.countSubstring.argtypes = [
    ctypes.c_char_p, ctypes.c_uint32,
    ctypes.c_char_p, ctypes.c_uint32,
]
lib.countSubstring.restype = ctypes.c_uint32

# Test analyzeString
text = b"Hello World!\nThis has 5 lines\nAnd UPPERCASE too\n42 numbers\nDone."
stats = lib.analyzeString(text, len(text))
print(f"Length:    {stats.length}")
print(f"Words:     {stats.words}")
print(f"Lines:     {stats.lines}")
print(f"Uppercase: {stats.uppercase}")
print(f"Lowercase: {stats.lowercase}")
print(f"Digits:    {stats.digits}")

# Test rot13
msg = ctypes.create_string_buffer(b"Hello World")
lib.rot13(msg, len(b"Hello World"))
print(f"\nROT13: {msg.value.decode()}")

# Apply rot13 again to get back original
lib.rot13(msg, len(b"Hello World"))
print(f"Back:  {msg.value.decode()}")

# Test countSubstring
haystack = b"abcabcabc"
needle = b"abc"
count = lib.countSubstring(haystack, len(haystack), needle, len(needle))
print(f"\n'{needle.decode()}' appears {count} times in '{haystack.decode()}'")

This works because Python's ctypes speaks the C ABI directly. It loads the shared library, looks up symbols by name (the names we exported), and calls them using the C calling convention. The StringStats struct in Python must match the extern struct layout in Zig exactly -- same field types, same order, same sizes. If they don't match, you get corrupt data.

This pattern -- Zig library with exported C-ABI functions, consumed via FFI -- works with practically any language. Ruby has fiddle and ffi, Node.js has node-ffi-napi, Go has cgo, Rust has extern "C". Your Zig library becomes a universal component.

Having said that, there's an important limitation: error handling. Zig's error unions can't cross the C boundary. You can't return !void from an exported function -- C doesn't understand Zig error types. Instead, return status codes (c_int with 0 = success, negative = error) or use output parameters. Same for slices -- C doesn't have Zig slices, so you pass pointer + length separately, as we did in the examples above.

Wat we geleerd hebben

  • The export keyword makes Zig functions visible to C with stable, unmangled symbol names. It also implies callconv(.c) -- the C calling convention.
  • callconv(.c) without export is for callback functions -- Zig functions that C calls through function pointers, not by name.
  • Static and shared libraries are built with addStaticLibrary and addSharedLibrary in build.zig. Static libs get linked into the final binary; shared libs are loaded at runtime.
  • extern struct guarantees C-compatible memory layout: fields in declaration order with standard padding. Regular Zig structs offer no such guarantee and MUST NOT cross the C boundary.
  • Callback functions use callconv(.c) with ?*anyopaque as the universal context parameter (C's void* pattern). Make sure the pointed-to data outlives the callback.
  • ABI rules differ across platforms: long size, struct return conventions, register usage, alignment requirements. Using extern struct and callconv(.c) handles these differences automaticaly -- Zig follows the target platform's C ABI.
  • The Python ctypes example demonstrates the real power: Zig produces a shared library that any language with C FFI can call. No C compiler needed. No bindings generator. Just export fn and build.

The interop story in Zig is genuinely best-in-class for systems languages. In episode 27 we called C from Zig (consuming existing C ecosystems), and today we exposed Zig to C (letting C and other languages consume our Zig code). Together, these two directions mean Zig plays nicely with the entire existing software ecosystem -- you can adopt it incrementally, one module at a time, without rewriting everything. For systems-level work involving memory-mapped I/O, hardware registers, and bare-metal control, that incremental adoption becomes even more powerful.

Exercises

  1. Write a Zig shared library that exports a RingBuffer implementation. Export functions ringbuf_create(capacity: u32) -> *anyopaque, ringbuf_push(buf: *anyopaque, value: i32) -> bool, ringbuf_pop(buf: *anyopaque, out: *i32) -> bool, and ringbuf_destroy(buf: *anyopaque). The ring buffer should use a fixed-size backing array allocated with std.heap.page_allocator. Write a C program (or Python ctypes script) that creates a ring buffer, pushes 10 values, pops 5, pushes 5 more, then pops until empty.

  2. Create a Zig library that exports a custom sorting function. Export zig_sort(arr: [*]i32, len: usize, ascending: bool) that sorts an integer array either ascending or descending based on the flag. Also export zig_is_sorted(arr: [*]const i32, len: usize, ascending: bool) -> bool that checks if an array is already sorted. Write a test program in C that creates several arrays, sorts them, and verifies with zig_is_sorted.

  3. Build a Zig shared library that implements a simple "calculator protocol" using extern struct for communication. Define CalcRequest with fields op: u8 ('+', '-', '*', '/'), a: f64, b: f64 and CalcResult with fields value: f64, error_code: i32 (0 = ok, 1 = division by zero, 2 = unknown op). Export calc_execute(request: *const CalcRequest) -> CalcResult. Then write a Python ctypes script that sends 5 different calculation requests and prints each result, including proper error handling for division by zero.

Bedankt en tot de volgende keer!

@scipio



0
0
0.000
2 comments
avatar

Thanks for your contribution to the STEMsocial community. Feel free to join us on discord to get to know the rest of us!

Please consider delegating to the @stemsocial account (85% of the curation rewards are returned).

Consider setting @stemsocial as a beneficiary of this post's rewards if you would like to support the community and contribute to its mission of promoting science and education on Hive. 
 

0
0
0.000
avatar

Congratulations @scipio! You have completed the following achievement on the Hive blockchain And have been rewarded with New badge(s)

You received more than 25000 upvotes.
Your next target is to reach 30000 upvotes.

You can view your badges on your board and compare yourself to others in the Ranking
If you no longer want to receive notifications, reply to this comment with the word STOP

0
0
0.000