System Call Implementation in the Kernel
Trace a system call from the userspace `syscall` instruction all the way through entry assembly, the dispatch table, SYSCALL_DEFINE macros, argument validation, and back — then add your own.
Overview
System calls are the fundamental boundary between user space and kernel space — the sole legal gateway through which a program running in unprivileged mode can request privileged services from the operating system. Every open(), write(), fork(), and mmap() you have ever called ultimately becomes a trap into the kernel, a brief journey through a carefully guarded interface, and a return back to your program with a result.
Understanding how system calls are implemented — not just invoked — is one of the most illuminating experiences in systems programming. It reveals how the CPU enforces privilege levels, how the kernel authors use macros to avoid mountains of boilerplate, how pointer arguments from user space must be treated as hostile input, and why the ABI of a system call can never change without breaking every binary ever compiled.
This module follows a single read() call from the moment the C library issues the syscall instruction all the way through the kernel's entry assembly trampoline, the dispatch table, the SYSCALL_DEFINE macro expansion, argument validation with copy_from_user, and finally the return path back to user space. You will then add your own toy system call to a real kernel tree and observe the whole machinery from the inside.
By the end, you will understand not just that system calls exist, but why every design decision was made — why there is a separate kernel stack, why copying user pointers is mandatory and not optional, why the syscall table is a plain array, and why stability is a promise the kernel community takes more seriously than almost anything else.
System calls sit at the very heart of every patch you will ever write that touches the user-facing API surface of the kernel. The moment you understand how a syscall travels from libc through SYSCALL_DEFINE to the VFS or the network stack, every kernel subsystem suddenly makes sense: they are all just elaborate call trees hanging off syscall entry points. Reading the implementation of sys_read teaches you the copy_to/from_user discipline that permeates the entire kernel codebase — skip it and you will introduce security vulnerabilities.
For a prospective kernel contributor, the syscall path is also the best-documented, most stable, and most reviewed code in the entire tree, making it the ideal study subject. The discipline required to add even a trivial syscall — argument validation, error codes, compat handlers, documentation, selftests — is exactly the discipline required to contribute anything well. Mastering this module means you can read arch/x86/entry/entry_64.S, follow a do_syscall_64 call, and trace it to any driver or filesystem handler in the tree, because now you know where the thread of execution enters the kernel.
Syscall Execution Path: User Space to Kernel and Back
A single syscall's journey: the CPU hardware switches privilege, the entry trampoline saves state, do_syscall_64 dispatches via the table, the SYSCALL_DEFINE handler copies user data safely, and the exit path handles signals before returning.
The concepts
01CPU Privilege Levels and the Need for a Controlled Gate
Modern CPUs enforce isolation between programs using hardware privilege rings (x86) or exception levels (ARM). User-space code runs at ring 3 (x86) or EL0 (ARM), where instructions that touch hardware directly — like reading from an I/O port or modifying page tables — trigger a fault. The kernel runs at ring 0 / EL1, where everything is permitted.
The only architecturally sanctioned way to cross this boundary upward (from unprivileged to privileged) is through a synchronous exception: on x86-64 the syscall instruction, on ARM64 the svc #0 instruction, on RISC-V the ecall instruction. Each of these causes the CPU to atomically:
- Save the current program counter and CPU flags to well-known registers or a kernel stack.
- Switch the privilege level.
- Jump to a fixed kernel-supplied handler address that was previously written into a CPU register (x86-64: the
LSTARMSR; ARM64: theVBAR_EL1vector base register).
Notice what the CPU does NOT do: it does not trust any pointer or address that came from user space. It jumps to a kernel-controlled address only. This is the hardware root of all kernel security. The kernel entry code then reads the syscall number from a register (rax on x86-64, x8 on ARM64, a7 on RISC-V) and uses it to dispatch to the right handler. That dispatch table — and the macros that populate it — is what the rest of this module is about.
/* How glibc issues a 'write' syscall on x86-64:
rax = syscall number (1 = __NR_write)
rdi = fd, rsi = buf, rdx = count */
mov $1, %rax /* __NR_write */
mov $1, %rdi /* stdout */
lea msg, %rsi /* pointer to buffer */
mov $13, %rdx /* byte count */
syscall /* trap into kernel */
/* return value (or -errno) lands in rax */
02The Kernel Entry Trampoline: entry_64.S and do_syscall_64
When the syscall instruction fires, the CPU is now in ring 0 but the stack pointer still points at the user stack — a pointer that must never be trusted. The very first thing the entry code does is switch to the kernel stack for this thread. On x86-64 this is done by the swapgs instruction (which swaps the GS base register so the per-CPU area becomes visible) followed by loading the kernel stack pointer from cpu_entry_area.
The entry trampoline (arch/x86/entry/entry_64.S, function entry_SYSCALL_64) then:
- Saves all callee-saved registers (the full
pt_regsstructure) onto the kernel stack. - Calls the C function
do_syscall_64(struct pt_regs *regs, int nr). - On return, checks whether the process has pending signals or needs rescheduling (the syscall slow path).
- Restores registers and executes
sysretqto return to user space.
The pt_regs structure is crucial: it is the snapshot of the user-space CPU state at the moment of the trap. If you have ever seen a kernel stack trace that shows entry_SYSCALL_64 at the bottom, now you know why — it is the outermost frame, the trampoline.
do_syscall_64 does one thing elegantly: it looks up sys_call_table[nr] (after bounds-checking and security audits like seccomp) and calls the function pointer. The return value from that function is written into regs->ax, which becomes the return value seen by user space.
/* Simplified do_syscall_64 — real code: arch/x86/entry/common.c */
__visible noinstr void do_syscall_64(struct pt_regs *regs, int nr)
{
add_random_kstack_offset(); /* stack canary entropy */
nr = syscall_enter_from_user_mode(regs, nr); /* seccomp etc */
if (likely(nr < NR_syscalls))
regs->ax = sys_call_table[nr](regs); /* dispatch! */
else
regs->ax = -ENOSYS;
syscall_exit_to_user_mode(regs); /* signals, rescheduling */
}
03The Syscall Table: sys_call_table
The syscall table is the simplest possible data structure: a plain array of function pointers, indexed by syscall number. On x86-64 it lives in arch/x86/entry/syscall_64.c and is generated by the build system from a table definition file (arch/x86/entry/syscalls/syscall_64.tbl).
Each line in syscall_64.tbl looks like:
1 common write sys_write
The number (1) is the ABI index. The string common means the handler is shared between 64-bit native and x32 ABI. sys_write is the C function name. The build system runs syscalltbl.sh which generates a C file that produces sys_call_table[].
On ARM64 and RISC-V the mechanism is similar but the table is architecture-independent and lives in include/uapi/asm-generic/unistd.h, populated by a different macro scheme.
Why a plain array? Because syscall dispatch happens on every single I/O operation — it must be as fast as possible. An array indexed by an integer is O(1) with a single bounds check. The table is marked const, lives in read-only memory after boot (with __ro_after_init), and is protected by kernel page protections against runtime modification — a critical security hardening against attackers who might try to hijack the table.
/* arch/x86/entry/syscall_64.c (generated, simplified) */
asmlinkage const sys_call_ptr_t sys_call_table[] = {
[0] = sys_read,
[1] = sys_write,
[2] = sys_open,
/* ... 400+ entries ... */
[NR_syscalls-1] = sys_ni_syscall, /* not-implemented stub */
};
/* Entries with no handler use this safe stub */
asmlinkage long sys_ni_syscall(void) { return -ENOSYS; }
04SYSCALL_DEFINE Macros: Boilerplate Elimination and Type Safety
Every system call implementation you read in the kernel uses a macro like SYSCALL_DEFINE3(write, ...) rather than writing asmlinkage long sys_write(...) by hand. This seems like mere style, but there are several important reasons:
1. Argument tracing. The SYSCALL_DEFINEn macros (where n is the argument count, 0–6) automatically instrument the function for ftrace / perf syscall tracepoints. When you run perf trace ls, the kernel fires a tracepoint at every syscall entry and exit — the macro wires that up for free.
2. Type safety on 32-bit compat paths. On a 64-bit kernel running 32-bit user-space binaries, arguments arrive in 32-bit registers. The macros generate both a native 64-bit handler and (if needed) a compat handler, ensuring argument widths are correct.
3. Seccomp argument inspection. seccomp(2) with BPF filters needs to inspect individual syscall arguments. The macro expansion stores argument metadata in a form seccomp can use.
4. Stack protector hints. The macros add __MAP and __SC_DECL helpers that the compiler can use to reason about argument provenance.
The macro expands into two things: the actual function body (named __se_sys_NAME), and a thin wrapper (__do_sys_NAME) that unpacks the pt_regs struct into named parameters. On x86-64, modern kernels pass struct pt_regs * to the table entry, and the wrapper extracts rdi, rsi, rdx, etc. from it. This is called the regparm-based calling convention for syscalls introduced around kernel 4.17.
/* How SYSCALL_DEFINE3 expands — conceptually */
/* What you WRITE: */
SYSCALL_DEFINE3(write, unsigned int, fd,
const char __user *, buf, size_t, count)
{
return ksys_write(fd, buf, count);
}
/* What the MACRO expands to (simplified): */
asmlinkage long sys_write(const struct pt_regs *regs);
static long __se_sys_write(unsigned int fd,
const char __user *buf, size_t count);
static inline long __do_sys_write(unsigned int fd,
const char __user *buf, size_t count)
{
return ksys_write(fd, buf, count);
}
asmlinkage long sys_write(const struct pt_regs *regs) {
return __se_sys_write((unsigned int)regs->di,
(const char __user *)regs->si,
(size_t)regs->dx);
}
static long __se_sys_write(unsigned int fd,
const char __user *buf, size_t count) {
long ret = __do_sys_write(fd, buf, count);
/* tracepoint, audit hooks fire here */
return ret;
}
05copy_to_user and copy_from_user: The Security Boundary in Code
This is perhaps the single most important concept in kernel programming for security. When a syscall receives a pointer from user space, that pointer could be:
- Pointing to kernel memory (a classic privilege escalation vector).
- Pointing to unmapped memory (would cause a kernel oops).
- Changing underneath you (TOCTOU: time-of-check to time-of-use race condition).
- Pointing to a memory-mapped device register.
The kernel therefore has a strict rule: you must never dereference a user-space pointer directly. You must use the copy_from_user(kernel_buf, user_ptr, size) and copy_to_user(user_ptr, kernel_buf, size) functions (or their scalar variants get_user / put_user).
These functions do several things:
- Access_ok check: Verify that the entire range
[user_ptr, user_ptr+size)lies within the valid user address space for this process (belowTASK_SIZE). This rejects kernel-address pointers. - Fault-tolerant copy: The actual memory copy uses a special code path that installs a fixup in the kernel's exception table. If a page fault occurs during the copy (because the user page is not resident or the mapping is invalid), the CPU's page-fault handler checks the exception table, finds the fixup for that instruction, and returns a partial-copy error rather than crashing the kernel. The function returns the number of bytes NOT copied (0 on full success).
- SMAP/PAN enforcement: On x86-64 with SMAP (Supervisor Mode Access Prevention) and ARM64 with PAN (Privileged Access Never), the CPU hardware will fault if kernel code touches user pages without going through these functions, which temporarily clear the protection bit.
Always check the return value of copy_from_user. A non-zero return means the copy failed partway; you must return -EFAULT to user space.
#include <linux/uaccess.h>
/* Imagine a syscall that reads a 'struct point' from user space */
struct point { long x; long y; };
SYSCALL_DEFINE1(my_point_abs, struct point __user *, uptr)
{
struct point kp; /* kernel-side copy — lives on kernel stack */
long dist;
/* NEVER: kp = *uptr; -- direct deref of user pointer! */
if (copy_from_user(&kp, uptr, sizeof(kp)))
return -EFAULT; /* partial or failed copy */
/* Now kp is safe to use */
dist = abs(kp.x) + abs(kp.y);
return dist; /* returned in rax to user space */
}
/* For single primitive values, prefer get_user / put_user */
int val;
if (get_user(val, (int __user *)uptr))
return -EFAULT;
06Argument Validation: Erring on the Side of -EINVAL
Beyond pointer safety, syscall implementations must validate every argument for semantic correctness before acting on it. This is a discipline that appears throughout the kernel and is worth studying explicitly.
File descriptors: fdget() / fdget_pos() look up a file descriptor in the current process's file descriptor table and return a reference-counted struct fd. If the fd is out of range or closed, they return an empty struct. Always check fd_empty(f) before using.
Sizes and counts: Never trust a user-supplied size without bounds checking. A size argument of SIZE_MAX passed to an allocation is an integer overflow waiting to happen. Check size > MAX_RW_COUNT for read/write, check for count == 0 (return 0 immediately), etc.
Flags and mode bits: Mask off unknown flags with ~(KNOWN_FLAGS) and return -EINVAL if any unknown bits are set. This preserves forward compatibility: a future kernel can define a new flag, and old kernels reject it rather than silently ignoring it.
Alignment: Some hardware or kernel structures require naturally aligned addresses. Check with IS_ALIGNED(addr, sizeof(type)).
The rule of thumb: validate eagerly, act late. Do all checks at the top of the function before modifying any kernel state. This makes error paths clean and prevents partial-mutation bugs where the kernel state is corrupted if a later check fails.
The __user annotation (from include/linux/compiler_types.h) is not enforced at runtime — it is a semantic tag for the Sparse static-analysis tool (make C=1). Sparse will warn if you dereference a __user pointer without copy_from_user. Run it on your code.
SYSCALL_DEFINE3(my_bounded_read,
int, fd, char __user *, buf, size_t, count)
{
struct fd f;
ssize_t ret;
/* 1. Trivial fast path */
if (count == 0)
return 0;
/* 2. Reject absurdly large requests */
if (count > MAX_RW_COUNT)
count = MAX_RW_COUNT; /* or return -EINVAL */
/* 3. Validate fd */
f = fdget_pos(fd);
if (fd_empty(f))
return -EBADF;
/* 4. Check file is readable */
if (!(f.file->f_mode & FMODE_READ)) {
ret = -EBADF;
goto out_fput;
}
/* 5. buf pointer validated inside vfs_read -> copy_to_user */
ret = vfs_read(f.file, buf, count, &f.file->f_pos);
out_fput:
fdput_pos(f);
return ret;
}
07The Kernel Stack and Per-Thread Context
When a thread enters the kernel via a syscall, it switches from its user-space stack to a dedicated kernel stack. On x86-64, each thread has an 8 KB (two pages) kernel stack allocated when the thread is created. On ARM64 it is 16 KB.
This is a very small stack. This is intentional and important:
- It bounds worst-case memory usage (N threads × 8 KB, not N × 8 MB).
- It prevents user space from arbitrarily consuming kernel memory by simply creating threads.
- It forces kernel code to be stack-disciplined: no large on-stack buffers, no deep recursion.
The pt_regs structure (containing the saved user-space register state) is placed at the top of the kernel stack by the entry trampoline. The rest of the 8 KB is available for the call tree from do_syscall_64 downward. If that tree overflows — CONFIG_VMAP_STACK places a guard page below the stack — the CPU will double-fault. CONFIG_KASAN and CONFIG_STACK_VALIDATION can detect stack overflows at build or runtime.
The current macro (actually an inline function using the gs segment register on x86-64) gives kernel code a pointer to the task_struct of the currently executing thread at any point during syscall execution. This is how, for example, copy_from_user knows which process's memory map to check — it calls access_ok() which consults current->mm.
/*
* Kernel stack layout during a syscall on x86-64
* (addresses grow downward)
*
* [high addr] top of kernel stack page
* pt_regs (user rip, rsp, rax, rbx ... saved here)
* do_syscall_64 frame
* sys_write frame
* ksys_write frame
* vfs_write frame
* ... deeper call tree ...
* [low addr] guard page (unmapped, triggers double fault if hit)
*
* Total budget: ~8 KB. Never put large arrays here.
*/
/* 'current' gives you the task_struct of the running thread */
#include <linux/sched.h>
printk(KERN_DEBUG "syscall by pid %d (%s)\n",
current->pid, current->comm);
08Syscall Entry/Exit: Slow Path, Signals, and Restartable Syscalls
The fast path through a syscall is: enter → dispatch → run handler → return. But there is a slow path that activates when the kernel needs to do extra work on the way back to user space.
syscall_exit_to_user_mode() checks a bitmask of pending work flags on the current thread before returning:
- TIF_SIGPENDING: A signal is queued. The signal handler must be invoked before returning to the original user-space instruction.
- TIF_NEED_RESCHED: The scheduler wants to run a different thread.
schedule()is called here. - TIF_NOTIFY_RESUME: ptrace, seccomp notification, or user-mode instruction extensions need attention.
This is also where restartable syscalls come into play. If a blocking syscall like read() on a pipe is interrupted by a signal, the handler must return either -EINTR (the call was interrupted, user sees the error) or -ERESTARTSYS (the kernel will automatically re-issue the syscall after the signal handler returns, transparently to the user). The ERESTARTSYS / ERESTARTNOINTR / ERESTARTNOHAND codes are kernel-internal; the signal delivery path in arch/x86/kernel/signal.c decides whether to rewind the user's instruction pointer to re-execute the syscall instruction.
This machinery is why read() on a pipe can sometimes return fewer bytes than requested without it being an error — and why wrapper libraries like glibc loop on EINTR.
/* A simplified interruptible wait — from kernel driver code */
long my_blocking_syscall_body(void)
{
DEFINE_WAIT(wait);
long ret = 0;
add_wait_queue(&my_wq, &wait);
for (;;) {
prepare_to_wait(&my_wq, &wait, TASK_INTERRUPTIBLE);
if (data_is_ready())
break;
if (signal_pending(current)) {
ret = -ERESTARTSYS; /* kernel will restart us */
break;
}
schedule(); /* yield CPU, wake up when signaled or ready */
}
finish_wait(&my_wq, &wait);
return ret;
}
09Adding a New Syscall: The Complete Walkthrough
Adding a syscall to the Linux kernel is intentionally bureaucratic — the committee reviews new syscalls with extreme scrutiny because of the ABI stability promise. Nonetheless, doing it yourself in a local kernel build is the single best learning exercise in this module. Here is the complete recipe for x86-64 on a v6.x kernel:
Step 1 — Add the number to arch/x86/entry/syscalls/syscall_64.tbl:
463 common my_hello sys_my_hello
(Pick a number not already used; check by grepping the tbl file.)
Step 2 — Declare it in include/linux/syscalls.h:
asmlinkage long sys_my_hello(int magic);
Step 3 — Implement it in a new file, e.g., kernel/my_hello.c:
#include <linux/syscalls.h>
#include <linux/printk.h>
SYSCALL_DEFINE1(my_hello, int, magic) {
if (magic != 42)
return -EINVAL;
pr_info("hello from syscall, pid=%d\n", current->pid);
return 0;
}
Step 4 — Wire it into the build by adding to kernel/Makefile:
obj-y += my_hello.o
Step 5 — Test it from user space:
#include <sys/syscall.h>
#include <unistd.h>
#include <stdio.h>
#define __NR_my_hello 463
int main(void) {
long r = syscall(__NR_my_hello, 42);
printf("ret=%ld\n", r); /* should print 0 */
return 0;
}
Step 6 — Add a kselftest under tools/testing/selftests/syscall_user_dispatch/ or a new directory. Real upstream submissions require this.
For real upstream submission: you also need a man2 page draft, an entry in include/uapi/asm-generic/unistd.h (for non-x86 arches), compat handlers if any argument is 32-bit-sensitive, and a thorough cover letter justifying why existing syscalls cannot do the job.
/* kernel/my_hello.c */
#include <linux/syscalls.h>
#include <linux/printk.h>
#include <linux/sched.h>
SYSCALL_DEFINE1(my_hello, int, magic)
{
if (magic != 42)
return -EINVAL;
pr_info("my_hello: called by pid %d (%s) with magic=%d\n",
current->pid, current->comm, magic);
return 0;
}
/* tools/testing/selftests/my_hello/my_hello_test.c */
#include <sys/syscall.h>
#include <unistd.h>
#include <assert.h>
#define __NR_my_hello 463
int main(void) {
assert(syscall(__NR_my_hello, 42) == 0);
assert(syscall(__NR_my_hello, 99) == -1); /* errno=EINVAL */
return 0;
}
10The Syscall ABI Stability Promise
The Linux kernel has one of the most serious compatibility promises in all of software: we do not break userspace. Linus Torvalds has rejected patches that break the syscall ABI in terms that leave no ambiguity. The reasoning is profound: every binary ever compiled and distributed — in a Docker image, a proprietary application, a 10-year-old embedded system — may rely on a syscall's exact number, argument layout, and semantics. The kernel is the only piece of software that must be forward-compatible with all of them.
This promise means:
- Syscall numbers never change or get reused. A removed syscall's slot is replaced with
sys_ni_syscall(the not-implemented stub) forever. The slot is never reassigned to a different syscall. - Argument types and orders are frozen. You can add behavior via flags, but you cannot reorder arguments or change their meaning.
- New flags must be zero-or-fail. Any new flag bit you add must be rejected by old kernels (which return
-EINVALfor unknown flags) and accepted by new ones, never silently ignored. - Struct layouts passed across the boundary are frozen (think
stat,sigaction,timespec). This is why the kernel hasstatx(2)(which uses a size field to version the struct) rather than extendingstat(2). - The UAPI headers (
include/uapi/) are specifically separated from internal kernel headers precisely because they form the public ABI. Changes there are reviewed with extra care.
For a learner, this means: when you read kernel code that looks needlessly complex or has multiple versions of a struct, the reason is almost always ABI stability. The complexity is the price of 30+ years of compatibility.
/* WHY statx exists instead of extending stat:
struct stat has a fixed layout from 1970s Unix.
You cannot add fields — old binaries compiled against
the old layout would read garbage from new fields.
statx() solves this with an explicit 'struct_size' field:
*/
/* User tells kernel how big their struct is */
int ret = statx(AT_FDCWD, path,
AT_STATX_SYNC_AS_STAT,
STATX_ALL,
&statxbuf); /* struct statx, 256 bytes */
/* The kernel only fills fields within the requested size.
A future kernel can grow struct statx; old binaries are safe
because they only passed in sizeof(old_struct_statx). */
/* Slots for retired syscalls are kept as ni_syscall forever */
/* From arch/x86/entry/syscalls/syscall_64.tbl: */
/* 59 is execve -- never reassigned, never removed */
/* 134 was sys_uselib -- now ni_syscall, slot preserved */
11Vetting Your Syscall Code with Sparse and ftrace
Two tools are indispensable when writing syscall code:
Sparse (make C=1) is a semantic checker that understands kernel type annotations like __user, __kernel, __rcu, __percpu. It will warn if you dereference a __user pointer directly (a security bug), mix address spaces, or annotate function pointers incorrectly. It is the first line of defense against copy_from_user omissions. Run it on every file you touch.
ftrace / perf trace let you observe syscall entry and exit in a running kernel. The SYSCALL_DEFINE macros automatically register tracepoints at syscalls:sys_enter_NAME and syscalls:sys_exit_NAME. You can observe them with:
sudo perf trace -e 'syscalls:sys_enter_write' ls
or with the ftrace interface:
echo 'syscalls:sys_enter_write' > /sys/kernel/tracing/set_event
cat /sys/kernel/tracing/trace_pipe
For your custom syscall, the tracepoint syscalls:sys_enter_my_hello is automatically created by the SYSCALL_DEFINE macro — you get observability for free.
strace works at the ptrace level (the kernel notifies a tracer process at each syscall entry/exit via PTRACE_SYSCALL). It is slower than ftrace but easier to use from userspace without root. Understanding that strace is implemented with ptrace — not with ftrace tracepoints — teaches you that there are multiple independent observation planes for the same event.
# Build with Sparse checking (in your kernel source tree)
make C=1 kernel/my_hello.o
# Look for: "warning: incorrect type in argument" (missing __user)
# or: "warning: dereference of noderef expression" (raw user ptr)
# Observe your syscall live with ftrace
cd /sys/kernel/tracing
echo 1 > tracing_on
echo 'syscalls:sys_enter_my_hello' > set_event
# In another terminal: run your test binary
cat trace_pipe
# Output: <...>-1234 [000] .... my_hello: magic=42
# perf stat for syscall frequency
perf stat -e 'syscalls:sys_enter_read,syscalls:sys_enter_write' \
dd if=/dev/zero of=/dev/null bs=4k count=10000
Key insights
- ✦The CPU privilege switch on `syscall`/`svc`/`ecall` is a hardware-enforced one-way gate — the kernel controls the destination address, not the user program, and this is the root of all OS security.
- ✦Dereferencing a user-space pointer directly in kernel code is not just a bug — it is a potential privilege escalation vulnerability. `copy_from_user` is not a suggestion; it is the law.
- ✦The `SYSCALL_DEFINE` macros are not sugar — they wire in tracepoints, seccomp argument inspection, and compat handling that you would otherwise have to write by hand every single time.
- ✦The syscall table is intentionally the simplest possible dispatch mechanism: a flat array of function pointers. Speed matters because it executes on every I/O.
- ✦ABI stability is not conservatism — it is a hard guarantee that every binary compiled against a given syscall number will work on any future kernel. The complexity you see in kernel structs (versioned sizes, flag-extension patterns) is the direct cost of honoring that guarantee.
- ✦The kernel stack is deliberately tiny (8–16 KB). This forces good coding discipline and bounds memory usage, but means you must never declare large local arrays in kernel code.
- ✦Restartable syscalls (`-ERESTARTSYS`) are the mechanism that makes blocking I/O and POSIX signal semantics compose correctly — the kernel can re-run a syscall after a signal handler transparently.
- ✦The `__user` annotation and Sparse checker form a compile-time enforcement layer for the copy_from/to_user discipline — always run `make C=1` on syscall code.
Common pitfalls
- ⚠Directly dereferencing a `__user` pointer (e.g., `kbuf = *user_ptr`) instead of using `copy_from_user`. Sparse catches this; runtime may not until an attacker exploits it.
- ⚠Not checking the return value of `copy_from_user`/`copy_to_user`. A non-zero return means partial copy — you must return `-EFAULT`, not proceed with stale data.
- ⚠Allocating large buffers on the kernel stack (e.g., `char buf[65536]`). The kernel stack is 8–16 KB total; stack overflows produce double faults that are hard to debug.
- ⚠Using `kmalloc` for a user-provided size without capping it — a user passing `SIZE_MAX` triggers integer overflow in the allocator. Always bound-check sizes before allocation.
- ⚠Forgetting to release resources on every error path — file descriptor references (`fdput`), locks, and allocated memory must be released even when validation fails midway through the function.
- ⚠Assuming a user pointer remains valid between two kernel operations (TOCTOU). Copy the data into kernel memory once and work with the kernel copy exclusively.
- ⚠Adding a syscall without a compat handler when any argument could be 32/64 bit sensitive — running a 32-bit binary on a 64-bit kernel will pass arguments in 32-bit registers and the handler will read garbage from the upper bits.
- ⚠Picking a syscall number that conflicts with another architecture's assignment — always check `include/uapi/asm-generic/unistd.h` AND all per-arch tbl files before assigning a number.
Practice
- 01easy
Build a minimal kernel module (not a full syscall — a character device is fine for this exercise) that exposes a `write()` handler. When user space writes a number N to `/dev/mydev`, the module writes `N * N` back and logs it with `pr_info`. Focus on using `copy_from_user` correctly and validating that N fits in a `long`. Read the returned value using `read()` on the device.
- 02medium
Add a real syscall `sys_my_getprocname` to a Linux kernel checkout (use a VM or QEMU). It should take no arguments and copy the current process's `comm` field (the 16-byte task name string) into a user-supplied buffer. Boot the patched kernel in QEMU and invoke your syscall with a small C test program that prints the result.
- 03easy
Run `make C=1` on a kernel file that you intentionally break by adding a direct `*user_ptr` dereference (annotated `__user`). Capture the Sparse warning. Then write a correct version using `get_user`. Document the exact warning message and explain in a comment why it matters.
- 04medium
Use `ftrace` to trace every `sys_enter_openat` event while running `ls /usr/bin`. Capture the trace output. Then write a 1-page explanation of what each column in the trace output means (timestamp, CPU, PID, flags, argument values). Finally, explain how the `SYSCALL_DEFINE` macro caused that tracepoint to exist.
- 05hard
Write a user-space program that benchmarks raw syscall overhead. Make a loop of 10 million `SYS_getpid` calls (which does almost no kernel work) and measure nanoseconds per call using `clock_gettime(CLOCK_MONOTONIC)`. Then enable `CONFIG_RETPOLINE` in a second kernel build and compare. Research why there is a difference.
Resources
Bootlin's Elixir cross-referencer lets you read and cross-reference entry_64.S, common.c, and syscall_64.tbl with hyperlinked identifiers — essential for tracing the full syscall path.
The official kernel documentation covering every step to add a syscall, including compat handlers, man pages, and why the bar is so high.
Deep-dive articles by Jonathan Corbet tracing the exact execution path of a syscall on x86-64, written when the pt_regs calling convention was introduced.
Documents syscall calling conventions for x86-64, ARM, ARM64, RISC-V, and others in one place — the definitive reference for which registers carry which arguments on each architecture.
Free online. Chapter 1 and the driver model chapters provide the broader kernel context in which syscalls operate. Dated but foundational.
The definitive user-space syscall reference by the man-pages maintainer. Read alongside kernel source to understand both sides of the interface.
Free slide decks covering the full kernel development workflow including syscall internals, used in professional embedded Linux training.
The formal spec defining which registers carry syscall numbers and arguments on x86-64, the calling convention that entry_64.S implements.
A concise community-maintained overview with links to recent kernel changes affecting syscall infrastructure — good for staying current.
Checkpoints — you can now…
- ✓You can read `arch/x86/entry/entry_64.S` and explain what every phase of `entry_SYSCALL_64` does before `do_syscall_64` is called.
- ✓You can expand a `SYSCALL_DEFINE3` macro by hand and name the three generated functions and what each one does.
- ✓You can explain why `copy_from_user` is mandatory rather than a direct pointer dereference, and name two hardware mechanisms (SMAP/PAN) that enforce this.
- ✓You can add a new syscall to a Linux kernel tree, boot it in QEMU, and invoke it successfully from a C test program using `syscall(__NR_your_name, ...)`.
- ✓You can run `make C=1` and interpret a Sparse `noderef` warning, identifying the exact line where a `__user` pointer was used unsafely.
- ✓You can use ftrace to observe `sys_enter_*` tracepoints for a running process and explain why those tracepoints exist automatically for any `SYSCALL_DEFINE` implementation.
- ✓You can explain the ABI stability promise, give two concrete examples of how it constrains syscall design (frozen struct layouts, no number reuse), and explain why `statx` exists instead of a new `stat` field.