Your program can’t do much on its own
Everything so far (arithmetic, loops, reading and writing memory) your program can do by itself. Now think about what it can’t do alone: print text, read a file, open a network connection, get the time, start another program, even get more memory. All of these involve hardware, or resources shared with other programs. If every program could talk to the disk directly, one buggy program could wreck everyone’s files. So the CPU has two privilege levels:
- User mode, where your program runs. Instructions that touch hardware or change page tables are forbidden; trying one stops your program.
- Kernel mode, where the core of the operating system (the kernel) runs. It can do anything. The kernel acts as a gatekeeper: it owns the hardware and offers a fixed menu of services. To use one, your program makes a system call (syscall), a special, controlled jump into the kernel.
What a syscall looks like
On x86-64 Linux, a syscall is a small ritual:
- Put the syscall’s number in
rax. Every service has a number:writeis 1,exitis 60. - Put the arguments in
rdi,rsi,rdx,r10,r8,r9. - Run the
syscallinstruction. The CPU switches to kernel mode and jumps to the kernel’s entry point. - The kernel checks your arguments (is that buffer really yours? is that file actually open?), does the work, and puts a result in
rax. - The CPU switches back to user mode and your program continues after the
syscallinstruction. A negative result means an error:-2is-ENOENT, “no such file or directory”. Notice that the 4th argument goes inr10, notrcxas in normal function calls. That’s because thesyscallinstruction itself overwritesrcx(it stores your return address there) andr11. | Call | Number | Arguments | read | 0 | fd, buf, count | write | 1 | fd, buf, count | mmap | 9 | addr, len, prot, flags, fd, offset | exit | 60 | status | openat | 257 | dirfd, path, flags, mode
Where’s printf?
Normally you never see syscalls, because libraries wrap them. When you call std.debug.print in Zig or console.log in Node, the library turns your text into bytes and eventually calls write on file descriptor 1 or 2. Underneath every I/O library in every language sits the same small set of syscalls.
To see that, here’s hello world talking to the kernel directly:
const std = @import("std");
const linux = std.os.linux;
pub fn main() void {
const msg = "hello from a raw syscall\n";
_ = linux.syscall3(.write, 1, @intFromPtr(msg), msg.len);
}
syscall3 means “a syscall with three arguments”. We pass the syscall (.write), fd 1 (standard output), the address of our bytes, and how many bytes to write. No formatting, no buffering, nothing in between.
Run it under strace and you’ll see every syscall the program makes: some from Zig’s startup code, then your own write(1, "hello from a raw syscall\n", 25). strace is one of the best tools for understanding what any program on Linux is really doing.
File descriptors: everything is a file
When you open a file, the kernel doesn’t give you the file itself. It keeps track of the open file internally and gives your process a small integer, a file descriptor (fd), to refer to it in later syscalls. read(fd, buf, n) means “read up to n bytes from whatever fd refers to, into buf”.
Every process starts with three already open:
| fd | Name | Usually connected to
| 0 | stdin | the keyboard, or an input pipe
| 1 | stdout | the terminal, or an output pipe
| 2 | stderr | the terminal, for error messages
Here’s the elegant part: files, terminals, pipes between programs and network sockets are all file descriptors, and read and write work on all of them. That’s the Unix idea “everything is a file”. When you type cat log.txt | grep error in a shell, the shell connects cat’s fd 1 to grep’s fd 0 through a pipe, and neither program knows or cares.
Syscalls aren’t free
A syscall costs far more than a normal function call: the CPU switches privilege level, the kernel validates everything, and caches get disturbed. One syscall typically costs hundreds of nanoseconds or more. That’s why I/O libraries buffer: instead of one write per character, they collect output in memory and write it out in big chunks. Reading a file one byte at a time with raw syscalls can be hundreds of times slower than reading 64 KiB at a time.
Processes and threads
A process is a running program: its own virtual address space, its own open file descriptors, and one or more threads. Processes are isolated from each other by the page tables from the last lesson.
A thread is one line of execution inside a process, with its own registers (including rip and rsp) and its own stack. All the threads of a process share the same address space and file descriptors, so they can share data directly. That’s powerful, and it’s also where race conditions come from.
There are usually more threads than CPU cores, so the kernel takes turns: every so often it pauses a thread, saves its registers, and loads another thread’s. This is a context switch. Switching to a thread in a different process also means switching page tables, which loses useful TLB entries, so it costs more.
Recap
- User mode can’t touch hardware; the kernel does it on your behalf.
- A syscall: number in
rax, arguments in registers, thesyscallinstruction, result back inrax. - Every I/O library is built on a few syscalls such as
read,write,openatandmmap. - File descriptors are small integers naming open files, pipes and sockets. 0, 1 and 2 are stdin, stdout and stderr.
- Syscalls are costly, so libraries buffer. Threads share an address space; processes don’t.