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

RegionHoldsPermissionsBacked by
Textmachine coderead + execute (not writable)the executable file (demand-paged, shareable)
Datainitialised globals/staticsread + writethe executable file
BSSuninitialised globalsread + writenothing — zero-filled on first touch
Heapmalloc / newread + writeanonymous memory (grows via brk/mmap)
mmap regionshared libs, mapped files, big allocationsvaries per mappingfiles or anonymous memory
Stackcall frames, locals, return addressesread + writeanonymous 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.

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.