Why C still matters
Most of the software your system runs on is written in C or exposes a C interface: the operating system’s libraries, SQLite, OpenSSL, zlib, SDL, raylib. The C ABI (application binary interface: the rules for calling functions and laying out data at the machine level) is the common language that lets code from different languages link together. The System V calling convention from the stack lesson is part of it. Zig was designed to plug straight into that world. You can use C libraries without writing wrapper code by hand, and C can call your Zig code.
Calling C from Zig
const c = @cImport(@cInclude("stdio.h"));
pub fn main() void {
_ = c.printf("hello from libc, %d\n", @as(c_int, 42));
}
What happens here:
@cInclude("stdio.h")names a C header, like#includein C.@cImportruns that header through Zig’s C translator and turns its declarations into Zig ones.c.printfis now a function you can call from Zig.- C’s
intbecomesc_intin Zig, because its size can differ between platforms.printftakes a variable number of arguments, so each one needs a definite C type, hence@as(c_int, 42). - Running with
-lclinks the C standard library:zig run hello.zig -lc.
Zig is also a C compiler
The zig binary includes a full C and C++ compiler based on clang: zig cc and zig c++. They’re drop-in replacements for gcc or clang, with one superpower: cross-compiling just works. Zig ships the headers and C library sources for many targets, so zig cc -target aarch64-linux-musl hello.c produces an ARM Linux program from an x86 laptop with no extra setup. Plenty of projects use Zig purely as a C toolchain for that reason.
Calling Zig from C
For C to call your Zig function, the function has to use C’s calling convention and have a predictable symbol name:
export fn add(a: c_int, b: c_int) c_int {
return a + b;
}
export does both. Build it as a library, and C code can declare int add(int, int); and call it. callconv(.c) does only the calling-convention part; you use it on callbacks you hand to a C library.
For data, use extern struct (C’s layout rules, from the memory lesson) so both sides agree on where every field is. Translated C headers also use [*c]T, the “C pointer” type: it might be null, and it might point to many items, because C doesn’t say which. Convert it to a proper Zig type (?*T or a slice) as soon as you know.
packed struct: control every bit
Sometimes data is defined bit by bit: a byte of flags in a file format, or a hardware register where bit 0 means “enabled” and bits 4–6 mean “speed”. In C you’d use masks and shifts like (reg >> 4) & 0x7. Zig’s packed struct lets you name the bits instead:
const Flags = packed struct(u8) {
readable: bool,
writable: bool,
executable: bool,
_reserved: u5 = 0,
};
const f: Flags = .{ .readable = true, .writable = false, .executable = true };
const byte: u8 = @bitCast(f); // 0b0000_0101 = 5
How the layout works:
packed struct(u8)says the whole struct is exactly oneu8. The compiler checks that the fields add up to 8 bits.- Fields are placed starting from the least significant bit:
readableis bit 0,writablebit 1,executablebit 2, and_reservedbits 3 to 7. - There’s no padding at all: a
booltakes 1 bit, au5takes 5. @bitCastconverts between the struct and the raw byte without changing any bits. Soreadableandexecutableset means bits 0 and 2 are on:0b0000_0101, which is 5. Readingf.writablecompiles to the same shift-and-mask you’d write by hand, but it’s readable and hard to get wrong.
Talking to hardware: volatile
On a computer with an operating system, you reach hardware through syscalls. On a microcontroller, or inside your own OS kernel, hardware devices often show up as special memory addresses: writing a byte to a certain address sends it out of a serial port. This is called memory-mapped I/O (MMIO).
The catch: the optimizer assumes memory behaves like memory. If you write to an address twice in a row, it may drop the first write. If you read it in a loop, it may read it once and reuse the value. For a device, every single access matters. Marking the pointer volatile tells the compiler to perform every read and write exactly as written:
const uart_data: *volatile u8 = @ptrFromInt(0x1000_0000);
uart_data.* = 'A'; // sends 'A' out of the serial port
@ptrFromInt turns a plain number into a pointer. That address is the serial port (UART) on QEMU’s emulated RISC-V virt machine, a popular starting point for writing a small operating system in Zig.
Recap
- The C ABI is how code from different languages links together; Zig speaks it natively.
@cImport(@cInclude(...))lets you call C directly; link libc with-lc.zig ccis a C compiler that cross-compiles easily.export fnmakes Zig callable from C;extern structmatches C layouts.packed structnames individual bits;volatilekeeps every hardware access.