The syscall: crossing the boundary into the kernel
Your process cannot open a file, read a byte from disk, or send a packet. It can only
compute over memory it already owns. Everything else belongs to the kernel, and the
syscall is the single doorway: your code loads a syscall number and arguments into
agreed registers, executes one special instruction, and the CPU switches to kernel mode
at an entry point the kernel installed at boot. read(), malloc growing the heap,
printf flushing a buffer — every effect that leaves your process bottoms out here.
The reset: a syscall is not a function call into the kernel. It is a controlled trap — a CPU mode switch to one fixed kernel entry point, with arguments passed in registers according to a contract the kernel publishes.
The boundary: privilege rings
x86-64 CPUs run code at privilege levels; in practice only two matter: ring 0 (kernel) and ring 3 (user). Ring 3 code cannot execute privileged instructions, talk to devices, or touch kernel memory — the page tables map kernel pages as inaccessible to user mode. This is what makes an OS an OS: a buggy or hostile process can be contained because the hardware, not convention, enforces the boundary.
The consequence: user code cannot "call" kernel code, because a plain call to a kernel
address would fault. Instead the CPU provides trap instructions (syscall on x86-64,
svc on ARM64) that atomically raise the privilege level and transfer control to
one kernel-chosen address. The kernel decides what to do by looking at the syscall
number you left in a register — you never choose the destination, only the request.
How it really works: from read() to syscall
When you call read(fd, buf, len), you're calling a small libc wrapper function. On
x86-64 Linux it does approximately this:
- Move the syscall number for
read(0) intorax. - Arguments are already almost in place: the System V function-call convention passes
them in
rdi,rsi,rdx,rcx,r8,r9— the syscall convention is the same except the 4th argument goes inr10, because thesyscallinstruction itself clobbersrcx(it saves the returnripthere) andr11(savedrflags). - Execute
syscall. The CPU jumps to the kernel's entry point in ring 0. - The kernel dispatches on
rax, does the work, puts the result back inrax, and returns to user mode withsysret.
The register contract on x86-64 Linux:
| Role | Register |
|---|---|
| syscall number | rax |
| args 1–6 | rdi, rsi, rdx, r10, r8, r9 |
| return value | rax |
| clobbered by the instruction | rcx (return rip), r11 (rflags) |
The -errno convention. The kernel does not know about the errno variable — that
is a libc fiction. On Linux, a failing syscall returns a small negative number in rax:
read on a bad file descriptor returns -9, which is -EBADF. The libc wrapper checks
whether the result is in [-4095, -1]; if so, it negates it, stores it in the
thread-local errno, and returns -1 to you. That is the entire mechanism behind
"returns -1 and sets errno". XNU (macOS) signals errors differently: the kernel returns
the positive errno value and sets the carry flag, and libSystem's wrapper does the
translation — same fiction, different contract.
Wrappers, syscall(2), and the vDSO
There are three depths at which you can make a syscall, plus one case where you don't cross at all:
- The libc wrapper (
read,write,mmap…) — what you should use. It knows the numbers, does the errno translation, and sometimes adds real logic (e.g. wrappers that call a newer syscall and fall back to an older one). syscall(2), the generic wrapper — you supply the number (SYS_gettid, …) and raw arguments; libc still performs the trap and errno translation. Useful for Linux syscalls that have no wrapper yet.- The raw instruction — inline asm loading registers yourself. Legitimate inside libc implementations, freestanding code, and teaching demos like the one below; everywhere else it's a portability bug waiting to happen.
- The vDSO — Linux maps a small shared object (the virtual dynamic shared object)
into every process, containing user-space implementations of hot, read-mostly
syscalls:
gettimeofday,clock_gettime,time,getcpuon x86-64. The kernel keeps a data page updated; the vDSO function just reads it. That's why a tightgettimeofdayloop shows nothing instrace— no crossing happens. macOS does the analogous trick with the commpage that libSystem reads.
Why avoid crossing? A syscall is far more expensive than a function call: mode switch,
speculation barriers (post-Spectre/Meltdown mitigations made this worse), cache and TLB
pollution, and on return the kernel may reschedule you. Tens to hundreds of nanoseconds
versus ~1 ns for a call — which is exactly why buffered I/O in libc exists: fwrite
batches many small writes into one write crossing.
Executable artifact: one write(), three ways down
The example lives in examples/systems-programming/the-syscall-crossing-into-the-kernel/.
It performs the same write through the libc wrapper, through syscall(2), and through
the raw trap instruction in inline asm (x86-64 and ARM64, Linux and macOS variants),
then makes read fail on purpose to expose what the kernel really returns before libc
dresses it up as errno. The heart of it:
// Raw 3-argument syscall, no libc: load the registers the kernel contract
// names, execute the trap instruction.
static long raw_syscall3(long number, long arg1, long arg2, long arg3,
int *is_error) {
#if defined(__x86_64__)
long result;
char carry;
__asm__ volatile(
"syscall\n\t"
"setc %1" /* XNU reports errors in the carry flag */
: "=a"(result), "=r"(carry)
: "a"(number), "D"(arg1), "S"(arg2), "d"(arg3)
: "rcx", "r11", "memory");
#if defined(__APPLE__)
*is_error = carry;
#else
(void)carry; /* Linux: error iff result is a small negative (-errno) */
*is_error = (unsigned long)result >= (unsigned long)-4095L;
#endif
return result;
#endif
}
Compile and run:
gcc -Wall -Wextra demo.c -o demo && ./demo
Real output from this machine (an x86-64 Mac, so the numbers are XNU's):
1: libc wrapper write()
write() returned 24
2: generic syscall(2) wrapper
syscall(SYS_write=4, ...) returned 30
3: raw trap instruction, no libc
raw syscall 0x2000004 returned 33 (is_error=0)
errno: libc read(-1,..) -> ret=-1, errno=9 (Bad file descriptor)
errno: raw read(-1,..) -> ret=9, is_error=1 (EBADF is 9)
Read the last two lines together: libc reported the polite fiction (-1, errno = 9),
while the raw syscall shows what XNU actually handed back — a positive 9 plus the
carry flag. On Linux the same raw call returns -9 and the first line's machinery
(negate, store, return -1) is the whole story of errno. Also note SYS_write is 4
here and the raw number is 0x2000004 (XNU's BSD syscall class); on x86-64 Linux it
is 1. Same source, different kernel contract.
Watching the crossing: strace and friends
The boundary is observable, because the kernel can log every trap a process makes:
- Linux:
strace ./demoprints every syscall with decoded arguments and return values —read(3, "abc", 128) = 3.strace -caggregates counts and time per syscall;ltraceshows library calls instead, which is exactly the wrapper-vs-syscall distinction of this note, live. - macOS:
dtruss ./demo(a DTrace script) is the rough equivalent, but SIP blocks it for most binaries unless partially disabled;dtrace/Instruments are the supported paths. - A syscall trace is often the fastest way to answer "what is this program actually
doing?" — no source needed. If a process is slow,
strace -ctelling you it made two million 1-bytewritecalls is a complete diagnosis.
macOS/BSD vs Linux: same idea, different contract
- Numbers differ per kernel.
writeis 1 on x86-64 Linux, 4 in XNU's BSD class, 64 on ARM64 Linux. A syscall number is meaningful only relative to one kernel — there is no portable numbering. - Linux's syscall table is a stable ABI. Numbers are never reused or renumbered; static binaries making raw syscalls keep working across kernel upgrades. This is a deliberate, load-bearing kernel promise.
- macOS makes the opposite promise. The stable interface is libSystem, not the
syscall table; Apple renumbers and changes kernel interfaces between releases, and
syscall(2)is officially deprecated. Go famously had to switch from raw syscalls to libSystem calls on macOS for exactly this reason. Ship raw syscalls on macOS and a routine OS update can break you. - Error signaling differs (
-errnoin the return value vs positive errno + carry flag), as the demo shows.
Failure modes & trade-offs
- Every syscall can fail; unchecked returns are latent bugs.
writecan return a short count on success — checking only!= -1is not enough. errnois only meaningful after a failure. Successful calls may leave stale values; test the return value first, then readerrno.- Raw syscalls skip libc's bookkeeping. Bypassing wrappers can desynchronize libc
state (buffered stdio, pthread bookkeeping, the runtime's view of
brk);forkvia raw syscall famously skipspthread_atforkhandlers. - Syscall cost is a design constraint. Chatty crossings (byte-at-a-time I/O) are a
classic performance failure; batching (buffered stdio,
writev, larger buffers) is the standard fix. - The boundary is also the security perimeter. Seccomp filters, sandboxes, and container runtimes reason about processes in terms of which syscalls they may make — another reason "what syscalls does this program make?" is worth knowing how to answer.
In practice
- Use the libc wrappers. Reach for
syscall(2)only for Linux syscalls without wrappers, and for raw asm only in freestanding code — where you are the libc. stracefirst, debugger second when a program misbehaves at the OS boundary: wrong path, missing permission, surprisingENOENT— the trace shows it immediately.- Learn to read
man 2pages. Section 2 is the syscall contract: arguments, return value, and the exacterrnovalues each call can produce. - Count crossings in hot paths. If profiling shows kernel time, ask "which syscalls, how often, how small?" before optimizing user-space code.
- This boundary is what you will build in the OS project. Installing the entry point, dispatching on the number, returning to user mode — the other side of this note.
ARM64 appendix
Same shape, different names. On ARM64 Linux the trap instruction is svc #0
(supervisor call): syscall number in x8, arguments in x0–x5, return value in
x0, with the same -errno convention. Numbers differ from x86-64 (write is 64, not
1) because the ARM64 port started from a clean, unified table. On Apple Silicon, XNU
takes the number in x16 and the trap is svc #0x80, with the carry-flag error
convention — the demo's __aarch64__ branches show both side by side:
#if defined(__APPLE__)
register long x16 __asm__("x16") = number; /* XNU: number in x16 */
__asm__ volatile("svc #0x80\n\t"
"cset %1, cs" ...);
#else
register long x8 __asm__("x8") = number; /* Linux: number in x8 */
__asm__ volatile("svc #0" ...);
#endif
Connects to: Systems Programming · The process address space & virtual memory · Registers & the ISA · System V AMD64 calling convention · The minimal standard library · OS from Scratch
Sources
- Michael Kerrisk — The Linux Programming Interface, ch. 3 "System Programming Concepts" — the syscall bible; the trap mechanism, wrapper functions, and error handling in depth. https://man7.org/tlpi/
syscall(2)— man7.org — the generic wrapper, plus the authoritative per-architecture table of trap instructions and register conventions (number, args, return). https://man7.org/linux/man-pages/man2/syscall.2.htmlsyscalls(2)— man7.org — the catalogue of Linux syscalls and the kernel's ABI-stability promise. https://man7.org/linux/man-pages/man2/syscalls.2.htmlvdso(7)— man7.org — why some "syscalls" never enter the kernel, and which ones per architecture. https://man7.org/linux/man-pages/man7/vdso.7.html- System V AMD64 ABI — appendix A documents the Linux x86-64 syscall calling convention alongside the function-call convention it mirrors. https://gitlab.com/x86-psABIs/x86-64-ABI
- Stevens & Rago — Advanced Programming in the UNIX Environment (APUE) — the classic treatment of the syscall/library-function distinction and errno discipline. http://www.apuebook.com/
- XNU
syscalls.master— Apple's actual BSD syscall table, where the macOS numbers in the demo come from. https://github.com/apple-oss-distributions/xnu/blob/main/bsd/kern/syscalls.master