C Interop
Zig has first-class C interoperability. You can call C functions, translate C headers into Zig declarations, and link against C libraries — all without any bindings generator or FFI overhead. The classic way to pull in a header is the @cImport builtin, which is what you will see in existing code; 0.16 deprecates it in favour of doing the same translation from build.zig, and this lesson covers both.
What You'll Learn
By the end of this lesson you will build a wrapper that takes a C string — a bare pointer with a terminator and no length — and hands the rest of your program a Zig slice that knows how long it is. That conversion, in both directions, is what the C boundary mostly consists of, along with the question that comes with it: once a pointer crosses, who is responsible for freeing what it points at.
This is an optional extension, not a capstone milestone — the record analyzer calls no C. What this lesson is for is being able to use the enormous body of existing C libraries without writing bindings, and to read the extern and [*:0] declarations you will meet in real Zig code. You arrive here able to reason about who owns a buffer and to pair an allocation with its release (Memory and Allocators), which is the exact skill the boundary tests, and knowing that a Zig string is a slice of bytes rather than a character type (String Handling).
One thing to know before you start: the Run button on this page cannot link libc. Every fence below is written to work without it, which is why the C-library section builds its own abs and atoi rather than calling the real ones. This is a limitation of the browser playground, not of Zig. If you paste a @cImport example into Run you will get error: 'stdlib.h' not found, alongside the note libc headers not available; compilation does not link against libc — that failure is the sandbox, not your code. On your own machine the same file works once you ask for libc:
// This program prints "abs = 42" — but only when compiled with -lc.
const std = @import("std");
const c = @cImport({
@cInclude("stdlib.h");
});
pub fn main() void {
std.debug.print("abs = {d}\n", .{c.abs(-42)});
}
Save it as main.zig and run zig run -lc main.zig, or zig build-exe -lc main.zig to produce a binary. The -lc flag is what makes the C headers and the C library available; without it the translation step cannot find stdlib.h. Everything else this lesson teaches — C types, pointer mapping, extern struct, sentinel-terminated strings — runs perfectly well in the playground, because none of it needs libc.
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 });
}
Two changes worth making to that fence, each with a prediction to commit first:
- Ask for the size of a pointer instead of a scalar. Add
.{ "char pointer", @sizeOf([*:0]const u8) }to thetypestuple and rerun. Predict the number before running. It prints 8 — a bare address, one machine word on this 64-bit target — while the C scalar types above it range from 1 to 8 bytes. That single number is the whole reason C strings need a terminator, and the next section builds on it. - Compare a C pointer with a Zig slice. Add
.{ "zig slice", @sizeOf([]const u8) }as well. Predict whether it matches the pointer. It prints 16, twice the pointer, because a Zig slice is an address and a length. Every conversion in this lesson exists to move between those two shapes.
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});
}
Note the shape of those last few lines: dupeZ allocates, and a defer allocator.free(c_copy) sits immediately underneath it. That pairing is not decoration, and at a C boundary it raises the question the rest of this lesson depends on.
Who owns memory that crosses the boundary?
Zig's allocator discipline says whoever allocated a buffer frees it. C has no such rule in the language — every C library states its own convention in its documentation, and the three you will meet are these. Some functions return a pointer into memory the library owns and will reuse, so you must copy it before the next call and must never free it. Some return memory the library allocated with malloc and expect you to call free on it, which means your Zig code must call C's free, not your allocator's — the two are different heaps and mixing them corrupts both. And some take a buffer you provide, fill it in, and never take ownership at all; that is the friendliest shape and the one to prefer when you get to choose.
The defer above is the third case. c_copy was allocated by a Zig allocator, so a Zig allocator frees it, and the defer guarantees that happens on every path out of the function. But defer fires at the end of the enclosing scope, and that is the trap when a pointer crosses: if you hand c_copy to a C function that stores the pointer for later — registering a callback, keeping a name in a table — then your defer frees the buffer while C still holds the address, and every later read through it is a use-after-free that Zig cannot see or warn about. When a C function keeps a pointer, the copy has to outlive the scope: allocate it somewhere with a longer life, and free it when the C library tells you it is done with it, not when your function returns.
The rule that survives all three cases: at a boundary, the lifetime is part of the API, and if the documentation does not tell you who frees, you do not yet know how to call the function.
That last line reads c_copy[c_copy.len] — one past the end, which for any ordinary slice would be out of bounds. Predict what the lengths and sizes below actually are; the answers are the whole C-boundary model in miniature.
Predict
A dupeZ'd copy of a 4-character string, and the two pointer types either side of the C boundary. Predict the four printed numbers.
const std = @import("std");
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const c_copy = try allocator.dupeZ(u8, "abcd");
defer allocator.free(c_copy);
std.debug.print("len = {d}\n", .{c_copy.len});
std.debug.print("byte at len = {d}\n", .{c_copy[c_copy.len]});
std.debug.print("sizeOf a C pointer = {d}\n", .{@sizeOf([*:0]const u8)});
std.debug.print("sizeOf a Zig slice = {d}\n", .{@sizeOf([]const u8)});
}The four numbers are 4, 0, 8, 16. dupeZ returns a [:0]u8, a sentinel-terminated slice whose len counts only the payload — so "abcd" gives 4 — while the type guarantees a 0 byte at index len, which is why reading one past the end is defined here and would panic for a plain []u8. The last two numbers are the C boundary: [*:0]const u8 is a bare address in one word, and []const u8 is an address plus a length in two. C needs the terminator because its pointer has nowhere to put the length; Zig's slice carries it, which is why std.mem.span exists to convert between the two.
Before writing one, assemble the lifecycle that a C string allocated at run time has to follow. The lines below are the body of a describe(allocator: std.mem.Allocator, text: []const u8) that duplicates a Zig string into an allocated C string, measures it the way a C function would, prints what it found, and releases it. Only one arrangement runs cleanly.
Arrange the code
These lines are the body of a describe(allocator, text) that makes a null-terminated copy of a Zig string, measures it the way a C function would, and releases it. For the text 'sensor' it must print exactly 'sensor: walked 6, terminator 0'. Reorder them so it does — then answer what the order is really testing: three of these lines only read the buffer and one of them destroys it, so what fixes the position of that last line relative to the others?
allocator.free(c_name);const terminator = c_name[walked.len];std.debug.print("{s}: walked {d}, terminator {d}\n", .{ walked, walked.len, terminator });const walked = std.mem.span(@as([*:0]const u8, c_name.ptr));const c_name = try allocator.dupeZ(u8, text);
Going the other way — building a C string for C to read — is where the sentinel bites back, because now you are responsible for putting it in the right place. The program below does that, and gets it wrong in a way that compiles and runs.
Debug
This copies a Zig string into a fixed buffer, terminates it so a C function could read it, then reads it back the way C would — by walking to the terminator. The round trip should return the same seven characters it started with. It compiles, runs, and reports a string that is not the one it was given. Commit a hypothesis about where the terminating zero actually landed, then fix it so the program prints 'round trip: interop (7 bytes)'.
const std = @import("std");
// Copy a Zig slice into a buffer that a C function can read, then hand back a
// C-style pointer to it. C finds the end of the string by looking for the 0.
fn toCString(buf: []u8, text: []const u8) [*:0]const u8 {
@memcpy(buf[0..text.len], text);
// Terminate the string so C knows where it ends.
buf[buf.len - 1] = 0;
return @ptrCast(buf.ptr);
}
pub fn main() !void {
var storage: [16]u8 = @splat('?');
const name = "interop";
const c_name = toCString(&storage, name);
// Read it back the way a C function would: walk to the terminator.
const round_trip = std.mem.span(c_name);
std.debug.print("original: {s} ({d} bytes)\n", .{ name, name.len });
std.debug.print("round trip: {s} ({d} bytes)\n", .{ round_trip, round_trip.len });
if (!std.mem.eql(u8, name, round_trip)) {
std.debug.print("CHECK FAILED: the round trip changed the string\n", .{});
return error.CheckFailed;
}
std.debug.print("round trip preserved the string\n", .{});
}
Expected output:
original: interop (7 bytes) round trip: interop (7 bytes) round trip preserved the string
Simulating C Library Calls
Zig can call C standard library functions directly. The fence below does not — it reimplements four of them in Zig instead, because this page's Run button cannot link libc, as the introduction explained. Read the four functions as specifications of what the real C ones do, with signatures that match: c_abs takes and returns a c_int, and c_atoi takes a [*:0]const u8, which is exactly the const char* a real atoi receives. On your own machine with -lc, deleting these four definitions and calling c.abs and c.atoi through @cImport gives the same results.
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");
}
@cImport is deprecated in 0.16
@cImport still works, and it is what you will meet in nearly every existing Zig codebase — but as of 0.16 it is deprecated. C translation is moving into the build system, so the forward-looking form puts the #include list in a header file and translates it as a build step:
// src/c.h holds the includes:
// #include <stdio.h>
// #include <stdlib.h>
// #include <string.h>
// build.zig
const translate_c = b.addTranslateC(.{
.root_source_file = b.path("src/c.h"),
.target = target,
.optimize = optimize,
});
const exe = b.addExecutable(.{
.name = "myapp",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = &.{
.{ .name = "c", .module = translate_c.createModule() },
},
}),
});
Your Zig code then writes const c = @import("c"); instead of const c = @cImport(...). The translated output is identical — the difference is that the translation is a cacheable build step you can configure, rather than a language builtin. Everything else in this lesson (C types, pointer mapping, extern struct, string conversion) is unaffected either way.
Struct Layout Compatibility
Zig structs can match C struct layout with extern struct.
Why extern changes anything
An ordinary Zig struct makes no promise about how its fields are arranged in memory. The compiler is free to reorder them, and it does — usually to pack small fields into the padding that alignment would otherwise waste. That freedom is a feature when only Zig reads the bytes, and a disaster the moment a C function reads them, because C guarantees the opposite: fields appear in declaration order, at offsets fixed by the platform's ABI. extern struct is how you give up the freedom and take the guarantee.
The difference is measurable. These two declarations have identical fields and different memory:
const std = @import("std");
const Mixed = struct { a: u8, b: u64, c: u8 };
const MixedExtern = extern struct { a: u8, b: u64, c: u8 };
pub fn main() void {
std.debug.print("default size: {d}, extern size: {d}\n", .{ @sizeOf(Mixed), @sizeOf(MixedExtern) });
std.debug.print("default offsets: a={d} b={d} c={d}\n", .{ @offsetOf(Mixed, "a"), @offsetOf(Mixed, "b"), @offsetOf(Mixed, "c") });
std.debug.print("extern offsets: a={d} b={d} c={d}\n", .{ @offsetOf(MixedExtern, "a"), @offsetOf(MixedExtern, "b"), @offsetOf(MixedExtern, "c") });
}
The default struct puts b first, at offset 0, and tucks the two single-byte fields in after it — 16 bytes total. The extern version keeps the declaration order you wrote, which forces seven bytes of padding after a to align b, and seven more after c to round the whole struct up — 24 bytes. Hand the default one to a C function expecting the C layout and it reads b's bytes as a and gets nonsense, with no error at any stage: the layouts are both valid, they just disagree.
The structs below are all extern for that reason. Every one of them is a shape a C library could hand you or expect from you, so declaration order has to be the order in memory.
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
Reading about the boundary is not the same as writing code that crosses it. This is a build task: a small program that reports its own pass/fail. Four methods on a CString wrapper are stubbed out — three that turn a C string into something Zig can reason about, and one that goes the other way and writes a C string out. Run it as-is and it fails immediately, naming the first check that did not pass. Implement each until every check passes and it prints All checks passed.
Everything here runs in the playground, because none of it needs libc: a [*:0]const u8 is an ordinary Zig type and the conversions are ordinary Zig code. That is the point of the wrapper — the C-shaped part of your program stays this small.
There is no assert to import: the starter defines its own check helper that prints the failure and returns an error.
Build
Finish the build. Four methods are stubbed out and the checks below them fail until each returns the right value. Run it as-is to see which check fails first, then implement all four until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down. Watch the edge cases the checks pin down: the empty string, a prefix longer than the string, and exactly where the terminator lands.
const std = @import("std");
// A tiny stand-in for an assert: print what went wrong and return an error.
// An error returned from main ends the program with a non-zero exit status.
fn check(ok: bool, comptime message: []const u8) !void {
if (!ok) {
std.debug.print("CHECK FAILED: {s}\n", .{message});
return error.CheckFailed;
}
}
// A wrapper over a C string: a bare pointer with a terminator and no length.
// Every method's job is to give the rest of the program a Zig view instead.
const CString = struct {
ptr: [*:0]const u8,
fn init(s: [*:0]const u8) CString {
return .{ .ptr = s };
}
// TODO 1: return the payload as a Zig slice. The length is not stored
// anywhere — it has to be found by walking to the terminator, which is
// exactly what std.mem.span does for a sentinel-terminated pointer.
// CString.init("abc").slice() -> "abc", a 3-byte slice
fn slice(self: CString) []const u8 {
_ = self;
return "";
}
// TODO 2: return the number of characters BEFORE the terminator.
// Build it on slice() rather than counting again.
// CString.init("abc").len() -> 3
fn len(self: CString) usize {
_ = self;
return 0;
}
// TODO 3: report whether the string begins with prefix. A prefix longer
// than the string is not a match — say so before you index.
// CString.init("hello").startsWith("he") -> true
fn startsWith(self: CString, prefix: []const u8) bool {
_ = self;
_ = prefix;
return false;
}
// TODO 4: copy the payload into out and terminate it, so a C function
// could read it. Return the sentinel-terminated slice of what you wrote.
// The terminator goes after the LAST CHARACTER, not at the end of out.
// Assume out is always large enough.
// writeInto(buf, CString.init("hi")) -> a 2-byte slice, buf[2] == 0
fn writeInto(self: CString, out: []u8) [:0]const u8 {
_ = self;
out[0] = 0;
return out[0..0 :0];
}
};
// --- Build checks: Do not edit below this line. ---
pub fn main() !void {
const greeting = CString.init("Hello, World!");
const empty = CString.init("");
try check(std.mem.eql(u8, greeting.slice(), "Hello, World!"), "slice should return the payload without the terminator");
try check(greeting.slice().len == 13, "the slice of a 13-character C string is 13 bytes, not 14");
try check(empty.slice().len == 0, "an empty C string is a 0-length slice, not a crash");
try check(greeting.len() == 13, "len should report 13 for a 13-character string");
try check(empty.len() == 0, "len of the empty string is 0");
try check(greeting.startsWith("Hello"), "'Hello, World!' starts with 'Hello'");
try check(!greeting.startsWith("World"), "'Hello, World!' does not START with 'World', though it contains it");
try check(greeting.startsWith(""), "every string starts with the empty prefix");
try check(!greeting.startsWith("Hello, World! and more"), "a prefix longer than the string must return false, not index out of bounds");
var buf: [32]u8 = @splat('?');
const written = greeting.writeInto(&buf);
try check(written.len == 13, "writeInto should report 13 written characters, not the size of the buffer");
try check(buf[13] == 0, "the terminator belongs at index 13, right after the last character");
try check(buf[14] == '?', "writeInto must not touch the buffer beyond the terminator");
try check(std.mem.eql(u8, std.mem.span(@as([*:0]const u8, @ptrCast(&buf))), "Hello, World!"), "reading the buffer back the way C would should give the original string");
std.debug.print("All checks passed.\n", .{});
std.debug.print("length: {d}\n", .{greeting.len()});
std.debug.print("written: {s} ({d} bytes)\n", .{ written, written.len });
}
Expected output:
All checks passed. length: 13 written: Hello, World! (13 bytes)
Once it passes, try two variations and predict each before running:
- Terminate at the end of the buffer. In
writeInto, changeout[s.len] = 0toout[out.len - 1] = 0. Predict what happens before running. It compiles, and it does not reach any check: the program panics withsentinel mismatch: expected 0, found 63on thereturn out[0..s.len :0]line. This is worth dwelling on, because it is Zig catching the debug block's bug at the moment the type is created rather than later when something reads the string. Writing[0..s.len :0]is a claim that a zero byte sits at indexs.len; the terminator went to index 31 instead, index 13 still holds the fill character?— decimal 63 — and Zig checks the claim instead of trusting it. The same mistake through a raw[*:0]pointer, as in the debug block above, has nothing to check it and stays silent. - Drop the length guard. In
startsWith, delete theif (prefix.len > s.len) return false;line. Predict what happens before running. The first threestartsWithchecks still pass, and the fourth — the prefix longer than the string — no longer returns false but panics with an index-out-of-bounds ons[0..prefix.len]. The program stops with a stack trace rather than aCHECK FAILEDline. That difference is worth noting: Zig turned a silent C-style overread into a loud crash, which is the trade the bounds check exists to make.
Two last questions — one reaching back to the lesson that taught what a Zig string actually is, and one crossing to C itself.
Recall
No notes. Every conversion in this lesson has turned a C pointer into a Zig slice or back again. String Handling, eleven lessons ago, made a claim about what a Zig string fundamentally is — a claim this whole lesson depends on. Which statement is it, and why does it make the C boundary as cheap as it is?
Transfer
In C, a string is a char* and its length is wherever the first zero byte happens to be, so strlen walks the bytes to find out. In Zig, a []const u8 carries its length in the value itself, so .len is a field read. Both languages are handling the same bytes in the same memory. Which statement identifies the real consequence of that difference, rather than a surface preference?
Key Takeaways
- Zig can call C functions directly with no bindings needed — via
@cImport/@cInclude, or viabuild.zig'saddTranslateCstep, which supersedes them in 0.16 - C types (
c_int,c_long, etc.) have platform-matching sizes [*:0]const u8is Zig's equivalent ofconst char*(null-terminated)- Use
std.mem.span()to convert C strings to Zig slices extern structmatches C struct layout;packed structcontrols bit layoutallocator.dupeZ()creates null-terminated copies for C APIs- Zig can link C libraries in
build.zigwithexe.root_module.linkSystemLibrary("libname", .{})
Everything in this lesson is pinned to Zig 0.16.0. The C types, pointer mappings, extern struct layout and sentinel-terminated slices are the settled part and are unlikely to move. The part that is already moving is how C headers get translated: @cImport is deprecated as of 0.16 in favour of addTranslateC in build.zig, so expect the builtin to keep working for a while and expect new code to stop using it. Check the release notes for 0.17 before relying on @cImport in something long-lived.
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
C interop showed you how Zig threads foreign code through explicit types. The next extension threads something else explicitly: concurrency. You'll see how std.Io is passed like an allocator, how io.async launches tasks, and how to await or cancel them.