Memory Systems · Virtual Memory

Splitting a Virtual Address into Page Number and Offset

Why translation happens at page granularity, how the address bits divide between frame number and offset, and what that division costs in fragmentation and alignment.

A virtual address is an integer that a program manipulates freely, and a physical address is an integer that selects a byte in a DRAM chip. Nothing in the instruction set requires the two to resemble each other. The mapping between them is established by a data structure the processor consults on every load and store, and that structure does not have an entry per byte. It has an entry per page.

That single decision, to translate at a granularity coarser than a byte, determines the shape of everything else in virtual memory: the size of page tables, the existence of the TLB, the meaning of a page fault, the alignment rules that leak into system call interfaces, and the reason a program can waste memory by allocating one byte.

Why Translation Cannot Be Per Byte

A byte-granular mapping table for a 48-bit address space would need 2^48 entries. At eight bytes per entry that is two petabytes of table per process, which is not a resource that can be managed but a resource that cannot exist. Translation therefore has to work on blocks, and the block size has to be large enough that the table shrinks to something a machine can hold, while small enough that the waste inside a block stays tolerable.

The compromise that has held for decades is 4096 bytes, twelve bits of offset. Four kilobytes is small enough that a process with a few megabytes of live data wastes little, and large enough that the table for a large address space becomes sparse and hierarchical rather than absurd. Other sizes exist and are used: 16 KiB pages on some RISC designs, 8 KiB on others, 64 KiB on some embedded architectures, and 2 MiB or 1 GiB superpages on x86-64 and ARM. Each choice moves the same two quantities in opposite directions.

The Bit Split

Once the page size is fixed, the address divides at a fixed bit position. The low bits select a byte inside the page and are passed through translation untouched. The high bits select the page and are replaced by the number of the physical frame that holds it.

#include <stdint.h>

#define PAGE_SHIFT 12
#define PAGE_SIZE  (1u << PAGE_SHIFT)

static inline uintptr_t page_offset(uintptr_t va)
{
    return va & (PAGE_SIZE - 1);      /* low 12 bits survive translation */
}

static inline uintptr_t page_number(uintptr_t va)
{
    return va >> PAGE_SHIFT;          /* high bits index the mapping structure */
}

On a 48-bit x86-64 virtual address the split leaves 36 bits of page number. The hardware does not store those 36 bits in one place; it walks a tree of tables, which is the subject of walking a multi-level page table. What matters here is the arithmetic consequence: 2^36 pages, each needing an eight-byte entry if stored flatly, is 512 GiB of table per address space. The hierarchy exists to avoid allocating entries for the vast ranges a process never touches.

The physical side of the split is asymmetric. The offset bits are identical on both sides; the frame number replaces the page number and may be shorter or longer than it. A 48-bit virtual address on a machine with 52-bit physical addressing produces a 40-bit frame number, so the translated address is wider than the original. The processor discards the high bits it cannot represent, and a system that programs a page-table entry with a frame number beyond the implemented physical width will fault rather than silently alias.

What the Offset Buys and What It Costs

Because the offset passes through unchanged, a page is contiguous in physical memory by construction. A 4096-byte region that begins at a page boundary is guaranteed to occupy one frame, so a device driver can hand that frame to a DMA engine as a single descriptor, and a filesystem can read it with one transfer. This is the property that makes page-aligned I/O efficient and the reason buffered I/O is specified in multiples of the block size rather than in bytes.

The cost is internal fragmentation. A process that maps one byte gets 4096 bytes of address space and, once the page is touched, a 4096-byte frame. An allocator that hands out 40-byte objects from a page-aligned arena pays the tail of the last page. Over a large number of small mappings the waste is bounded by one page per mapping, which is negligible when mappings are few and substantial when they are numerous. A program that creates a hundred thousand small file mappings wastes on the order of four hundred megabytes of address space and, worse, a page-table entry per mapping.

The other cost is that protection and sharing are per page, not per object. Two variables in the same page cannot have different permissions. A read-only table placed next to a writable counter forces the whole page to be writable, which is why linkers place read-only sections on separate pages and why hardening schemes insert guard pages between regions. The mechanism that enforces this is the permission bits in the page-table entry, described in the translation cache and its misses: a write to a page whose entry lacks write permission traps, and the handler decides whether that is a copy-on-write event or a violation.

Alignment Rules That Leak Through the Interface

The granularity is visible in the system call interface whether or not the documentation emphasises it. mmap requires that the offset argument be a multiple of the page size; passing a value that is not produces EINVAL rather than a rounded mapping. mprotect requires the address to be page-aligned. munmap rounds its length up to a whole number of pages, so unmapping 100 bytes at a page boundary releases 4096. msync operates on page multiples for the same reason.

This is not an arbitrary restriction. The mapping structure has no way to express a range that starts in the middle of a page, because the entry it would have to modify covers the whole page. Interfaces that appear to allow byte granularity do so by rounding at the boundary and documenting the rounding, which is why the man pages state the rounding explicitly rather than leaving it to inference.

A related boundary case appears in memory access itself. A load or store whose operand straddles a page boundary requires two translations, and the two pages may have different permissions or may not both be present. The processor handles this by splitting the access internally, and the architectural result is that a write crossing into a read-only page faults even though the first half succeeded. Compilers avoid generating such accesses for aligned data, and the ABI alignment rules for multi-byte types exist partly to make this avoidance possible.

What Changes When the Granule Does

The split is not universal. Architectures offer a choice of page size, and the choice moves the boundary: a 16 KiB page leaves fourteen offset bits and therefore a shorter page number for the same virtual width, while a 64 KiB page leaves sixteen. Fewer page-number bits mean fewer levels of tables, which is why designs with larger granules use fewer and wider table levels. A scheme indexing nine bits per level needs four levels to cover a 32-bit page number; one indexing thirteen bits needs three.

The trade is the one that produced 4096 bytes in the first place, pushed in both directions. A larger granule reduces the number of entries needed to cover a given range and raises the minimum allocation unit, so the waste inside a partially used page grows with the page size and a mapping of one byte reserves the whole granule of address space. The contiguity requirement also hardens, because a 64 KiB frame needs 64 KiB of physically adjacent memory rather than 4 KiB, and the allocator’s ability to supply that falls as the machine fragments.

User space does not have to guess. The page size is queryable at run time through sysconf(_SC_PAGESIZE), and code that depends on mapping granularity, such as an allocator or a runtime that manages page-aligned regions itself, reads it rather than assuming a constant. A program that hard-codes 4096 is correct only where 4096 is the granule, and the failure on a larger one is a mapping that rounds differently than expected rather than an outright error.

Where the Split Stops Working

The 4 KiB choice is a compromise that shows strain in two directions. Workloads with large sequential footprints, such as databases and virtual machines, spend a disproportionate share of their translation budget on entries that all map adjacent frames. The response is the superpage, which changes the split so that more offset bits pass through untranslated and fewer bits need entries. The trade is that a superpage must be physically contiguous, so allocating one requires either luck or compaction, and the waste inside a partially used superpage is measured in megabytes.

The other direction is the tiny allocation. A page is the smallest unit the hardware can map, so any allocator that wants sub-page granularity must multiplex many objects inside one page and accept that they share a translation entry. This is why malloc implementations manage their own pools rather than calling mmap per object: the page is the unit of mapping, but not the unit of allocation, and conflating the two produces both fragmentation and a page-table entry per object.

Between those extremes sits the ordinary case, where a process maps a few hundred megabytes of code, data, heap, and shared libraries. The split works, the tables stay sparse, and the offset quietly guarantees that a page of data can be moved, shared, or written back as a single unit. Every later mechanism in this cluster, from fault handling to reverse mapping, assumes that guarantee.

References

  1. Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 3A: System Programming Guide — Intel Corporation
  2. Operating Systems: Three Easy Pieces — Paging: Introduction — Arpaci-Dusseau and Arpaci-Dusseau
  3. POSIX.1-2017, System Interfaces — mmap() — IEEE Std 1003.1-2017