The problem: how do you safely call code you are not allowed to jump to?
From the earlier guides in this rung you already hold two facts. First, the CPU runs in one of two privilege levels: ordinary code runs in user mode, where dangerous instructions and direct hardware access are simply forbidden, while the kernel runs in kernel mode where everything is allowed. Second, a system call is how user code asks the kernel to do one of those forbidden things on its behalf — open a file, write bytes, get a new process. This guide answers the mechanical question those two facts leave open: how does control actually cross that line, given that you are not allowed to just jump into the kernel's code?
Notice why a plain jump cannot work. If user code could simply `jmp` to a kernel address and start running, the whole protection would be a fiction — any program could land on whatever kernel instruction it liked and run it with full privilege. The hardware therefore refuses: a user-mode jump into kernel memory faults instead of succeeding. So the crossing cannot be something you steer. It has to be a controlled handover where the CPU itself, not your code, decides exactly where kernel execution begins. That single idea — you may request entry, but only the hardware decides where you land — is the entire design.
The trap: one instruction that changes who you are
The crossing is performed by a single dedicated instruction. On modern x86-64 Linux it is the `syscall` instruction; older systems used a software interrupt like `int 0x80`. Executing it triggers a trap — a deliberate, synchronous switch into kernel mode. This is the same family of events as a page fault or an interrupt, all covered by traps, faults, and interrupts, but with one telling difference: a fault is the CPU catching you doing something wrong, whereas a trap from `syscall` is you politely knocking. You ran the instruction on purpose, asking to be let in.
When `syscall` executes, the hardware does several things in one indivisible step, and crucially you do not choose any of them. It raises the privilege level to kernel mode. It saves where you were — the return address (your rip) and the flags — so the kernel can send you back later. And it loads rip from a register the kernel set up at boot time, the syscall entry point, so execution jumps to exactly one fixed kernel address that the kernel chose in advance. You cannot redirect this. Whatever syscall you are making, you always enter the kernel through the same single door, and the kernel's gatekeeper code runs first. This is the trap and mode switch in its entirety.
Passing the message: the syscall number and its arguments
If every syscall enters through the same door, how does the kernel know which service you want — open, or write, or fork? You tell it with a number. Every system call has a fixed syscall number: on x86-64 Linux, read is 0, write is 1, open is 2, and so on. Before running `syscall`, you place that number in a specific register, rax, and the arguments in a fixed sequence of registers: rdi, rsi, rdx, r10, r8, r9 for up to six arguments. This register layout is a strict contract — the kernel reads exactly those registers and nothing else — and it is part of the same ABI that governs ordinary calls, just with its own dedicated rulebook for the kernel boundary.
So the full handshake reads like a tiny protocol: load rax with the syscall number, load the argument registers, execute `syscall`, and when control returns, find the result back in rax. There is no need to pass arguments on the stack the way an ordinary function might, because the kernel cannot trust your stack and the register convention is faster and simpler to check. A literal write of one byte to standard output, written by hand in assembly, looks like the sketch below — and this is exactly what the C call `write(1, buf, 1)` compiles down to underneath, once libc has done its part.
; x86-64 Linux: write(1, msg, 13) mov rax, 1 ; syscall number 1 = write mov rdi, 1 ; arg1: fd = 1 (standard output) lea rsi, [msg] ; arg2: pointer to the bytes mov rdx, 13 ; arg3: how many bytes syscall ; <-- trap into the kernel here ; on return: rax = bytes written, or a negative error code register map: rax=number rdi rsi rdx r10 r8 r9 = args rax=result
Inside the door, and back out again
Once the trap lands you at the kernel entry point, the kernel's job begins, and it does not trust you for an instant. Its gatekeeper code first switches to a kernel stack — your user stack might be corrupt or malicious, so the kernel never runs on it. Then it reads rax and checks the number is a real syscall in its table; an out-of-range number is rejected, not obeyed. Then, before acting on any pointer argument you passed (like that rsi pointing at your bytes), it validates that the pointer really lives in your address space and not in the kernel's, so a program cannot trick the kernel into reading or writing kernel memory on its behalf. Only after these checks does it dispatch to the actual handler — the function that performs the real work.
When the handler finishes, the return path mirrors the entry. The kernel places the result in rax, restores your saved registers and rip, lowers the privilege level back to user mode with a dedicated return instruction (`sysret` or `iret`), and execution resumes on the very next instruction after your `syscall`, exactly as if it had been a single step. Two conventions make the result readable. A success returns a non-negative value in rax — for write, the number of bytes actually written. A failure returns a small negative value whose magnitude is the error code. That negative-on-error convention is what libc later turns into the errno mechanism that the next guide is entirely about; here, the key point is simply that the result rides home in rax.
Why the crossing costs something
It is tempting to think of a syscall as just a slightly fancier function call, but that picture hides a real cost and leads people to write slow programs. A plain user-mode `call` and `ret` are a handful of cheap instructions. A syscall, by contrast, must switch privilege levels, switch stacks, save and restore extra processor state, run the kernel's validation, and switch back — and on modern hardware it may also disturb CPU features that speculate ahead, costing extra cycles to recover. The honest summary is that the cost of a syscall is on the order of hundreds to thousands of times a plain function call, not two or three times.
This single fact explains a huge amount of real systems design. It is why writing one byte a million times with a million separate write() syscalls is dramatically slower than buffering those bytes in user space and flushing them with a few large writes — you are paying the crossing toll once instead of a million times. It is why high-performance servers fight to do more work per syscall. And it is the reason the standard library wraps unbuffered kernel I/O in buffered streams in the first place, a thread the final guide of this rung picks up directly. Once you can see the trap as a real border crossing with a real toll, batching stops being a trick and becomes the obvious thing to do.