Demystifying System Calls: Building a Tiny strace in Zig

The entire project discussed in this demo is linked below.

github.com/msharran/codingchallenges.fyi/strace/zig-strace

Setting Some Context

Source: README.md — project purpose, ptrace.zig — ptrace wrapper

I use strace whenever I want to understand what a program is doing at the boundary between userspace and the Linux kernel. It shows the system calls made by a process, the arguments passed to them, and the values returned by the kernel.

For example, a simple command such as ls eventually has to ask the kernel to open files, map memory, write output, and exit. strace lets me see those operations instead of treating the command as a black box.

I wanted to understand how this works, so I built a very small version of strace in Zig using ptrace(2).

Note: I consider these devlogs my personal journal of what I’m learning, so I will not be writing a full-fledged article here. I will just write down my learnings and thoughts concisely.

My Local Setup

Source: README.md — requirements and build commands, build.zig — executable target

  • My development environment for this project is an ARM64 Linux VM running inside a MacBook using OrbStack.
  • The project uses Zig 0.15.1.
  • The implementation requires Linux 5.3 or later because it uses PTRACE_GET_SYSCALL_INFO.
  • The current syscall mapping is specific to ARM64/AArch64.

The project is built using:

zig build -Dtarget=aarch64-linux

The generated executable is:

zig-out/bin/zig_strace

Realistic Outcome

Source: main.zig — syscall tracing and output, README.md — example output

The final implementation can run a command as a child process and print every syscall entry and exit until that command finishes.

./zig-out/bin/zig_strace ls 2>&1 | grep -w syscall

The output looks roughly like this:

info(syscall): brk(0, 0, 0, 0, ffff8557fd58, a)
info(syscall): retval=0xaaaacc620000 error=0
info(syscall): mmap(0, 2000, 3, 22, ffffffffffffffff, 0)
info(syscall): retval=0xffff85560000 error=0
info(syscall): openat(ffffffffffffff9c, ffff85563b78, 80000, 0, ffff855825b0, ffffffffffffffff)
info(syscall): retval=0x3 error=0
info(syscall): write(1, aaaacc6032b0, 2d, ffff85572740, 4e128c0908db9974, aaaacc603260)
info(syscall): retval=0x2d error=0
info(syscall): exit_group(0, 0, ffff85572f00, 120, ffff85581a30, 17f)

It does not decode pointers, flags, file paths, or error names yet. The arguments and return values are printed as raw hexadecimal values. However, it captures the main behavior I wanted to understand: stop a process at syscall entry and exit, inspect what happened, and then let it continue.

Building It

Source: zig-strace — project directory, main.zig, ptrace.zig, wait.zig

Full project is available here.

Project Structure

The project is small. main.zig manages the tracee and tracer processes, ptrace.zig wraps the ptrace operations, and wait.zig interprets the child process status returned by waitpid().

zig-strace$ tree -L2
.
├── Makefile
├── README.md
├── build.zig
├── build.zig.zon
└── src
    ├── main.zig
    ├── ptrace.zig
    └── wait.zig

Creating The Tracee Process

Code: main.zig — argument parsing, fork, and parent/child branches

The entrypoint reads the command and its arguments, then calls fork(2).

const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);

if (args.len < 2) {
    log_tracer.err("Usage: {s} <command> [args...]", .{args[0]});
    return;
}

const pid = try posix.fork();

if (pid == 0) {
    return runChildAsTracee(allocator, args);
} else {
    try traceChild(allocator, pid);
}

After fork(), the same program continues in two processes:

zig_strace
├── child  -> becomes the tracee and runs the requested command
└── parent -> becomes the tracer and observes the child

The child gets pid == 0, while the parent gets the actual PID of the child. The parent needs this PID for every later ptrace() and waitpid() call.

Asking The Parent To Trace The Child

Code: main.zig — ptrace import and traceMe() call, ptrace.zig — Tracee and traceMe() implementation

The child first calls PTRACE_TRACEME and then stops itself using SIGSTOP.

pub fn traceMe(_: Self) !void {
    try posix.ptrace(linux.PTRACE.TRACEME, 0, 0, 0);
    try posix.raise(linux.SIG.STOP);
}

PTRACE_TRACEME tells the kernel that this process expects its parent to trace it.

The SIGSTOP is the synchronization point between the two processes. It gives the parent a chance to set its tracing options before the child replaces itself with the requested program.

At this moment, the process state looks roughly like this:

child:  PTRACE_TRACEME -> raise(SIGSTOP) -> stopped
                                         |
parent:                     waitpid() <---'

Without this stop, the child could continue into exec() before the parent has completed its tracing setup.

Preparing And Executing The Command

Code: main.zig — building argv and calling execvpeZ()

The child takes everything after the zig_strace executable name and builds a null-terminated argument list.

const child_cmd = args[1..];
const argv = try allocator.alloc(?[*:0]const u8, child_cmd.len + 1);
defer allocator.free(argv);

for (args[1..], 0..) |arg, i| {
    argv[i] = arg;
}
argv[child_cmd.len] = null;

The null terminator is required because exec follows the C ABI convention for its argument list.

The child then calls:

return posix.execvpeZ(args[1], argv_ptr, &envp);

The Z suffix in Zig means that the strings are null-terminated. If this call succeeds, it does not return. The kernel replaces the child process image with the requested program, while the PID stays the same.

This distinction was important for me:

fork() -> creates another process
exec() -> replaces the program running inside that process

So zig_strace ls does not create an unrelated ls process and then search for it. It creates the child itself, marks it for tracing, and replaces that child with ls.

Setting The ptrace Options

Code: main.zig — parent tracing setup, ptrace.zig — option constants and ptrace calls, ptrace.zig — setOptions() and cont()

The parent waits for the child’s initial SIGSTOP and configures two ptrace options:

var res = try wait.sigStop(pid, 0);

try ptracer.setOptions(ptrace.O_TRACESYSGOOD | ptrace.O_TRACEEXEC);
try ptracer.cont(null);
res = try wait.sigStop(pid, 0);

The options are:

  • PTRACE_O_TRACESYSGOOD — marks syscall stops with SIGTRAP | 0x80, so they can be distinguished from normal SIGTRAP stops.
  • PTRACE_O_TRACEEXEC — asks the kernel to stop the child when exec() replaces its process image.

The parent continues the child once so it can reach exec(), then waits again for the exec stop.

The complete setup flow is:

child                              parent
-----                              ------
PTRACE_TRACEME
raise(SIGSTOP) ------------------> waitpid()
                                   PTRACE_SETOPTIONS
              <------------------ PTRACE_CONT
exec(ls) ------------------------> waitpid()
                                   ready to trace syscalls

Stopping At Every System Call

Code: main.zig — syscall tracing loop, ptrace.zig — PTRACE_SYSCALL and syscall information

Once the child has executed the requested program, the tracer enters a loop:

while (true) {
    try ptracer.syscall();
    res = wait.sigSyscallStop(pid, 0) catch |err| switch (err) {
        error.ChildExited => return,
        else => return err,
    };

    const syscall_info = try ptracer.getSyscallInfo(allocator);
    defer allocator.destroy(syscall_info);

    ...
}

PTRACE_SYSCALL resumes the child just like PTRACE_CONT, but asks the kernel to stop it at the next syscall boundary.

Each syscall has two boundaries:

userspace
   |
   | syscall entry  -> syscall number and arguments are available
   v
kernel executes the syscall
   |
   | syscall exit   -> return value and error status are available
   v
userspace continues

The tracer repeats PTRACE_SYSCALL after every stop. This lets one loop observe entry, exit, the next entry, the next exit, and so on until the child terminates.

Knowing Whether It Is A Syscall Stop

Code: wait.zig — waitpid() status and syscall-stop checks, main.zig — handling child exit from the wait

The tracer uses waitpid() to wait whenever the child is stopped.

pub fn sigStop(pid: posix.pid_t, flags: u32) !posix.WaitPidResult {
    const res = posix.waitpid(pid, flags);
    if (linux.W.IFEXITED(res.status)) return error.ChildExited;
    if (!linux.W.IFSTOPPED(res.status)) return error.ChildNotStopped;
    return res;
}

However, a traced process can stop for reasons other than a syscall. The extra 0x80 bit added by PTRACE_O_TRACESYSGOOD is what makes the syscall stop recognizable:

const stop_signal = linux.W.STOPSIG(res.status);
if (stop_signal == (linux.SIG.TRAP | 0x80)) {
    return res;
} else {
    return error.NotSyscallStopSignal;
}

This is one of the parts that made ptrace click for me. The tracer does not continuously read the child process while it runs. The kernel stops the child, waitpid() reports why it stopped, the tracer inspects it, and then explicitly resumes it.

The loop is really:

resume child -> kernel stops child -> waitpid returns
-> inspect stop -> resume child again

Reading The Syscall Information

Code: ptrace.zig — ptrace_syscall_info layout, ptrace.zig — getSyscallInfo(), main.zig — reading the structure

At a syscall stop, the tracer calls PTRACE_GET_SYSCALL_INFO:

pub fn getSyscallInfo(
    self: Self,
    allocator: std.mem.Allocator,
) !*ptrace_syscall_info {
    const syscall_info = try allocator.create(ptrace_syscall_info);
    errdefer allocator.destroy(syscall_info);

    try posix.ptrace(
        linux.PTRACE.GET_SYSCALL_INFO,
        self.pid,
        @sizeOf(ptrace_syscall_info),
        @intFromPtr(syscall_info),
    );

    if (syscall_info.op == 0) {
        return error.NotAtSyscallStop;
    }

    return syscall_info;
}

The kernel writes into a ptrace_syscall_info structure supplied by the tracer. I represented the Linux structure in Zig using an extern struct and extern union so its memory layout follows the C ABI.

pub const ptrace_syscall_info = extern struct {
    op: u8,
    arch: u32,
    instruction_pointer: u64,
    stack_pointer: u64,
    data: extern union {
        entry: extern struct {
            nr: u64,
            args: [6]u64,
        },
        exit: extern struct {
            rval: i64,
            is_error: u8,
        },
        ...
    },
};

The op field tells me which part of the union is valid:

  • syscall entry gives the syscall number and six raw argument values.
  • syscall exit gives the return value and whether the kernel considers it an error.

This is similar to the C and Go event structs in my eBPF project. Whenever the kernel and userspace exchange a binary structure, the layout must match exactly. Field sizes, ordering, alignment, and padding all matter.

Mapping A Number To A Syscall Name

Code: ptrace.zig — syscallNameFromNum(), main.zig — mapping at syscall entry

The kernel reports a syscall number, not a name such as openat or write.

Instead of maintaining my own ARM64 lookup table, I use Zig’s architecture-specific linux.SYS enum:

pub fn syscallNameFromNum(syscall_num: u64) []const u8 {
    const syscall_enum = std.meta.intToEnum(linux.SYS, syscall_num)
        catch return "unknown";
    return @tagName(syscall_enum);
}

The flow is:

kernel syscall number
  |
  v
Zig linux.SYS enum
  |
  v
enum tag name such as openat, mmap, write, or exit_group

Syscall numbers are architecture-specific. A number that represents one syscall on ARM64 is not guaranteed to represent the same syscall on x86-64. Using Zig’s target-specific enum avoids hardcoding a second table, but the current project is still only tested and intended for ARM64.

Printing Entry And Exit

Code: main.zig — entry and exit logging, ptrace.zig — entry and exit helpers

The tracer uses op to decide whether to print syscall arguments or the result:

if (syscall_info.isEntry()) {
    const syscall_name = ptrace.syscallNameFromNum(
        syscall_info.data.entry.nr,
    );
    log_syscall.info(
        "{s}({x}, {x}, {x}, {x}, {x}, {x})",
        .{
            syscall_name,
            syscall_info.data.entry.args[0],
            syscall_info.data.entry.args[1],
            syscall_info.data.entry.args[2],
            syscall_info.data.entry.args[3],
            syscall_info.data.entry.args[4],
            syscall_info.data.entry.args[5],
        },
    );
} else if (syscall_info.isExit()) {
    log_syscall.info(
        "retval=0x{x} error={}",
        .{
            syscall_info.data.exit.rval,
            syscall_info.data.exit.is_error,
        },
    );
}

At entry, all six argument registers are printed because the tracer does not yet know the signature of each syscall. At exit, it prints the raw return value and the error bit.

A real strace knows how to interpret each syscall individually. For openat, it reads the pathname from the tracee’s memory and decodes flags such as O_RDONLY. For write, it knows which argument is the file descriptor, which one is a buffer pointer, and which one is the byte count.

My implementation deliberately stops before that layer. I wanted to understand process tracing and syscall stops first. Pretty formatting can come after the core loop works.

Gist Of The Complete Flow

Source: main.zig — complete child and tracer flow, ptrace.zig — tracing operations, wait.zig — process-stop synchronization

The complete flow of zig_strace is now:

1. Read a command from the CLI.
2. Fork the zig_strace process.
3. Let the child request tracing using PTRACE_TRACEME.
4. Stop the child using SIGSTOP.
5. Let the parent wait for that stop and set ptrace options.
6. Continue the child into exec().
7. Replace the child program with the requested command.
8. Resume it using PTRACE_SYSCALL.
9. Wait at each syscall entry and exit.
10. Read syscall information and print it.
11. Repeat until the child exits.

In terms of Linux operations, the important flow is:

fork()
  |
  v
PTRACE_TRACEME + SIGSTOP
  |
  v
waitpid() + PTRACE_SETOPTIONS
  |
  v
PTRACE_CONT + exec()
  |
  v
PTRACE_SYSCALL
  |
  v
waitpid() + PTRACE_GET_SYSCALL_INFO
  |
  `---- repeat until exit

That is enough to capture the same basic syscall sequence shown by the real strace for a command such as ls.

What I Want To Remember

Source: main.zig — tracer control loop, wait.zig — kernel stop reporting through waitpid()

strace is mainly a controlled loop between a tracer, a tracee, and the kernel.

My current mental model is:

tracee process
  |
  | enters or exits a syscall
  v
kernel stops tracee
  |
  | reports status through waitpid()
  v
tracer process
  |
  | reads syscall information using ptrace()
  | prints it
  | resumes the tracee
  `--------------------------------------> repeat

The tracer is not passively watching a stream of events. It is actively controlling when the tracee can continue.

This is also the main difference in my head between this project and my eBPF egress monitor. The eBPF program observes kernel events without stopping the process that caused them. ptrace stops and controls one particular process so the tracer can inspect it at precise boundaries.

Key Take Aways

Source: main.zig — end-to-end implementation, ptrace.zig — tracing primitives and syscall data, README.md — limitations and future improvements

  1. fork() creates the child that will become the traced process.
  2. PTRACE_TRACEME lets the child ask its parent to trace it.
  3. SIGSTOP gives the parent time to configure tracing before exec().
  4. exec() replaces the child program but keeps the same process ID.
  5. PTRACE_SYSCALL stops the tracee at both syscall entry and exit.
  6. waitpid() is the synchronization mechanism between the tracer and tracee.
  7. PTRACE_O_TRACESYSGOOD makes syscall stops distinguishable from other stops.
  8. PTRACE_GET_SYSCALL_INFO provides syscall arguments at entry and the result at exit.
  9. Syscall numbers and their mappings depend on the CPU architecture.
  10. A small tracer can show the core behavior of strace without decoding every syscall argument.

From here, the project can grow carefully: decode arguments based on each syscall signature, read strings from the tracee’s memory, print error names, follow forked child processes, and support more architectures.

However, I’m going to stop here.