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).
- 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
r10for argument 4 where the C ABI usesrcx; the reason (thesyscallinstruction overwritesrcxwith the return address) is from memory, not verified here. - Trap.
syscall/svc #0raises privilege and jumps to the entry address the kernel configured. - 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).
- Dispatch. Index the table by number; out of range or empty gives
ENOSYS. - 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. - 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
errnoand returns -1 (syscalls(2)). Go's amd64 assembly treats values from0xfffffffffffff001up (that is, -4095 to -1) as errors (asm_linux_amd64.s). - Signals. A call blocked when a handler runs is either restarted (with
SA_RESTART) or fails withEINTR; epoll/poll/select and sleeps are never restarted (signal(7)). - 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/nullcopies 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/writevmove many buffers per call;sendmmsg/recvmmsgmany 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_entercall 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_SYSCALLstops 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
EINTRor setSA_RESTART; short reads and writes are normal. - Raw
syscall()skips wrapper logic such as choosing betweentruncateandtruncate64; 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
-1returns, short counts andEINTR. - Assuming a sandbox keeps native speed: gVisor intercepts calls in its own kernel and documents higher per-call overhead (gVisor).
Variants and alternatives
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
syscallpackage issues raw system calls on Linux (source); Rust'sstdcalls 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
| name | lang | repo | install | verdict | licence | health | as_of | notes |
|---|---|---|---|---|---|---|---|---|
| Linux | C | torvalds/linux | — | — | — | — | — | reference entry code and syscall tables |
| rustix | Rust | bytecodealliance/rustix | cargo add rustix | — | — | — | — | safe wrappers; default linux_raw backend issues raw Linux syscalls and vDSO calls |
| liburing | C | axboe/liburing | apt install liburing-dev | — | — | — | — | io_uring helpers |
| strace | C | strace/strace | apt install strace | — | — | — | — | syscall tracer on ptrace |
| libseccomp | C | seccomp/libseccomp | apt install libseccomp-dev | — | — | — | — | build seccomp filters |
| gVisor | Go | google/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.