CMD Guide
HomeSystem DesignOS & Kernel Internals

Syscalls & the User/Kernel Boundary

A system call works because the CPU physically runs in one of two privilege levels, and the only sanctioned way to cross from the low-privilege one to the high-privilege one is a hardware trap: a single instruction (syscall on x86-64) that atomically raises the privilege level, swaps the stack and instruction pointer to a kernel-controlled entry point, and hands control to code the application can never jump into directly. Everything about syscall cost and syscall batching falls out of that one fact.

User mode vs kernel mode: why the wall exists

An x86-64 core is always executing at a current privilege level (CPL), one of four rings; in practice operating systems use only two: ring 3 (user mode) and ring 0 (kernel mode). The ring is not a software convention — it is bits in a hardware register the CPU checks on every privileged operation. In ring 3 the core will fault if you try to touch a page marked supervisor-only, execute HLT, reprogram the MMU, disable interrupts, or talk to a device port. Those powers exist only in ring 0.

This wall is what makes a multi-tenant machine possible. Your process cannot read another process's memory, corrupt the page tables, or monopolise the CPU, because the hardware refuses — and refusing means trapping into the kernel, which decides what happens next. The kernel is the one program trusted with ring-0 powers; every other program must ask it to act on their behalf. That request is a system call.

The trap mechanism, concretely

Say your program calls read(fd, buf, 4096). Glibc's wrapper does not contain the read logic — it marshals arguments into registers and executes one syscall instruction. On Linux x86-64 the contract is fixed by the ABI:

When syscall executes, the CPU does several things in one shot, in microcode: it loads the kernel entry address from the model-specific register IA32_LSTAR into rip, saves the old user rip into rcx and the flags into r11, and switches CPL to ring 0. It does not automatically switch the stack — the kernel entry stub (entry_SYSCALL_64) does that by swapping to the per-CPU kernel stack via the swapgs/GSBASE trick, then pushes a full register frame. Only now does ordinary C kernel code run: it validates rax against the syscall table, checks that buf points into the caller's address space (never trusting a user pointer), and dispatches to ksys_read. On the way out, sysret restores rip from rcx, flags from r11, drops back to ring 3, and your wrapper returns.

The older path, int 0x80, did the same job through the interrupt descriptor table and is noticeably slower — the ratio isn't a fixed constant, it varies with microarchitecture and with whether Meltdown/Spectre mitigations are active, but it lands roughly in the low single digits of multiples on most modern chips. syscall/sysret was added precisely to make the common case cheap, and that is the mechanism worth remembering, not a specific number.

File descriptors: the kernel's handle to an open resource

Every open(), socket(), or pipe() returns a small integer called a file descriptor. It is not a pointer to bytes; it is an index into the process's open file table, a kernel structure that records where the object lives (file, socket, pipe), the current offset, and the flags (O_RDONLY, O_NONBLOCK, etc.). This is why all Unix I/O syscalls — read, write, close, epoll_ctl — take an int fd: the kernel translates that integer back to the kernel object on every boundary crossing.

The first three descriptors are reserved: 0 = stdin, 1 = stdout, 2 = stderr. You can see them concretely in /proc/<pid>/fd:

$ ls -l /proc/self/fd
0 -> /dev/pts/0
1 -> /dev/pts/0
2 -> /dev/pts/0

When a process fork()s, the child inherits copies of the parent's descriptors, so both processes share the same open file table entry and the same offset. When it exec()s, descriptors marked FD_CLOEXEC are closed automatically; the rest remain open. A common server bug is leaking descriptors into child processes or across exec, which is why well-written code sets O_CLOEXEC (or the equivalent) at creation time. File descriptors are the namespace the syscall boundary uses to name kernel objects — understanding that makes dup2(), redirection, and epoll much less magical.

What a syscall actually costs vs a function call

A normal function call is a call/ret pair — a few cycles, ~1 ns, all in ring 3. A syscall is not "a slightly bigger function call"; it is a privilege transition, and the cost splits into two parts that people routinely conflate:

So the rule of thumb is not "a syscall is expensive" but "a syscall is on the order of 100–1000x a function call and it makes your next few thousand instructions slower." A function that does one syscall per call, invoked in a hot loop, is a performance smell.

Why batching syscalls matters: readv and io_uring

If each crossing is expensive and pollutes caches, the fix is structural: cross fewer times, do more work per crossing. Two mechanisms embody this at different scales.

Scatter/gather: readv and writev

Instead of four write() calls to emit a header, two body chunks, and a trailer, writev(fd, iov, 4) passes an array of struct iovec (pointer + length pairs) in one crossing; the kernel walks the vector and writes all four regions. One mode switch instead of four, and the kernel can also submit the whole thing to the device as a single I/O. This is why HTTP servers assemble responses with writev rather than concatenating buffers first — it avoids both the extra crossings and the memory copy.

Amortising to near-zero: io_uring

io_uring goes further and attacks the per-operation crossing itself. It sets up two ring buffers in memory shared between user space and kernel: a submission queue (SQ) and a completion queue (CQ). To issue I/O you write submission entries directly into the SQ ring — no syscall — and to reap results you read the CQ ring — no syscall. A single io_uring_enter() call can submit hundreds of queued operations and collect hundreds of completions at once. With SQ-polling mode a dedicated kernel thread continuously drains the SQ ring in the background, so a busy server can keep submitting I/O by only writing to shared memory — no io_uring_enter call needed on the steady-state hot path at all, as long as the kernel poll thread stays awake within its idle timeout. The boundary crossing, once per operation, becomes one crossing per batch — or, under SQPOLL, effectively none while the workload keeps the ring fed.

The traced difference for reading 1000 small records: with blocking read() that is 1000 crossings (~100–500 µs of pure switch tax, plus pollution). With plain io_uring you queue 1000 SQEs and issue one io_uring_enter — a single crossing — then harvest completions from the CQ ring with zero further syscalls. With SQPOLL enabled, even that one io_uring_enter call is unnecessary once the poll thread is running: you write the 1000 SQEs to shared memory and the kernel thread picks them up on its own.

Observing the boundary with strace

strace makes the invisible boundary visible. It uses ptrace(PTRACE_SYSCALL) (or the faster seccomp-BPF backend) to stop the traced process on every syscall entry and exit, printing the decoded name, arguments, and return value. Two flags matter most:

$ strace -T -e trace=read,write cat file.txt
read(3, "hello\n", 131072)  = 6 <0.000012>
read(3, "", 131072)         = 0 <0.000004>
write(1, "hello\n", 6)       = 6 <0.000019>

-T appends each call's wall-clock duration in <seconds>; -e trace= filters to the calls you care about. The killer view for performance work is -c, which aggregates instead of tracing line-by-line:

$ strace -c -f ./myserver
%% time     seconds  usecs/call     calls    errors syscall
------ ----------- ----------- --------- --------- ----------------
 61.2    0.184000           4     46000           read
 27.4    0.082000          41      2000           futex
  8.1    0.024000          12      2000           write

That table instantly answers "is this program syscall-bound?" A million read calls of a few bytes each screams "add buffering or switch to io_uring." Note that strace itself adds two ptrace stops per syscall, so it inflates per-call latency — use it to find which and how many calls, not to measure their true production cost (use perf or eBPF for that). On Linux, strace -c for counts and perf trace for low-overhead timing are the standard pairing.

Pitfalls a working engineer hits

Handling partial reads — a read() loop you can drop into production

A common interview trap is treating read(fd, buf, n) as all-or-nothing. It is not. It returns the number of bytes actually copied, which can be anywhere from 0 (EOF) to n, and it can be interrupted by a signal. Correct code loops on the remainder.

ssize_t read_all(int fd, void *buf, size_t n) {
    char *p = buf;
    size_t left = n;
    while (left > 0) {
        ssize_t r = read(fd, p, left);
        if (r > 0) {
            p += r;
            left -= r;
        } else if (r == 0) {
            break;                       // EOF early
        } else if (errno == EINTR) {
            continue;                    // signal, not a real error
        } else {
            return -1;                   // errno set by read()
        }
    }
    return n - left;                     // bytes actually read
}

The same shape applies to write(): loop until EAGAIN (non-blocking), EINTR, or the buffer is exhausted. Sockets, pipes, and terminals routinely produce partial I/O; disk files usually do not, but "usually" is not a safe contract.

The vDSO fast path: syscalls that are not syscalls

Not every glibc wrapper traps into the kernel. For frequently called, read-only kernel data — the current time (clock_gettime, gettimeofday, time) and the current CPU/NUMA node (getcpu) — the kernel maps a small shared library called the vDSO (virtual dynamic shared object) into every user process. Calls like clock_gettime(CLOCK_REALTIME) and gettimeofday() execute entirely in user space by reading the kernel-updated page, avoiding the ring switch and cache pollution. Note what is not in the vDSO: getpid(), gettid() and friends always trap — if you need a cheap process or thread identity in a hot loop, cache it yourself.

$ ldd /bin/date | grep vdso
        linux-vdso.so.1 (0x00007fff...

$ strace -e trace=clock_gettime date
# no clock_gettime line appears — it never crossed the boundary

This matters when you benchmark syscall cost: if you use gettimeofday inside your timing loop, you are not measuring a syscall at all. It also matters for correctness: vDSO reads are fast but still synchronized with the kernel, so they give a consistent snapshot of kernel state without a trap.

Trade-offs & when to use what

The design question is always "how do I do the necessary work with the fewest, cheapest crossings?" — and the answer depends on scale and complexity budget.

ApproachCrossingsUse whenCost / when NOT
Plain blocking read/write1 per opLow I/O rate, simple code, correctness over throughputDies under high op rates; each call pays the full switch tax
readv/writev1 per groupYou already have several buffers to move at once (headers + body)Helps only when regions are naturally batched; no async benefit
epoll + non-blocking sockets~2 per ready op (epoll_wait + read)Many concurrent connections, mostly network I/OStill one read syscall per ready fd; readiness model, not completion; awkward for disk I/O
io_uring~1 per batch, ~0 with SQPOLL while the ring stays fedHigh-throughput storage and network I/O; you can afford the complexityNewer, larger API surface, security-sensitive (disabled in some sandboxes); SQPOLL burns a CPU core for the poll thread; overkill for low I/O rates

io_uring vs epoll is the sharp comparison. epoll is a readiness interface — it tells you a fd is ready, then you still issue a read syscall per ready fd, and it never covered regular-file disk I/O well. io_uring is a completion interface that batches submission and completion and covers both disk and network. Choose epoll when you have an existing reactor and moderate load; reach for io_uring when syscall count is your measured bottleneck and per-op crossings dominate your profile — and prove it with strace -c first.

🪜 Drill ladder: Syscalls & the User/Kernel Boundary

  1. What is the ABI contract for a Linux x86-64 syscall? Syscall number in rax, arguments in rdi, rsi, rdx, r10, r8, r9; negative return values -4095..-1 are errno codes.
  2. Why is a syscall much more expensive than a function call? Privilege-level switch, stack swap, kernel entry/exit, plus cache/TLB/branch-predictor pollution that slows subsequent user instructions.
  3. What does EINTR mean and what is the correct response? A signal arrived before the call completed; retry the same call, checking the actual bytes transferred first.
  4. How do you handle a partial read()? Loop on the remainder until EOF, EAGAIN, or a non-EINTR error; never assume read(n) returns n.
  5. When does clock_gettime not take a syscall? When it uses the vDSO page, which is mapped into every process for fast read-only kernel data.
  6. When should you trust strace timings vs perf? Use strace for counts and call identities; use perf or eBPF for true wall-clock cost because ptrace overhead inflates timings.

Takeaways

Recall question

Your service reads 4-byte messages from a socket in a tight loop and strace -c shows 2 million read calls dominating CPU time. Name two distinct fixes and explain which boundary cost each one attacks.

Do the math first: 2M crossings x ~250 ns ≈ 0.5 s of pure mode-switch CPU per second of traffic — the boundary alone eats half a core before any payload work. Fix 1 (buffering): a 64 KB user-space buffer turns 2M reads of 4 B (8 MB of payload) into 8 MB / 64 KB ≈ 122 syscalls — a ~16,000x crossing reduction — attacking crossing count. Fix 2 (io_uring): keep per-message processing but batch submissions/completions through the shared rings — attacks crossing count and, with SQPOLL, the per-op crossing entirely. Both also shrink the indirect cache-pollution bill, because the kernel runs on your core less often.


Sources: Brendan Gregg, Systems Performance (2nd ed.) and BPF Performance Tools; Soares & Stumm, "FlexSC: Flexible System Call Scheduling with Exception-Less System Calls" (OSDI 2010) — numbers cited are from that paper's benchmark suite, not universal constants; Jens Axboe, io_uring design papers and liburing; the Linux x86-64 syscall ABI and entry_SYSCALL_64 kernel source; Bovet & Cesati, Understanding the Linux Kernel; strace(1) and perf-trace(1) man pages. Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on Syscalls & the User/Kernel Boundary? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Syscalls & the User/Kernel Boundary** (System Design) and want to truly understand it. Explain Syscalls & the User/Kernel Boundary from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Syscalls & the User/Kernel Boundary** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Syscalls & the User/Kernel Boundary** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Syscalls & the User/Kernel Boundary** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes