The Unix Syscall Model

Every C program you’ve ever written eventually calls the kernel. printf calls write. malloc calls mmap or brk. Even exiting a program is a syscall (exit_group). Understanding this handoff — the moment your code stops running and the kernel takes over — is the single most useful mental model you’ll build in this phase. Once you see it, you’ll never read a C program the same way again.

User mode vs kernel mode — the one-picture version

Your CPU has (at least) two privilege rings. Ring 3 (user mode) is where your process runs: it can execute arithmetic, jump around its own memory, call functions in its address space. Ring 0 (kernel mode) is where the kernel runs: it can talk to hardware, remap memory, kill processes, mount filesystems. A syscall is the doorway between them.

On x86-64 Linux, this doorway is the syscall instruction. It:

  1. Saves user-mode registers.

  2. Switches CPU to ring 0.

  3. Loads the kernel’s stack.

  4. Jumps to the kernel’s syscall entry point.

  5. Kernel dispatches based on rax (syscall number) to the right handler.

  6. On return, does all of the above in reverse.

That transition is not free. Anchor these numbers in your head:

Operation

Approximate cost (modern x86-64 Linux, 2020s)

Function call within your process

~1 ns

syscall entry, no Spectre/Meltdown mitigations

~230 ns

syscall entry, with mitigations (KPTI, retpoline)

~700 ns

Full read/write on a socket (kernel-side work included)

~1500 ns

Context switch to another process

1-5 μs (dominated by TLB/cache pollution, not the switch itself)

Source anchors: LWN and kernel-internals.org syscall cost analyses; verify on your box with perf bench syscall basic.

Takeaway for the ML engineer in you: if your inference loop makes 100 syscalls per request and you’re at 1000 rps, that’s 70ms/sec of pure kernel-entry overhead — before doing any actual work. This is why io_uring and batching exist. But we get there in Phase 5.

strace — the syscall X-ray

strace intercepts every syscall your process makes and prints it. It is the fastest debugging tool in existence for a class of bugs you’ll increasingly hit: “the program does the wrong thing but I can’t tell what it’s actually asking the OS to do.”

strace -f -e trace=openat,read,write,close ./myprog
strace -c ./myprog                    # summary: which syscalls, how often, how expensive
strace -p <pid>                       # attach to a running process
strace -f -e trace=network ./myprog   # only network syscalls

ltrace is the same idea but for library calls (calls into libc, not the kernel). Less used, but useful when you want to see malloc and free, not brk and mmap.

One habit that will save you hours: when a program “hangs,” always strace -p it first. Nine times out of ten it’s blocked in read, futex, or poll, and now you know exactly where.

errno discipline — the rule you must never break

Every syscall that can fail returns -1 (or NULL for pointer-returning ones like mmap) and sets the global-per-thread errno. The discipline is:

  1. Check the return value immediately.

  2. If it indicates failure, read errno before doing anything else that could clobber it.

  3. Use strerror(errno) or perror("context") to translate it, or match against EAGAIN/EINTR/etc.

ssize_t n = read(fd, buf, BUFSZ);
if (n < 0) {
    int saved = errno;             // save immediately
    fprintf(stderr, "read: %s\n", strerror(saved));
    /* ... cleanup that may itself set errno ... */
    return -saved;
}

Almost every “flaky” C program I’ve reviewed at Zoho had one of these errors:

  • Checked n == 0 as failure (it’s not — it’s EOF for read, or a legitimate empty message for a socket).

  • Called printf between the syscall and the errno read (printf can set errno).

  • Called a function that might do its own syscalls before reading errno.

Retry-on-EINTR — the pattern you owe yourself

Any slow syscall (read, write, accept, wait, poll, select, sleep, …) can be interrupted by a signal delivered to your process. When that happens, the syscall returns -1 with errno == EINTR and has done no work (for most cases; read/write may have done partial work).

You have two options:

Option A: retry loop (correct for most cases).

ssize_t xread(int fd, void *buf, size_t n) {
    ssize_t r;
    do { r = read(fd, buf, n); } while (r < 0 && errno == EINTR);
    return r;
}

Option B: install the signal handler with SA_RESTART so the kernel restarts the syscall for you automatically. This is what sigaction gives you and signal() sometimes silently doesn’t (see file 03).

Do not ignore EINTR. It doesn’t happen often — until you install a SIGCHLD handler for a shell, or a SIGWINCH handler for a TUI, and then it happens all the time and your program mysteriously fails every time the terminal is resized.

Why read(2) may return less than you asked for — the short-read reality

This is the one every self-taught programmer gets wrong. read(fd, buf, 4096) is not a promise to give you 4096 bytes. It says: “give me up to 4096 bytes, whatever’s available right now, or block until at least one byte is.” Legitimate reasons for a short read:

  • Reading from a pipe/socket and only 200 bytes have arrived — you get 200 bytes.

  • Reading from a terminal — you get one line, ended by newline.

  • Reading from a file near the end — you get whatever’s left.

  • Reading and a signal arrived after some bytes were transferred — you get the partial.

For files this rarely surprises anyone. For sockets and pipes it is the default case. The fix is the standard read_full/write_full wrapper:

ssize_t read_full(int fd, void *buf, size_t n) {
    size_t total = 0;
    while (total < n) {
        ssize_t r = read(fd, (char*)buf + total, n - total);
        if (r < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (r == 0) break;   // EOF
        total += r;
    }
    return (ssize_t)total;
}

write has an analogous write_full. Put both in your personal C toolbox on day one and never write bare read/write on a socket again.

What most people get wrong about this

They treat syscalls as function calls. They aren’t. A function call is a jump within your address space; a syscall is a context switch into the kernel and back, with potential blocking, signals, interruption, partial completion, and errno side effects. Internalise the difference and half of the “weird bugs” in your career vanish.

Practice this week

  1. Run strace -c ls / and identify the three most-called syscalls. Explain why.

  2. Write read_full/write_full from scratch. Test them with a pipe where the writer sends 1 byte per second, and verify your reader assembles them correctly.

  3. Write a program that calls sleep(100) and then, from another terminal, send it SIGUSR1. Observe with strace -p that sleep returned early with EINTR.

  4. Look up any three of: EAGAIN, EWOULDBLOCK, EINTR, EPIPE, ECONNRESET, EMFILE, ENFILE. Know cold what triggers each.

References

  • man 2 intro — the master intro page listing all sections and conventions.

  • man 2 syscalls — full list of Linux syscalls with kernel version added.

  • APUE ch. 1-3.

  • TLPI ch. 3 (“System Programming Concepts”).

  • Julia Evans, “How does strace work?” — one of the clearest short explanations online.


Return to README.md · Next: 02_file_io_and_fds.md