Learn Zig Series (#72) - System Call Wrappers
Learn Zig Series (#72) - System Call Wrappers
What will I learn
- What system calls are and how they form the kernel API boundary between userspace and kernel;
- How to make raw syscalls using
std.os.linux.syscalland the syscall ABI (registers, return values, errno encoding); - How to wrap raw syscalls in type-safe Zig functions with proper error sets;
- How ioctl works as the universal device control interface and how to call it from Zig;
- How to use strace to debug system call issues when something goes wrong at the kernel boundary;
- How Zig's
std.posixwrappers compare to raw syscalls and when you'd choose one over the other; - How to implement getrandom() and memfd_create() from scratch as practical syscall wrappers.
Requirements
- A working modern computer running macOS, Windows or Ubuntu;
- An installed Zig 0.14+ distribution (download from ziglang.org);
- The ambition to learn Zig programming.
Difficulty
- Intermediate
Curriculum (of the Learn Zig Series):
- Zig Programming Tutorial - ep001 - Intro
- Learn Zig Series (#2) - Hello Zig, Variables and Types
- Learn Zig Series (#3) - Functions and Control Flow
- Learn Zig Series (#4) - Error Handling (Zig's Best Feature)
- Learn Zig Series (#5) - Arrays, Slices, and Strings
- Learn Zig Series (#6) - Structs, Enums, and Tagged Unions
- Learn Zig Series (#7) - Memory Management and Allocators
- Learn Zig Series (#8) - Pointers and Memory Layout
- Learn Zig Series (#9) - Comptime (Zig's Superpower)
- Learn Zig Series (#10) - Project Structure, Modules, and File I/O
- Learn Zig Series (#11) - Mini Project: Building a Step Sequencer
- Learn Zig Series (#12) - Testing and Test-Driven Development
- Learn Zig Series (#13) - Interfaces via Type Erasure
- Learn Zig Series (#14) - Generics with Comptime Parameters
- Learn Zig Series (#15) - The Build System (build.zig)
- Learn Zig Series (#16) - Sentinel-Terminated Types and C Strings
- Learn Zig Series (#17) - Packed Structs and Bit Manipulation
- Learn Zig Series (#18b) - Addendum: Async Returns in Zig 0.16
- Learn Zig Series (#19) - SIMD with @Vector
- Learn Zig Series (#20) - Working with JSON
- Learn Zig Series (#21) - Networking and TCP Sockets
- Learn Zig Series (#22) - Hash Maps and Data Structures
- Learn Zig Series (#23) - Iterators and Lazy Evaluation
- Learn Zig Series (#24) - Logging, Formatting, and Debug Output
- Learn Zig Series (#25) - Mini Project: HTTP Status Checker
- Learn Zig Series (#26) - Writing a Custom Allocator
- Learn Zig Series (#27) - C Interop: Calling C from Zig
- Learn Zig Series (#28) - C Interop: Exposing Zig to C
- Learn Zig Series (#29) - Inline Assembly and Low-Level Control
- Learn Zig Series (#30) - Thread Safety and Atomics
- Learn Zig Series (#31) - Memory-Mapped I/O and Files
- Learn Zig Series (#32) - Compile-Time Reflection with @typeInfo
- Learn Zig Series (#33) - Building a State Machine with Tagged Unions
- Learn Zig Series (#34) - Performance Profiling and Optimization
- Learn Zig Series (#35) - Cross-Compilation and Target Triples
- Learn Zig Series (#36) - Mini Project: CLI Task Runner
- Learn Zig Series (#37) - Markdown to HTML: Tokenizer and Lexer
- Learn Zig Series (#38) - Markdown to HTML: Parser and AST
- Learn Zig Series (#39) - Markdown to HTML: Renderer and CLI
- Learn Zig Series (#40) - Key-Value Store: In-Memory Store
- Learn Zig Series (#41) - Key-Value Store: Write-Ahead Log
- Learn Zig Series (#42) - Key-Value Store: TCP Server
- Learn Zig Series (#43) - Key-Value Store: Client Library and Benchmarks
- Learn Zig Series (#44) - Image Tool: Reading and Writing PPM/BMP
- Learn Zig Series (#45) - Image Tool: Pixel Operations
- Learn Zig Series (#46) - Image Tool: CLI Pipeline
- Learn Zig Series (#47) - Build a Shell: Parsing Commands
- Learn Zig Series (#48) - Build a Shell: Process Spawning
- Learn Zig Series (#49) - Build a Shell: Built-in Commands
- Learn Zig Series (#50) - Build a Shell: Job Control and Signals
- Learn Zig Series (#51) - HTTP Server: Accept Loop and Parsing
- Learn Zig Series (#52) - HTTP Server: Router and Responses
- Learn Zig Series (#53) - HTTP Server: Static Files and MIME
- Learn Zig Series (#54) - HTTP Server: Middleware and Logging
- Learn Zig Series (#55) - ECS Game Engine: Architecture
- Learn Zig Series (#56) - ECS Game Engine: Component Storage
- Learn Zig Series (#57) - ECS Game Engine: Systems and Queries
- Learn Zig Series (#58) - ECS Game Engine: Terminal Rendering
- Learn Zig Series (#59) - Assembler: Instruction Encoding
- Learn Zig Series (#60) - Assembler: Two-Pass Assembly
- Learn Zig Series (#61) - Assembler: Disassembler and Binary Inspector
- Learn Zig Series (#62) - File Systems: Reading Directories and Metadata
- Learn Zig Series (#63) - File Watching: Detecting Changes
- Learn Zig Series (#64) - Process Management: Fork, Exec, Wait
- Learn Zig Series (#65) - Pipes and Inter-Process Communication
- Learn Zig Series (#66) - Shared Memory and Semaphores
- Learn Zig Series (#67) - Signal Handling Deep Dive
- Learn Zig Series (#68) - Unix Domain Sockets
- Learn Zig Series (#69) - Daemonization: Background Services
- Learn Zig Series (#70) - Timers and Scheduling
- Learn Zig Series (#71) - Resource Limits and Capabilities
- Learn Zig Series (#72) - System Call Wrappers (this post)
Learn Zig Series (#72) - System Call Wrappers
Solutions to Episode 71 Exercises
Exercise 1: Resource limit enforcer that reads limits from a config file
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const Rlimit = extern struct { cur: u64, max: u64 };
const LimitSpec = struct {
resource: u32,
value: u64,
};
fn parseSuffix(raw: []const u8) !u64 {
if (raw.len == 0) return error.EmptyValue;
const last = raw[raw.len - 1];
if (last == 'K' or last == 'k') {
return (try std.fmt.parseInt(u64, raw[0 .. raw.len - 1], 10)) * 1024;
} else if (last == 'M' or last == 'm') {
return (try std.fmt.parseInt(u64, raw[0 .. raw.len - 1], 10)) * 1024 * 1024;
} else if (last == 'G' or last == 'g') {
return (try std.fmt.parseInt(u64, raw[0 .. raw.len - 1], 10)) * 1024 * 1024 * 1024;
}
return try std.fmt.parseInt(u64, raw, 10);
}
fn resourceFromName(name: []const u8) ?u32 {
if (std.mem.eql(u8, name, "NOFILE")) return 7;
if (std.mem.eql(u8, name, "AS")) return 9;
if (std.mem.eql(u8, name, "CPU")) return 0;
if (std.mem.eql(u8, name, "CORE")) return 4;
if (std.mem.eql(u8, name, "NPROC")) return 6;
if (std.mem.eql(u8, name, "FSIZE")) return 1;
if (std.mem.eql(u8, name, "STACK")) return 3;
if (std.mem.eql(u8, name, "DATA")) return 2;
return null;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var args = std.process.args();
_ = args.next(); // skip program name
const config_path = args.next() orelse {
try stdout.print("Usage: rlimit_enforcer [args...]\n", .{});
return;
};
const target_program = args.next() orelse {
try stdout.print("Error: no target program specified\n", .{});
return;
};
_ = target_program;
// parse config
var file = try std.fs.cwd().openFile(config_path, .{});
defer file.close();
var buf: [4096]u8 = undefined;
const n = try file.readAll(&buf);
var lines = std.mem.splitScalar(u8, buf[0..n], '\n');
var limits: [16]LimitSpec = undefined;
var limit_count: usize = 0;
while (lines.next()) |line| {
const trimmed = std.mem.trim(u8, line, " \t\r");
if (trimmed.len == 0 or trimmed[0] == '#') continue;
var parts = std.mem.splitScalar(u8, trimmed, ' ');
const name = parts.next() orelse continue;
const val_str = parts.next() orelse continue;
const res = resourceFromName(name) orelse continue;
const val = parseSuffix(val_str) catch continue;
limits[limit_count] = .{ .resource = res, .value = val };
limit_count += 1;
}
// apply limits
for (limits[0..limit_count]) |spec| {
const rlim = Rlimit{ .cur = spec.value, .max = spec.value };
_ = linux.syscall2(.setrlimit, spec.resource, @intFromPtr(&rlim));
}
try stdout.print("Applied {d} resource limits from {s}\n", .{ limit_count, config_path });
}
The key insight is parsing the suffix multipliers (K/M/G) before converting to raw bytes. The setrlimit call sets both soft and hard to the same value -- that's the strictest enforcement possible, preventing the child from raising its own limit.
Exercise 2: Capability inspector reading /proc//status
const std = @import("std");
const cap_names = [_][]const u8{
"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_DAC_READ_SEARCH",
"CAP_FOWNER", "CAP_FSETID", "CAP_KILL",
"CAP_SETGID", "CAP_SETUID", "CAP_SETPCAP",
"CAP_LINUX_IMMUTABLE", "CAP_NET_BIND_SERVICE", "CAP_NET_BROADCAST",
"CAP_NET_ADMIN", "CAP_NET_RAW", "CAP_IPC_LOCK",
"CAP_IPC_OWNER", "CAP_SYS_MODULE", "CAP_SYS_RAWIO",
"CAP_SYS_CHROOT", "CAP_SYS_PTRACE", "CAP_SYS_PACCT",
"CAP_SYS_ADMIN", "CAP_SYS_BOOT", "CAP_SYS_NICE",
"CAP_SYS_RESOURCE", "CAP_SYS_TIME", "CAP_SYS_TTY_CONFIG",
"CAP_MKNOD", "CAP_LEASE", "CAP_AUDIT_WRITE",
"CAP_AUDIT_CONTROL", "CAP_SETFCAP", "CAP_MAC_OVERRIDE",
"CAP_MAC_ADMIN", "CAP_SYSLOG", "CAP_WAKE_ALARM",
"CAP_BLOCK_SUSPEND", "CAP_AUDIT_READ", "CAP_PERFMON",
"CAP_BPF", "CAP_CHECKPOINT_RESTORE",
};
fn decodeCaps(hex: []const u8) !u64 {
return try std.fmt.parseInt(u64, std.mem.trim(u8, hex, " \t\r\n"), 16);
}
fn printCaps(writer: anytype, label: []const u8, bitmask: u64) !void {
try writer.print(" {s}:\n", .{label});
var found: u32 = 0;
for (cap_names, 0..) |name, i| {
if ((bitmask >> @intCast(i)) & 1 == 1) {
try writer.print(" [{d:>2}] {s}\n", .{ i, name });
found += 1;
}
}
if (found == 0) try writer.print(" (none)\n", .{});
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
var args = std.process.args();
_ = args.next();
const pid_str = args.next() orelse "self";
var path_buf: [64]u8 = undefined;
const path = try std.fmt.bufPrint(&path_buf, "/proc/{s}/status", .{pid_str});
var file = std.fs.cwd().openFile(path, .{}) catch |err| {
try stdout.print("Cannot open {s}: {s}\n", .{ path, @errorName(err) });
return;
};
defer file.close();
var buf: [8192]u8 = undefined;
const n = try file.readAll(&buf);
var lines = std.mem.splitScalar(u8, buf[0..n], '\n');
var cap_prm: u64 = 0;
var cap_eff: u64 = 0;
var cap_bnd: u64 = 0;
while (lines.next()) |line| {
if (std.mem.startsWith(u8, line, "CapPrm:")) {
cap_prm = decodeCaps(line[7..]) catch 0;
} else if (std.mem.startsWith(u8, line, "CapEff:")) {
cap_eff = decodeCaps(line[7..]) catch 0;
} else if (std.mem.startsWith(u8, line, "CapBnd:")) {
cap_bnd = decodeCaps(line[7..]) catch 0;
}
}
try stdout.print("Capabilities for PID {s}:\n\n", .{pid_str});
try printCaps(stdout, "Permitted (CapPrm)", cap_prm);
try printCaps(stdout, "Effective (CapEff)", cap_eff);
try printCaps(stdout, "Bounding (CapBnd)", cap_bnd);
}
The /proc/<pid>/status file contains hex-encoded bitmasks where each bit position corresponds to a capability number. Decoding is straightforward -- parse the hex, then test each bit.
Exercise 3: Process jail combining three isolation layers
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
const Rlimit = extern struct { cur: u64, max: u64 };
const PR_SET_NO_NEW_PRIVS: u32 = 38;
fn setrlimit(resource: u32, rlim: Rlimit) void {
_ = linux.syscall2(.setrlimit, resource, @intFromPtr(&rlim));
}
fn runJailed() !void {
const stdout = std.io.getStdOut().writer();
// layer 1: resource limits
setrlimit(7, .{ .cur = 32, .max = 32 }); // NOFILE
setrlimit(9, .{ .cur = 64 * 1024 * 1024, .max = 64 * 1024 * 1024 }); // AS = 64MB
setrlimit(0, .{ .cur = 5, .max = 5 }); // CPU = 5s
setrlimit(6, .{ .cur = 2, .max = 2 }); // NPROC = 2
// layer 2: no new privs
_ = linux.syscall5(.prctl, PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0);
try stdout.print("[jail] All layers applied. Testing:\n", .{});
// test A: open 50 files
var opened: u32 = 0;
for (0..50) |_| {
_ = posix.open("/dev/null", .{ .ACCMODE = .RDONLY }, 0) catch break;
opened += 1;
}
try stdout.print(" Files opened: {d}/50 (blocked by RLIMIT_NOFILE)\n", .{opened});
// test B: allocate 100MB
var chunks: u32 = 0;
for (0..25) |_| { // 25 * 4MB = 100MB attempted
const result = linux.mmap(null, 4 * 1024 * 1024,
linux.PROT.READ | linux.PROT.WRITE,
.{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0);
const signed: isize = @bitCast(result);
if (signed < 0) break;
chunks += 1;
}
try stdout.print(" Memory 4MB chunks: {d}/25 (blocked by RLIMIT_AS)\n", .{chunks});
// test C: fork 5 grandchildren
var forks: u32 = 0;
for (0..5) |_| {
const pid = posix.fork() catch break;
if (pid == 0) posix.exit(0);
_ = posix.waitpid(pid, 0);
forks += 1;
}
try stdout.print(" Forks succeeded: {d}/5 (blocked by RLIMIT_NPROC)\n", .{forks});
// test D: write to read-only path
const write_ok = if (std.fs.cwd().createFile("/proc/self/status", .{})) |f| blk: {
f.close();
break :blk true;
} else |_| false;
try stdout.print(" Write to /proc: {s}\n", .{if (write_ok) "allowed (bad!)" else "blocked (good)"});
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Process jail demo -- forking jailed child\n\n", .{});
const pid = try posix.fork();
if (pid == 0) {
runJailed() catch |err| {
std.debug.print("Jail error: {s}\n", .{@errorName(err)});
};
posix.exit(0);
}
const result = posix.waitpid(pid, 0);
const status = result.status;
if (status.signal()) |sig| {
try stdout.print("\nChild killed by signal {d}\n", .{@intFromEnum(sig)});
} else {
try stdout.print("\nChild exited with code {d}\n", .{status.exit_status()});
}
}
The three layers stack independently: rlimits block resource consumption, PR_SET_NO_NEW_PRIVS prevents privilege escalation through exec, and the read-only filesystem paths prevent unauthorized writes. Each layer catches different attack vectors.
We've spent the last several episodes building on top of what Zig's standard library gives us -- posix.fork(), posix.open(), posix.read(). But have you ever wondered what happens UNDERNEATH those nice wrapper functions? When you call posix.open(), how does your program actually talk to the kernel?
The answer is system calls -- and today we're going to peel back that layer entirely. We'll make raw syscalls by hand, understand the ABI (Application Binary Interface) that defines how arguments move between registers, and then build our own type-safe wrappers. By the end of this episode you'll understand exactly what happens at the boundary between your code and the Linux kernel ;-)
What system calls are: the kernel API boundary
Your program runs in userspace. The kernel runs in kernel space. These are different privilege levels enforced by the CPU hardware itself (ring 3 vs ring 0 on x86). Your program cannot directly access hardware, manage memory pages, or talk to devices -- it must ASK the kernel to do these things on its behalf.
A system call is that request mechanism. It's a controlled transfer from userspace to kernel space. On x86-64 Linux, the instruction is syscall (literally -- that's the assembly mnemonic). The kernel has a table of ~450 numbered functions, and you invoke them by number.
Here's the thing that surprises people coming from higher-level languages: EVERYTHING your program does that involves the outside world goes through a syscall. Opening files? open() syscall. Reading bytes? read() syscall. Allocating memory? mmap() syscall. Sending a network packet? sendto() syscall. Even exit() is a syscall -- your program cannot terminate without the kernel's involvement.
const std = @import("std");
const linux = std.os.linux;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// every one of these uses syscalls internally:
// stdout.print -> write(1, buf, len) syscall
// std.time.timestamp -> clock_gettime syscall
// std.process.getEnvMap -> reads /proc/self/environ (open + read syscalls)
// std.posix.fork -> clone syscall
try stdout.print("System call numbers (x86-64 Linux):\n\n", .{});
const syscalls = [_]struct { num: u32, name: []const u8 }{
.{ .num = 0, .name = "read" },
.{ .num = 1, .name = "write" },
.{ .num = 2, .name = "open" },
.{ .num = 3, .name = "close" },
.{ .num = 9, .name = "mmap" },
.{ .num = 39, .name = "getpid" },
.{ .num = 56, .name = "clone" },
.{ .num = 57, .name = "fork" },
.{ .num = 59, .name = "execve" },
.{ .num = 60, .name = "exit" },
.{ .num = 62, .name = "kill" },
.{ .num = 228, .name = "clock_gettime" },
.{ .num = 318, .name = "getrandom" },
.{ .num = 319, .name = "memfd_create" },
};
for (syscalls) |sc| {
try stdout.print(" {d:>3}: {s}\n", .{ sc.num, sc.name });
}
try stdout.print("\nTotal syscalls in Linux 6.x: ~450\n", .{});
try stdout.print("Zig exposes them via std.os.linux.SYS enum\n", .{});
}
The numbers are stable -- Linux guarantees that syscall numbers never change for a given architecture. That's the whole point: it's a stable ABI. Userspace binaries compiled 20 years ago still work because syscall 1 is still write and syscall 2 is still open. This stability is a fundamental design principle of Linux.
Making raw syscalls with std.os.linux.syscall
Zig gives you direct access to the syscall instruction through std.os.linux.syscall0 through syscall6 (the number indicates how many arguments). These are NOT wrappers -- they compile down to literally loading registers and executing the syscall instruction. Zero overhead, zero abstraction:
const std = @import("std");
const linux = std.os.linux;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// raw getpid -- no arguments
const pid = linux.syscall0(.getpid);
try stdout.print("PID (raw syscall): {d}\n", .{pid});
// raw write -- write(fd=1, buf, len)
const msg = "Hello from raw write syscall!\n";
const written = linux.syscall3(
.write,
1, // fd 1 = stdout
@intFromPtr(msg.ptr),
msg.len,
);
_ = written;
// raw getuid -- no arguments
const uid = linux.syscall0(.getuid);
try stdout.print("UID: {d}\n", .{uid});
// raw clock_gettime
var ts: linux.timespec = undefined;
const clock_result = linux.syscall2(
.clock_gettime,
0, // CLOCK_REALTIME
@intFromPtr(&ts),
);
const clock_signed: isize = @bitCast(clock_result);
if (clock_signed == 0) {
try stdout.print("Time: {d}.{d:0>9} seconds since epoch\n", .{
ts.sec, ts.nsec,
});
}
// raw uname
var uts: linux.utsname = undefined;
const uname_result = linux.syscall1(.uname, @intFromPtr(&uts));
const uname_signed: isize = @bitCast(uname_result);
if (uname_signed == 0) {
const sysname = std.mem.sliceTo(&uts.sysname, 0);
const release = std.mem.sliceTo(&uts.release, 0);
const machine = std.mem.sliceTo(&uts.machine, 0);
try stdout.print("System: {s} {s} ({s})\n", .{ sysname, release, machine });
}
}
Notice the pattern: every argument becomes a usize -- pointers get converted with @intFromPtr, integers pass directly. The return value is also a usize. This is the raw register-level interface. No types, no safety, no error handling. Just numbers going into registers and a number coming back.
The syscall ABI: registers, return values, error codes
On x86-64 Linux, the syscall convention is:
- RAX: syscall number (input) / return value (output)
- RDI: argument 1
- RSI: argument 2
- RDX: argument 3
- R10: argument 4 (note: NOT RCX -- that's clobbered by
syscall) - R8: argument 5
- R9: argument 6
The return value in RAX is either the successful result OR a negative error code. The range -4095 to -1 indicates an error, where the absolute value is the errno. Everything else is success. This is different from the C library convention where errno is a separate thread-local variable:
const std = @import("std");
const linux = std.os.linux;
const E = linux.E;
fn syscallToError(result: usize) !usize {
const signed: isize = @bitCast(result);
if (signed >= -4095 and signed < 0) {
const errno: u16 = @intCast(@as(usize, @bitCast(-signed)));
return switch (errno) {
@intFromEnum(E.ACCES) => error.AccessDenied,
@intFromEnum(E.NOENT) => error.FileNotFound,
@intFromEnum(E.EXIST) => error.PathAlreadyExists,
@intFromEnum(E.INVAL) => error.InvalidArgument,
@intFromEnum(E.MFILE) => error.TooManyOpenFiles,
@intFromEnum(E.NOSYS) => error.SyscallNotSupported,
@intFromEnum(E.PERM) => error.PermissionDenied,
@intFromEnum(E.FAULT) => error.BadAddress,
else => error.Unexpected,
};
}
return result;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// successful call
const pid = try syscallToError(linux.syscall0(.getpid));
try stdout.print("getpid() = {d}\n", .{pid});
// call that will fail: open a nonexistent file
const result = linux.syscall3(
.open,
@intFromPtr("/tmp/definitely_not_a_real_file_xyz123".ptr),
0, // O_RDONLY
0,
);
const signed: isize = @bitCast(result);
try stdout.print("\nopen() returned: {d} (raw)\n", .{signed});
try stdout.print("Errno value: {d} (ENOENT = {d})\n", .{
@as(usize, @bitCast(-signed)),
@intFromEnum(E.NOENT),
});
// using our error conversion
_ = syscallToError(result) catch |err| {
try stdout.print("Converted to Zig error: {s}\n", .{@errorName(err)});
};
// another failure: invalid syscall number
const bad = linux.syscall0(@enumFromInt(99999));
const bad_signed: isize = @bitCast(bad);
try stdout.print("\nInvalid syscall returned: {d}\n", .{bad_signed});
try stdout.print("Errno: {d} (ENOSYS = {d})\n", .{
@as(usize, @bitCast(-bad_signed)),
@intFromEnum(E.NOSYS),
});
}
This is the fundamental contract between userspace and the kernel. The kernel NEVER raises a signal or throws an exception to indicate a syscall error (with the specific exception of SIGPIPE for write-to-closed-pipe, which is a different mechanism). It always returns a value in RAX, and the caller checks whether that value is in the error range. Zig's standard library does this check for you in std.posix, but when you go raw, you're responsible.
Wrapping syscalls in type-safe Zig functions
Now here's where Zig really shines. We can take these raw untyped register operations and wrap them in functions that have proper error sets, proper argument types, and proper return types. The compiler optimizes away all the wrapping -- you get safety at zero runtime cost:
const std = @import("std");
const linux = std.os.linux;
const SyscallError = error{
AccessDenied,
FileNotFound,
InvalidArgument,
TooManyOpenFiles,
PermissionDenied,
BadAddress,
Unexpected,
};
fn checkResult(result: usize) SyscallError!usize {
const signed: isize = @bitCast(result);
if (signed >= -4095 and signed < 0) {
const errno: u16 = @intCast(@as(usize, @bitCast(-signed)));
return switch (errno) {
13 => error.AccessDenied, // EACCES
2 => error.FileNotFound, // ENOENT
22 => error.InvalidArgument, // EINVAL
24 => error.TooManyOpenFiles,// EMFILE
1 => error.PermissionDenied, // EPERM
14 => error.BadAddress, // EFAULT
else => error.Unexpected,
};
}
return result;
}
// type-safe getpid wrapper
pub fn getpid() u32 {
return @intCast(linux.syscall0(.getpid));
}
// type-safe write wrapper
pub fn write(fd: i32, buf: []const u8) SyscallError!usize {
const result = linux.syscall3(
.write,
@as(usize, @bitCast(@as(isize, fd))),
@intFromPtr(buf.ptr),
buf.len,
);
return checkResult(result);
}
// type-safe close wrapper
pub fn close(fd: i32) SyscallError!void {
const result = linux.syscall1(
.close,
@as(usize, @bitCast(@as(isize, fd))),
);
_ = try checkResult(result);
}
// type-safe dup2 wrapper
pub fn dup2(oldfd: i32, newfd: i32) SyscallError!i32 {
const result = linux.syscall2(
.dup2,
@as(usize, @bitCast(@as(isize, oldfd))),
@as(usize, @bitCast(@as(isize, newfd))),
);
return @intCast(@as(isize, @bitCast(try checkResult(result))));
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
const pid = getpid();
try stdout.print("PID: {d}\n", .{pid});
const msg = "Written via type-safe wrapper!\n";
const n = try write(1, msg);
try stdout.print("write() returned: {d} bytes\n", .{n});
// demonstrate error handling
write(-1, "bad fd") catch |err| {
try stdout.print("write(-1) error: {s}\n", .{@errorName(err)});
};
try stdout.print("\nThe wrapper adds type safety with zero runtime cost.\n", .{});
try stdout.print("In release mode, these compile to identical instructions.\n", .{});
}
The crucial design choice: separate error sets per wrapper vs one big global error set. In production code (like Zig's std library), each function has its own error set listing only the errors that function can actually return. This lets the compiler verify that callers handle all possible errors -- you can't forget one. Our simplified SyscallError above is a teaching shortcut; real wrappers would be more precise.
Ioctl: the Swiss army knife of device control
Every device driver in Linux has a different set of operations it supports -- terminal settings, disk geometry, network interface configuration, GPU commands. Rather than adding a new syscall for each one, Linux uses a single catchall: ioctl (input/output control). It takes a file descriptor, a request code, and an optional argument (usually a pointer to a struct):
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
// terminal ioctl constants
const TIOCGWINSZ: u32 = 0x5413; // get window size
const TIOCGPGRP: u32 = 0x540F; // get foreground process group
const TCGETS: u32 = 0x5401; // get terminal attributes
const Winsize = extern struct {
ws_row: u16,
ws_col: u16,
ws_xpixel: u16,
ws_ypixel: u16,
};
const Termios = extern struct {
c_iflag: u32,
c_oflag: u32,
c_cflag: u32,
c_lflag: u32,
c_line: u8,
c_cc: [19]u8,
_padding: [3]u8 = .{ 0, 0, 0 },
c_ispeed: u32,
c_ospeed: u32,
};
fn ioctl(fd: i32, request: u32, arg: usize) isize {
const result = linux.syscall3(
.ioctl,
@as(usize, @bitCast(@as(isize, fd))),
request,
arg,
);
return @bitCast(result);
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("ioctl demonstrations:\n\n", .{});
// get terminal window size
var ws: Winsize = undefined;
const ws_result = ioctl(1, TIOCGWINSZ, @intFromPtr(&ws));
if (ws_result == 0) {
try stdout.print("Terminal size: {d} rows x {d} cols", .{ ws.ws_row, ws.ws_col });
if (ws.ws_xpixel > 0) {
try stdout.print(" ({d}x{d} pixels)", .{ ws.ws_xpixel, ws.ws_ypixel });
}
try stdout.print("\n", .{});
} else {
try stdout.print("TIOCGWINSZ failed (not a terminal?)\n", .{});
}
// get foreground process group
var pgrp: i32 = 0;
const pgrp_result = ioctl(0, TIOCGPGRP, @intFromPtr(&pgrp));
if (pgrp_result == 0) {
try stdout.print("Foreground process group: {d}\n", .{pgrp});
}
// get terminal attributes
var tio: Termios = undefined;
const tio_result = ioctl(0, TCGETS, @intFromPtr(&tio));
if (tio_result == 0) {
try stdout.print("\nTerminal flags:\n", .{});
try stdout.print(" Input flags: 0x{x:0>8}\n", .{tio.c_iflag});
try stdout.print(" Output flags: 0x{x:0>8}\n", .{tio.c_oflag});
try stdout.print(" Control flags: 0x{x:0>8}\n", .{tio.c_cflag});
try stdout.print(" Local flags: 0x{x:0>8}\n", .{tio.c_lflag});
try stdout.print(" ECHO enabled: {s}\n", .{
if (tio.c_lflag & 0x8 != 0) "yes" else "no",
});
try stdout.print(" ICANON mode: {s}\n", .{
if (tio.c_lflag & 0x2 != 0) "yes (line buffered)" else "no (raw)",
});
}
try stdout.print("\nioctl request codes encode: direction, type, number, size\n", .{});
try stdout.print("TIOCGWINSZ = 0x{x}: 'T' type, GET direction, Winsize size\n", .{TIOCGWINSZ});
}
The request code (like TIOCGWINSZ = 0x5413) encodes four pieces of information: the direction (read/write/both), the device type (a character, like 'T' for terminal), a sequential number, and the size of the argument struct. This encoding lets the kernel validate ioctl calls before passing them to the driver. If you pass the wrong size, the kernel can catch it early insted of letting the driver read garbage.
The reason ioctl exists as one big multiplex syscall rather than hundreds of specific ones: adding a syscall is a HUGE deal in Linux (it becomes part of the stable ABI forever). Adding an ioctl command to a driver is trivial -- it's just a switch case in the driver code. So ioctl became the escape hatch for device-specific functionality that doesn't warrant a dedicated syscall.
Using strace to debug system call issues
When something goes wrong at the system call level -- a file that won't open, a socket that refuses to bind, a mysterious EINVAL -- strace is your best friend. It intercepts every syscall your program makes and prints the arguments and return values. Here's how to read its output and how to write code that's easy to trace:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Run this program with: strace -f ./program 2>trace.log\n", .{});
try stdout.print("Then examine trace.log to see every syscall.\n\n", .{});
// deliberate mix of successful and failing calls for strace demo
try stdout.print("Making various syscalls for strace to capture:\n", .{});
// 1. successful open + close
const fd = posix.open("/etc/hostname", .{ .ACCMODE = .RDONLY }, 0) catch |err| {
try stdout.print(" open /etc/hostname: {s}\n", .{@errorName(err)});
return;
};
try stdout.print(" opened /etc/hostname as fd {d}\n", .{fd});
var buf: [256]u8 = undefined;
const n = try posix.read(fd, &buf);
try stdout.print(" read {d} bytes: {s}", .{ n, buf[0..n] });
posix.close(fd);
// 2. failing open (no such file)
_ = posix.open("/tmp/nonexistent_strace_demo", .{ .ACCMODE = .RDONLY }, 0) catch |err| {
try stdout.print(" open nonexistent: {s} (strace shows ENOENT)\n", .{@errorName(err)});
};
// 3. getpid (always succeeds, useful as a marker in traces)
const pid = linux.syscall0(.getpid);
try stdout.print(" getpid = {d}\n", .{pid});
// 4. failing ioctl (bad fd)
const ioctl_result = linux.syscall3(.ioctl, 999, 0x5413, @intFromPtr(&buf));
const ioctl_signed: isize = @bitCast(ioctl_result);
try stdout.print(" ioctl(999, TIOCGWINSZ) = {d} (EBADF)\n", .{ioctl_signed});
try stdout.print("\nstrace output for this would look like:\n", .{});
try stdout.print(" openat(AT_FDCWD, \"/etc/hostname\", O_RDONLY) = 3\n", .{});
try stdout.print(" read(3, \"myhost\\n\", 256) = 7\n", .{});
try stdout.print(" close(3) = 0\n", .{});
try stdout.print(" openat(AT_FDCWD, \"/tmp/nonexistent_...\", O_RDONLY) = -1 ENOENT\n", .{});
try stdout.print(" getpid() = {d}\n", .{pid});
try stdout.print(" ioctl(999, TIOCGWINSZ, 0x...) = -1 EBADF\n", .{});
try stdout.print("\nUseful strace flags:\n", .{});
try stdout.print(" -e trace=open,read,write -- filter specific syscalls\n", .{});
try stdout.print(" -e trace=network -- only network syscalls\n", .{});
try stdout.print(" -e trace=file -- only file syscalls\n", .{});
try stdout.print(" -p PID -- attach to running process\n", .{});
try stdout.print(" -f -- follow forks\n", .{});
try stdout.print(" -t -- timestamps\n", .{});
try stdout.print(" -c -- count/summary mode\n", .{});
}
A pro tip: when debugging a complex program, insert a getpid() syscall as a "marker" before the problematic section. In the strace output you can search for getpid() to find exactly where in the trace to start reading. It's the syscall equivalent of a printf("HERE\n") debug statement ;-)
The -c flag gives you a syscall profile -- how many times each syscall was called and total time spent. Extremely useful for performance debugging. If your program is spending 80% of its time in futex(), you've got a contention problem. If it's all in read(), you might need buffering.
Comparing Zig's std.posix wrappers with raw syscalls
So when should you use std.posix.open() vs raw linux.syscall3(.open, ...)? The answer is almost always use the standard library -- but understanding the raw layer helps you debug issues and write wrappers for syscalls that Zig doesn't expose yet:
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
try stdout.print("Comparing std.posix wrappers vs raw syscalls:\n\n", .{});
// approach 1: std.posix (recommended)
{
const fd = try posix.open("/dev/null", .{ .ACCMODE = .WRONLY }, 0);
defer posix.close(fd);
_ = try posix.write(fd, "hello");
try stdout.print("[std.posix] open + write + close: OK\n", .{});
try stdout.print(" Pros: type-safe, cross-platform, proper error sets\n", .{});
try stdout.print(" Cons: limited to what std exposes\n", .{});
}
// approach 2: raw syscall (when you need it)
{
const result = linux.syscall3(
.openat,
@as(usize, @bitCast(@as(isize, linux.AT.FDCWD))),
@intFromPtr("/dev/null".ptr),
1, // O_WRONLY
);
const signed: isize = @bitCast(result);
if (signed < 0) {
try stdout.print("[raw] openat failed\n", .{});
} else {
const fd: i32 = @intCast(signed);
_ = linux.syscall3(.write, @as(usize, @bitCast(@as(isize, fd))), @intFromPtr("hello".ptr), 5);
_ = linux.syscall1(.close, @as(usize, @bitCast(@as(isize, fd))));
try stdout.print("[raw syscall] openat + write + close: OK\n", .{});
try stdout.print(" Pros: access ANY syscall, maximum control\n", .{});
try stdout.print(" Cons: no type safety, Linux-only, manual error check\n", .{});
}
}
try stdout.print("\nWhen to use raw syscalls:\n", .{});
try stdout.print(" - Syscall not exposed by std (memfd_create, io_uring_setup)\n", .{});
try stdout.print(" - Need exact control over flags/args\n", .{});
try stdout.print(" - Debugging what std is doing underneath\n", .{});
try stdout.print(" - Writing your own platform abstraction layer\n", .{});
try stdout.print(" - Performance-critical paths (avoid std overhead, if any)\n", .{});
try stdout.print("\nWhen to use std.posix:\n", .{});
try stdout.print(" - Everything else (99%% of the time)\n", .{});
try stdout.print(" - Cross-platform code (works on macOS/BSD too)\n", .{});
try stdout.print(" - Correct error handling out of the box\n", .{});
try stdout.print(" - Maintained and tested by the Zig team\n", .{});
// note: std.posix.open actually calls openat under the hood
// (open is deprecated in modern Linux, openat is the real call)
try stdout.print("\nFun fact: posix.open() calls openat(AT_FDCWD, ...) internally.\n", .{});
try stdout.print("The 'open' syscall is effectively deprecated in favor of openat.\n", .{});
}
Having said that, the performance difference is effectively zero. Zig's std.posix wrappers compile down to the exact same syscall instruction in release mode. The "overhead" is purely at compile time -- the type checking, error set narrowing, and cross-platform abstraction all disappear by the time machine code is generated. You use raw syscalls when std doesn't expose what you need, not for performance.
Practical example: implementing getrandom() and memfd_create()
Let's build proper wrappers for two useful syscalls that demonstrate different patterns. getrandom fills a buffer with random bytes (the modern replacement for reading /dev/urandom). memfd_create creates an anonymous file backed by RAM (incredibly useful for passing data between processes without touching the filesystem):
const std = @import("std");
const linux = std.os.linux;
const posix = std.posix;
// --- getrandom wrapper ---
const GetrandomError = error{
WouldBlock, // EAGAIN with GRND_NONBLOCK
Interrupted, // EINTR (signal during read)
InvalidFlags, // EFAULT or EINVAL
Unexpected,
};
const GRND_NONBLOCK: u32 = 0x0001;
const GRND_RANDOM: u32 = 0x0002;
fn getrandom(buf: []u8, flags: u32) GetrandomError!usize {
const result = linux.syscall3(
.getrandom,
@intFromPtr(buf.ptr),
buf.len,
flags,
);
const signed: isize = @bitCast(result);
if (signed < 0) {
const errno: u32 = @intCast(@as(usize, @bitCast(-signed)));
return switch (errno) {
11 => error.WouldBlock, // EAGAIN
4 => error.Interrupted, // EINTR
14, 22 => error.InvalidFlags,
else => error.Unexpected,
};
}
return @intCast(signed);
}
// --- memfd_create wrapper ---
const MemfdError = error{
TooManyOpenFiles,
InvalidName,
PermissionDenied,
Unexpected,
};
const MFD_CLOEXEC: u32 = 0x0001;
const MFD_ALLOW_SEALING: u32 = 0x0002;
fn memfdCreate(name: [*:0]const u8, flags: u32) MemfdError!i32 {
const result = linux.syscall2(
.memfd_create,
@intFromPtr(name),
flags,
);
const signed: isize = @bitCast(result);
if (signed < 0) {
const errno: u32 = @intCast(@as(usize, @bitCast(-signed)));
return switch (errno) {
24 => error.TooManyOpenFiles, // EMFILE
22 => error.InvalidName, // EINVAL
1 => error.PermissionDenied, // EPERM
else => error.Unexpected,
};
}
return @intCast(signed);
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// demonstrate getrandom
try stdout.print("getrandom() -- cryptographic random bytes:\n", .{});
var random_bytes: [32]u8 = undefined;
const got = try getrandom(&random_bytes, 0);
try stdout.print(" Requested 32, got {d} bytes: ", .{got});
for (random_bytes[0..16]) |b| {
try stdout.print("{x:0>2}", .{b});
}
try stdout.print("...\n", .{});
// demonstrate memfd_create
try stdout.print("\nmemfd_create() -- anonymous RAM-backed file:\n", .{});
const memfd = try memfdCreate("my_shared_buffer", MFD_CLOEXEC);
try stdout.print(" Created memfd: fd={d}\n", .{memfd});
// write data to it
const data = "This data lives in RAM, not on disk!";
_ = try posix.write(memfd, data);
try stdout.print(" Wrote {d} bytes to memfd\n", .{data.len});
// seek back and read it
_ = linux.syscall3(.lseek, @as(usize, @bitCast(@as(isize, memfd))), 0, 0);
var read_buf: [128]u8 = undefined;
const n = try posix.read(memfd, &read_buf);
try stdout.print(" Read back: \"{s}\"\n", .{read_buf[0..n]});
// show it exists in /proc
var link_buf: [128]u8 = undefined;
var proc_path_buf: [64]u8 = undefined;
const proc_path = try std.fmt.bufPrint(&proc_path_buf, "/proc/self/fd/{d}", .{memfd});
const link = posix.readlink(proc_path, &link_buf) catch "(unreadable)";
try stdout.print(" /proc link: {s}\n", .{link});
// resize it (ftruncate works on memfds)
_ = linux.syscall2(.ftruncate, @as(usize, @bitCast(@as(isize, memfd))), 4096);
try stdout.print(" Resized to 4096 bytes\n", .{});
posix.close(memfd);
try stdout.print(" Closed -- memory freed, no filesystem cleanup needed.\n", .{});
try stdout.print("\nUse cases for memfd_create:\n", .{});
try stdout.print(" - Shared memory between parent/child (pass fd over fork)\n", .{});
try stdout.print(" - Temporary buffers without /tmp pollution\n", .{});
try stdout.print(" - Loading code for JIT (memfd + exec permissions)\n", .{});
try stdout.print(" - Sealed memory regions (immutable shared data)\n", .{});
}
The getrandom wrapper is interesting because it can be interrupted by signals (returning EINTR) and can fail if the entropy pool isn't ready yet (EAGAIN with GRND_NONBLOCK). A robust implementation would retry on EINTR and loop until the full buffer is filled -- just like read() on a regular file might return fewer bytes than requested.
The memfd_create wrapper shows a different pattern: it creates a resource (returns an fd) rather than performing an action. The MFD_CLOEXEC flag ensures the fd is automatically closed if the process exec's a new program -- this prevents fd leaks into child processes, which is a common security issue.
Exercises
Implement a syscall tracer that uses
PTRACE_SYSCALLto intercept and log all syscalls made by a child process. Fork a child, attach to it with ptrace, then for each syscall entry/exit event, read the syscall number from the child's registers (usingPTRACE_GETREGS) and print the syscall name and return value. Run it on a simple program like/bin/lsand print a summary of which syscalls were called and how many times each. This is a simplified version of what strace does internally.Write a safe ioctl wrapper library that provides type-safe wrappers for the three terminal ioctls:
TIOCGWINSZ(get window size),TIOCSWINSZ(set window size), andTCGETS/TCSETS(get/set terminal attributes). Each wrapper should take properly typed arguments (Winsize struct, Termios struct) and return proper Zig error sets. Include a function that puts the terminal into raw mode (disable ECHO and ICANON), reads a single keypress, then restores the original settings. Handle the case where stdin is not a terminal (piped input).Build a memfd-based IPC channel using your memfd_create wrapper. The parent creates a memfd, writes a message struct (with a sequence number, timestamp, and payload), then forks a child. The child inherits the fd, mmaps it, reads the message, modifies the sequence number, and writes a response. The parent then reads the child's response. Implement proper synchronization using a futex (another raw syscall) in the shared memory region so the parent waits for the child to finish writing before reading. This demonstrates memfd + mmap + futex as a zero-copy IPC mechanism.
Bedankt en tot de volgende keer!
- System calls are the ONLY way userspace communicates with the kernel -- every file operation, memory allocation, and network call goes through a numbered syscall interface
- On x86-64 Linux, arguments go in RDI, RSI, RDX, R10, R8, R9 and the return value comes back in RAX -- values in the range -4095 to -1 indicate errors (negated errno)
- Zig exposes raw syscalls via
std.os.linux.syscall0throughsyscall6-- these compile to literalsyscallinstructions with zero overhead - Wrapping raw syscalls in typed Zig functions gives you compile-time safety (proper error sets, pointer types, enums) at zero runtime cost -- the abstraction disappears in release builds
- ioctl is the multiplexed catch-all for device-specific operations -- request codes encode direction, type, number, and argument size
- strace intercepts all syscalls with their arguments and return values -- invaluable for debugging "why does open return EACCES" type problems
std.posixwrappers handle error translation and cross-platform differences -- use them by default, go raw only when std doesn't expose the syscall you need- getrandom() is the modern way to get random bytes (replaces /dev/urandom reads) and memfd_create() gives you anonymous RAM-backed fds (no filesystem cleanup)
Leave Learn Zig Series (#72) - System Call Wrappers to:
Read more #stem posts
Best Posts From scipio
We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.
More Posts From scipio
- Learn Zig Series (#113) - Ring Buffers: Lock-Free
- Learn AI Series (#131) - Graph Neural Networks
- Learn Zig Series (#112) - Cuckoo Filters
- Learn Ethical Hacking (#90) - Security as a Mindset - Thinking Like a Defender
- Learn AI Series (#130) - Causal Inference and ML
- Learn Zig Series (#111) - Bloom Filters
- Learn Ethical Hacking (#89) - The Attacker's Perspective - A Day in the Life
- Learn AI Series (#129) - AutoML and Neural Architecture Search
- Learn Zig Series (#110) - Tries: Prefix Trees
- Learn Ethical Hacking (#88) - Building Your Security Career - From Episode 1 to Here