Overview
Our HTTP client from the previous chapter consumed data authored in Zig (32); real systems often have to lean on years of C code instead. This chapter shows how Zig 0.15.2 treats C as a first-class citizen: we pull in headers with @cImport, export Zig functions back to C, and verify that records keep their ABI promises. c.zig
The standard library now routes both std.c and std.builtin.CallingConvention through the same modernization that touched the I/O stack, so this chapter highlights the most relevant changes while keeping the examples runnable with nothing more than zig run. builtin.zig, v0.15.2
The C Interoperability Architecture
Before diving into @cImport mechanics, it’s valuable to understand how Zig’s C interop layer is organized. The following diagram shows the complete architecture from user code down to libc and system calls:
This architecture reveals that std.c is not a monolithic module—it’s a dispatcher that uses compile-time logic (builtin.os.tag) to import platform-specific C type definitions. When you write Zig code for macOS, std.c pulls types from c/darwin.zig; on FreeBSD, it uses c/freebsd.zig; on Windows, os/windows.zig; and so forth. These platform-specific modules define C types like c_int, timespec, fd_t, and platform constants, then interface with either libc (when -lc is specified) or direct system calls (on Linux). Importantly, Zig’s own standard library (std.fs, std.net, std.process) uses this same C interop layer—when you call std.posix.open(), it resolves to std.c.open() internally. Understanding this architecture helps you reason about why certain C types are available on some platforms but not others, why -lc is needed for linking libc symbols, and how your @cImport code sits alongside Zig’s built-in C interop.
Learning Goals
- Wire a Zig executable to C headers and companion source using
@cImportand the built-in C toolchain. - Export Zig functions with a C ABI so existing C code can invoke them without glue.
- Map C structs onto Zig
externstructs and confirm that layout, size, and call semantics align.
Importing C APIs into Zig
@cImport compiles a slice of C code alongside your Zig module, honoring include paths, defines, and extra C sources you pass on the command line. This lets one executable take dependencies on both languages without a separate build system.
Round-tripping through
The first example pulls a header and C source that multiply two integers, then demonstrates calling a Zig-exported function from inline C in the same header.
// Import the Zig standard library for basic functionality
const std = @import("std");
// Import C header file using @cImport to interoperate with C code
// This creates a namespace 'c' containing all declarations from "bridge.h"
const c = @cImport({
@cInclude("bridge.h");
});
// Export a Zig function with C calling convention so it can be called from C
// The 'export' keyword makes this function visible to C code
// callconv(.c) ensures it uses the platform's C ABI for parameter passing and stack management
export fn zig_add(a: c_int, b: c_int) callconv(.c) c_int {
return a + b;
}
pub fn main() !void {
// Create a fixed-size buffer for stdout to avoid heap allocations
var stdout_buffer: [128]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const out = &stdout_writer.interface;
// Call C function c_mul from the imported header
// This demonstrates Zig calling into C code seamlessly
const mul = c.c_mul(6, 7);
// Call C function that internally calls back into our exported zig_add function
// This demonstrates the round-trip: Zig -> C -> Zig
const sum = c.call_zig_add(19, 23);
// Print the result from the C multiplication function
try out.print("c_mul(6, 7) = {d}\n", .{mul});
// Print the result from the C function that called our Zig function
try out.print("call_zig_add(19, 23) = {d}\n", .{sum});
// Flush the buffered output to ensure all data is written
try out.flush();
}
This program includes bridge.h via @cInclude, links the accompanying bridge.c, and exports zig_add with the platform’s C calling convention so inline C can call back into Zig.
$ zig run \
-Ichapters-data/code/33__c-interop-import-export-abi \
chapters-data/code/33__c-interop-import-export-abi/01_c_roundtrip.zig \
chapters-data/code/33__c-interop-import-export-abi/bridge.cc_mul(6, 7) = 42
call_zig_add(19, 23) = 42Passing -I keeps the header discoverable, and listing the C file on the same command line instructs the Zig compiler to compile and link it into the run artifact. build.zig
Exporting Zig functions to C
Zig functions gain a C ABI when you mark them export and select callconv(.c), which expands to the target’s default C calling convention. Anything callable from inline C via @cImport can also be called from a separately compiled C object with the same prototype, so this pattern works equally well when you ship a shared library.
Understanding C Calling Conventions
The callconv(.c) annotation is not a single universal calling convention—it resolves to platform-specific conventions based on the target architecture. The following diagram shows how this resolution works:
When you write callconv(.c), Zig automatically selects the appropriate C calling convention for your target. On x86_64 Linux, macOS, or BSD systems, this resolves to System V ABI—arguments pass in registers rdi, rsi, rdx, rcx, r8, r9, then stack; return values use rax. On x86_64 Windows, it becomes Win64 calling convention—arguments pass in rcx, rdx, r8, r9, then stack; the caller must reserve shadow space. On ARM (aarch64), it’s AAPCS (ARM Architecture Procedure Call Standard) with its own register usage rules. This automatic resolution is why the same export fn zig_add(a: i32, b: i32) callconv(.c) i32 works correctly across platforms without modification—Zig generates the right prologue, epilogue, and register usage for each target. When debugging calling convention mismatches or writing assembly interop, knowing which convention is active helps you match register assignments and stack layouts correctly.
Matching data layouts and ABI guarantees
Being callable is only half the work; you also need to agree on layout rules so that structs and aggregates have the same size, alignment, and field ordering on both sides of the boundary.
Understanding ABIs and Object Formats
The Application Binary Interface (ABI) defines calling conventions, name mangling, struct layout rules, and how types are passed between functions. Different ABIs have different rules, which affect C interop compatibility:
The ABI choice affects how extern struct fields are laid out. The gnu ABI (GNU toolchain, used on most Linux systems) follows specific struct padding and alignment rules from GCC. The msvc ABI (Microsoft Visual C++) has different rules—for example, long is 32-bit on Windows x64 but 64-bit on Linux x64. The musl ABI targets musl libc with slightly different calling conventions than glibc. The none ABI is for freestanding environments with no libc. When you declare extern struct SensorData, Zig uses the target’s ABI rules to compute field offsets and padding, ensuring they match what C would produce. The object format (ELF, Mach-O, COFF, WASM) determines which linker is used and how symbols are encoded, but the ABI determines the actual memory layout. This is why the chapter emphasizes @sizeOf checks—if Zig and C disagree about struct size, you likely have an ABI mismatch or wrong target specification.
for shared layouts
This example mirrors a C struct that the sensor firmware publishes. We import the header, declare an extern struct with matching fields, and double-check that Zig and C agree about the size before calling helper routines compiled from C.
// Import the Zig standard library for basic functionality
const std = @import("std");
// Import C header file using @cImport to interoperate with C code
// This creates a namespace 'c' containing all declarations from "abi.h"
const c = @cImport({
@cInclude("abi.h");
});
// Define a Zig struct with 'extern' keyword to match C ABI layout
// The 'extern' keyword ensures the struct uses C-compatible memory layout
// without Zig's automatic padding optimizations
const SensorSample = extern struct {
temperature_c: f32, // Temperature reading in Celsius (32-bit float)
status_bits: u16, // Status flags packed into 16 bits
port_id: u8, // Port identifier (8-bit unsigned)
reserved: u8 = 0, // Reserved byte for alignment/future use, default to 0
};
// Convert a C struct to its Zig equivalent using pointer casting
// This demonstrates type-punning between C and Zig representations
// @ptrCast reinterprets the memory layout without copying data
fn fromC(sample: c.struct_SensorSample) SensorSample {
return @as(*const SensorSample, @ptrCast(&sample)).*;
}
pub fn main() !void {
// Create a fixed-size buffer for stdout to avoid allocations
var stdout_buffer: [256]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const out = &stdout_writer.interface;
// Print size comparison between C and Zig struct representations
// Both should be identical due to 'extern' struct attribute
try out.print("sizeof(C struct) = {d}\n", .{@sizeOf(c.struct_SensorSample)});
try out.print("sizeof(Zig extern struct) = {d}\n", .{@sizeOf(SensorSample)});
// Call C functions to create sensor samples with specific values
const left = c.make_sensor_sample(42.5, 0x0102, 7);
const right = c.make_sensor_sample(38.0, 0x0004, 9);
// Call C function that operates on C structs and returns a computed value
const total = c.combined_voltage(left, right);
// Convert C structs to Zig structs for idiomatic Zig access
const zig_left = fromC(left);
const zig_right = fromC(right);
// Print sensor data from the left port with formatted output
try out.print(
"left port {d}: {d} status bits, {d:.2} °C\n",
.{ zig_left.port_id, zig_left.status_bits, zig_left.temperature_c },
);
// Print sensor data from the right port with formatted output
try out.print(
"right port {d}: {d} status bits, {d:.2} °C\n",
.{ zig_right.port_id, zig_right.status_bits, zig_right.temperature_c },
);
// Print the combined voltage result computed by C function
try out.print("combined_voltage = {d:.3}\n", .{total});
// Flush the buffered output to ensure all data is written
try out.flush();
}
The helper functions originate from abi.c, so the run command links both files and exposes the C aggregation routine to Zig.
$ zig run \
-Ichapters-data/code/33__c-interop-import-export-abi \
chapters-data/code/33__c-interop-import-export-abi/02_abi_layout.zig \
chapters-data/code/33__c-interop-import-export-abi/abi.csizeof(C struct) = 8
sizeof(Zig extern struct) = 8
left port 7: 258 status bits, 42.50 °C
right port 9: 4 status bits, 38.00 °C
combined_voltage = 1.067If the @sizeOf assertions disagree, double-check padding bytes and prefer extern struct over packed unless you have an explicit reason to change ABI rules.
translate-c and build integration
For larger headers, consider running zig translate-c to snapshot them into Zig source. The build system can also register C objects and headers via addCSourceFile and addIncludeDir, making the zig run invocations above part of a repeatable package instead of ad-hoc commands.
Notes & Caveats
- Zig does not automatically link platform libraries; pass
-lcor add the appropriate build options when importing APIs that live outside your project. @cImportemits one translation unit; wrap headers in#pragma onceor include guards to avoid duplicate definitions just as you would in pure C projects.- Avoid
packedunless you control both compilers and targets; packed fields can change alignment guarantees and lead to unaligned loads on architectures that forbid them.
Exercises
- Extend
bridge.hwith a function that returns a struct by value and demonstrate consuming it from Zig without copying through pointers. - Export a Zig function that fills a caller-provided C buffer and inspect its symbol with
zig build-objplusllvm-nmor your platform’s equivalent. - Swap
extern structfor apacked structin the ABI example and run it on a target with strict alignment to observe the differences in emitted machine code.
Caveats, alternatives, edge cases
- Some C ABIs mangle names (e.g., Windows
__stdcall); override the calling convention or use@exportwith an explicit symbol name when interoperating with non-default ABIs. @cImportcannot compile C—translate headers with `extern "C"` wrappers or use a C shim when binding C libraries.- When bridging variadic functions, prefer writing a Zig wrapper that marshals arguments explicitly; Zig’s variadics only cover C’s default promotions, not custom ellipsis semantics.