Home › Hardware & OS › Kernel mechanisms
algorithm low

System call interface

Problem A program must ask the operating system for things only it may do, such as touching files, the network or another process, without being able to read or corrupt the system's own memory. Each such request has a fixed price.

The controlled gate between user code and the kernel: number and arguments in registers, a trap instruction, a validated handler, a result or error code.

O(1) dispatch per call; the cost is the privilege switch plus per-call argument checks and copies, paid once per call, not per byte
syscallkernelvdsoerrnoprivilegeabibatchingseccomp
Overview

Programs run unprivileged and cannot touch devices or other processes. They load a call number and arguments into registers and execute a trap instruction. The CPU raises its privilege and jumps to one fixed kernel entry, which looks the number up in a table, checks every argument and returns a result or a negative error code. The narrow gate lets every request be checked, filtered and traced.

Key facts

Use when

  • Counting crossings per request in servers, databases, runtimes and sandboxes.
  • Filtering, tracing or emulating what a program asks of the OS (containers, debuggers, WebAssembly hosts).

Avoid when

  • Optimising before counting syscalls per second.

Deep dive

How it works

In: a call number and up to six word-sized arguments (integers or user-space pointers; six on x86-64 and arm64) · Out: one result word, or an error code · State: per-thread saved user registers and a kernel stack; the dispatch table is fixed · Cost: O(1) dispatch; measured on this Mac (Apple M4 Pro) about 0.6-0.7 µs for the cheapest std calls (see Code).

  1. Set up registers. User code (usually libc) puts the number and arguments where the ABI says. The per-architecture tables are in syscall(2). x86-64 uses r10 for argument 4 where the C ABI uses rcx; the reason (the syscall instruction overwrites rcx with the return address) is from memory, not verified here.
  2. Trap. syscall / svc #0 raises privilege and jumps to the entry address the kernel configured.
  3. Save and switch. The kernel saves the user registers, moves to a kernel stack, and with page-table isolation switches CR3: "on the order of a hundred cycles" per move, at every entry and exit (PTI).
  4. Dispatch. Index the table by number; out of range or empty gives ENOSYS.
  5. Validate and copy. User pointers are never dereferenced directly: the kernel checks the range and copies with copy_from_user / copy_to_user (names from memory; the model below shows the check). Reasons, from memory: a pointer may name kernel memory or be unmapped, and user memory can change between check and use, so the kernel copies once and validates the copy.
  6. Return. The handler's result goes to the return register. On Linux, failure is a negative errno in that register; libc negates it, stores it in errno and returns -1 (syscalls(2)). Go's amd64 assembly treats values from 0xfffffffffffff001 up (that is, -4095 to -1) as errors (asm_linux_amd64.s).
  7. Signals. A call blocked when a handler runs is either restarted (with SA_RESTART) or fails with EINTR; epoll/poll/select and sleeps are never restarted (signal(7)).
  8. Skip the gate when you can. The vDSO is a kernel-mapped shared library; the time calls read kernel-maintained memory and run as ordinary function calls (vdso(7)).

Code

Part 1 is a toy kernel: dispatch table, copy_from_user-style bounds check with overflow guard, -errno to Result, with asserts. Part 2 measures std: a clock read, an fstat, a 1-byte write and one 4096-byte write. macOS is not Linux: the register ABI above does not apply here.

use std::fs::OpenOptions;
use std::hint::black_box;
use std::io::Write;
use std::time::Instant;

// ---- Part 1: toy dispatch model (not a real kernel) ----
const EBADF: i64 = 9;
const EFAULT: i64 = 14;
const EINVAL: i64 = 22;
const ENOSYS: i64 = 38;
const USER_END: u64 = 4096; // toy user address space: [0, 4096)

struct Kernel { user: Vec<u8>, out: Vec<u8> }
type Handler = fn(&mut Kernel, [u64; 6]) -> Result<u64, i64>;

// copy_from_user: never trust the pointer; check range, guard overflow
fn copy_from_user(k: &Kernel, ptr: u64, len: u64) -> Result<Vec<u8>, i64> {
    let end = ptr.checked_add(len).ok_or(EFAULT)?;
    if end > USER_END { return Err(EFAULT); }
    Ok(k.user[ptr as usize..end as usize].to_vec())
}

fn sys_write(k: &mut Kernel, a: [u64; 6]) -> Result<u64, i64> {
    if a[0] != 1 { return Err(EBADF); }
    if a[2] > 1 << 20 { return Err(EINVAL); }
    let buf = copy_from_user(k, a[1], a[2])?;
    k.out.extend_from_slice(&buf);
    Ok(buf.len() as u64)
}

const TABLE: [Option<Handler>; 2] = [Some(sys_write), None]; // 0 = write, 1 = reserved

// kernel side: entry returns result or -errno in one register
fn syscall_entry(k: &mut Kernel, nr: u64, args: [u64; 6]) -> i64 {
    match TABLE.get(nr as usize).copied().flatten() {
        None => -ENOSYS,
        Some(h) => match h(k, args) { Ok(v) => v as i64, Err(e) => -e },
    }
}

// libc side: values in -4095..=-1 are errors -> Err(errno), like the -1 + errno rule
fn libc_call(k: &mut Kernel, nr: u64, args: [u64; 6]) -> Result<u64, i64> {
    let r = syscall_entry(k, nr, args);
    if (-4095..0).contains(&r) { Err(-r) } else { Ok(r as u64) }
}

fn model_test() {
    let mut k = Kernel { user: vec![7; USER_END as usize], out: vec![] };
    assert_eq!(libc_call(&mut k, 0, [1, 100, 5, 0, 0, 0]), Ok(5));
    assert_eq!(k.out, vec![7; 5]);
    assert_eq!(libc_call(&mut k, 0, [1, 4094, 5, 0, 0, 0]), Err(EFAULT)); // runs past the end
    assert_eq!(libc_call(&mut k, 0, [1, u64::MAX, 2, 0, 0, 0]), Err(EFAULT)); // ptr+len overflows
    assert_eq!(libc_call(&mut k, 0, [3, 0, 1, 0, 0, 0]), Err(EBADF));
    assert_eq!(libc_call(&mut k, 99, [0; 6]), Err(ENOSYS));
    assert_eq!(k.out.len(), 5); // failed calls changed nothing
}

// ---- Part 2: real measurement through std (macOS, not Linux) ----
fn per_call_ns(mut f: impl FnMut(), n: u32) -> f64 {
    let t = Instant::now();
    for _ in 0..n { f(); }
    t.elapsed().as_secs_f64() * 1e9 / n as f64
}

fn stats(mut xs: Vec<f64>) -> (f64, f64) {
    xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
    (xs[0], xs[xs.len() / 2])
}

fn main() {
    model_test();
    let mut null = OpenOptions::new().write(true).open("/dev/null").unwrap(); // unbuffered File
    let n = 1_000_000;
    let runs = 9;
    let report = |name: &str, mut f: Box<dyn FnMut()>, n: u32, per: u32| {
        let (min, med) = stats((0..runs).map(|_| per_call_ns(&mut *f, n) / per as f64).collect());
        println!("{name:<34} min {min:8.2} ns  median {med:8.2} ns");
    };
    let one = [1u8];
    let mut f2 = null.try_clone().unwrap();
    report("Instant::now()", Box::new(|| { black_box(Instant::now()); }), n, 1);
    report("File::metadata (fstat)", Box::new(move || { black_box(null.metadata().unwrap()); }), n, 1);
    report("write 1 byte to /dev/null", Box::new(move || { f2.write_all(black_box(&one)).unwrap(); }), n, 1);
    let mut f3 = OpenOptions::new().write(true).open("/dev/null").unwrap();
    let big = vec![0u8; 4096];
    report("write 4096 B (one call)", Box::new(move || { f3.write_all(black_box(&big)).unwrap(); }), n / 4, 1);
}

Complexity and constants

Dispatch is one array index. Fixed costs per crossing: privilege switch, register save, page-table switch where PTI is on, argument checks, return path. Copied bytes add to this.

Measured on this Mac (Apple M4 Pro), macOS, release build, 3 runs of 9 x 1,000,000 calls, min/median: Instant::now() min 14.5-14.7 ns, median 14.6-14.8 ns; File::metadata (fstat) min 593-598 ns, median 597-613 ns; 1-byte write to /dev/null min 705-714 ns, median 710-721 ns; one 4096-byte write min 703-710 ns, median 707-726 ns. Rust's std reads the macOS clock with CLOCK_UPTIME_RAW, which its source comment says equals mach_absolute_time() (source); that it avoids a kernel entry is not verified here, but it costs about 1/50 of a write (derived).

Efficiency

  • Cost: one cheap call is 0.6-0.7 µs on this Mac, measured; a clock read about 15 ns. Per-call latency, not bandwidth, is what the numbers show; /dev/null copies nothing, so real writes add copy time. Not profiled further here.
  • Levers:
    • Batch bytes — one 4096 B write took the same time as one 1 B write here; 4096 one-byte writes would cost about 2.9 ms (derived: 4096 x 0.71 µs) — any chatty I/O loop.
    • Vectored and multi-message calls — readv/writev move many buffers per call; sendmmsg/recvmmsg many datagrams (sendmmsg(2)) — no figure here — scatter/gather, UDP servers.
    • Shared rings — io_uring queues requests in shared memory; with SQ polling no io_uring_enter call is needed (io_uring(7)) — no figure here — many concurrent I/Os, see io_uring.
    • vDSO — time calls without a trap (vdso(7)) — timestamps in hot loops.
    • Readiness batching — event loops learn of many ready descriptors per call — many connections.
  • Naive → standard → state of the art: one syscall per small item · about 0.7 µs each here; buffered or vectored I/O, one call per batch · crossing cost amortised over the batch; shared rings with kernel-side polling, or bypassing the kernel · no crossing per operation on the fast path.

Implementation notes

  • Count before tuning with a ptrace-based tracer such as strace: PTRACE_SYSCALL stops the tracee at every entry and exit (ptrace(2)), so tracing slows the program (ptrace).
  • Filter at the gate: a seccomp filter can fail a call with a chosen errno (SECCOMP_RET_ERRNO) or forward it to a supervisor (SECCOMP_RET_USER_NOTIF) (seccomp(2); seccomp-bpf). eBPF tracepoints observe without stopping the program.
  • Handle EINTR or set SA_RESTART; short reads and writes are normal.
  • Raw syscall() skips wrapper logic such as choosing between truncate and truncate64; prefer the wrapper (syscalls(2)).

Pitfalls

  • Treating the number as portable. Linux docs call the syscall interface "very stable over time" (kernel docs). Elsewhere the library is the boundary: Go moved Darwin calls to libSystem for "forward-compatibility" in Go 1.12 (notes); OpenBSD aborts a process whose system call instruction sits outside the registered libc range (pinsyscalls(2)). Windows not verified here.
  • Reusing numbers across systems: Linux with PTI, other CPUs and macOS differ; use ratios.
  • Ignoring -1 returns, short counts and EINTR.
  • Assuming a sandbox keeps native speed: gVisor intercepts calls in its own kernel and documents higher per-call overhead (gVisor).

Variants and alternatives

vDSO / vsyscallread-only data calls without a trap.
io_uringsubmit by writing to shared rings (io_uring).
Exception-less callsFlexSC, named in the title; paper body not read here (Soares and Stumm).
Library OSanswer calls in user space (library OS); kernel bypass — map the device into the process (bypass).

In production

  • High-throughput servers: io_uring, sendmmsg, epoll loops.
  • Sandboxes: seccomp filters in container runtimes; gVisor's Sentry intercepts application calls.
  • Runtimes: Go's syscall package issues raw system calls on Linux (source); Rust's std calls libc, e.g. libc::write (source).
  • Tracing and debugging: strace, debuggers (ptrace), dtrace-style tools.
  • Portable guests: WASI is a set of standards-track API specifications for Wasm programs, a syscall-like boundary for many hosts (wasi.dev).

Implementations

namelangrepoinstallverdictlicencehealthas_ofnotes
LinuxCtorvalds/linux—————reference entry code and syscall tables
rustixRustbytecodealliance/rustixcargo add rustix————safe wrappers; default linux_raw backend issues raw Linux syscalls and vDSO calls
liburingCaxboe/liburingapt install liburing-dev————io_uring helpers
straceCstrace/straceapt install strace————syscall tracer on ptrace
libseccompCseccomp/libseccompapt install libseccomp-dev————build seccomp filters
gVisorGogoogle/gvisor—————application kernel intercepting syscalls

Sources

  • Soares, Stumm, FlexSC: Flexible System Call Scheduling with Exception-Less System Calls, OSDI 2010. paper
  • Man pages (man7.org), linked inline: syscall(2), syscalls(2), vdso(7), signal(7), io_uring(7), ptrace(2), seccomp(2).
  • Linux docs: PTI, stable API; others inline.

Used by

library os · plannedOS services linked into the application as a library in one address space

Systems (6):

Alternatives and related

Context switchingA machine has more runnable tasks than cores, yet each task expects to own the processor. Something must pause one task and resume another exactly where it stopped, without either noticing, and do it often.Interrupt handlingA CPU busy with its own work must notice events from devices and timers promptly. Notifying it per event costs more than the work when events arrive in thousands per second, but waiting too long adds delay.Virtual memory and the TLBPrograms that jump around large in-memory data (hash tables, graphs, model weights) can run far slower than expected because every address must be translated. This guide shows when translation becomes the bottleneck and when huge pages fix it.io_uringA Linux program doing many file and network operations pays one system call each, which dominates CPU at high rates. It needs to start and collect many operations with few kernel crossings.epoll event loopA server must keep tens of thousands of network connections open and react to whichever have data, without a thread per connection and without checking every connection on each wakeup.
async io runtime · plannedFutures/tasks executor over a reactor (epoll/io_uring) with wakers and timers (listed in Moved to the wiki)
seccomp bpf · plannedSyscall filtering with seccomp BPF programs
Process sandboxingSoftware that must open files from strangers, run submitted code or call a risky library worries that one bug hands the attacker everything the process can touch. The step is the moment untrusted input first reaches code.
ptrace process control · plannedPtrace: attach, read and write registers and memory, single-step, intercept signals
ebpf · plannedVerified in-kernel programs on hooks (kprobes, XDP, tc) with maps
Kernel-bypass networkingA router, load balancer or firewall must handle tens of millions of small packets per second per core. The operating system's per-packet overhead is too high, so packets must reach the program more directly.