Zig Inline Assembly
Inline assembly lets you write CPU instructions directly inside Zig code. Use it when no Zig or standard library function exposes what you need — specific CPU instructions, system calls, performance-critical micro-optimizations, or hardware control that only assembly can provide. Zig's inline assembly syntax is explicit about inputs, outputs, and which registers are used, preventing the subtle bugs that plague assembly in C.
When to Use Inline Assembly
Use inline assembly for: ✓ Reading CPU registers (cycle counter, control registers) ✓ Atomic operations not exposed by std.atomic ✓ System calls directly (OS kernel interface) ✓ CPU-specific instructions (CPUID, RDTSC, PAUSE, HLT) ✓ Micro-optimizations where compiler output is not ideal Do NOT use for: ✗ Anything achievable with Zig built-ins or std library ✗ General-purpose code (harms readability and portability) ✗ SIMD — use @Vector instead (portable, safe)
Basic Syntax
asm volatile ("instruction"
: [output] "constraint" (zig_variable)
: [input] "constraint" (zig_variable)
: "clobbered_registers"
);
Parts explained: ┌─────────────────┬──────────────────────────────────────┐ │ "instruction" │ The assembly code (AT&T or Intel │ │ │ syntax, x86 uses AT&T by default) │ ├─────────────────┼──────────────────────────────────────┤ │ [output] │ Zig variables the asm writes to │ ├─────────────────┼──────────────────────────────────────┤ │ [input] │ Zig variables the asm reads from │ ├─────────────────┼──────────────────────────────────────┤ │ clobbers │ Registers the asm modifies (beyond │ │ │ output), so compiler avoids them │ └─────────────────┴──────────────────────────────────────┘
Reading the CPU Timestamp Counter
The RDTSC instruction returns the number of CPU clock cycles since the processor was last reset — the fastest available timer:
const std = @import("std");
fn rdtsc() u64 {
var lo: u32 = undefined;
var hi: u32 = undefined;
asm volatile ("rdtsc"
: [lo] "={eax}" (lo),
[hi] "={edx}" (hi),
);
return (@as(u64, hi) << 32) | lo;
}
pub fn main() void {
const start = rdtsc();
var sum: u64 = 0;
for (0..1_000_000) |i| sum +%= i;
const end = rdtsc();
std.debug.print("Sum: {d}\n", .{sum});
std.debug.print("Cycles: {d}\n", .{end - start});
}
rdtsc instruction:
CPU cycle counter (64-bit)
│
EDX:EAX ← low 32 bits in EAX, high 32 bits in EDX
│
Combine: (hi << 32) | lo → u64 cycle count
CPUID — Detecting CPU Features
fn cpuid(leaf: u32) struct { eax: u32, ebx: u32, ecx: u32, edx: u32 } {
var eax: u32 = undefined;
var ebx: u32 = undefined;
var ecx: u32 = undefined;
var edx: u32 = undefined;
asm volatile ("cpuid"
: [eax] "={eax}" (eax),
[ebx] "={ebx}" (ebx),
[ecx] "={ecx}" (ecx),
[edx] "={edx}" (edx),
: [leaf] "{eax}" (leaf),
);
return .{ .eax = eax, .ebx = ebx, .ecx = ecx, .edx = edx };
}
pub fn main() void {
const info = cpuid(0);
// info.eax = max supported CPUID leaf
std.debug.print("Max CPUID leaf: {d}\n", .{info.eax});
}
A Simple Addition in Assembly (x86-64)
fn addAsm(a: i64, b: i64) i64 {
return asm volatile ("addq %[b], %[a]"
: [a] "=r" (-> i64), // output: a register, return value
: [a] "0" (a), // input: tied to output constraint 0
[b] "r" (b), // input: any register
);
}
pub fn main() void {
const result = addAsm(15, 27);
std.debug.print("15 + 27 = {d}\n", .{result}); // 42
}
System Call — Linux x86-64
Making a system call directly bypasses libc. This is how low-level programs and OS kernels communicate with the Linux kernel:
fn syscall3(number: usize, a1: usize, a2: usize, a3: usize) usize {
return asm volatile ("syscall"
: [ret] "={rax}" (-> usize),
: [num] "{rax}" (number),
[a1] "{rdi}" (a1),
[a2] "{rsi}" (a2),
[a3] "{rdx}" (a3),
: "rcx", "r11", "memory"
);
}
pub fn main() void {
const msg = "Hello from syscall!\n";
// syscall write(1, msg.ptr, msg.len)
// 1 = stdout, syscall number 1 = write on Linux x86-64
_ = syscall3(1, 1, @intFromPtr(msg.ptr), msg.len);
}
Linux x86-64 syscall calling convention: rax = syscall number rdi = arg1, rsi = arg2, rdx = arg3 r10 = arg4, r8 = arg5, r9 = arg6 syscall instruction → kernel handles it → result in rax
Constraint Codes
Common constraint codes:
"r" → any general-purpose register
"m" → memory location
"i" → immediate constant
"=r" → output: any register (written)
"=m" → output: memory (written)
"0" → tied to constraint 0 (same register as first output)
"{rax}"→ specific register (rax)
"{eax}"→ specific register (eax, 32-bit)
Clobber strings:
"memory" → asm may read/write any memory
"cc" → asm modifies CPU flags (condition codes)
"rax" → asm modifies rax beyond declared outputs
Inline Assembly on ARM (AArch64)
// ARM does not use AT&T syntax — registers use x0-x30 naming
fn armAdd(a: u64, b: u64) u64 {
return asm volatile ("add %[result], %[a], %[b]"
: [result] "=r" (-> u64),
: [a] "r" (a),
[b] "r" (b),
);
}
Safety Warnings
⚠ Inline assembly bypasses ALL Zig safety checks.
⚠ Wrong constraints corrupt memory silently.
⚠ Missing clobbers cause incorrect compiler optimizations.
⚠ Assembly is architecture-specific — not portable.
⚠ asm volatile prevents compiler from removing or reordering.
Use volatile when timing or side effects matter.
Omit volatile to allow the compiler to optimize.
Always prefer Zig built-ins, std.atomic, or @Vector before reaching for inline assembly. Reserve assembly for the rare cases where no higher-level API exists and the performance requirement is fully justified by measurement.
