The System-Call Interface

In your C program you write \texttt{n = read(fd, buf, 4096)} and it looks exactly like calling \texttt{sqrt} or a helper you wrote yourself. It is not. A plain function call stays inside your process, costs a few cycles, and never leaves user mode. \texttt{read} does something categorically different: it crosses the privilege boundary into the kernel, because only the kernel is allowed to touch a disk, a socket, or another process's pages. That crossing is the system call — the single narrow doorway between the billions of lines of unprivileged code the world runs and the few million lines that are actually in charge of the machine.

You met this boundary in the graduate view as the idea of limited direct execution. This lesson opens the doorway and looks at its hinges: the trap that crosses it, the trap table that decides where control lands, the ABI that says how arguments are passed, and the surprising cost — hundreds of cycles — that makes "avoid the syscall" a design mantra.

A trap, not a jump

A user program cannot simply \texttt{jmp} to a kernel function. If it could, isolation would be a fiction — any process could jump into the middle of the scheduler and do as it pleased. Instead the hardware provides a synchronous trap: a special instruction that atomically (a) raises the privilege level to ring 0, (b) switches to a trusted kernel stack, and (c) transfers control to one fixed entry point the kernel registered at boot. The program never chooses where in the kernel it lands — it only asks, and the kernel's entry code dispatches.

All three are the same idea wearing different names: a deliberate, hardware-mediated exception that the program raises on itself to request a service. The kernel-side entry point is the top of the trap table — the array of handlers indexed by a number the caller supplies.

The shape of a crossing

Follow a single \texttt{read} from user space, into the kernel, and back. The vertical line is the privilege boundary; nothing crosses it except through the trap.

Notice how little the program did: it loaded a number and some arguments into registers and ran one instruction. Everything else — the mode switch, the stack switch, the dispatch, the return — is machinery the hardware and kernel provide. That is the whole point of a mechanism: build the expensive, delicate crossing once, and expose it as a single instruction.

The calling convention — the syscall ABI

Because the caller and the kernel are compiled separately (often years apart), they must agree on an exact, frozen contract for how the request is encoded: which register holds the call number, which hold the arguments, where the result comes back. This is the system-call ABI, and on Linux/x86-64 it is set in stone — breaking it would break every binary ever compiled.

Rolex86-64 LinuxNotes
syscall number\texttt{rax}e.g. 0 = read, 1 = write, 60 = exit
arg 1\texttt{rdi}note: user ABI arg 4 is \texttt{rcx}, but…
arg 2\texttt{rsi}
arg 3\texttt{rdx}
arg 4\texttt{r10}\texttt{r10} replaces \texttt{rcx}, which \texttt{syscall} clobbers
arg 5\texttt{r8}
arg 6\texttt{r9}six register args max — no stack args
return value\texttt{rax}errors come back as -4095 \le \texttt{rax} \le -1 (a negative \texttt{errno})

The \texttt{read()} you call in C is not the syscall — it is a thin wrapper in the C library that marshals its arguments into these registers, executes \texttt{syscall}, and translates a negative return into -1 plus a set \texttt{errno}. The kernel never sees your language; it sees registers.

Dispatch through the trap table

Once inside, the kernel's entry stub does the least work it can — saves the user registers, then uses the number in \texttt{rax} as an index into a fixed array of function pointers (Linux calls it \texttt{sys\_call\_table}). A bounds check rejects out-of-range numbers; a valid one is a single indexed indirect call. Here is that dispatch, in miniature.

// A trap table in miniature: the kernel indexes an array of handlers by the syscall // number the user left in "rax". Out-of-range numbers are rejected, not obeyed. type Handler = (a: number, b: number, c: number) => number; const NR_READ = 0, NR_WRITE = 1, NR_GETPID = 39, NR_EXIT = 60; // The trap table: number -> handler. Gaps are unimplemented calls. const trapTable: Record<number, Handler> = { [NR_READ]: (fd, _buf, n) => { console.log(` sys_read(fd=${fd}, ${n} bytes)`); return n; }, [NR_WRITE]: (fd, _buf, n) => { console.log(` sys_write(fd=${fd}, ${n} bytes)`); return n; }, [NR_GETPID]: () => { console.log(` sys_getpid()`); return 4242; }, [NR_EXIT]: (code) => { console.log(` sys_exit(${code})`); return 0; }, }; function trap(nr: number, a = 0, b = 0, c = 0): number { const h = trapTable[nr]; if (!h) { console.log(` #GP: syscall ${nr} not implemented -> return -ENOSYS`); return -38; } return h(a, b, c); } // A tiny program's stream of syscalls (the number is what really matters): console.log("rax=0 ->", trap(NR_READ, 3, 0, 4096)); console.log("rax=1 ->", trap(NR_WRITE, 1, 0, 12)); console.log("rax=39 ->", trap(NR_GETPID)); console.log("rax=999->", trap(999)); // bogus number: rejected console.log("rax=60 ->", trap(NR_EXIT, 0));

What a crossing costs

A regular function call is a handful of cycles. A round trip through the kernel is hundreds — historically 150 to 500{+} cycles on x86, and made dramatically worse after 2018 by the Spectre/Meltdown mitigations, which force page-table isolation (a full address-space switch) and speculation barriers on every entry and exit. Where does the money go?

The lesson every high-performance system learns: make fewer crossings. Three families of tricks recur throughout this course:

The original 386 entered the kernel via \texttt{int 0x80}, a general interrupt gate — and general is slow, because the CPU must read the descriptor from the IDT, do privilege checks, and push a full interrupt frame. As clock speeds rose in the late 1990s the fixed overhead of a gate became a real tax on syscall-heavy workloads. Intel's answer (and AMD's, for 64-bit) was a pair of purpose-built, no-frills instructions — \texttt{sysenter}/\texttt{sysexit} and later \texttt{syscall}/\texttt{sysret} — that skip the descriptor lookup by keeping the kernel entry point and stack in fast MSRs. It cut the raw crossing from a few hundred cycles to well under a hundred. Then Meltdown arrived in 2018 and page-table isolation quietly gave much of that speed back — a reminder that the syscall boundary is where security and performance fight hardest.

A frequent confusion is to equate the C function \texttt{read()} with the kernel's \texttt{sys\_read}. They are not the same thing. The libc \texttt{read()} is ordinary user-mode code that marshals your arguments into \texttt{rdi}/\texttt{rsi}/\texttt{rdx}, puts 0 in \texttt{rax}, executes the one \texttt{syscall} instruction, and then unpacks the result. You can prove it to yourself by running \texttt{strace}: it shows the syscalls, and a single \texttt{printf} may perform zero syscalls (buffered) or one \texttt{write} — never a "printf syscall", because there is no such thing. Keep the two layers distinct: the library gives you a comfortable C API; the ABI is the register contract underneath.