JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

How a System Call Is Really Implemented

You call read() and bytes appear. But read() is not where the reading happens — it is a doorbell. This guide follows one system call all the way across the user/kernel boundary and back, register by register, and shows why that crossing costs more than an ordinary function call.

read() is a doorbell, not the door

Guide 2 left you holding the mechanism: a trap deliberately throws the CPU into the kernel through an entry in the interrupt descriptor table, flipping it from user mode to kernel mode. A system call is what you get when a program chooses to throw that trap on purpose, to ask the kernel for something it is not allowed to do itself — open a file, send a packet, fork a process. This guide follows one such request from the line of C you wrote all the way down to the kernel function that does the work, and back. By the end, read() will look very different to you.

Here is the first surprise, and it is the most important idea in this guide. The read() you call from C is not the system call. It is an ordinary C function living in the C library — a thin wrapper — and almost all it does is package up your arguments and trigger the trap. The kernel side, the code that actually moves bytes off the disk, has the same job but lives on the other side of the wall. So a library call and a system call are genuinely different animals: calling read() in your source looks identical to calling any function, but somewhere inside it the program stops being a normal function call and becomes a request to a different, more privileged program.

Inside the wrapper: a number in a register

Crack open libc's read() and the magic is almost disappointingly plain. The kernel does not know your call is named "read" — it knows it by a number. Every system call has an entry in the system-call number scheme: on 64-bit Linux, read is 0, write is 1, open is 2, and so on. The wrapper's job is to load that number into a fixed register, load your arguments into the other agreed registers, and execute one special instruction. Nothing more dramatic than filling in a form whose fields are CPU registers.

Which registers? That is fixed by a strict contract — a small ABI just for system calls, deliberately not the same as the ordinary C calling convention you met in the assembly rung. On x86-64 Linux the call number goes in rax, and the up-to-six arguments go in rdi, rsi, rdx, r10, r8, r9. Then the wrapper runs the syscall instruction, which is the actual trap: it flips the CPU into kernel mode and jumps to a single fixed kernel entry point. When control returns, the kernel has left its answer in rax — either a non-negative result (bytes read) or a negative error code.

// what libc's read(fd, buf, n) becomes on x86-64 Linux:

  mov  rax, 0        ; syscall number for read
  mov  rdi, fd       ; arg1: file descriptor
  mov  rsi, buf      ; arg2: pointer to your buffer
  mov  rdx, n        ; arg3: how many bytes
  syscall            ; <-- THE TRAP: cross into the kernel
  ; on return, rax holds bytes read, or a negative error
  ; e.g. rax == -14  means -EFAULT (you passed a bad pointer)
The whole wrapper, stripped down: a number in rax, arguments in fixed registers, one syscall instruction. The trap is a single line.

Notice the negative-result convention, because it explains a thing C programmers see constantly. The kernel cannot set your user-space errno — that is your variable, in your memory, and the kernel does not touch it. So the kernel returns a negative number to mean failure, and the libc wrapper does the translation: if rax came back as a small negative value, the wrapper negates it, stores it into errno, and returns -1 to your C code. That is the real reason every example in this whole site checks for a -1 return and then reads errno — the -1 is the wrapper's signal, errno is the unpacked detail.

On the kernel side: from one entry point to the right handler

The syscall instruction lands the CPU at exactly one address in the kernel — the same entry point for read, write, open, and every other call. So the kernel's first task is to figure out which call you meant. It reads the number you left in rax and uses it as an index into the system-call table: a plain array of function pointers, one slot per syscall number. Entry 0 points at the kernel's read handler, entry 1 at write, and so on. This is the kernel-side twin of the libc wrapper — the moment your numeric request becomes a concrete function call inside the kernel.

Before any of that, the entry code has housekeeping to do, and it explains where the cost goes. It must save your user-space registers somewhere safe (the kernel is about to clobber them), switch from your user stack to a per-task kernel stack, and only then dispatch through the table. When the handler returns, all of that unwinds in reverse: restore your registers, switch back to the user stack, drop the privilege level, and resume your code at the instruction after syscall. The bookkeeping is small but real, which is the seed of the cost we measure at the end.

Why the kernel can't just dereference your pointer

Now we reach the part that trips up everyone learning kernel code. Your read() handed the kernel a pointer — buf, the address where the bytes should go. The kernel is running in kernel mode, with the power to touch any memory in the machine. So why can't it just do *buf = byte; and copy the data straight in? Two reasons, and both are about safety. First, buf is a user-space address that means something only in your process's page tables; the kernel must read and write it as user memory, not assume it is mapped the way kernel addresses are. Second, and more sharply: you might have lied.

Suppose your buf pointed not at your own buffer but at a kernel address, or at memory you do not own. A naive kernel that just dereferenced it would happily read or overwrite something precious — a textbook privilege escalation. So the kernel never touches a user pointer directly. It funnels every byte through guarded helpers, the copy-to/from-user routines: copy_from_user() to pull your arguments in, copy_to_user() to push results out. These check that the address really lies in user space, and they are written so that if the pointer is bad, the access faults cleanly and the syscall returns -EFAULT instead of crashing the kernel. That -EFAULT is the -14 we saw come back in rax earlier.

This is the kernel/user boundary made concrete, and it is worth pausing on, because it overturns a beginner's mental model. The wall is not only about privilege — kernel mode versus user mode — it is also about trust in addresses. Even with the power to touch all memory, the kernel deliberately treats every pointer that came from user space as suspect, and reaches across the boundary only through these checked, fault-tolerant copies. Get this wrong in real kernel code and you have written a security hole; this is one of the genuinely hard, error-prone parts of systems programming, not a formality.

What the crossing costs — and the fast path

Add up the round trip and you can feel where the time goes: the trap and mode switch itself, saving and restoring registers, the stack swap, the table dispatch, the checked copies, then the whole sequence unwound in reverse. None of it is huge, but none of it is free either. The honest headline is that a system call costs far more than an ordinary function call — typically on the order of hundreds of CPU cycles versus a handful for a plain call. A function call jumps and returns inside your own privilege level; a syscall changes mode, touches the kernel's machinery, and comes back.

This cost is exactly why so much systems wisdom is about avoiding syscalls, not just making them. It is why stdio buffers your writes and flushes in big chunks instead of one syscall per character; why a busy server reaches for batching interfaces that handle many events in a single crossing. "Do more per trap" is a recurring theme you will meet again and again as you climb — the boundary is a tollbooth, and the trick is to drive through it loaded, not empty.

Hardware and kernels have fought back, which is the last piece. Old x86 made the trap through a software interrupt (the int 0x80 instruction), which was genuinely slow; modern chips added the dedicated syscall instruction precisely to make the mode switch cheaper. And the kernel keeps a fast path: for common, simple calls that need no heavyweight bookkeeping, the entry code takes a streamlined route, saving only the registers it must and skipping work it can prove is unnecessary, before falling back to the slow general path for anything tricky. Some calls go further still — a handful that only read kernel data, like getting the time, can be served without trapping at all, through a shared read-only page the kernel maps into every process.

Step back and hold the whole journey. Your read() was a libc wrapper that loaded a number into rax, put arguments in fixed registers, and ran one trap instruction. The CPU switched mode and landed at the kernel's single entry point, which saved your state, swapped stacks, and dispatched through the syscall table to the real handler. That handler never trusted your pointers, copying every byte through checked user-copy helpers, then returned a number that the wrapper turned back into a result and errno. The crossing is what makes the kernel safe — and what makes it cost. Guide 4 follows what happens when, mid-syscall, the kernel decides to run a different process instead, and you meet the scheduler.