Free lesson · Operating Systems
These high-level operations all use system calls underneath:
Your Program Cannot Touch Hardware
Here is a fact that surprises many engineers who have been writing code for years without thinking about it: your program cannot directly access the disk, the network, the screen, or any other hardware device. It cannot allocate memory from physical RAM. It cannot create a new process. It cannot even find out what time it is.
Every single one of these operations requires help from the operating system. Your program has to ask the OS to do it on its behalf. The mechanism for asking is called a system call - often shortened to syscall.
A system call is a controlled entry point into the OS kernel. When your program needs something that only the OS can provide - reading a file, opening a network socket, creating a process, allocating memory - it makes a system call. The CPU switches from user mode (where your program runs, with limited privileges) to kernel mode (where the OS runs, with full hardware access), the OS performs the requested operation, and control returns to your program.
This might seem like unnecessary overhead. Why not let programs talk to hardware directly? The answer is protection. If every program could access hardware directly, a bug in any program could corrupt data on disk, crash the network interface, or overwrite another program's memory. The OS acts as a gatekeeper - programs request services through a controlled interface, and the OS decides whether to grant the request based on permissions, resource availability, and security policies.
The System Call Interface
Every operating system provides a set of system calls that programs can use. On Linux, there are roughly 300-400 system calls. On macOS and Windows, the numbers are similar. You do not need to know all of them for interviews. You need to know the most important categories and a few specific calls in each.
graph TD
SC["System Calls"] --> PM["Process Management"]
SC --> FM["File Management"]
SC --> DM["Device Management"]
SC --> IM["Information Management"]
SC --> CM["Communication"]
PM --> PM1["fork() - create process"]
PM --> PM2["exec() - run program"]
PM --> PM3["wait() - wait for child"]
PM --> PM4["exit() - terminate process"]
PM --> PM5["kill() - send signal"]
FM --> FM1["open() - open file"]
FM --> FM2["read() - read data"]
FM --> FM3["write() - write data"]
FM --> FM4["close() - close file"]
FM --> FM5["lseek() - move position"]
DM --> DM1["ioctl() - device control"]
DM --> DM2["mmap() - map memory"]
IM --> IM1["getpid() - get process ID"]
IM --> IM2["time() - get time"]
IM --> IM3["uname() - get system info"]
CM --> CM1["socket() - create socket"]
CM --> CM2["bind() - bind address"]
CM --> CM3["listen() - listen"]
CM --> CM4["accept() - accept connection"]
CM --> CM5["send/recv() - transfer data"]
This diagram categorizes the most important system calls into five groups. Process Management includes fork, exec, wait, exit, and kill. File Management includes open, read, write, close, and lseek. Device Management includes ioctl and mmap. Information Management includes getpid, time, and uname. Communication includes socket, bind, listen, accept, and send/recv.
Process Management System Calls
These are the calls you learned about in earlier topics. fork() creates a new process by copying the calling process. exec() replaces the current process's program with a different program. wait() blocks the parent until a child process terminates. exit() terminates the calling process. kill() sends a signal to another process (despite its name, it does not always kill - SIGTERM is a request to terminate, SIGSTOP is a request to pause, and signals can be used for communication).
File Management System Calls
These are the calls your program uses every time it interacts with files. open() opens a file and returns a file descriptor - a small integer that represents the open file. Your program uses this file descriptor in all subsequent operations on that file. read() reads data from an open file descriptor into a buffer in your program's memory. write() writes data from a buffer to an open file descriptor. close() closes the file descriptor and releases the associated resources. lseek() moves the current read/write position within the file.
The file descriptor model is one of Unix's most powerful design decisions. File descriptors are not just for files on disk - they represent any I/O stream. Standard input (fd 0), standard output (fd 1), and standard error (fd 2) are file descriptors. Network sockets are file descriptors. Pipes between processes are file descriptors. This uniformity means the same read() and write() calls work whether you are reading from a file, a network socket, or a pipe. This is the Unix philosophy of "everything is a file."
Communication System Calls
Network communication uses a specific set of system calls. socket() creates a new network socket (similar to open() for files - it returns a file descriptor). bind() associates the socket with a specific network address and port. listen() marks the socket as a server socket that accepts incoming connections. accept() blocks until a client connects, then returns a new file descriptor for that connection. send() and recv() (or write() and read(), since sockets are file descriptors) transfer data over the connection.
These calls are the foundation of every networked application. When you build a web server, a database client, a chat application, or any system that communicates over the network, you are using these system calls (or a library that wraps them).
How a System Call Actually Works
When your program makes a system call, a precisely choreographed sequence of events happens. Understanding this sequence is important because it explains the performance cost of system calls and why minimizing unnecessary system calls is a real optimization in production systems.
sequenceDiagram
participant App as Your Program<br/>(User Mode)
participant Lib as C Library<br/>(e.g., glibc)
participant CPU as CPU
participant Kernel as OS Kernel<br/>(Kernel Mode)
participant HW as Hardware<br/>(Disk, Network, etc.)
App->>Lib: Call read(fd, buf, 4096)
Lib->>Lib: Put syscall number in register<br/>Put arguments in registers
Lib->>CPU: Execute SYSCALL instruction<br/>(triggers mode switch)
Note over CPU: CPU switches from<br/>User Mode → Kernel Mode
CPU->>Kernel: Trap to kernel<br/>Jump to syscall handler
Kernel->>Kernel: Look up syscall number<br/>in syscall table
Kernel->>Kernel: Validate arguments<br/>(is fd valid? is buf writable?)
Kernel->>HW: Perform the operation<br/>(read from disk)
HW-->>Kernel: Data returned
Kernel->>Kernel: Copy data to user buffer
Kernel-->>CPU: Return result
Note over CPU: CPU switches from<br/>Kernel Mode → User Mode
CPU-->>Lib: Return value<br/>(bytes read or error)
Lib-->>App: Return value
This sequence diagram shows the full journey of a system call. The application calls read through the C library. The library loads the syscall number and arguments into CPU registers and executes the SYSCALL instruction, which triggers a mode switch from user mode to kernel mode. The kernel's syscall handler looks up the syscall number, validates the arguments, performs the operation (reads from disk), copies data to the user's buffer, and returns. The CPU switches back to user mode, and the return value propagates back through the library to the application.
Let us break down each step.
Step 1: Your program calls a library function. When you call read() in C or os.read() in Python, you are not directly making a system call. You are calling a wrapper function in the C standard library (glibc on Linux). This wrapper function handles the mechanics of setting up the system call.
Step 2: The library sets up registers. Each system call has a unique number (for example, on x86-64 Linux, read is syscall number 0, write is number 1, open is number 2, fork is number 57). The library puts the syscall number in a specific CPU register (rax on x86-64) and the arguments in other specific registers (rdi, rsi, rdx, etc.).
Step 3: The SYSCALL instruction triggers a mode switch. The library executes a special CPU instruction (syscall on x86-64, int 0x80 on older x86, svc on ARM). This instruction is special - it is the only way for user-mode code to enter the kernel. The CPU immediately switches from user mode to kernel mode, saves the user program's state, and jumps to a pre-configured kernel entry point (the syscall handler).
Step 4: The kernel handles the request. The kernel's syscall handler reads the syscall number from the register, looks it up in a table of system call functions, validates the arguments (is the file descriptor valid? does the user have permission? is the buffer address within the process's memory?), and performs the operation.
Step 5: The kernel returns. The kernel places the return value in a register, switches the CPU back to user mode, and resumes the user program. The library wrapper function reads the return value and returns it to your program.
The Performance Cost of System Calls
System calls are not free. Each one involves a mode switch (user → kernel → user), register saving and restoring, argument validation, and potentially cache disruption. A single system call takes hundreds of nanoseconds to a few microseconds - fast in absolute terms, but slow compared to a regular function call (which takes a few nanoseconds).
For a program that makes millions of system calls per second - like a database reading and writing small records, or a web server handling thousands of requests - the overhead of system calls can become a meaningful fraction of total execution time.
This is why real systems use techniques to minimize system calls. Buffered I/O combines many small reads and writes into fewer large ones. When you call fread() in C (the buffered version), it reads a large chunk from the OS into a buffer using a single read() system call, and then serves your small reads from the buffer without additional system calls. Instead of 1,000 system calls to read 1,000 bytes one at a time, you get 1 system call to read 4,096 bytes and 1,000 buffer accesses.
Memory-mapped I/O (mmap()) maps a file directly into the process's virtual address space. Once mapped, reading from the file is just reading from memory - no read() system calls needed. The OS handles loading the file data into memory transparently via the page fault mechanism. This is how databases like PostgreSQL access their data files and why mmap() comes up in system design interviews.
Batching combines multiple operations into a single system call. Linux's io_uring interface lets a program submit many I/O operations at once and collect all the results in one batch, dramatically reducing the per-operation system call overhead.
Interview: The interviewer asks: "What is a system call?"
"A system call is the interface between a user program and the operating system kernel. When a program needs a service that requires kernel privileges - file I/O, process creation, memory allocation, network communication - it makes a system call. The CPU switches from user mode to kernel mode, the kernel performs the operation, and control returns to the program. System calls are the only way user programs can access hardware resources or OS services."
If they follow up with "why not let programs access hardware directly?":
"Direct hardware access would eliminate protection. One buggy program could overwrite another's data on disk or crash the network interface. The system call interface lets the OS validate every request - check permissions, verify arguments, enforce resource limits - before allowing the operation. This is the foundation of OS security and stability."
Common System Calls Every Engineer Should Know
You do not need to memorize all 300+ Linux system calls. But there are a handful that come up frequently in interviews and in understanding how systems work. Here are the ones worth knowing, grouped by what they do:
| System Call | What It Does | When It Comes Up |
|---|---|---|
fork() |
Create a copy of the calling process | Process creation, concurrency |
exec() |
Replace the current program with a new one | Running programs, shell commands |
wait() |
Wait for a child process to terminate | Process cleanup, zombie prevention |
exit() |
Terminate the calling process | Process lifecycle |
open() |
Open a file, return a file descriptor | File I/O, "everything is a file" |
read() |
Read data from a file descriptor | File I/O, network I/O |
write() |
Write data to a file descriptor | File I/O, network I/O |
close() |
Close a file descriptor | Resource cleanup |
mmap() |
Map a file or memory region into the address space | Memory-mapped I/O, shared memory |
socket() |
Create a network socket | Networking, server design |
select()/poll()/epoll() |
Monitor multiple file descriptors for I/O readiness | I/O multiplexing, high-performance servers |
brk()/sbrk() |
Adjust the program's heap size | Memory allocation (used by malloc) |
clone() |
Create a new process or thread (Linux-specific) | Thread creation, containers |
ioctl() |
Device-specific control operations | Hardware interaction, terminal control |
The most important insight from this table is that a surprisingly small set of system calls - fork, exec, open, read, write, close, socket, and mmap - underpin the vast majority of what programs do. Every time your Python program reads a file, writes to the console, makes an HTTP request, or spawns a subprocess, it is ultimately using some combination of these system calls.
Tracing System Calls in Practice
One of the most powerful debugging tools on Linux is strace, which shows every system call a program makes in real time. This is incredibly useful for understanding what a program is actually doing at the OS level.
strace -c python3 -c "print('Hello, World!')"
The -c flag tells strace to summarize the system calls instead of printing each one individually. You will see something like: the program made a total of several hundred system calls just to print "Hello, World!" - opening shared libraries, mapping them into memory, reading configuration files, setting up the Python runtime, and finally calling write() to send "Hello, World!" to standard output.
This is a great way to build intuition about the relationship between high-level code and system calls. Try tracing a simple HTTP request with strace curl https://example.com and you will see the socket(), connect(), send(), recv() calls that make networking work at the OS level.
Tip: For the interview: Being able to say "I've used strace to debug production issues" demonstrates real-world OS-level debugging experience. If you have used it, mention it. If you have not, try it before your interview - even a few minutes of tracing programs will build useful intuition.
import os
import sys
# These high-level operations all use system calls underneath:
# 1. getpid() -> sys_getpid system call
pid = os.getpid()
print(f"getpid() syscall returned: {pid}")
# 2. print() -> ultimately calls write() system call on fd 1
print(f"This print statement uses the write() syscall on fd 1 (stdout)")
# 3. os.getcwd() -> sys_getcwd system call
cwd = os.getcwd()
print(f"getcwd() syscall returned: {cwd}")
# 4. Even checking the time is a system call
import time
t = time.time()
print(f"time() syscall returned: {t}")
Exercise: Starter: Pick three things you do regularly in code (open a file, make an HTTP request, print to the console). For each one, identify which system call(s) are being used underneath. Use the table above if needed.
Intermediate: On a Linux system, run
strace -e trace=read,write,open,close python3 -c "f = open('/etc/hostname', 'r'); print(f.read()); f.close()". Watch the system calls that correspond to opening, reading, and closing a file. Match each line of the strace output to the Python code that triggered it.Advanced: Compare the number of system calls made by a program that reads a file one byte at a time versus one that reads the whole file at once. Use
strace -cto count. For example, comparepython3 -c "f = open('/etc/hosts'); [f.read(1) for _ in range(1000)]; f.close()"againstpython3 -c "f = open('/etc/hosts'); f.read(); f.close()". How manyread()system calls does each make? What does this tell you about the importance of buffering?