Assembly (x86-64, AArch64, RISC-V) Module 3 – How a CPU runs your program
Memory and a process's address space
What memory a running program sees: virtual addresses, the regions of an address space, pages and page size, and why addresses change on every run from ASLR.
What you will learn
- Describe the regions of a Linux process: text, rodata, data, bss, heap, mappings and stack
- Explain virtual addresses, pages and the job of the memory-management unit
- Find the page size of the machine you are on instead of hard-coding it
- Explain why addresses change between runs and what ASLR and position-independent code do
- Calculate the next page boundary for any size on any page size
Before you start
What you’ll learn · Before you start
What you will learn
- Describe the regions of a Linux process: text, rodata, data, bss, heap, mappings and stack
- Explain virtual addresses, pages and the job of the memory-management unit
- Find the page size of the machine you are on instead of hard-coding it
- Explain why addresses change between runs and what ASLR and position-independent code do
- Calculate the next page boundary for any size on any page size
Before you start
On this page
Print the address of a local variable, run the program twice, and you will usually see two different numbers. Nothing is wrong: a running program never touches physical memory. It sees an address space: The set of virtual addresses a process can use, divided into regions (code, read-only data, writable data, heap, mapped libraries and the stack), each with its own read, write and execute permissions. of virtual address: An address a program uses, which the memory-management unit translates to a physical address through per-process page tables. Two processes can use the same virtual address and map to different physical memory., which the memory-management unit: The hardware that translates a process's virtual addresses to physical addresses a page at a time, using page tables, and enforces each page's read, write and execute permissions, faulting on a violation. maps to real memory one page: The fixed-size unit in which the memory-management unit maps and protects memory. Common sizes are 4 KiB, 16 KiB (Apple silicon) and 64 KiB; a request is always rounded up to whole pages. at a time, checking read, write and execute permission on every access.
That space is split into regions: code in .text, constants in .rodata, writable globals in .data and .bss, the
heap that malloc grows, shared libraries and large allocations in the middle, and the stack near the top. By the end
you can name each region, say why two processes can use the same address, read the page size from the machine (16384
bytes on an Apple-silicon Mac, 4096 on most x86-64 Linux) instead of assuming it, explain why addresses move between
runs, and round a size up to whole pages.
A process sees regions, each with its own permissions
When the kernel starts a program it maps the file’s sections into memory with the right permissions and sets up a
stack. From low addresses to high, a Linux process holds: .text (your machine code, read and execute), .rodata
(constants, read-only), .data (initialised globals, read and write), .bss (globals that start at zero), the heap
(which malloc grows upward), a middle region of shared libraries and mmap allocations, and the stack (which grows
downward from near the top). Walk down them:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- Every address is virtual. The program sees virtual addresses. The memory-management unit maps each page to real memory and checks its permissions. Two programs can both use address 0x400000 and never collide.
- Code and constants at the bottom. .text holds the machine code, mapped read and execute. .rodata holds constants such as strings, read-only. A write to either faults, which is how the hardware stops code from rewriting itself.
- Writable static data. .data holds initialised globals and .bss holds those that start at zero. .bss takes no space in the file; the loader just maps zeroed pages for it.
- The heap grows up. malloc hands out memory from the heap, which grows towards higher addresses. Under the hood it comes from the kernel through brk or mmap, a page at a time.
- Libraries and mappings. Shared libraries and large allocations are mapped in this middle region. Their addresses change every run because of address-space layout randomisation.
- The stack grows down. The stack starts near the top and grows towards lower addresses as functions call. The system caps its size with a limit (RLIMIT_STACK on Linux); growing past the cap faults and ends the program.
Each region has its own permissions, which the hardware enforces: a write to .text or .rodata faults, which is
how the machine stops code from rewriting itself and a string constant from being overwritten. On Linux a process can
read its own map; the script below prints /proc/self/maps, one line per region, in the format that proc(5)
documents:
#!/bin/sh
# See a process's own address space on Linux. /proc/self/maps lists every mapped region of the process that reads
# it, with its address range, permissions (r, w, x and p for private or s for shared), file offset, device, inode
# and the file behind it, or a name such as [heap], [stack] or [vdso]. On macOS the nearest tool is "vmmap <pid>".
# Addresses change on every run because of ASLR, so no two runs print the same numbers.
cat /proc/self/maps import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import linux_lab
# In the Linux lab, an AArch64 machine, cat prints the map of its own process. Addresses, device and inode numbers
# change from run to run, so they are shown as …; the permissions, offsets and names are as printed.
linux_lab.run("""
uname -m
getconf PAGESIZE
sh maps-in-the-lab.sh
""", maps=True) Output
$ uname -m aarch64 $ getconf PAGESIZE 4096 $ sh maps-in-the-lab.sh …-… r-xp 00000000 … … /usr/bin/cat …-… r--p 0000f000 … … /usr/bin/cat …-… rw-p 00010000 … … /usr/bin/cat …-… rw-p 00000000 00:00 0 [heap] …-… rw-p 00000000 00:00 0 …-… r-xp 00000000 … … /usr/lib/aarch64-linux-gnu/libc.so.6 …-… ---p 0019c000 … … /usr/lib/aarch64-linux-gnu/libc.so.6 …-… r--p 0019d000 … … /usr/lib/aarch64-linux-gnu/libc.so.6 …-… rw-p 001a0000 … … /usr/lib/aarch64-linux-gnu/libc.so.6 …-… rw-p 00000000 00:00 0 …-… r-xp 00000000 … … /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1 …-… rw-p 00000000 00:00 0 …-… rw-p 00000000 00:00 0 …-… r--p 00000000 00:00 0 [vvar] …-… r-xp 00000000 00:00 0 [vdso] …-… r--p 0002e000 … … /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1 …-… rw-p 00030000 … … /usr/lib/aarch64-linux-gnu/ld-linux-aarch64.so.1 …-… rw-p 00000000 00:00 0 …-… rw-p 00000000 00:00 0 [stack]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 maps_lab_session.py
The map belongs to cat itself. Its first three lines are /usr/bin/cat: the start of the file with its code and,
in this AArch64 build, its constants, readable and executable; a page of data the dynamic loader filled in at
start-up and then made read-only; and .data with .bss, readable and writable. Above them come the heap, then the C
library and the dynamic loader, a line for each part with its own permissions, the kernel’s [vvar] and [vdso]
pages, and the stack at the top. Each range shows as …-… because ASLR moves it on every run.
Key idea
A program’s memory is a set of regions with separate permissions, not one flat array. Code is executable but not writable, constants are read-only, and the stack and heap grow towards each other.
Checkpoint
Check: Regions and their permissions
Each process gets private memory, protected page by page
The addresses a program uses are virtual: the memory-management unit translates each one to a physical address
through page tables, in units of a page. Because the mapping is per process, two programs can both use the virtual
address 0x400000 and never interfere, since each maps to different physical memory. Permissions are held per page,
so a single page can be read-only while the next is writable.
This is also why a null-pointer dereference faults cleanly: the page at virtual address 0 is left unmapped, so any access to it traps at once instead of corrupting something. The whole scheme lets the operating system give every process its own private, protected view of memory on shared hardware. The table separates what belongs to the process from what belongs to the page:
| Question about a virtual address | What answers it | Scope |
|---|---|---|
| Which physical memory does it reach? | the process’s page table | per process: another process can use the same address for different memory |
| May it be read, written or executed? | the permission bits of its page | per page: neighbouring pages can differ |
| Does it reach anything at all? | whether its page is mapped | per page: address 0 is unmapped, so a null access faults |
The first row is why two processes can use the same address and never meet; the second is why one page can be read-only while the next is writable.
Checkpoint
Check: Private memory, page by page
Page size is not universal
The page size is a property of the machine and the kernel, not a fixed 4096. Ask for it. The run below asks three
ways: getconf PAGESIZE, which is the shell’s face of sysconf(_SC_PAGESIZE); sysctl hw.pagesize; and a tiny
program that calls getpagesize() through libSystem and exits 0 only when it gets 16384, the Apple-silicon value.
import pathlib
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent / "sessions"))
import session
# The page size is a property of the machine and the kernel, not a constant. Ask for it instead of hard-coding it.
# getconf PAGESIZE is the shell face of sysconf(_SC_PAGESIZE) and sysctl reports the macOS value; then a tiny
# program calls getpagesize() through libSystem and exits 0 only if the value is 16384, the Apple-silicon page size.
session.run("""
getconf PAGESIZE
/usr/sbin/sysctl hw.pagesize hw.cachelinesize
cat getpagesize.s
clang -o getpagesize getpagesize.s
./getpagesize; echo "page-is-16384 -> exit=$?"
""") Output
$ getconf PAGESIZE
16384
$ /usr/sbin/sysctl hw.pagesize hw.cachelinesize
hw.pagesize: 16384
hw.cachelinesize: 128
$ cat getpagesize.s
.text
.globl _main
.align 2
_main:
stp x29, x30, [sp, #-16]!
mov x29, sp
bl _getpagesize // w0 = the page size in bytes (takes no arguments, returns an int in w0)
mov w1, #0x4000 // 16384, the page size of Apple silicon
cmp w0, w1
cset w0, ne // exit 0 if the page size is 16384, else 1
ldp x29, x30, [sp], #16
ret
$ clang -o getpagesize getpagesize.s
$ ./getpagesize; echo "page-is-16384 -> exit=$?"
page-is-16384 -> exit=0
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 pagesize_session.py
Linux on x86-64 uses 4096, while Linux on arm64 is built for 4 KiB, 16 KiB or 64 KiB pages. So portable code asks:
sysconf(_SC_PAGESIZE), AT_PAGESZ from the auxiliary vector, or getpagesize(), whose manual page says a program
should not hard-code a page size; on macOS, Apple documents the vm_page_size variable. The page size matters
because the kernel maps and protects memory a whole page at a time: a one-byte mmap still costs a page, and mmap
always returns page-aligned memory.
Hard-coding 4096
Code that writes #define PAGE_SIZE 4096 is right on most x86-64 Linux machines and wrong on a Mac with 16 KiB
pages or a server with 64 KiB pages. Read the page size at run time and derive every alignment and rounding from it.
Checkpoint
Check: Page size is not universal
Why addresses change between runs
Run the same program twice and its stack, heap, libraries and often its code sit at different addresses each time.
That is address-space layout randomisation: A defence that places a process's regions (stack, heap, libraries and position-independent code) at randomised addresses on each run, so code cannot rely on a fixed address. It raises attack cost but does not fix bugs. (ASLR): the kernel and loader place regions at
randomised addresses so that an attacker cannot rely on a fixed address, a defence the security module returns to.
Randomising the code too needs a position-independent executable: An executable whose code reaches other code and data through PC-relative addressing instead of fixed addresses, so it runs correctly at any load address. This is what lets the loader randomise its placement under ASLR.:
its code uses PC-relative addressing (lea [rip+..] on x86-64, adr on AArch64, auipc on RISC-V) instead of fixed
addresses, so it runs correctly wherever it is loaded.
The practical effect is that no program or test should contain a raw address. The examples on these pages that print
an address show it as 0x…, because the real value differs on every run. Two runs are expected to agree on what a
program computes, never on the addresses it happens to be loaded at.
Key idea
Addresses are not stable across runs. Position-independent code plus ASLR means the same program loads at different addresses each time, so never bake an address into a program or a test.
Checkpoint
Check: Why addresses change between runs
Practise: round up to whole pages
A mapping is a whole number of pages, so a request is rounded up to the next page boundary. page_round_up(size, pagesize) does it with one idiom: add pagesize - 1, then clear the low bits with a mask. Read the model, then
answer the practice questions for 4 KiB, 16 KiB and 64 KiB pages.
"""Rounding a size up to a whole number of pages, the way mmap does.
A page is the unit the memory-management unit maps and protects. A mapping is a whole number of pages, so the page
size decides how much a request really costs. page_round_up uses the standard idiom: add (pagesize - 1), then clear
the low bits with a mask. It works for any power-of-two page size.
"""
def page_round_up(size, pagesize):
assert pagesize and (pagesize & (pagesize - 1)) == 0, "page size must be a power of two"
return (size + pagesize - 1) & ~(pagesize - 1)
for pagesize in (4096, 16384, 65536):
print(f"page size {pagesize} bytes ({pagesize // 1024} KiB):")
for size in (1, 4095, 4096, 4097, 70000):
up = page_round_up(size, pagesize)
pages = up // pagesize
print(f" {size:>6} B -> {up:>6} B ({pages} {'page' if pages == 1 else 'pages'})") Output
page size 4096 bytes (4 KiB):
1 B -> 4096 B (1 page)
4095 B -> 4096 B (1 page)
4096 B -> 4096 B (1 page)
4097 B -> 8192 B (2 pages)
70000 B -> 73728 B (18 pages)
page size 16384 bytes (16 KiB):
1 B -> 16384 B (1 page)
4095 B -> 16384 B (1 page)
4096 B -> 16384 B (1 page)
4097 B -> 16384 B (1 page)
70000 B -> 81920 B (5 pages)
page size 65536 bytes (64 KiB):
1 B -> 65536 B (1 page)
4095 B -> 65536 B (1 page)
4096 B -> 65536 B (1 page)
4097 B -> 65536 B (1 page)
70000 B -> 131072 B (2 pages)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 page_round.py
Practice that adapts to you
Practise this lesson
10 questions about 4 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
What is a virtual address, and why does every process get its own address space?
A virtual address is the address a program uses, which the memory-management unit translates to a physical address
through page tables, in units of a page. Each process has its own page tables, so its virtual addresses are private:
two processes can both use 0x400000 and map to different physical memory, and neither can read or write the other’s
pages. This buys three things. It gives protection: permissions are held per page, so code pages are executable but
not writable, constant pages are read-only, and the unmapped page at address 0 makes a null dereference fault at once
instead of corrupting memory. It gives isolation: a bug or a crash in one process cannot touch another. And it gives
flexibility: the kernel can place a program’s regions anywhere, back them with physical pages on demand, swap them
out, and randomise their addresses with ASLR, which is why the same program loads at different addresses on each run.
The program is built as a position-independent executable so its PC-relative code runs correctly wherever it lands.
Two more questions to practise, each with follow-ups:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
Code that aligns buffers to a hard-coded 4096-byte page works on a Linux x86-64 server but misbehaves on an Apple-silicon Mac. Explain the cause and the fix.
-
Mid to staff
Explain, defensively, what ASLR and position-independent executables protect against, what they rely on to work, and what they do not fix.
Flashcards · 6 cards
Recall it, then check
Say the answer to yourself before you look: recalling it is what makes it stick.
-
What regions does a process see in its virtual address space?
Code (.text), read-only constants (.rodata), initialised data (.data), zeroed data (.bss), the heap, shared libraries and other mmap mappings, and the stack. Each region carries its own read, write and execute permissions.
-
How does the memory-management unit keep each process's memory private?
It translates virtual addresses to physical memory one page at a time, through page tables that belong to each process. Two processes can use the same virtual address and reach different physical pages, and neither can touch the other's.
-
Why does a null pointer dereference fault at once instead of reading garbage?
The page at address 0 is left unmapped, so the memory-management unit has no translation for it and raises a fault on the first access.
-
Is a page always 4096 bytes?
No. An Apple silicon Mac reports 16384, and Linux on arm64 may use 4, 16 or 64 KiB pages. Ask at run time with sysconf(_SC_PAGESIZE) or the AT_PAGESZ entry instead of assuming 4 KiB.
-
How do you round a size up to whole pages, and what does 70000 bytes become?
Compute (size + pagesize - 1) and then clear the low bits with & ~(pagesize - 1). 70000 bytes becomes 73728 (18 pages) with 4 KiB pages and 81920 (5 pages) with 16 KiB pages.
-
Why must a program or a test never rely on a fixed address?
Address-space layout randomisation places the stack, heap and libraries at different addresses on each run, and a position-independent executable lets the code itself move too.
Key takeaways
- A process sees a virtual address space with regions:
.text,.rodata,.data,.bss, the heap, shared libraries andmmapmappings, and the stack; each region has its own read, write and execute permissions. - The memory-management unit maps virtual addresses to physical memory a page at a time, so each process is private and protected and a null dereference faults on the unmapped page at address 0.
- Page size is not a fixed 4096: an Apple-silicon Mac reports 16384 through
getconf,sysctlandgetpagesize(), and Linux on arm64 can use 4, 16 or 64 KiB pages, so read it at run time withsysconf(_SC_PAGESIZE)orAT_PAGESZ. - ASLR places regions at randomised addresses each run, and a position-independent executable lets the code move
too, so addresses are never stable and the examples mask them with
0x…. - Round a size up to whole pages with
(size + pagesize - 1) & ~(pagesize - 1); the model shows 70000 bytes becoming 73728 (18 pages) at 4 KiB and 81920 (5 pages) at 16 KiB.
Terms in this lesson
- address space
- The set of virtual addresses a process can use, divided into regions (code, read-only data, writable data, heap, mapped libraries and the stack), each with its own read, write and execute permissions.
- address-space layout randomisation
- A defence that places a process's regions (stack, heap, libraries and position-independent code) at randomised addresses on each run, so code cannot rely on a fixed address. It raises attack cost but does not fix bugs.
- memory-management unit
- The hardware that translates a process's virtual addresses to physical addresses a page at a time, using page tables, and enforces each page's read, write and execute permissions, faulting on a violation.
- page
- The fixed-size unit in which the memory-management unit maps and protects memory. Common sizes are 4 KiB, 16 KiB (Apple silicon) and 64 KiB; a request is always rounded up to whole pages.
- position-independent executable
- An executable whose code reaches other code and data through PC-relative addressing instead of fixed addresses, so it runs correctly at any load address. This is what lets the loader randomise its placement under ASLR.
- virtual address
- An address a program uses, which the memory-management unit translates to a physical address through per-process page tables. Two processes can use the same virtual address and map to different physical memory.
References
- mmap(2): map or unmap files or devices into memory (Linux man-pages project)
- getauxval(3): AT_PAGESZ and the auxiliary vector (Linux man-pages project)
- proc(5): the /proc/[pid]/maps file (Linux man-pages project)
- System V Application Binary Interface: sections (.text, .data, .rodata, .bss) (SCO Group (gABI mirror))
- sysconf(3): _SC_PAGESIZE (Linux man-pages project)
- Addressing Architectural Differences in Your macOS Code (Apple Developer Documentation)
- Linux kernel arch/arm64/Kconfig: ARM64_4K_PAGES, ARM64_16K_PAGES, ARM64_64K_PAGES (Linux kernel project)
- getpagesize(2), the Linux manual page (Linux man-pages project)
Report a problem with this lesson
Kept only in this browser. Your Learn progress