7.1 Exceptions, Interrupts, and System Calls
User-mode machine instructions may fault on integer division or invalid address accesses; devices and timers can request interrupts. The OS configures controlled entry points and preserves context. Events can also occur in kernel mode. A C division-by-zero expression has undefined behavior and guarantees no particular signal; ordinary nontrapping floating-point division may instead produce infinity or NaN.
A page fault demonstrates how the processor pauses the current instruction and enters kernel mode. This chapter establishes a more complete exception control flow model, tracing a Linux system call; POSIX signal handling is addressed separately in the next lesson.
read Crosses a Privilege Boundary
User programs can perform arithmetic and access their own mappings, but cannot directly configure page tables, schedule other processes, or arbitrarily control devices. When reading files, programs request services through the kernel’s system-call interface. Save this as read_once.c and compile with cc -std=c17 -Wall -Wextra -Werror read_once.c -o read_once. It reads once, up to 64 bytes, and writes that result; it does not copy an entire file.
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
static ssize_t read_retry(int file_descriptor, void *buffer, size_t capacity) {
for (;;) {
ssize_t result = read(file_descriptor, buffer, capacity);
if (result < 0 && errno == EINTR) {
continue;
}
return result;
}
}
int main(void) {
char buffer[64];
ssize_t count = read_retry(STDIN_FILENO, buffer, sizeof buffer);
if (count < 0) {
perror("read");
return 1;
}
size_t written = 0;
while (written < (size_t)count) {
ssize_t result = write(STDOUT_FILENO,
buffer + written,
(size_t)count - written);
if (result > 0) {
written += (size_t)result;
} else if (result < 0 && errno == EINTR) {
continue;
} else {
if (result < 0) {
perror("write");
} else {
fputs("write made no progress\n", stderr);
}
return 1;
}
}
return 0;
}This little snippet illustrates that system calls aren't guaranteed to complete with exactly the requested amount: read can short-read, write can partially complete, a signal might generate EINTR, and the file descriptor might be nonblocking. This example expects blocking descriptors; EAGAIN is an error, while event-driven retries need a separate readiness policy. EINTR retries must also respect cancellation. A closed output pipe may terminate the process via default SIGPIPE before it can report EPIPE; arrange signal disposition according to the application protocol.
Exceptional Control Flow Transfers to a Handler
Normal control flow is altered by instructions such as branch, call, and return. Exceptional control flow is handled through cooperation between the processor and the OS, which redirects control to a preconfigured handler.
Across architectures, you can see several types of sources:
- Synchronous exceptions triggered by the current instruction, such as page fault, invalid instruction, or breakpoint;
- Interrupts from external devices or timers asynchronously;
- Software explicitly executed system-call instructions;
- Machine check and other severe hardware events.
"interrupt, trap, fault, abort" is a common four-way classification in x86 textbooks: trap typically returns to the next instruction, fault can be retried after correction, and abort generally cannot be reliably recovered. However, the terminology and return PC rules are specific to the ISA; RISC-V treats synchronous events uniformly as exceptions and groups them with asynchronous interrupts under the term trap. When reading the kernel, always verify the terminology used by the target architecture.
If a machine store actually executes and address zero is unmapped, a typical path is as follows; this is not a promise about undefined C behavior:
User instruction tries to store
-> MMU cannot obtain a writable translation
-> Synchronous page-fault exception
-> Kernel checks the VMA and finds no valid repair
-> Arrange a SIGSEGV for the faulting thread
-> The default disposition terminates the process and may generate a core dumpThe CPU detects the translation/permission failure; the OS decides whether to fix it and sends what signal; only then can the shell print "Segmentation fault." Calling all three "CPU kills the process" erases the critical boundaries.
User and Kernel Modes Describe Privilege
The general-purpose OS platforms discussed here provide unprivileged and privileged execution levels. Microcontrollers need not expose the same hierarchy. x86 typically runs user processes in ring 3 and the OS kernel in ring 0; AArch64 uses exception levels, while RISC-V uses U/S/M modes.
“A CPU only has user mode and kernel mode” is incorrect: hypervisor, firmware, secure monitor, and machine mode can all exist. High privilege does not mean bypassing all system constraints; IOMMU, secure world, hardware errors, and platform policies still impose limitations.
The user mode typically cannot:
- Modify the page-table root or interrupt control configuration;
- Execute privileged instruction;
- Access a virtual mapping marked supervisor-only;
- Directly DMA-assign any device to arbitrary physical memory.
This boundary enables the kernel to centrally verify resource ownership, addresses, and permissions. Drivers, file systems, and the network stack may still have vulnerabilities, so privilege boundaries require minimal permissions, memory safety, and attack surface control to be effective.
Trace an x86-64 Linux System Call
Take glibc read(fd, buffer, count) as an example:
- User code calls the libc wrapper using the C ABI;
- The wrapper places the syscall number and arguments according to the Linux x86-64 syscall ABI;
syscallinstruction enters the kernel configuration entry, altering privilege levels and certain processor states;- Entry assembly establishes the Linux kernel execution context, saves user state, and switches to the appropriate kernel stack;
- The dispatcher checks the syscall number and invokes the read path;
- The VFS and file implementation check file descriptor, permissions, user buffer, and current file status;
- Data might already be in the page cache, might come from a pipe or socket, or might block a thread while waiting for the device to complete;
- The kernel returns a result or negative error code, and the entry path restores the required user state;
- The libc wrapper converts errors into
-1and setserrno; otherwise, it returns the byte count.
The first six parameters on x86-64 Linux typically use rdi, rsi, rdx, r10, r8, and r9; the system call number is in rax, and the return value is in rax; syscall also modifies rcx and r11. This is the Linux ABI, not ISO C, and does not apply to AArch64/RISC-V.
In 64-bit mode, syscall does not automatically switch RSP to a kernel stack. It saves architectural return-address/flags state, while Linux entry assembly performs the required stack switch and register saves. An interrupt gate also saves only its specified hardware frame, not every register.
Call a Syscall Number through libc
Linux/glibc provides the syscall(2) function, which bypasses specific wrappers but still has correct register and error conversion arranged by libc:
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/syscall.h>
#include <sys/types.h>
#include <unistd.h>
int main(void) {
long raw_path = syscall(SYS_getpid);
pid_t wrapper_path = getpid();
printf("syscall(SYS_getpid)=%ld getpid()=%ld\n",
raw_path, (long)wrapper_path);
return raw_path == (long)wrapper_path ? 0 : 1;
}It's suitable for experiments or invoking Linux syscalls without existing wrappers, but not a replacement for clear, portable standard APIs. Writing inline assembly easily misses clobbers, constraints, stack alignment, errno, and architecture differences; only do it when explicitly necessary and after ABI review.
Not all "querying the OS for information" libc functions enter the kernel every time. Linux vDSO allows clock_gettime, gettimeofday, and similar functions to read kernel-maintained data from user space when conditions are met. Before benchmarking, use strace, perf trace, or disassembly to confirm the actual path.
What Contributes to Syscall Overhead?
The cost of a privilege transition is not synonymous with "context switching." The current thread can enter the kernel and return without switching to another thread. The overhead might come from:
- Entry/exit instructions and register state management;
- Security mitigation measures and architecture serialization;
- dispatcher, permissions, file descriptor, and parameter validation;
copy_from_user/copy_to_userand potential fault;- Impact of kernel code/data on cache, TLB, and branch predictor;
- Actual service work, lock contention, scheduling, I/O wait.
SMEP/SMAP are hardware mechanisms that restrict the kernel from executing or accessing user mappings, not a complete "user pointer validity checker." The kernel still needs to use dedicated user-access primitives for secure copying and fault handling.
KPTI might mitigate side-channel attacks by switching page tables on certain platforms; mechanisms like PCID can reduce TLB flushes. It's not true that every syscall necessarily flushes the TLB or branch predictor. The cost of such operations depends on CPU architecture, kernel configuration, microcode, and security policies.
Measure a Small Syscall on the Target System
The actual path and duration of getpid vary depending on system implementation. Save this Linux program as time_getpid.c and compile with cc -std=c17 -O2 -Wall -Wextra -Werror time_getpid.c -o time_getpid. It calls syscall(SYS_getpid) and subtracts an approximate loop baseline. Timer or syscall failures stop the experiment rather than being reported as successful measurements:
#define _GNU_SOURCE
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <time.h>
#include <unistd.h>
enum { ITERATIONS = 1000000 };
static uint64_t now_nanoseconds(void) {
struct timespec timestamp;
if (clock_gettime(CLOCK_MONOTONIC, ×tamp) != 0) {
perror("clock_gettime");
exit(EXIT_FAILURE);
}
return (uint64_t)timestamp.tv_sec * UINT64_C(1000000000)
+ (uint64_t)timestamp.tv_nsec;
}
int main(void) {
volatile long sink = 0;
uint64_t baseline_start = now_nanoseconds();
for (int index = 0; index < ITERATIONS; index++) {
sink ^= index & 1;
}
uint64_t baseline = now_nanoseconds() - baseline_start;
uint64_t syscall_start = now_nanoseconds();
for (int index = 0; index < ITERATIONS; index++) {
long pid = syscall(SYS_getpid);
if (pid < 0) {
perror("syscall(SYS_getpid)");
return 1;
}
sink ^= pid;
}
uint64_t syscall_total = now_nanoseconds() - syscall_start;
if (syscall_total <= baseline) {
puts("measurement noise is larger than the observed difference");
return sink == -1;
}
printf("estimated syscall path: %.1f ns/call\n",
(double)(syscall_total - baseline) / ITERATIONS);
return sink == -1;
}The estimate includes libc’s generic syscall wrapper, return checking, and differences between the loops; it is not pure hardware privilege-transition time. Baseline subtraction cannot remove every error. A reliable experiment should also:
- Fix CPU affinity, do warm-up and multi-round distribution;
- Record CPU, kernel, mitigation, governor, and virtualization environment;
- Check if
clock_gettimeitself uses vDSO; - Use a profiler/trace to confirm the loop actually executes the target syscall;
- Avoid using the inline result of a regular function for asymmetric comparisons.
A system call itself might take only a short time, but the overall latency of read can be dominated by pipe waiting, network latency, page faults, or storage I/O. Don't refer to the result of /dev/zero as "reading from disk."
Batching Reduces Repeated Boundary Work
Calling read once per byte incurs substantial fixed overhead; using a moderate buffer reduces entry count. writev/readv support scatter/gather operations, batching syscalls allows multiple requests to be submitted at once, and io_uring can reduce round trips between submission and completion in supported scenarios.
But a larger buffer isn't always better. Interaction latency, memory usage, backpressure, partial I/O, and fairness all play a role in the decision. Network protocols may also require timely flushing. The right principle is to spread out the cost of boundary decisions based on workload, not to treat "4096" as a universal optimum.
Async I/O does not bypass all syscalls; initialization, registration, submission policies, completion, and fallback still involve kernel interfaces; it optimizes the interaction model and amortization paths.
Trace Calls and Account for the Probe
strace observes Linux system calls and their results:
strace -c ./program
strace -f -e trace=openat,read,write,close ./program-c summarizes call counts, errors, and trace observations over time; -f follows threads/processes as they are created. Results are influenced by tracing itself, scheduling, and I/O state and do not equal production latency without probes.
strace primarily uses Linux mechanisms like ptrace to intercept syscall stops, and modern versions might use other kernel capabilities to optimize specific features. Dynamic linkers and runtimes generate mmap, openat, etc., before main, so syscalls appearing in the trace don't necessarily originate from the line you wrote.
For low-overhead statistics or kernel path analysis, combine perf, eBPF, and ftrace, but each tool has permission, sampling, and observation limitations.
Return to User Mode with Valid State
Before returning to user mode, the kernel must ensure:
- Return PC and stack pointer to user-accessible ranges;
- Privilege and interrupt state are legal;
- Pending work is completed according to OS rules, such as signal delivery and rescheduling;
- The return value follows the kernel ABI; libc then updates user-space
errnoas the API requires; - Don't expose kernel state that shouldn't be exposed.
Some system calls may return EINTR when interrupted by a signal, or may automatically restart under conditions like SA_RESTART. If partial data transfer has already occurred, the API might directly return the number of bytes completed. Callers cannot simply rely on a "failure then retry" template to handle all operations with side effects.
Observe and Test the Boundary
- Use
strace -cto compare byte-by-byte reading with 64 KiB buffer reading, recording call counts and total throughput. - Let input come from a regular file, pipe, and
/dev/zero, explain why the samereadtakes different paths. - Compare libc
getpidwithsyscall(SYS_getpid), and use trace to verify whether each enters the kernel. - List what each of the ISA, Linux syscall ABI, and C ABI is responsible for in a single syscall entry.
- Explain why a page fault is a synchronous exception, while the completion of a disk I/O operation via a device interrupt is an asynchronous event.
- Construct a partial write scenario to verify that the example loop doesn't lose a byte.
The Kernel Can Also Deliver Signals to User Code
A system call occurs when a program deliberately crosses boundaries, while a signal is arranged and executed by the kernel within the user context to provide notification. The next article, POSIX Signals and Asynchronous Safety, discusses pending signals, masks, sigaction, EINTR, and self-pipe.