The Process Address Space
Undergraduate you learned that a process has "some memory". The graduate view is sharper and more useful:
a process is, in essence, three things bolted together — a virtual address space
(a private map of the machine's memory), one or more threads of execution running inside
it, and a kernel bookkeeping object (Linux's
\texttt{task\_struct}) that ties them to open files, credentials, and
signals. Kill the address space and the process is gone; the identity of a process is largely the
identity of its address space.
You already understand
paging — how
the MMU translates virtual pages to physical frames. This lesson climbs one level of abstraction above
the page table to the layout the process sees: where code, data, heap, libraries, and stack
live; how the kernel represents that layout as a set of memory regions; and the two-step dance —
\texttt{fork} then \texttt{exec} — by which one
address space gives birth to another and then reincarnates as a new program.
The classic map
Here is the layout every Unix process wears. Addresses run high at the top to low at the bottom. Two
regions grow toward each other — the stack downward from the top of user space, the heap upward
— with a vast unmapped gap between them that on a 64-bit machine is measured in terabytes.
The crucial subtlety: this is all virtual. None of these addresses name real RAM; the
MMU maps only the pages actually touched to physical frames, on demand. The top half —
kernel space — is mapped identically into every process (so a syscall doesn't
need an address-space switch to reach kernel code), but the privilege bit makes it unreadable from user
mode: touch it and you take a fault. The bottom page is deliberately left unmapped so that dereferencing
\texttt{NULL} faults loudly instead of silently corrupting memory.
The segments, precisely
| Region | Holds | Permissions | Backed by |
| Text | machine code | read + execute (not writable) | the executable file (demand-paged, shareable) |
| Data | initialised globals/statics | read + write | the executable file |
| BSS | uninitialised globals | read + write | nothing — zero-filled on first touch |
| Heap | malloc / new | read + write | anonymous memory (grows via brk/mmap) |
| mmap region | shared libs, mapped files, big allocations | varies per mapping | files or anonymous memory |
| Stack | call frames, locals, return addresses | read + write | anonymous memory (grows on fault) |
BSS is the clever one: a program with a \texttt{100\,MB} zero-initialised
array adds nothing to its executable on disk — the loader just records "reserve this range, zero-filled",
and the pages materialise, already zeroed, only when first written.
How the kernel actually stores the map: VMAs
"Text, data, heap, stack" is the picture; the kernel's data structure is a set of
virtual memory areas — Linux calls each a \texttt{vm\_area\_struct}
(VMA). Each VMA is a contiguous run of virtual pages sharing the same properties: a start and end address,
permissions (r/w/x), and a
backing object (a file + offset, or anonymous). The whole address space is just a sorted collection of
these regions hanging off the process's memory descriptor
(\texttt{mm\_struct}). Run \texttt{cat /proc/self/maps}
on Linux and you are reading the VMA list directly.
- a process's address space is a set of VMAs — contiguous virtual ranges, each with
permissions and a backing object (file or anonymous);
- a page fault looks up the faulting address in the VMA set: found and permitted →
map a frame (demand paging / copy-on-write); not found → \texttt{SIGSEGV};
- the \texttt{task\_struct} (the PCB) points at the
\texttt{mm\_struct} that owns the VMAs, plus the file table, credentials,
and signal state — everything that is the process besides its threads.
Making a new process: fork, then exec
Unix creates processes with a famously peculiar two-step. \texttt{fork()}
clones the calling process — same code, same data, same open files — producing a child that is a
near-perfect copy, distinguished only by the return value: the child sees
0, the parent sees the child's PID. Copying an entire address space would be
ruinous, so the kernel uses copy-on-write (COW): parent and child share every
physical page read-only, and a page is duplicated only when one of them writes to it. A fork of a
\texttt{4\,GB} process copies only page tables, not the gigabytes.
Then \texttt{exec()} does the opposite: it discards the current
address space entirely and loads a new program image in its place — new text, data, heap, stack — keeping
only the PID and open file descriptors. The idiom, then, is fork-exec-wait: the shell
forks a copy of itself, the child execs the command, and the parent waits for it to finish. Trace it.
// The fork-exec-wait pattern, as a state model. fork() "returns twice"; exec() replaces
// the child's image; wait() collects the exit status. COW means fork copies page tables, not pages.
type Proc = { pid: number; program: string; pages: number };
let nextPid = 1000;
function fork(parent: Proc): { parent: Proc; child: Proc } {
const child: Proc = { pid: ++nextPid, program: parent.program, pages: parent.pages };
console.log(`fork(): parent pid=${parent.pid} -> child pid=${child.pid}`);
console.log(` COW: ${parent.pages} pages shared read-only (copied only on write)`);
console.log(` in parent, fork() returns ${child.pid}; in child, fork() returns 0`);
return { parent, child };
}
function exec(p: Proc, program: string, pages: number): Proc {
console.log(`exec("${program}"): pid ${p.pid} discards its address space`);
console.log(` old image "${p.program}" (${p.pages} pages) -> new image "${program}" (${pages} pages)`);
return { pid: p.pid, program, pages }; // same PID, brand-new address space
}
function wait(parent: Proc, child: Proc, status: number): void {
console.log(`wait(): parent pid=${parent.pid} reaps child pid=${child.pid}, exit status ${status}`);
}
// The shell runs "ls": fork a copy of the shell, exec ls in the child, wait for it.
const shell: Proc = { pid: 1000, program: "bash", pages: 5000 };
const { parent, child } = fork(shell);
const running = exec(child, "ls", 300);
console.log(` child is now running "${running.program}"`);
wait(parent, running, 0);
console.log(`parent "${parent.program}" continues, unchanged`);
It looks wasteful — why clone a whole process just to immediately throw it away with
\texttt{exec}? The genius is the gap between them. Between
\texttt{fork} and \texttt{exec}, the child is a copy
of the parent running the parent's code, and can quietly rearrange its own world before the new program
starts: redirect \texttt{stdout} to a file, close descriptors it shouldn't
leak, set up a pipe, drop privileges, change directory. Every shell redirection
(\texttt{ls > out.txt}) and every pipe
(\texttt{a | b}) is built in that window. A single monolithic
\texttt{spawn} call would need a parameter for every possible adjustment; fork
gives you the full power of ordinary code instead. That said, \texttt{fork} has
aged awkwardly — a 2019 paper by Baumann, Appavoo, Krieger and Roscoe, "A fork() in the road,"
argues it is a poor fit for multithreaded, huge-memory modern programs, which is why
\texttt{posix\_spawn} and \texttt{vfork} exist.
A tempting mistake is to think a process's 2^{48}-byte address space (256 TB
on a typical x86-64 layout) means it uses that memory, or even that the pictured regions are
fully populated. They are not. The address space is a map of possibilities: only pages
the process actually touches get physical frames, allocated lazily on the first access (demand paging).
A freshly \texttt{malloc}'d gigabyte consumes essentially zero
physical RAM until you write to it. Likewise, after a \texttt{fork}, parent and
child appear to have two full copies of memory, but COW means there is still only one physical
copy until a write forces a split. Always separate the virtual layout (what addresses are
legal) from the physical reality (what frames are committed) — conflating them makes memory
accounting nonsensical.