
Linux System Calls Tutorial: A Step-by-Step Guide
Have you ever wondered how a user program actually reaches the kernel to do privileged work?
I set the goal for this tutorial: show how a program crosses the CPU boundary, what the kernel does next, and how you can verify behavior with tracing tools.
I explain what you will be able to do by the end: read syscall man pages correctly, reason about wrappers versus raw entry, and recognize common entry mechanisms on x86.
Scope is clear: user space vs kernel space, syscall numbers and tables, x86 legacy and fast paths, plus the vDSO shortcut.
Guardrails I use in real work: I prefer libc wrappers for correctness; I only handcraft raw assembly for learning or controlled experiments.
Why this matters in production: when latency, permissions, or sandboxing break, low-level knowledge saves hours of debugging.
Key Takeaways
- I map how a program invokes kernel services at the CPU boundary.
- I show which paths x86 uses and when vDSO helps.
- I recommend using wrappers for safety; use raw calls for study.
- I preview tracing with strace and sysdig to expose behavior.
- This guide targets seasoned admins and devs working on Intel/AMD x86_64.
What a system call is and why Linux programs use it
Think of a system call as a formal request from your program to the kernel to perform work it cannot do itself. I use that doorway whenever code needs privileged access: open a file, talk to a device, or create a process.
A call differs from a normal C library function in one key way: a call triggers a CPU mode switch into kernel mode. A pure library function stays in user space and runs without that transition.
Some libc functions map directly to kernel entry points—chdir is a clean example. Others do extra work first: fwrite buffers data, batches writes, and updates bookkeeping before invoking write.
Common jobs handled by kernel entry
- Files: open, read, write, sync.
- Processes: fork, clone, execve.
- Network I/O: socket, connect, accept.
- Devices and control: ioctl and mmap for device memory.
| Layer | Typical function | Does it enter kernel? | Notes |
|---|---|---|---|
| User library | fwrite | No, unless buffer flush | Buffers and coalesces writes |
| Wrapper | chdir | Yes | Maps directly to kernel entry |
| Kernel | openat | Yes | See man 2 openat for RETURN VALUE/ERRORS |
Where I look for details
I start at man 2 syscalls to get the index. Then I jump to specific pages: man 2 openat, man 2 execve, man 2 mmap. Read the RETURN VALUE and ERRORS sections first—those spell out what to check when behavior differs across machines.
User space vs kernel space: CPU privilege levels and kernel mode
I describe the split as a hard CPU fence. The processor enforces who may touch sensitive state. The fence keeps faults and crashes out of the kernel.
I run apps in Ring 3. The kernel runs in Ring 0. Ring 3 cannot execute privileged instructions or poke hardware. That restriction prevents rogue programs from corrupting device registers or kernel memory.
Ring 3 vs Ring 0 — practical view
Ring 3 runs your code and normal libraries. Ring 0 runs the scheduler, drivers, and memory manager. A privilege level change lets the kernel perform I/O or change address space on your behalf.
| Privilege level | Who runs here | Observable effects |
|---|---|---|
| Ring 3 (user) | Applications, libraries | Permission errors like EPERM; limited hardware access |
| Ring 0 (kernel) | Kernel core, drivers | Can program devices, change page tables, handle interrupts |
| Transition | Instruction + entry point | CPU switches mode; kernel code executes; return to user |
What “entering the kernel” means
A dedicated instruction triggers the switch. The cpu changes privilege level and jumps to a kernel entry handler. Registers and a saved state let the kernel validate arguments and act safely.
Bad pointers or missing rights show up as EFAULT or EPERM when the kernel checks them. Observing these errors with tracing tools makes the boundary visible.
Every syscall needs two pieces: an entry mechanism (the instruction or gate) and a target kernel function (looked up in a table). I will cover those next.
How the kernel finds the right function: syscall numbers and the syscall table
At the CPU boundary, a small numeric ID points the processor to the right kernel routine. The stable mental model is simple: number + arguments. The number identifies the service; the arguments carry the data.
Loading the number and arguments into registers
I put the call number and its arguments into ABI-defined registers before entry—usually via libc. That setup is predictable: one register holds the number, others hold the argument values. The CPU instruction that triggers entry then hands control to the kernel.
Table lookup to a kernel function pointer, conceptually
Inside the kernel, the dispatcher reads the number and checks bounds. It indexes a table and loads a function pointer. The kernel validates the pointer and runs the target.
- Why numbers exist: compact identifiers that survive the user/kernel boundary.
- Where mappings come from: arch/x86/…/syscall_64.tbl and similar .tbl files used at build time.
- Common debugging trap: wrong number or wrong calling convention looks like random EINVAL or EFAULT—garbage arguments confuse the kernel.
| Step | What moves | Why it matters |
|---|---|---|
| User | number, arguments in registers | Compact entry; ABI agreement |
| Entry | processor switches mode | Safe kernel execution |
| Kernel | table index → function pointer | Fast dispatch to implementation |
system calls Linux tutorial: making a call from C with glibc wrappers
I default to the C library wrappers for most kernel-facing work. They handle ABI setup, errno, and expected user-space hooks. That keeps code predictable across distros and libc versions.
Why wrappers matter
Wrappers normalize return values and set errno. They implement cancellation points and run atexit handlers. They also hide ABI quirks that vary by architecture.
In practice I call open, read, write, fork/clone, and friends through glibc. That gives me consistent output and known behavior for library-driven tooling.
When to use syscall(2) and what you give up
I use syscall(2) only for new or missing interfaces, or when I need a minimal path to reproduce a kernel bug. It calls by number and avoids wrapper logic.
- Pros: minimal layers; direct entry for testing.
- Cons: you lose type safety, errno helpers, and user-space hooks like atexit.
- Risk: other libraries expect wrapper behavior—bypassing them can break cleanup and tools.
| Approach | When to use | What you lose |
|---|---|---|
| glibc wrapper | Default for app code | Nothing; safer semantics |
| syscall(2) | Unwrapped or new syscall | Wrapper fixes, atexit, type checks |
| Raw entry (assembly) | Experiments, demos | User-space cleanup and portability |
I recommend: start with wrappers, trace with strace to confirm behavior, then use syscall(2) only when you need the minimal path.
Legacy entry: int 0x80 on x86 and what it teaches you
I teach int $0x80 because it peels back the curtain on how user code hands privileged work to the kernel.

Register convention on 32‑bit
On 32‑bit x86 the call number lives in %eax. Arguments follow in %ebx, %ecx, %edx, %esi, %edi, %ebp.
That layout forces you to think about how each register carries information across the boundary.
Following the int $0x80 path
- You load the number and arguments into registers.
- The int instruction raises a software interrupt; the CPU consults the IDT and switches mode.
- The kernel handler reads %eax, indexes the syscall table, and invokes the implementation.
Return to user space
The kernel uses the saved user state on the stack; an iret restores EIP/CS/EFLAGS and execution resumes in user mode.
| Stage | Key registers | Role |
|---|---|---|
| User prep | %eax, %ebx..%ebp | Number + args |
| Entry | IDT, CPU state | Mode switch to kernel |
| Return | stack | Saved state restored with iret |
Note: int $0x80 is a teaching tool—rarely the right choice for production but common in old binaries, compatibility paths, and certain writeups.
Fast system calls on 32-bit: sysenter and sysexit
A different entry instruction shaves cycles—but it shifts more responsibility to user space. On 32‑bit x86 the sysenter/sysexit pair is a fast path provided by the CPU. It avoids the full interrupt machinery that int $0x80 uses.
Why sysenter is different
sysenter runs faster because the CPU does less work. It does not save a user return address for you. That missing save makes return handling a contract between user and kernel—one broken contract causes crashes or wrong pointers on return.
MSRs you should know
The kernel programs three MSRs: IA32_SYSENTER_EIP (the kernel entry address), IA32_SYSENTER_ESP (the Ring 0 stack pointer), and IA32_SYSENTER_CS (the kernel code segment selector). Together they tell the hardware where to land and which stack to use.
| Mechanism | State saved | Speed |
|---|---|---|
| int 0x80 | CPU saves return state | slower |
| sysenter | minimal save—kernel assumes setup | faster |
| __kernel_vsyscall | User-mapped stub sets state | fast and consistent |
Why care? If the linux kernel’s MSR plumbing or the user stub misaligns, you see odd faults near entry. glibc picks the best mechanism at runtime—so your C code usually stays stable. __kernel_vsyscall is an early vDSO-style helper: the kernel maps a small stub into user space so processes can prepare for sysenter reliably.
Fast system calls on 64-bit: syscall and sysret
The syscall instruction on x86-64 is the common bridge—lean, fast, and predictable.
CPU expects the call number in a specific place and arguments in ABI registers. On x86-64 the convention is: rax = number, rdi/rsi/rdx/r10/r8/r9 = args. Executing the instruction hands control to kernel entry; sysret returns to user mode.
Here is a minimal learning plan in assembly: use write (number 1) to send bytes to fd 1 (stdout), then exit (number 60). This shows how a small program produces console output and terminates cleanly.
| Register | Role | Example |
|---|---|---|
| rax | call number | 1 for write |
| rdi | first argument | file descriptor (1 = stdout) |
| rsi | second argument | pointer to buffer |
- I verify the bytes appear on stdout.
- I check the process exit status matches the exit call.
- I run strace to confirm the exact syscall sequence.
Reality check: this is for controlled learning. For production I prefer libc wrappers that handle edge cases, errno, and compatibility.
vDSO and virtual system calls: faster paths without a full kernel transition
A tiny shared object mapped into each process can remove a full kernel transition for common helpers. The kernel provides that mapping so user code can run a few coordinated functions quickly.
What the vDSO is and why it is mapped
The vDSO is a small, kernel-provided shared object in a process’s address space. It exposes helpers the kernel agrees to keep up to date.
Those helpers avoid a costly mode switch for frequent operations—things like reading a clock or translating monotonic time. Faster paths reduce latency and CPU overhead for hot code.
How glibc finds and uses the vDSO
glibc detects the vDSO at runtime. If a function exists there, the library calls it instead of issuing a full kernel entry.
You do not change source code: the choice is automatic. That makes apps faster with no code changes.
Timing expectations and measurement tips
Benchmarks may show very low overhead for some “kernel” calls because they actually run in user space via the vDSO. That can mislead microbenchmarks.
- Expect some time readings to avoid a mode switch.
- Confirm behavior with tracing: use strace or perf to see whether the process enters kernel mode.
- Pair microbenchmarks with a tracing tool to avoid false conclusions about intervention or memory effects.
| Aspect | vDSO | Full kernel entry |
|---|---|---|
| Where it runs | User | Kernel |
| Typical latency | Low | Higher |
| Use case | Time helpers, small transforms | I/O, privileged ops |
Note: __kernel_vsyscall was an early variant of this idea. Modern vDSO is safer and more flexible—another way the kernel reduces overhead while keeping control.
Tracing system calls to understand what a program is doing
I start tracing when I need to know exactly which files and sockets a process touches. Tracing turns vague symptoms into concrete evidence: which file was missing, which process spawned, which network connection failed.
Using strace for a quick view of file, process, and network activity
strace is my first tool for fast answers. Run a targeted trace:
strace -f -e trace=open,connect,execve,read,write -o trace.log ./mycommand
I scan for openat/open, execve, connect, and permission errors (EACCES, ENOENT). That shows files, directories, spawned commands, and immediate failures.
Using sysdig with filters for targeted troubleshooting
When I need system-wide context, I switch to sysdig. Capture just the process I care about:
sysdig proc.name=mycommand and evt.is_io=true
Use evt.type=connect, evt.type=execve, evt.type=clone to focus output and avoid noise.
Calls worth watching and a quick checklist
- clone — threads/process creation.
- execve — what actually runs.
- open/creat — file access and creation.
- connect / accept — outbound and inbound network.
- read/write, readv/writev — I/O patterns on descriptors.
- brk, mmap, munmap — memory growth and mappings.
- select, poll — blocked or waiting states.
- kill — signals and sudden exits.
| Tool | Scope | Best for |
|---|---|---|
| strace | Single process | Quick debug of file and exec activity |
| sysdig | System-wide | Filtered, correlated traces across processes |
| perf/other | Microbenchmarks | Latency and high-volume I/O profiling |
- Reproduce the issue with minimal input.
- Capture a short trace window with strace or sysdig.
- Filter for errors: ENOENT, EACCES, ECONNREFUSED.
- Map errors to config, file paths, or network endpoints and fix.
Where to go next: safer experiments and deeper kernel reading
Begin with a reproducible lab: boot a VM you can snapshot, keep a known-good kernel entry in GRUB, and isolate experiments from production.
Read the arch syscall table for your architecture, follow the generated headers, and trace one system call from entry to implementation. That reveals the full path: number → entry stub → handler.
For a custom syscall you must edit arch/x86/entry/syscalls/syscall_64.tbl, add the handler with SYSCALL_DEFINE, copy user data with helpers like strncpy_from_user, rebuild, and reboot into the test kernel.
Safety rules: never trust user pointers—return -EFAULT on bad input. Validate changes with dmesg for printk output and strace to confirm the call path. Note: tables and conventions shift across kernel versions—always match source to the running kernel.
Next reads: man 2 syscalls, the architecture entry code in the tree, and tool docs for strace/sysdig. Test in a VM, iterate, and keep snapshots.
