zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x0D
Part II · Zig

C interop & bit-level layout

Call C directly, export to C, pack bits exactly, and poke hardware registers.

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:

  1. @cInclude("stdio.h") names a C header, like #include in C.
  2. @cImport runs that header through Zig’s C translator and turns its declarations into Zig ones. c.printf is now a function you can call from Zig.
  3. C’s int becomes c_int in Zig, because its size can differ between platforms. printf takes a variable number of arguments, so each one needs a definite C type, hence @as(c_int, 42).
  4. Running with -lc links 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:

  1. packed struct(u8) says the whole struct is exactly one u8. The compiler checks that the fields add up to 8 bits.
  2. Fields are placed starting from the least significant bit: readable is bit 0, writable bit 1, executable bit 2, and _reserved bits 3 to 7.
  3. There’s no padding at all: a bool takes 1 bit, a u5 takes 5.
  4. @bitCast converts between the struct and the raw byte without changing any bits. So readable and executable set means bits 0 and 2 are on: 0b0000_0101, which is 5. Reading f.writable compiles 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 cc is a C compiler that cross-compiles easily.
  • export fn makes Zig callable from C; extern struct matches C layouts.
  • packed struct names individual bits; volatile keeps every hardware access.
Try it

Cross-compile the libc hello world for another machine: zig build-exe hello.zig -lc -target aarch64-linux-musl, then inspect the result with file hello.

11 flashcards for this lesson

not started
See the questions
  1. How do you use a C header from Zig?
  2. What is zig cc?
  3. What does export fn do?
  4. Bit layout of packed struct(u8) { a: bool, b: bool, rest: u6 }?
  5. Why mark an MMIO pointer volatile?
  6. How do you turn an integer address into a pointer in Zig?
  7. What is [*c]T?
  8. What is an ABI?
  9. What is memory-mapped I/O?
  10. What is an ABI?
  11. What is memory-mapped I/O?