Lecture 02: System Calls and Operating System Structures

  1. What are System Calls?
  2. System Call Lifecycle and Examples
  3. SOS Structures: Monolithic, Layered, Microkernel
  4. Performance and Modularity Trade-offs
  5. Summary and Q&A

Slide Deck

Key Takeaways


Notes for Digging Deeper: Mastering the Linux System Call Interface

Introduction: The Ring 3 to Ring 0 Boundary

A system call (syscall) is the fundamental programmatic interface between a user-space application and the Linux kernel. Because user applications execute in a restricted environment (Ring 3 on x86 architectures), they cannot directly access hardware, manage physical memory, or spawn processes. To perform these privileged operations, the application must issue a system call, temporarily yielding control to the kernel (Ring 0) to execute the request securely.

Understanding Protection Rings: Protection rings are a hardware-enforced security mechanism built directly into the CPU to protect critical system functions from faults and malicious behavior. On x86 architectures, there are traditionally four hierarchical levels of privilege, numbered from 0 to 3. Ring 0 is the innermost layer possessing absolute, unrestricted access to the CPU, memory, and hardware; this is where the operating system kernel operates. Ring 3 is the outermost layer with the fewest privileges, serving as the sandbox where everyday user applications run. (Note: Rings 1 and 2 were historically intended for custom device drivers but are generally bypassed and unused by modern operating systems like Linux and Windows). This strict architectural separation guarantees that if a Ring 3 application (like a web browser or a student’s C program) crashes or misbehaves, it cannot bring down the entire Ring 0 operating system with it.

Below are the core concepts every systems programmer must master to interact with the Linux kernel effectively, efficiently, and securely.

flowchart TB subgraph UserSpace [User Space - Ring 3] App[User Application] LibC[glibc Wrapper] App -- "1. Function Call (e.g., open)" --> LibC end subgraph Boundary [Hardware / Context Switch] Trap((syscall instruction)) LibC -- "2. Load Registers (RAX = Syscall #)" --> Trap Trap -. "8. Return to User Space (RAX = Result / -Error)" .-> LibC LibC -. "9. Error Handling (Set errno, return -1)" .-> App end subgraph KernelSpace [Kernel Space - Ring 0] Table[Syscall Table] Mem[Memory Boundary] Sec[Security Checks] Hw[Hardware Execution] Trap -- "3. Privilege Escalation" --> Table Table -- "4. Index Lookup (e.g., sys_openat)" --> Mem Mem -- "5. Transfer Data (copy_from_user)" --> Sec Sec -- "6. Verify Capabilities / TOCTOU" --> Hw Hw -- "7. Fulfill Request" --> Trap end classDef user fill:#e1f5fe,stroke:#01579b,stroke-width:2px,color:#000; classDef kernel fill:#e8f5e9,stroke:#1b5e20,stroke-width:2px,color:#000; classDef boundary fill:#fff3e0,stroke:#e65100,stroke-width:2px,color:#000; class App,LibC user; class Trap boundary; class Table,Mem,Sec,Hw kernel;
Core ConceptFunctional DescriptionKey Identifiers & Mechanisms
1. The BoundaryThe rigid separation between restricted applications and privileged hardware access.User Space (Ring 3) $\rightarrow$ Kernel Space (Ring 0)

2. Syscall FlowApplications rarely call the kernel directly; they use standard library wrappers to initiate the hardware trap.App $\rightarrow$ glibc $\rightarrow$ syscall trap $\rightarrow$ Kernel

3. NumberingThe kernel identifies requests by integer indices, not string names. These numbers are strictly architecture-dependent.x86_64: RAX register holds the syscall ID.

4. The errno IllusionThe kernel returns negative error codes directly. libc intercepts these, sets the errno variable, and returns -1.Kernel: -ENOENT

libc: errno = ENOENT, returns -1

5. Data TransferThe kernel cannot blindly trust user pointers. Memory must be securely copied or mapped across the boundary.copy_from_user()

copy_to_user()

mmap()

6. Execution ModeI/O syscalls block the thread by default until data is ready. Non-blocking mode returns immediately if I/O is unavailable.Blocking (Default)

Non-Blocking (O_NONBLOCK)

7. InterruptionsBlocked system calls can be interrupted by signals. The programmer must anticipate this and retry the call.Error State: -1

Variable: errno == EINTR

8. RestartabilityThe OS can be configured to automatically resume certain interrupted system calls after a signal handler completes.Flag: SA_RESTART (via sigaction)

9. MultiplexingManaging thousands of concurrent I/O streams without blocking or wasting CPU cycles on iteration.Legacy: select(), poll()

Modern: epoll(), io_uring

10. Security (TOCTOU)Defending against Time-of-Check to Time-of-Use race conditions by locking path resolutions relative to directory descriptors.Avoid: open()

Standard: openat(), fstatat()

The Syscall Flow and the libc Wrapper

User programs rarely trigger system calls directly using assembly language. Instead, they rely on the C Standard Library (glibc), which acts as an intermediary.

  1. Invocation: The application calls a standard library function (e.g., open()).
  2. Trap Generation: The glibc wrapper sets up the necessary CPU registers and executes a software interrupt or a dedicated architecture instruction (e.g., syscall on x86_64, int 0x80 on older 32-bit systems, or svc on ARM).
  3. Context Switch: The CPU switches from user mode to kernel mode.
  4. Execution & Return: The kernel verifies the request, performs the privileged operation, and returns the result (and control) back to user space.

Syscall Numbering and Architecture Dependence

The kernel does not identify system calls by string names; it uses a unique integer index mapped to a kernel function table (the syscall table). This numbering is strictly architecture-dependent. For example, open() is syscall 2 on x86_64, syscall 5 on x86 (32-bit), and syscall 56 on ARM64.

Modern Engineering Note: Modern architectures like ARM64 and RISC-V actually omitted the legacy open() system call entirely to save space and enforce security, relying exclusively on the newer, directory-relative openat() syscall.

The Calling Convention and the errno Illusion

To pass arguments into the kernel, the glibc wrapper places them into specific CPU registers before triggering the syscall instruction. On x86_64, the convention uses:

The errno Illusion: The Linux kernel knows nothing about the C variable errno. If a syscall fails, the kernel returns a negative error code directly in the RAX register (e.g., -ENOENT). The glibc wrapper intercepts this, negates it, stores the positive error code in the thread-local errno variable, and returns -1 to the user’s C code.

Code Example: Bypassing libc wrappers You can invoke the kernel directly using the syscall() function, bypassing standard library wrappers:

#include <unistd.h>
#include <sys/syscall.h>

int main() {
    // Equivalent to write(1, "Hello Kernel\n", 13);
    // Directly invoking syscall number 1 (SYS_write on x86_64)
    syscall(SYS_write, 1, "Hello Kernel\n", 13);
    return 0;
}

The Data Boundary (Transferring Memory)

The kernel cannot simply “trust” pointers passed from user space, as the user might pass a pointer to protected kernel memory or unmapped pages. Data transfer mechanisms include:

Blocking vs. Non-Blocking Syscalls

By default, I/O system calls are blocking. If you call read() on a network socket and no data is available, the kernel removes your thread from the CPU run queue and puts it to sleep until data arrives.

Non-Blocking calls instruct the kernel to return immediately if the operation cannot be completed instantly, typically returning -1 and setting errno to EAGAIN or EWOULDBLOCK.

Code Example: Setting Non-Blocking Mode

#include <fcntl.h>
#include <stdio.h>

void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    if (flags == -1) return;
    
    // Append O_NONBLOCK to existing flags
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

Interrupted Syscalls and Restartability

If a process is blocked in a system call (e.g., waiting for terminal input) and a signal arrives (like SIGALRM or SIGCHLD), the kernel interrupts the syscall to deliver the signal.

Code Example: The Robust I/O Loop A seasoned systems programmer never assumes a single read() or write() will succeed uninterrupted. You must wrap them in an EINTR retry loop:

#include <unistd.h>
#include <errno.h>

ssize_t robust_read(int fd, void *buf, size_t count) {
    ssize_t bytes_read;
    while (1) {
        bytes_read = read(fd, buf, count);
        // If the read was interrupted by a signal, try again
        if (bytes_read == -1 && errno == EINTR) {
            continue; 
        }
        return bytes_read; // Return success or a legitimate error
    }
}

Multiplexing Syscalls (The Evolution of I/O)

When writing high-performance servers (like Nginx or Redis), a single thread must manage tens of thousands of open network sockets without blocking on any of them.

Security and TOCTOU Race Conditions

Because system calls traverse security boundaries, they are highly vulnerable to manipulation.

Code Example: Preventing TOCTOU with openat()

#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>

void secure_file_creation(const char *dir_path, const char *filename) {
    // Open the directory itself
    int dir_fd = open(dir_path, O_RDONLY | O_DIRECTORY);
    
    // Safely create a file relative to the opened directory descriptor
    // Prevents attackers from modifying the path resolution between checking and opening
    int file_fd = openat(dir_fd, filename, O_CREAT | O_WRONLY | O_EXCL, 0600);
    
    close(file_fd);
    close(dir_fd);
}

Required Reading and External Resources

To deepen your understanding of the kernel boundary, consult the following authoritative resources:

📎 Attached Resources

SDB Watermark