System Calls: Crossing into the Kernel

A system call is a request from a user program to the kernel: open a file, read, write, create a process, allocate memory, send a packet.

Concept

What a System Call Is A system call is a request from a user program to the kernel: open a file, read, write, create a process, allocate memory, send a packet. It is the only safe door into kernel mode.

Steps on x86-64 Linux:

The library puts the system call number in rax and arguments in rdi, rsi, rdx, r10, r8, r9.

The syscall instruction switches to kernel mode and jumps to the kernel's entry point (set up in a model-specific register at boot).

The kernel saves user registers, looks up the handler in the system call table, validates arguments (never trusting user pointers), does the work.

sysret returns to user mode with the result in rax (negative values = -errno, which libc converts to -1 + errno).

Other ISAs: ARM64 uses svc #0 (number in x8), RISC-V ecall (number in a7).

fork/clone, execve, exit_group, wait4, kill openat, read, write, close, lseek, fstat ioctl, mmap of device memory getpid, uname, clock_gettime

pipe, socket, sendmsg, mmap shared memory brk, mmap, munmap, mprotect Cost and the vDSO

A system call costs far more than a function call: the mode switch, register saving, security mitigations (Spectre/Meltdown, Module 25) and cache/TLB effects. For very frequent read-only calls (clock_gettime, gettimeofday), Linux maps a small shared library, the vDSO, into every process so they run entirely in user mode.

sequenceDiagram
    participant App as Application
    participant Libc as C library
    participant CPU
    participant Kernel
    App->>Libc: write(1, buf, 17)
    Libc->>CPU: rax=1 rdi=1 rsi=buf rdx=17<br/>syscall
    CPU->>Kernel: ring 0<br/>entry_SYSCALL_64
    Kernel->>Kernel: ksys_write:<br/>check fd, copy data
    Kernel-->>CPU: rax = 17, sysret
    CPU-->>Libc: back in user mode
    Libc-->>App: returns 17

Why It Matters

strace is one of the most useful debugging tools there is: it shows exactly which files a program opens, why it fails (ENOENT, EACCES) and where it waits. Understanding syscall cost explains why buffered I/O, batching and mmap are faster.

Syntax

strace./prog Trace every system call strace -e trace=openat,read Trace selected calls Count calls and time strace -p PID Attach to a running process syscall(SYS_write, fd, buf, n) Invoke any system call directly

Example

11.1 Tracing the system calls of the running example 11.2 The cost of a system call

$ strace -o trace.txt ./hello
speed = 120 km/h
$ wc -l < trace.txt
39
$ head -3 trace.txt
execve("./hello", ["./hello"], 0x7ffe1221ae80 /* 23 vars */) = 0
brk(NULL)                               = 0x60e989c81000
arch_prctl(0x3001 /* ARCH_??? */, 0x7ffedf31af60) = -1 EINVAL (Invalid argument)
$ tail -3 trace.txt
write(1, "speed = 120 km/h\n", 17)      = 17
exit_group(0)                           = ?
+++ exited with 0 +++
$ cut -d'(' -f1 trace.txt | sort | uniq -c | sort -rn | head -8
      8 mmap
      4 pread64
      3 newfstatat
      3 mprotect
      3 brk
      2 openat
      2 close
      2 arch_prctl
$ strace -o t2.txt -e trace=openat,read,write,close cat bench.txt > copy.txt && grep -A5 '"bench.txt"' t2.txt
openat(AT_FDCWD, "bench.txt", O_RDONLY) = 3
read(3, "ECU-BENCH-01\n", 131072)       = 13
write(1, "ECU-BENCH-01\n", 13)          = 13
read(3, "", 131072)                     = 0
close(3)                                = 0
close(1)                                = 0
#define _GNU_SOURCE
#include <stdio.h>
#include <time.h>
#include <unistd.h>
#include <sys/syscall.h>

static double now(void)
{
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec * 1e9 + t.tv_nsec;
}

__attribute__((noinline)) static long plain_function(long x) { __asm__ volatile(""); return x + 1; }

int main(void)
{
    enum { N = 2000000 };
    volatile long sink = 0;
    struct timespec ts;

    double t0 = now();
    for (int i = 0; i < N; i++) sink += plain_function(i);
    double t1 = now();
    for (int i = 0; i < N; i++) sink += syscall(SYS_getppid);
    double t2 = now();
    for (int i = 0; i < N; i++) { clock_gettime(CLOCK_MONOTONIC, &ts); sink += ts.tv_nsec; }     /* vDSO: no kernel entry */
    double t3 = now();
    for (int i = 0; i < N; i++) { syscall(SYS_clock_gettime, CLOCK_MONOTONIC, &ts); sink += ts.tv_nsec; }
    double t4 = now();

    printf("function call                   %6.1f ns\n", (t1 - t0) / N);
    printf("getppid system call             %6.1f ns\n", (t2 - t1) / N);
    printf("clock_gettime via vDSO          %6.1f ns\n", (t3 - t2) / N);
    printf("clock_gettime as a real syscall %6.1f ns\n", (t4 - t3) / N);
    retu
…

Expected Result

In 11.1 the "hello" program made 39 system calls, almost all of them startup work by the dynamic linker (mmap of libraries, mprotect for RELRO, openat of libc.so.6) — and exactly one write for the actual output, followed by exit_group. The cat trace shows the classic sequence: openat returns file descriptor 3, read gets 13 bytes, write to descriptor 1, read returns 0 (end of file), close.

On this machine a system call cost several hundred times more than a function call; the vDSO version of clock_gettime avoided the kernel entirely. (Absolute numbers depend heavily on the CPU, the security mitigations enabled and virtualization — this kernel runs in a WSL2 virtual machine.)

function call                      1.3 ns
getppid system call              602.3 ns
clock_gettime via vDSO            17.8 ns
clock_gettime as a real syscall  648.6 ns

Common Mistakes

Ignoring return values: write may write fewer bytes than requested; read returning 0 means end of file.

Checking errno without first checking that the call failed.

Many tiny system calls (unbuffered write of one byte at a time).

Passing user-space pointers to kernel code without copying/validating (kernel developers: always copy_from_user).

Best Practices

Use buffered I/O (stdio, BufferedStream) or batch system calls (writev, io_uring).

Reach for strace first when a program behaves differently than expected.

Handle EINTR for blocking calls interrupted by signals.

Interview Relevance

Very high: "what is a system call and how does it work?", "why are system calls expensive?", "what happens when you call printf?", "what is the vDSO?", "how would you find which file a program fails to open?".

Practice Questions

strace -f a shell command with a pipe (ls | wc -l) and find the pipe2, clone and execve calls.

Write the same 1 MB file with 1-byte write calls and with one call; compare the time.

Find the system call numbers of read, openat and mmap in /usr/include/asm/unistd_64.h.

Use strace -c on python3 -c pass and compare the count with the C program.

Quick Quiz

Q1. (Multiple choice) Which instruction enters the kernel for a system call on x86-64 Linux? A) int 3 B) call C) syscall D) sysenter only Answer: C.

Q2. (Predict the output) How many write system calls did the hello program make?

Answer: One (verified in the trace).

Q3. (True/False) clock_gettime always enters the kernel.

Answer: False. On Linux it usually runs in the vDSO in user mode (verified: tens of nanoseconds vs hundreds).

Q4. (Find the bug) n = read(fd, buf, 4096); buf[4096] = 0; printf("%s", buf);

Answer: read may return fewer bytes (or 0/−1); terminate at buf[n] after checking n > 0, and buf[4096] is out of bounds for a 4096-byte buffer.

Coding Challenge

Implement copy_file(src, dst) with open, read, write and close, handling partial writes, and copy /etc/hostname to a temporary file, printing the number of bytes copied.

#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>

static long copy_file(const char *src, const char *dst)
{
    int in = open(src, O_RDONLY);
    int out = open(dst, O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (in < 0 || out < 0) return -1;
    char buf[4096];
    long total = 0;
    ssize_t n;
    while ((n = read(in, buf, sizeof buf)) > 0) {
        for (ssize_t done = 0; done < n; ) {                 /* write() may write less than asked */
            ssize_t w = write(out, buf + done, n - done);
            if (w < 0) { close(in); close(out); return -1; }
            done += w;
        }
        total += n;
    }
    close(in);
    close(out);
    return n < 0 ? -1 : total;
}

int main(void)
{
    long n = copy_file("/etc/hostname", "/tmp/hostname.copy");
    printf("copied %s\n", n > 0 ? "OK" : "FAILED");
    printf("bytes match: %s\n", n == copy_file("/tmp/hostname.copy", "/tmp/hostname.copy2") ? "yes" : "no");
    return 0;
}