Loading content...
A program sitting on disk is inert—a sequence of bytes containing instructions and data, but doing nothing. The operating system's program execution services transform this static artifact into dynamic computation. This transformation involves loading the program into memory, allocating resources, creating execution contexts, and managing the program's lifecycle from start to termination.
Program execution is perhaps the most fundamental service an operating system provides. Without it, every other OS capability—file systems, networking, user interfaces—would be meaningless, as there would be no programs to use them. Understanding how programs execute reveals the core mechanisms that make computing possible.
By the end of this page, you will understand the complete journey from executable file to running process. You'll learn how the OS loader works, how memory is allocated, how execution contexts are created, how programs can launch other programs, and how the OS manages program termination and resource cleanup. This knowledge forms the foundation for understanding processes, threads, and system performance.
When you launch a program—by double-clicking an icon, typing a command, or calling an API—the operating system executes a sophisticated loading sequence. This program loader is responsible for preparing the executable for execution.
Phase 1: Executable file parsing
Executable files aren't raw machine code. They're structured containers with metadata describing how to load and run the program. Common executable formats include:
Each format encodes:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748
ELF Executable Structure (Linux)═══════════════════════════════════════════════════════════════ ┌────────────────────────────────────────────────────────────┐│ ELF Header ││ - Magic number: 0x7F 'E' 'L' 'F' ││ - Class: 32-bit or 64-bit ││ - Endianness: Little or big endian ││ - OS/ABI: Linux, FreeBSD, etc. ││ - Entry point: Address where execution begins ││ - Program header table offset ││ - Section header table offset │├────────────────────────────────────────────────────────────┤│ Program Headers ││ Describe segments to load into memory: ││ ┌──────────────────────────────────────────────────────┐ ││ │ PT_LOAD (Code) - Executable instructions [R-X] │ ││ │ PT_LOAD (Data) - Initialized variables [RW-] │ ││ │ PT_DYNAMIC - Dynamic linking info │ ││ │ PT_INTERP - Path to dynamic linker │ ││ └──────────────────────────────────────────────────────┘ │├────────────────────────────────────────────────────────────┤│ Sections ││ .text - Executable code ││ .rodata - Read-only data (string literals, constants) ││ .data - Initialized global/static variables ││ .bss - Uninitialized data (zero-filled at runtime) ││ .symtab - Symbol table ││ .strtab - String table ││ .rel.dyn - Dynamic relocations ││ .rel.plt - PLT relocations (for lazy binding) ││ .dynamic - Dynamic linking information ││ .got - Global Offset Table ││ .plt - Procedure Linkage Table │├────────────────────────────────────────────────────────────┤│ Section Headers ││ Metadata for each section (for linking and debugging) │└────────────────────────────────────────────────────────────┘ Example: Examining ELF with readelf$ readelf -h /bin/lsELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Type: DYN (Position-Independent Executable) Entry point address: 0x6ab0 ...Phase 2: Memory allocation and mapping
After parsing the executable header, the loader allocates virtual memory regions and maps segments:
Memory layout of a typical process:
High Memory
┌─────────────────────────────────────────────────┐
│ Kernel Space │ (Not accessible from user mode)
├─────────────────────────────────────────────────┤ ← 0xFFFF...
│ Stack │ ↓ Grows down
│ (Local variables, return addresses) │
├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
│ (Unused space) │
├─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─┤
│ Heap │ ↑ Grows up
│ (Dynamic memory allocation) │
├─────────────────────────────────────────────────┤
│ BSS Segment │ (Uninitialized data)
├─────────────────────────────────────────────────┤
│ Data Segment │ (Initialized global variables)
├─────────────────────────────────────────────────┤
│ Text Segment │ (Program code - read/execute)
├─────────────────────────────────────────────────┤ ← Entry point (e.g., 0x400000)
│ Memory-mapped files │ (Shared libraries, mmap'd files)
└─────────────────────────────────────────────────┘
Low Memory ← 0x0000...
Modern systems use Address Space Layout Randomization (ASLR) for security, loading executables at random addresses each time. This requires executables to be position-independent—able to run at any address. PIE achieves this through relative addressing and the Global Offset Table (GOT). When you compile with -fPIE on Linux, you're requesting position-independent code.
Most programs don't exist in isolation—they rely on shared libraries (.so on Linux, .dll on Windows, .dylib on macOS) that provide common functionality. Dynamic linking resolves these dependencies at runtime, enabling:
libc in memory serves all programsHow dynamic linking works:
The executable contains a PT_INTERP program header specifying the dynamic linker (/lib64/ld-linux-x86-64.so.2 on Linux). When the kernel loads the executable, it actually starts the dynamic linker first, which then:
1234567891011121314151617181920212223242526272829
# Examining dynamic dependencies of a program $ ldd /bin/ls linux-vdso.so.1 (0x00007ffc7ed4a000) libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f3b2e6c0000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f3b2e400000) libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f3b2e660000) /lib64/ld-linux-x86-64.so.2 (0x00007f3b2e730000) # The program 'ls' depends on:# - linux-vdso.so.1: Virtual Dynamic Shared Object (kernel-provided)# - libselinux.so.1: SELinux library# - libc.so.6: The C standard library# - libpcre2-8.so.0: Regular expression library# - ld-linux-x86-64.so.2: The dynamic linker itself # Tracing dynamic linker operations$ LD_DEBUG=libs /bin/ls 18295: find library=libselinux.so.1 [0]; searching 18295: search cache=/etc/ld.so.cache 18295: trying file=/lib/x86_64-linux-gnu/libselinux.so.1 18295: calling init: /lib/x86_64-linux-gnu/libselinux.so.1 ... # Symbol resolution during loading$ LD_DEBUG=symbols /bin/ls 2>&1 | grep printf 18300: symbol=printf; lookup in file=/bin/ls [0] 18300: symbol=printf; lookup in file=/lib/x86_64-linux-gnu/libc.so.6 [0] 18300: binding file /bin/ls [0] to /lib/x86_64-linux-gnu/libc.so.6 [0]: normal symbol 'printf'Lazy binding and the PLT/GOT:
To avoid the overhead of resolving all symbols at startup, most systems use lazy binding. External function calls go through the Procedure Linkage Table (PLT), which initially points to resolver code. On first call, the resolver finds the actual function address, updates the Global Offset Table (GOT), and jumps to the function. Subsequent calls go directly through the GOT.
First call to printf():
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ call PLT │ → │ PLT stub │ → │ Resolver │ → │ Updates GOT │
│ entry │ │ (indirect) │ │ finds printf│ │ with address│
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│
▼
┌─────────────┐
│ printf() │
│ in libc │
└─────────────┘
Subsequent calls:
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ call PLT │ → │ PLT stub │ → │ printf() │ (GOT already resolved)
│ entry │ │ (indirect) │ │ in libc │
└─────────────┘ └─────────────┘ └─────────────┘
When multiple programs require different versions of the same library, conflicts arise—historically called 'DLL Hell' on Windows. Modern solutions include: versioned symbols (GLIBC), private library directories (application bundles), containerization (Docker), and language-level vendoring (Go, Rust static linking).
Every running program exists as a process—an instance of a program in execution with its own resources. The operating system provides system calls to create new processes, each with subtly different semantics across platforms.
The fork-exec model (Unix/Linux):
Unix-like systems use a two-step process creation model:
This separation provides remarkable flexibility:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
#include <stdio.h>#include <stdlib.h>#include <unistd.h>#include <sys/wait.h> /** * Demonstrates the fork-exec pattern for process creation * This is how shells launch programs */int main() { printf("Parent process (PID: %d) starting\n", getpid()); pid_t pid = fork(); /* Create child process */ if (pid < 0) { /* Fork failed */ perror("fork failed"); exit(1); } else if (pid == 0) { /* This code runs in the CHILD process */ printf("Child process (PID: %d) created\n", getpid()); /* Set up the child's environment before exec */ /* This is where shells handle I/O redirection, pipes, etc. */ /* Replace this process with 'ls -la' */ char *args[] = {"ls", "-la", "/home", NULL}; execvp("ls", args); /* If exec succeeds, we never reach here */ /* If we get here, exec failed */ perror("exec failed"); exit(1); } else { /* This code runs in the PARENT process */ printf("Parent waiting for child (PID: %d)\n", pid); int status; waitpid(pid, &status, 0); /* Wait for child to complete */ if (WIFEXITED(status)) { printf("Child exited with status: %d\n", WEXITSTATUS(status)); } } return 0;} /*Output:Parent process (PID: 1234) startingChild process (PID: 1235) createdParent waiting for child (PID: 1235)[output of ls -la /home]Child exited with status: 0*/Copy-on-Write (COW) optimization:
Naively, fork() would need to copy the entire address space of the parent—potentially gigabytes of memory—just to throw it away at exec(). Modern systems use Copy-on-Write:
This makes fork nearly instantaneous regardless of process size.
Before fork:
┌─────────────────────┐
│ Parent Process │
│ Pages: [A][B][C] │ (Writable)
└─────────────────────┘
After fork (COW):
┌─────────────────────┐ ┌─────────────────────┐
│ Parent Process │ │ Child Process │
│ Pages: [A][B][C] │ ── │ Pages: [A][B][C] │ (Both read-only, shared)
└─────────────────────┘ └─────────────────────┘
After parent writes to page B:
┌─────────────────────┐ ┌─────────────────────┐
│ Parent Process │ │ Child Process │
│ Pages: [A][B'][C] │ │ Pages: [A][B][C] │ (B copied, parent has B')
└─────────────────────┘ └─────────────────────┘
Windows process creation:
Windows uses a different model with CreateProcess(), which combines loading and execution in a single call:
BOOL CreateProcess(
LPCTSTR lpApplicationName, // Executable path
LPTSTR lpCommandLine, // Command line arguments
LPSECURITY_ATTRIBUTES lpProcessAttributes,
LPSECURITY_ATTRIBUTES lpThreadAttributes,
BOOL bInheritHandles, // Handle inheritance
DWORD dwCreationFlags, // Creation flags
LPVOID lpEnvironment, // Environment block
LPCTSTR lpCurrentDirectory, // Working directory
LPSTARTUPINFO lpStartupInfo, // Window settings
LPPROCESS_INFORMATION lpProcessInformation // Output
);
This is more complex but allows setting all attributes in a single call. However, it's less flexible than fork-exec for scenarios like setting up pipes between processes.
posix_spawn() provides a more efficient alternative when you just want to launch a program without fork's full flexibility. vfork() was a historical optimization where the child shared the parent's address space entirely (dangerous but fast). Modern COW makes vfork largely obsolete, though it still exists for special cases.
When a program runs, the OS maintains extensive state on its behalf. This execution context includes everything needed to pause and resume the program—essential for multitasking.
Components of execution context:
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
Process Control Block (PCB) / Task Struct════════════════════════════════════════════════════════════════ ┌────────────────────────────────────────────────────────────────┐│ PROCESS IDENTIFICATION ││ ├── PID (Process ID): 1234 ││ ├── PPID (Parent PID): 1230 ││ ├── PGID (Process Group): 1230 ││ ├── SID (Session ID): 1100 ││ └── Command: /usr/bin/myapp --flag value │├────────────────────────────────────────────────────────────────┤│ CPU STATE (saved during context switch) ││ ├── General Registers: RAX, RBX, RCX, RDX, RSI, RDI, R8-R15 ││ ├── Stack Pointer (RSP): 0x7ffd12345678 ││ ├── Base Pointer (RBP): 0x7ffd12345680 ││ ├── Instruction Pointer (RIP): 0x55555555a040 ││ ├── Flags Register (RFLAGS): 0x0000000000000246 ││ └── Floating Point / SIMD State (XMM0-15, YMM0-15) │├────────────────────────────────────────────────────────────────┤│ MEMORY MANAGEMENT ││ ├── Page Table Pointer (CR3): 0x00000000abc123000 ││ ├── Virtual Memory Areas (VMAs): ││ │ ├── 0x555555554000-0x555555556000: r-x (code) ││ │ ├── 0x555555556000-0x555555558000: rw- (data) ││ │ ├── 0x7f1234560000-0x7f1234600000: r-x (libc.so) ││ │ └── 0x7ffd12300000-0x7ffd12400000: rw- (stack) ││ └── Memory Usage: 45 MB resident, 120 MB virtual │├────────────────────────────────────────────────────────────────┤│ FILE DESCRIPTORS ││ ├── 0: /dev/pts/1 (stdin) ││ ├── 1: /dev/pts/1 (stdout) ││ ├── 2: /dev/pts/1 (stderr) ││ ├── 3: /var/log/app.log (write) ││ └── 4: socket:[12345] (TCP connection) │├────────────────────────────────────────────────────────────────┤│ SCHEDULING ││ ├── State: Running / Runnable / Sleeping / Stopped / Zombie ││ ├── Priority: 120 (nice 0) ││ ├── Time Slice: 4ms remaining ││ ├── CPU Affinity: All CPUs ││ └── Voluntary Context Switches: 1523 │├────────────────────────────────────────────────────────────────┤│ CREDENTIALS & LIMITS ││ ├── Real UID/GID: 1000/1000 ││ ├── Effective UID/GID: 1000/1000 ││ ├── Max Open Files: 1024 ││ ├── Max Virtual Memory: unlimited ││ └── Max CPU Time: unlimited │└────────────────────────────────────────────────────────────────┘ Linux: Examine with /proc/[pid]/ filesystem$ ls /proc/1234/cmdline cwd environ exe fd fdinfo maps mem stat status ...Context switching:
When the OS switches from one process to another, it must:
Context switching is expensive—typically 1-10 microseconds depending on the amount of state. This overhead is why excessive process switching hurts performance and why thread switching (which shares address space) is faster.
On Linux, the /proc virtual filesystem exposes process information. Each process has a directory /proc/[pid]/ containing files like status (overview), maps (memory regions), fd/ (open file descriptors), and cmdline (command line). This provides a powerful interface for process inspection without system calls.
Every program eventually terminates—either by choice (normal exit) or by force (signals, crashes). The OS must ensure orderly cleanup regardless of how termination occurs.
Termination mechanisms:
exit() or returns from main()exit(code) with exit statusabort() for abnormal termination123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
#include <stdio.h>#include <stdlib.h>#include <signal.h>#include <unistd.h>#include <sys/wait.h> /* Normal exit - most common termination */void normal_exit() { printf("Performing cleanup...\n"); // Cleanup code: close files, free resources exit(0); // Exit with success status} /* Exit with error status */void error_exit(const char *msg) { fprintf(stderr, "Error: %s\n", msg); exit(1); // Non-zero indicates failure} /* Return from main (equivalent to exit(return_value)) */int main_return_demo() { // atexit handlers still run return 42; // Same as exit(42)} /* Signal handler for graceful shutdown */volatile sig_atomic_t shutdown_requested = 0; void sigterm_handler(int sig) { printf("\nReceived signal %d, shutting down gracefully...\n", sig); shutdown_requested = 1; // Don't exit here - let main loop handle it} int graceful_shutdown_demo() { /* Register signal handler */ signal(SIGTERM, sigterm_handler); signal(SIGINT, sigterm_handler); // Ctrl+C printf("Running... (PID: %d)\n", getpid()); printf("Send SIGTERM to terminate gracefully\n"); while (!shutdown_requested) { // Main work loop sleep(1); } printf("Cleanup complete, exiting\n"); return 0;} /* atexit handlers - run in reverse order of registration */void cleanup_database() { printf("Closing database connection\n"); }void cleanup_files() { printf("Closing open files\n"); }void cleanup_memory() { printf("Freeing allocated memory\n"); } int atexit_demo() { atexit(cleanup_memory); atexit(cleanup_files); atexit(cleanup_database); printf("Doing work...\n"); exit(0); // Output order: // Doing work... // Closing database connection // Closing open files // Freeing allocated memory}OS cleanup on process termination:
When a process terminates, the OS performs extensive cleanup:
The zombie problem:
After a process terminates, it enters the 'zombie' state—its resources are freed but its PCB remains so the parent can retrieve the exit status. If the parent never calls wait(), zombies accumulate. This is why proper process management is essential in long-running servers.
| Exit Code | Meaning | Usage |
|---|---|---|
| 0 | Success | Program completed without errors |
| 1 | General error | Unspecified failure |
| 2 | Misuse of shell command | Invalid arguments or syntax |
| 126 | Cannot execute | Permission problem or not executable |
| 127 | Command not found | Specified command doesn't exist |
| 128+N | Fatal signal N | Terminated by signal N (e.g., 137 = 128+9 = SIGKILL) |
| 130 | SIGINT (Ctrl+C) | Process interrupted by user |
| 137 | SIGKILL | Process killed forcefully |
| 143 | SIGTERM | Process terminated gracefully |
While most signals can be caught and handled, SIGKILL (9) and SIGSTOP (19) cannot. This ensures the OS can always terminate misbehaving processes. Always try SIGTERM first to allow graceful shutdown; only use SIGKILL as a last resort since it prevents cleanup.
The OS controls what can execute and with what privileges. This security layer prevents unauthorized code execution and limits the damage from compromised programs.
File execution permissions:
On Unix-like systems, files must have the execute permission bit set:
$ ls -l /bin/ls
-rwxr-xr-x 1 root root 142144 Jan 15 10:00 /bin/ls
^ ^ ^
│ │ └─ Others can execute
│ └──── Group can execute
└─────── Owner can execute
The OS kernel checks these permissions before loading an executable. Without execute permission, the program won't run regardless of its content.
Privilege elevation mechanisms:
Sometimes programs need elevated privileges temporarily:
setuid/setgid bits: When these special permission bits are set, the program runs with the file owner's privileges rather than the invoking user's. For example, /usr/bin/passwd is setuid root so normal users can change their passwords (which requires writing to /etc/shadow).
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 59976 /usr/bin/passwd
^
└─ 's' indicates setuid bit
Capabilities (Linux): Fine-grained privileges that can be granted to executables without full root access. Instead of "all or nothing," capabilities like CAP_NET_BIND_SERVICE (bind to ports < 1024) can be granted individually.
sudo/UAC: User-level privilege elevation with authentication, logging which user ran what command with elevated rights.
Modern operating systems layer multiple security mechanisms. Even if an attacker bypasses one protection (e.g., ASLR), others (DEP, sandboxing) still apply. This defense-in-depth approach makes exploitation progressively harder.
We've traced the complete journey of program execution—from static bytes on disk to dynamic, running processes. Let's consolidate the key insights:
What's next:
With program execution covered, we'll explore I/O Operations—how the OS abstracts diverse hardware devices into a uniform interface for reading and writing data. This includes everything from reading files to network communication.
You now understand how operating systems load, execute, and manage programs. From parsing executable formats through dynamic linking, process creation, execution context management, and termination—these services form the foundation of all computing activity.