Ch. 21 · Operating Systems

User Mode, Kernel Mode and Privileged Operations

How the CPU stops your code doing privileged work, what a system call costs, and why clock_gettime skips the kernel, with real M1 measurements.

~6 min readbeginnerupdated Oct 12, 2026

Every program you write runs with one hand tied behind its back. It can do arithmetic, read and write its own memory and call its own functions, but it can’t touch a disk controller, change a page table, mask interrupts or read another process’s memory. Those operations need the CPU to be in a more privileged mode, and the only way to get there is to ask the kernel. Interviewers ask about this boundary because it explains a lot of later topics: why system calls show up in profiles, why a page fault costs what it does, and why “just run it as root” doesn’t give a program direct hardware access.

The CPU enforces the boundary, not the kernel’s good manners

Privilege is a hardware feature. On x86-64 the levels are called rings, and normal code runs in ring 3 while the kernel runs in ring 0. On ARM64, including the M1 these examples ran on, they’re exception levels: applications run at EL0 and the kernel at EL1. The current level is part of the processor’s state, and certain instructions and registers are only usable at the higher level.

You can watch the hardware refuse. This C program tries to read TTBR0_EL1, the ARM register holding the base address of the current page tables. The kernel needs it constantly; a user program has no business seeing it:

#include <stdio.h>
#include <stdint.h>
int main(void) {
    uint64_t v;
    printf("about to read TTBR0_EL1 from user mode\n"); fflush(stdout);
    __asm__ volatile("mrs %0, ttbr0_el1" : "=r"(v));
    printf("got %llx\n", (unsigned long long)v);
    return 0;
}
c

Running it from Python so the exit status is easy to decode:

about to read TTBR0_EL1 from user mode
returncode -4 SIGILL
Text

The instruction never completed. The CPU raised an exception, switched itself to EL1, and jumped to the kernel’s exception handler. The kernel saw an undefined instruction coming from user mode and turned it into the signal SIGILL, whose default action killed the process. The same thing protects page tables, interrupt masking and device registers. Memory works the same way: kernel pages are mapped without user-access permission, so a user-mode load from a kernel address faults instead of returning data.

A misconception worth correcting if it comes up: root is not kernel mode. A process running as UID 0 still executes at EL0 or ring 3. Root means the kernel’s permission checks say yes more often, for example to open /dev/disk0 or bind port 80. The actual device access still happens inside the kernel.

Three ways into the kernel

Code enters kernel mode in exactly three ways, and it helps to name them in an interview:

  1. System calls. The program asks deliberately. It places a call number and arguments in registers and executes a trap instruction: syscall on x86-64, svc on ARM64. The CPU switches mode and jumps to an entry point the kernel registered at boot. User code can’t choose where in the kernel it lands, which is the whole point.
  2. Exceptions. The running instruction caused a problem: a page fault, a divide by zero, the illegal instruction above. The CPU switches mode and runs the kernel’s handler for that exception type. Many are routine; a page fault on a lazily allocated page is normal and invisible to the program.
  3. Interrupts. A device or the timer needs attention, unrelated to whatever instruction was running. The timer interrupt is what lets the kernel take the CPU back from a program stuck in an infinite loop.

During a system call the kernel runs on behalf of the calling thread, on that thread’s kernel stack. No separate kernel process wakes up. That’s why a system call is called a mode switch, not a context switch. It only becomes a context switch if the call has to wait, say for data from the network, and the scheduler picks another thread to run.

On the way in, the kernel saves the user registers, then validates everything it was handed. A pointer argument must point into the caller’s own memory; Linux copies data across with helpers like copy_from_user that handle a bad pointer by returning EFAULT instead of crashing the kernel. On the way out it restores the registers and executes a return-from-exception instruction (eret on ARM64, sysret on x86-64), dropping back to user mode.

Measuring what a crossing costs

This loop calls an empty function, then three things that look similar from C, five million times each:

for (int i = 0; i < N; i++) sink += plain(i);                    // ordinary function
for (int i = 0; i < N; i++) sink += getppid();                   // trivial system call
for (int i = 0; i < N; i++) sink += read(fd, &c, 1);             // fd is /dev/zero
for (int i = 0; i < N; i++) { clock_gettime(CLOCK_REALTIME, &ts); sink += ts.tv_nsec; }
c

Two runs on the M1, with user and system CPU time taken from getrusage:

function call               3.2 ns/call   user 0.02s  sys 0.00s
getppid() syscall         167.4 ns/call   user 0.50s  sys 0.27s
read(/dev/zero, 1 byte)   546.4 ns/call   user 0.53s  sys 1.94s
clock_gettime()            16.2 ns/call   user 0.08s  sys 0.00s

function call               2.6 ns/call   user 0.01s  sys 0.00s
getppid() syscall         116.0 ns/call   user 0.39s  sys 0.19s
read(/dev/zero, 1 byte)   380.1 ns/call   user 0.38s  sys 1.49s
clock_gettime()            15.1 ns/call   user 0.08s  sys 0.00s
Text

getppid() does almost no work, so its 116 to 167 ns is mostly the crossing itself: about 40 to 60 times an ordinary call. read() does real work inside the kernel (file descriptor lookup, a trip through the device layer, copying a byte to user memory) and most of its time shows up as system time. Notice that the split between user and sys isn’t perfectly clean at this granularity; time spent right at the boundary gets charged to one side or the other.

clock_gettime() is the interesting one. It looks like a system call and costs 15 ns with no system time at all, because it never enters the kernel. macOS maps a read-only “commpage” into every process with the data needed to compute the time, and Linux does the same thing with the vDSO. Frequently called, read-only queries are worth that trick. A candidate who knows why gettimeofday is cheap on Linux usually knows the rest of this material too.

Why batching matters

The practical consequence is that per-call overhead punishes small operations. dd makes this easy to see, because block size sets how many bytes each read and write moves:

$ /usr/bin/time -p dd if=/dev/zero of=/dev/null bs=1 count=2000000
2000000 bytes transferred in 1.957896 secs (1021505 bytes/sec)
real 1.96
user 0.35
sys 1.54

$ /usr/bin/time -p dd if=/dev/zero of=/dev/null bs=1m count=2
2097152 bytes transferred in 0.000120 secs (17476266667 bytes/sec)
Text

Same data, four million system calls versus four. The first version spends most of its time in sys. This is why standard libraries buffer I/O, why writev exists, and why high-throughput servers move many messages per call. On Linux you’d confirm a syscall-heavy process with strace -c or perf trace. On macOS, dtruss does the same job but needs System Integrity Protection relaxed, so I measured with time and getrusage instead.

Interview practice: a logger stuck in system time

A service writes each log line with its own unbuffered write() call. Under load, top shows it spending about 40% of its CPU time in system time, and throughput is lower than expected. Explain what’s happening and how you’d fix it.

A strong answer

Each write() is a trip into the kernel: trap, save registers, validate the descriptor and buffer, copy the bytes, return. For short log lines that fixed cost dominates the useful work, the same effect as dd with 1-byte blocks. High system time with small writes is the signature. I’d confirm it by counting calls (strace -c -p <pid> on Linux) and checking the average bytes per call.

The fix is to batch. Accumulate lines in a user-space buffer and flush when it reaches a size such as 64 KB or after a short interval like 100 ms, whichever comes first. Alternatively, hand lines to a background thread that writes them in bulk. Both turn thousands of system calls into a few.

The trade-off is what happens on a crash: lines still in the user-space buffer are lost when the process dies. Many loggers flush immediately for errors and buffer everything else. And none of this makes logs durable against power loss; write() only hands data to the kernel’s page cache, and surviving a power cut needs fsync, which is a separate decision with a much larger cost.

More on the kernel boundary

More in Operating Systems

read ✓Operating Systems · hard

Operating Systems: CPU Cache Locality

Measured on an M1: column-order matrix walks up to 30x slower, shuffled lists at 97 ns a node, and false sharing slowing two counters 4x.

~6 min readread →
read ✓Operating Systems · easy

Context Switching and Scheduling Overhead

What a context switch saves and loses, voluntary vs involuntary switches, a 3.5 µs pipe ping-pong on an M1, and the real cost of 512 threads.

~6 min readread →
esc