System Calls
An application cannot read a disk, send a packet or create a process on its own; it deposits a request number and arguments in registers, executes a trap instruction, and the kernel does it — a few hundred nanoseconds per crossing, which is why batching interfaces exist.
The problem
Progressive depth
The same mechanism at different altitudes — start where you are.
Programs cannot touch hardware or other processes. They put a request in registers and execute a special instruction; the kernel does the work with full privileges and hands back a result.
The application cannot touch the disk
In user mode the CPU refuses privileged instructions and the page tables hide every device and every kernel structure (see User Mode vs Kernel Mode). So the only way for a program to make anything happen outside its own memory is to ask the kernel. A system call is that request: a well-defined operation with a number and arguments, performed by the kernel on the caller’s behalf, with the caller’s identity and permissions checked at every step.
Everything your program does that is not pure computation on its own memory is a syscall underneath. Reading a config file: openat, read, close. Printing to the terminal: write on descriptor 1. Connecting to a database: socket, connect, then sendto/recvfrom or write/read. Waiting for a lock: futex. Allocating a large buffer: mmap. Starting a subprocess: clone (or fork) then execve. Waiting for 10,000 sockets: epoll_wait. The kernel is not a library you link; it is a service you call through a trap.
- Application codefs.writeSync / f.write / std::ofstream — the runtime’s API↓
- Language runtimeNode’s libuv, CPython’s io module, libstdc++ — calls the C library↓
- libc wrapper: write()loads syscall number and args into registers, executes the trap instruction↓
- Trap: user mode → kernel modeCPU switches privilege, jumps to the kernel’s syscall entry on the task’s kernel stack↓
- Kernel: sys_write → VFS → file systemvalidates fd, permissions, copies data from user buffer into the page cache↓
- Block layer → driver → devicelater: writeback issues NVMe commands; write() usually returns before this↓
- Return to user modereturn value in a register: bytes written, or −errno
Anatomy of a call
On x86-64 Linux the convention is: syscall number in rax, up to six arguments in rdi, rsi, rdx, r10, r8, r9, then the syscall instruction. The CPU saves the user instruction pointer and flags, switches to ring 0 and the task’s kernel stack, and jumps to the entry point the kernel registered at boot. The kernel indexes its syscall table by rax, calls the handler, puts the result in rax (a small negative number is -errno) and executes sysret. write is number 1, read is 0, openat is 257, mmap is 9, clone is 56, futex is 202, epoll_wait is 232. ARM64 Linux uses x8 for the number, x0–x5 for arguments and the svc #0 instruction; the numbers differ.
The libc wrapper is what makes this look like a function. write() in glibc is a few instructions: move arguments into place, syscall, and if rax is in the range −4095…−1 negate it into the thread-local errno and return −1. This is why errno exists — the raw kernel interface returns a negative error inline, and the C convention wanted −1 plus an out-of-band code. It is also why errno is per-thread: two threads making syscalls concurrently must not overwrite each other’s error.
Some "syscalls" never enter the kernel. clock_gettime, gettimeofday and getcpu are served from the vDSO, a page of kernel-provided code mapped into every process, reading a shared memory page the kernel updates; a timestamp costs ~20 ns instead of a trap. Runtimes that need timestamps on every event (Node’s event loop, Go’s scheduler, Python’s time.perf_counter) rely on this.
$ strace -T -e trace=openat,write,close python3 -c 'open("out.txt","w").write("hi\n")'
openat(AT_FDCWD, "out.txt", O_WRONLY|O_CREAT|O_TRUNC|O_CLOEXEC, 0666) = 3 <0.000031>
write(3, "hi\n", 3) = 3 <0.000012>
close(3) = 0 <0.000009>
+++ exited with 0 +++
# <…> is wall time inside the call, as measured by strace (which itself slows every call ~10×)A catalogue worth knowing
A few dozen syscalls cover almost everything a server does; they group naturally by what they touch. Learning them is learning the vocabulary of strace output, of kernel profiles, and of the documentation for every I/O library you will ever use. The names below are the Linux/POSIX ones; Windows has equivalents behind a different API (NtCreateFile, NtReadFile, NtCreateUserProcess…) reached through ntdll.dll rather than libc.
- Files:
openat,read,write,pread/pwrite,lseek,fstat,close,fsync— see File Descriptors and Follow a File Read. - Sockets:
socket,bind,listen,accept4,connect,sendto/recvfrom,shutdown,setsockopt— see The Socket: A Descriptor With Two Kernel Buffers Behind It and Follow send() Through the OS to recv(). - Memory:
mmap,munmap,mprotect,brk,madvise— see What Happens When I Allocate Memory? and Memory Mapping. - Processes and threads:
clone(also whatpthread_createuses),fork,execve,wait4,exit_group,kill— see Creating Processes: fork, exec, wait and Signals: Asynchronous Notifications From the Kernel. - Synchronisation:
futex— the primitive under every mutex, condition variable and semaphore in glibc, Go, Rust and the JVM; only called when there is contention — see Mutexes. - Waiting for many things:
poll,epoll_create1/epoll_ctl/epoll_wait,kqueueon BSD/macOS,io_uring_enter— see I/O Multiplexing: select, poll, epoll, kqueue, IOCP.
libc is the wrapper; runtimes call libc
The kernel’s syscall interface is a binary ABI — numbers and registers — and almost nothing calls it directly. The C library (glibc or musl on Linux, libSystem on macOS, the CRT plus ntdll on Windows) wraps every syscall in a C function, and every language runtime calls those. Node.js reaches the kernel through libuv, which calls read/write/epoll_wait from libc. CPython’s os.read is a thin wrapper around libc read. A C++ std::ifstream ends in read through the standard library’s filebuf. The JVM’s NIO calls epoll_wait through JNI into libc.
Two notable exceptions prove the rule. Go on Linux issues syscalls itself, without libc, because its runtime needed control over which OS thread blocks; on macOS and Windows it goes through the system library because those platforms do not promise a stable raw-syscall ABI. Which is the second point: Linux guarantees the syscall numbers and semantics forever (Linus’s "we do not break user space"), while macOS and Windows guarantee only the library layer and reserve the right to renumber underneath. Statically linked Linux binaries run on any kernel; the same trick on macOS is unsupported.
This layering is why strace (Linux), dtruss (macOS) and Process Monitor (Windows) are so revealing: they sit at the syscall boundary and show exactly what a program asked the OS for, independent of the language it was written in. A Python script and a Rust binary opening the same file produce the same openat line.
What a syscall costs, and why batching exists
The bare round trip — trap in, dispatch, trivial handler, return — costs on the order of 100–300 ns on a modern x86-64 core, and closer to 1 µs on kernels running with Spectre/Meltdown mitigations that flush predictors or switch page tables on entry. That is 100–1000× a function call. It is also a pipeline flush and a partial cache disturbance, so a syscall-heavy loop runs slower than the sum of its traps suggests.
The consequence is that the *number* of syscalls, not the bytes moved, often bounds I/O throughput. Writing a 1 GB file in 1-byte write calls is a billion traps; in 64 kB calls it is 16,000. Every I/O library buffers for this reason: stdio, BufferedWriter, Node streams and Python’s io all accumulate bytes in user space and issue one large write. Reading 10,000 sockets with one read each is 10,000 traps per pass; epoll_wait returns the ready ones in one call so you only trap for sockets that have data.
The kernel offers explicitly batched interfaces where the pattern is common: readv/writev move several buffers in one call; sendfile and splice move data between descriptors without a user-space copy (Zero-Copy: Serving a File Without Touching It); recvmmsg/sendmmsg handle many UDP datagrams per call; and io_uring (Linux 5.1+) goes furthest — the application writes requests into a ring buffer shared with the kernel and reads completions from another, and in polled mode does I/O with *no* syscalls at all. Every one of these exists because a trap costs more than the work it requests.
- ~100–300 ns per trap unmitigated; up to ~1 µs with KPTI/retpoline-era mitigations; ~20 ns for vDSO calls.
- Throughput-bound code should count syscalls per unit of work:
strace -corperf trace -sgives the histogram. - Batching interfaces:
readv/writev,sendmmsg,epoll_wait(many events per call),sendfile/splice,io_uring.
Key points
- A syscall is the only way user code causes anything to happen outside its own memory: files, sockets, processes, memory mappings, waiting.
- Mechanism: number and arguments in registers, a trap instruction, kernel dispatch through a table, result in a register, return to user mode.
- libc wraps the ABI into functions and translates negative returns into
errno; runtimes (Node, CPython, JVM, C++ stdlib) call libc, not the kernel — Go on Linux is the notable exception. - Linux keeps the raw syscall ABI stable; macOS and Windows stabilise only the library layer.
- A trap costs ~100 ns–1 µs, 100–1000× a function call; syscall count, not byte count, often bounds I/O.
- Buffering,
readv/writev,epoll,sendfileandio_uringall exist to amortise that cost.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why can’t my program write to the disk itself?
Because the disk is shared, and a program that could issue its own commands could read every other user’s files and corrupt the file system. The trap boundary lets the kernel check permissions, serialise access and keep the file system consistent on every request.
▸Why does errno exist instead of a return code?
The kernel returns −errno in the result register. C wanted −1 plus a separate code, so the libc wrapper splits them and stores the code in a per-thread variable. Modern languages hide this again as exceptions or Result types.
▸Why is io_uring faster than read()/write()?
It replaces one trap per operation with a shared ring: many operations submitted per trap, or none at all in polled mode. The I/O is the same; the crossings are gone.
▸Why does strace slow programs down so much?
It stops the traced process at every syscall entry and exit with ptrace, which is itself several syscalls and two context switches per traced call. Use perf trace or eBPF tools for low-overhead tracing.
System call trace
fd = open("data.txt", O_RDONLY);n = read(fd, buf, 4096);write(1, buf, n);close(fd);
- User mode: libc wrapperputs args in rdi, rsi, rdx↓
- Trap: syscall instructionrax = number → ring 0↓
- Kernel handlersys_call_table[rax]↓
- ResourceVFS / page cache / driver↓
- Return: sysretrax = result → ring 3
$ strace ./a.out
How it fails
What the failure looks like from inside real software.
- Unbuffered logging: one
writeper log line at 200k lines/s costs a full core in traps; the fix is a buffered writer flushed on newline or timer. EMFILEfromaccept4/openat: the descriptor table is full because something leaks descriptors — see File Descriptors.EAGAINtreated as an error on a non-blocking socket: the syscall is saying "not yet", and the correct response is to wait inepoll/kqueue, not to fail the request.- A syscall-heavy service lost 20% throughput after a kernel security update: mitigations raised the per-trap cost; batching or io_uring recovered it.
EINTRfrom a blocking call interrupted by a signal, unhandled: a read that silently returned nothing and a partial result treated as EOF.