Memory Pagination: Understanding the Virtual Memory Abstraction
Imagine trying to run dozens of modern applications simultaneously on a computer with limited physical memory. Without some clever mechanisms in place, your system would quickly grind to a halt. This is where memory pagination comes in—one of the most elegant solutions in operating system design that allows computers to run programs requiring more memory than physically available.
Memory Abstraction: Paging and Its Role in Modern Systems
What is Paging?
Let’s start with a simple analogy. Think of computer memory like a large library. In a library without any organization system, finding a specific book would require searching through every shelf. Similarly, in early computing systems, memory allocation was a continuous, unorganized space where finding and allocating memory was inefficient.
Paging is like introducing a card catalog system to that library. The logical address space (what programs “see”) is divided into fixed-size units called pages, while the physical memory is divided into equally-sized blocks called frames. This division creates a structured approach to memory management.
What makes paging particularly powerful is that a page from the logical address space can be mapped to any available frame in physical memory. It’s like being able to store books from the same author on different shelves throughout the library, yet still having a catalog that tells you exactly where each book is located.
The beauty of paging is its transparency—application developers don’t need to worry about where in physical memory their code will run. The operating system, through its memory management unit (MMU), handles all the translation behind the scenes.
Why is Paging Used?
When I first learned about memory management, I wondered why we needed such a complex system. Couldn’t we just allocate memory sequentially? The answer lies in several critical advantages that paging offers:
Elimination of External Fragmentation
External fragmentation occurs when free memory exists in small, non-contiguous chunks that can’t be used effectively. Imagine trying to park a bus in a parking lot that has enough total free space, but spread across individual car spots—it simply won’t fit.
With paging, since both logical and physical memory are divided into fixed-size chunks, any free frame can accommodate any page. It’s like having a parking lot where all spaces are the same size, and you can park anywhere there’s an opening.
Simplification of Memory Allocation
Memory allocation becomes remarkably simpler with paging. The operating system needs only to find any available frame for a new page, rather than searching for a contiguous block of memory large enough for an entire process.
Consider how much easier it is to find a single empty shelf for a book versus finding several adjacent empty shelves for a multi-volume encyclopedia.
Efficient Use of Physical Memory
Modern systems use techniques like demand paging, where pages are loaded into memory only when accessed. This is like a library that only retrieves books from storage when someone actually requests them, rather than keeping all books on display at all times.
This approach dramatically reduces memory overhead and allows systems to run more processes simultaneously than would otherwise be possible.
Support for Virtual Memory
Paging is the cornerstone of virtual memory implementation. Virtual memory creates the illusion of having more memory than physically available by using disk storage as an extension of RAM.
It’s comparable to a library that maintains a small display area but has a vast storage warehouse. Books (pages) can be moved between display (RAM) and storage (disk) as needed, giving the impression of an almost unlimited display capacity.
Process Isolation
Security in multi-user, multi-process environments is paramount. Paging provides robust isolation by ensuring that each process can only access its own memory pages.
Think of it as having separate, private reading rooms in our library where each reader can only access the books assigned to them, preventing them from interfering with others’ materials.
How Does Paging Work?
Now, let’s dive deeper into the mechanics of paging:
Logical Addresses vs. Physical Addresses
When an application runs, it generates logical addresses—locations in memory from the application’s perspective. These logical addresses must be translated to physical addresses—actual locations in hardware memory—before any data can be accessed.
In paging systems, logical addresses consist of two parts:
- Page Number: Identifies which page in the logical address space
- Offset: Specifies the exact byte position within that page
For example, with a page size of 4KB (4,096 bytes), a logical address of 12,345 would be broken down as:
- Page Number: 12,345 ÷ 4,096 = 3 (integer division)
- Offset: 12,345 % 4,096 = 57
This means the address refers to the 57th byte of page 3.
Translation via Page Table
The operating system maintains a page table for each process. This table is essentially a mapping directory that converts page numbers to frame numbers.
When the CPU encounters a logical address, the memory management unit (MMU) extracts the page number and looks it up in the page table. This lookup returns the corresponding frame number in physical memory. The physical address is then constructed by combining this frame number with the original offset.
Formula for Address Translation:
Physical Address = (Frame Number × Page Size) + Offset
Using our previous example:
- Page Number: 3
- Offset: 57
- If the page table indicates Page 3 maps to Frame 8
- Physical Address = (8 × 4,096) + 57 = 32,825
This translation happens automatically in hardware for every memory access, making it extremely fast despite its complexity.
Let’s visualize this with a simple diagram:
Logical Address (12,345)
┌─────────────┬───────────┐
│ Page Number │ Offset │
│ 3 │ 57 │
└─────────────┴───────────┘
│ │
▼ │
┌─────────────┐ │
│ Page Table │ │
├─────────────┤ │
│ Page 0 → 11 │ │
│ Page 1 → 5 │ │
│ Page 2 → 9 │ │
│ Page 3 → 8 │ │
│ Page 4 → 14 │ │
└─────────────┘ │
│ │
▼ ▼
┌─────────────┬───────────┐
│Frame Number │ Offset │
│ 8 │ 57 │
└─────────────┴───────────┘
Physical Address (32,825)
Implementing Paging in C: A Practical Example
Let’s explore how we might implement a simplified paging system in C. This example won’t be a complete operating system implementation, but it will demonstrate the core concepts.
Basic Paging Structure
First, let’s define the structures we’ll need:
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
// Define sizes (in bytes)
#define PAGE_SIZE 4096
#define PHYSICAL_MEMORY_SIZE (64 * PAGE_SIZE) // 64 frames
#define VIRTUAL_MEMORY_SIZE (128 * PAGE_SIZE) // 128 pages
// Define page table entry structure
typedef struct {
uint32_t frame_number : 12; // 12 bits for frame number (4096 possible frames)
uint8_t present : 1; // Is this page in physical memory?
uint8_t writable : 1; // Can this page be written to?
uint8_t user_accessible : 1; // Can user-mode processes access this page?
uint8_t accessed : 1; // Has this page been accessed?
uint8_t dirty : 1; // Has this page been modified?
} PageTableEntry;
// Define process structure
typedef struct {
PageTableEntry* page_table; // Each process has its own page table
uint32_t page_table_size; // Number of pages in the virtual address space
// Other process information would go here
} Process;
// Simulate physical memory
uint8_t physical_memory[PHYSICAL_MEMORY_SIZE];
// Track which frames are free
uint8_t frame_allocation_map[PHYSICAL_MEMORY_SIZE / PAGE_SIZE];
Now, let’s implement some basic functions to handle memory allocation and address translation:
// Initialize a new process with its own page table
Process* create_process() {
Process* process = (Process*)malloc(sizeof(Process));
// Calculate number of pages needed for this process
process->page_table_size = VIRTUAL_MEMORY_SIZE / PAGE_SIZE;
// Allocate page table
process->page_table = (PageTableEntry*)calloc(process->page_table_size, sizeof(PageTableEntry));
// Initialize all pages as not present in physical memory
for (uint32_t i = 0; i < process->page_table_size; i++) {
process->page_table[i].present = 0;
}
return process;
}
// Find a free frame in physical memory
int find_free_frame() {
for (int i = 0; i < PHYSICAL_MEMORY_SIZE / PAGE_SIZE; i++) {
if (frame_allocation_map[i] == 0) {
frame_allocation_map[i] = 1; // Mark as allocated
return i;
}
}
return -1; // No free frames
}
// Allocate a physical frame for a virtual page
int allocate_page(Process* process, uint32_t page_number) {
// Check if page is already allocated
if (process->page_table[page_number].present) {
return process->page_table[page_number].frame_number;
}
// Find a free frame
int frame = find_free_frame();
if (frame == -1) {
// No free frames, would need to implement page replacement
printf("Error: No free frames available\n");
return -1;
}
// Update page table
process->page_table[page_number].frame_number = frame;
process->page_table[page_number].present = 1;
process->page_table[page_number].writable = 1;
process->page_table[page_number].user_accessible = 1;
process->page_table[page_number].accessed = 0;
process->page_table[page_number].dirty = 0;
return frame;
}
// Translate virtual address to physical address
uint32_t translate_address(Process* process, uint32_t virtual_address) {
// Extract page number and offset
uint32_t page_number = virtual_address / PAGE_SIZE;
uint32_t offset = virtual_address % PAGE_SIZE;
// Check if page is in bounds
if (page_number >= process->page_table_size) {
printf("Error: Page number out of bounds\n");
return -1;
}
// Check if page is present in physical memory
if (!process->page_table[page_number].present) {
// Page fault: Allocate a frame for this page
printf("Page fault: Allocating frame for page %u\n", page_number);
int frame = allocate_page(process, page_number);
if (frame == -1) {
return -1; // Allocation failed
}
}
// Mark page as accessed
process->page_table[page_number].accessed = 1;
// Calculate physical address
uint32_t frame_number = process->page_table[page_number].frame_number;
uint32_t physical_address = (frame_number * PAGE_SIZE) + offset;
return physical_address;
}
Now let’s add functions to read and write memory:
// Write a byte to a virtual address
void write_byte(Process* process, uint32_t virtual_address, uint8_t value) {
uint32_t physical_address = translate_address(process, virtual_address);
if (physical_address == -1) {
return; // Translation failed
}
// Check if page is writable
uint32_t page_number = virtual_address / PAGE_SIZE;
if (!process->page_table[page_number].writable) {
printf("Error: Page is not writable\n");
return;
}
// Mark page as dirty (modified)
process->page_table[page_number].dirty = 1;
// Write to physical memory
physical_memory[physical_address] = value;
}
// Read a byte from a virtual address
uint8_t read_byte(Process* process, uint32_t virtual_address) {
uint32_t physical_address = translate_address(process, virtual_address);
if (physical_address == -1) {
return 0; // Translation failed
}
// Read from physical memory
return physical_memory[physical_address];
}
Finally, let’s add a simple example of how to use this paging system:
// Example usage
int main() {
// Initialize frame allocation map
for (int i = 0; i < PHYSICAL_MEMORY_SIZE / PAGE_SIZE; i++) {
frame_allocation_map[i] = 0; // All frames free initially
}
// Create a process
Process* process = create_process();
// Write some values to memory
printf("Writing values to virtual memory...\n");
for (uint32_t i = 0; i < 5; i++) {
uint32_t addr = i * PAGE_SIZE + i; // Access different pages
write_byte(process, addr, 65 + i); // ASCII 'A', 'B', 'C', etc.
printf("Wrote '%c' to virtual address %u\n", 65 + i, addr);
}
// Read the values back
printf("\nReading values from virtual memory...\n");
for (uint32_t i = 0; i < 5; i++) {
uint32_t addr = i * PAGE_SIZE + i;
uint8_t value = read_byte(process, addr);
printf("Read '%c' from virtual address %u\n", value, addr);
}
// Clean up
free(process->page_table);
free(process);
return 0;
}
This code demonstrates the basic concepts of paging:
- Each process has its own page table mapping virtual pages to physical frames
- Address translation breaks an address into page number and offset
- Page faults occur when accessing a page not currently in physical memory
- Pages can have attributes like present, writable, accessed, and dirty
Enhancing Our Implementation with TLB
In real systems, page table lookups would be extremely slow if done for every memory access. To speed things up, processors use a Translation Lookaside Buffer (TLB)—a small, specialized cache that stores recent address translations.
Let’s extend our example to include a simple TLB:
// Define TLB entry structure
typedef struct {
uint32_t page_number;
uint32_t frame_number;
uint8_t valid;
} TLBEntry;
// Define TLB
#define TLB_SIZE 16
TLBEntry tlb[TLB_SIZE];
uint32_t tlb_next_entry = 0;
// Initialize TLB
void init_tlb() {
for (int i = 0; i < TLB_SIZE; i++) {
tlb[i].valid = 0;
}
}
// Look up address in TLB
int tlb_lookup(uint32_t page_number, uint32_t* frame_number) {
for (int i = 0; i < TLB_SIZE; i++) {
if (tlb[i].valid && tlb[i].page_number == page_number) {
*frame_number = tlb[i].frame_number;
return 1; // TLB hit
}
}
return 0; // TLB miss
}
// Update TLB with new mapping
void tlb_update(uint32_t page_number, uint32_t frame_number) {
// Use simple round-robin replacement
tlb[tlb_next_entry].page_number = page_number;
tlb[tlb_next_entry].frame_number = frame_number;
tlb[tlb_next_entry].valid = 1;
// Move to next entry
tlb_next_entry = (tlb_next_entry + 1) % TLB_SIZE;
}
Now, let’s modify our address translation function to use the TLB:
// Translate virtual address to physical address with TLB
uint32_t translate_address(Process* process, uint32_t virtual_address) {
// Extract page number and offset
uint32_t page_number = virtual_address / PAGE_SIZE;
uint32_t offset = virtual_address % PAGE_SIZE;
uint32_t frame_number;
// Check if page is in bounds
if (page_number >= process->page_table_size) {
printf("Error: Page number out of bounds\n");
return -1;
}
// Try TLB first
if (tlb_lookup(page_number, &frame_number)) {
printf("TLB hit for page %u\n", page_number);
} else {
// TLB miss: Check page table
printf("TLB miss for page %u\n", page_number);
// Check if page is present in physical memory
if (!process->page_table[page_number].present) {
// Page fault: Allocate a frame for this page
printf("Page fault: Allocating frame for page %u\n", page_number);
int frame = allocate_page(process, page_number);
if (frame == -1) {
return -1; // Allocation failed
}
frame_number = frame;
} else {
frame_number = process->page_table[page_number].frame_number;
}
// Update TLB with this translation
tlb_update(page_number, frame_number);
}
// Mark page as accessed
process->page_table[page_number].accessed = 1;
// Calculate physical address
uint32_t physical_address = (frame_number * PAGE_SIZE) + offset;
return physical_address;
}
And we would need to initialize the TLB in our main function:
int main() {
// Initialize frame allocation map
for (int i = 0; i < PHYSICAL_MEMORY_SIZE / PAGE_SIZE; i++) {
frame_allocation_map[i] = 0; // All frames free initially
}
// Initialize TLB
init_tlb();
// Create a process
Process* process = create_process();
// Rest of the function remains the same...
}
Paging vs. Segmentation: A Deeper Comparison
Now that we understand paging better, let’s compare it more thoroughly with segmentation, another memory management approach:
Fundamental Philosophy
Paging is based on a fixed-size division approach. It’s like dividing a book into pages of equal length, regardless of where chapters begin or end. This regularity simplifies physical memory management but doesn’t align with how programs are naturally structured.
Segmentation, on the other hand, divides memory based on logical program units—like having chapters in a book that can be any length but represent cohesive sections. Programs naturally consist of distinct sections like code, data, stack, and heap, which segmentation preserves as separate units.
Memory Utilization Challenges
Paging eliminates external fragmentation (unusable gaps between allocated memory blocks) but introduces internal fragmentation. Since pages have fixed sizes, the last page of a process might not be fully utilized, wasting some memory. For example, if a process needs 4.5 pages, it will be allocated 5 full pages, wasting half a page.
Segmentation has the opposite problem. It eliminates internal fragmentation since segments can be exactly the size needed, but it creates external fragmentation as segments of different sizes are allocated and deallocated over time.
Let’s visualize this with memory diagrams:
For paging:
Physical Memory with Paging (4KB pages)
┌───────┬───────┬───────┬───────┬───────┬───────┐
│Frame 0│Frame 1│Frame 2│Frame 3│Frame 4│Frame 5│...
│Process│Process│Process│Process│Process│ Free │
│ A │ A │ B │ C │ A │ │
│ (Full)│ (Full)│ (Full)│ (Full)│(Half) │ │
└───────┴───────┴───────┴───────┴───────┴───────┘
▲
│
Internal Fragmentation
(Half of Frame 4 wasted)
For segmentation:
Physical Memory with Segmentation
┌────────────┬──────┬──────────────┬──────┬────────┐
│ Process A │ Free │ Process B │ Free │Process │...
│ Code Seg │Space │ Data Seg │Space │ C │
│ (1.2 MB) │(0.1MB│ (0.8 MB) │(0.2MB│(0.5 MB)│
└────────────┴──────┴──────────────┴──────┴────────┘
▲ ▲
│ │
External Fragmentation
(Small free spaces that can't be used)
Address Translation Complexity
Paging has a straightforward address translation: divide the address into page number and offset, look up the page number in the page table to get the frame number, then combine the frame number with the offset.
Segmentation requires more complex translation: the address includes a segment number and an offset, and segments can have different sizes. The system must check that the offset is within the segment’s length bounds before calculating the physical address.
Protection and Sharing
Paging makes sharing code between processes simple. If two processes use the same code (like a shared library), that code can be loaded into memory once and mapped into both processes’ address spaces through their respective page tables.
Segmentation naturally aligns with protection needs—different segments can have different access permissions (read, write, execute). For example, code segments can be marked as read-only and executable, while data segments can be writable but not executable.
Implementation in Modern Systems
Most modern systems use a hybrid approach called paged segmentation or segmented paging. Let’s explore how this works:
Combining Paging and Segmentation: The Best of Both Worlds
In modern systems, we often see a hybrid approach that leverages the benefits of both paging and segmentation. Let’s examine how this works in practice:
Segmented Paging
In segmented paging, the address space is first divided into segments that represent logical units of the program (like code, data, stack). Then, each segment is further divided into pages of fixed size.
The logical address in this scheme consists of three components:
- Segment Number
- Page Number (within the segment)
- Offset (within the page)
Address translation becomes a two-step process:
- The segment number is used to locate the segment’s page table
- The page number is used to find the corresponding frame in physical memory
Here’s a C structure that could represent this:
typedef struct {
uint32_t base_address; // Base address of the segment's page table
uint32_t limit; // Size of the segment in pages
uint8_t present : 1; // Is this segment in memory?
uint8_t privilege : 2; // Privilege level (0-3)
uint8_t readable : 1; // Can be read
uint8_t writable : 1; // Can be written
uint8_t executable : 1; // Can be executed
} SegmentDescriptor;
typedef struct {
SegmentDescriptor* segment_table; // Segment descriptor table
uint32_t segment_count; // Number of segments
PageTableEntry** page_tables; // Array of page tables, one for each segment
} ProcessMemoryMap;
The address translation might look like this:
uint32_t translate_segmented_paging_address(ProcessMemoryMap* memory_map,
uint32_t segment,
uint32_t page,
uint32_t offset) {
// Check if segment is valid
if (segment >= memory_map->segment_count) {
printf("Error: Segment number out of bounds\n");
return -1;
}
// Check segment permissions and limits
SegmentDescriptor* seg_desc = &memory_map->segment_table[segment];
if (!seg_desc->present) {
printf("Error: Segment not present in memory\n");
return -1;
}
if (page >= seg_desc->limit) {
printf("Error: Page number exceeds segment limit\n");
return -1;
}
// Get page table for this segment
PageTableEntry* page_table = memory_map->page_tables[segment];
// Check if page is present
if (!page_table[page].present) {
// Handle page fault
printf("Page fault: Segment %u, Page %u\n", segment, page);
// Page fault handling code would go here
return -1;
}
// Get frame number
uint32_t frame = page_table[page].frame_number;
// Calculate physical address
uint32_t physical_address = (frame * PAGE_SIZE) + offset;
return physical_address;
}
Intel x86 Memory Model: A Real-World Example
The Intel x86 architecture uses a form of segmented paging that has evolved over time. In protected mode, the processor uses segment registers to select segment descriptors from descriptor tables (GDT or LDT). The segmentation unit produces a linear address, which is then translated by the paging unit into a physical address.
Here’s a simplified version of how it works:
- The application generates a logical address: (Segment Selector, Offset)
- The processor uses the segment selector to retrieve the segment descriptor
- The base address from the segment descriptor is added to the offset to form a linear address
- The paging unit translates the linear address to a physical address:
- The upper bits form the page directory index
- The middle bits form the page table index
- The lower bits form the offset within the page
This is a complex but powerful approach that has supported decades of backward compatibility while allowing modern operating systems to implement efficient memory management.
Practical Implementations: Page Replacement Algorithms
When physical memory is full and a new page needs to be loaded, the operating system must choose a page to evict. Several algorithms exist for this purpose, each with different characteristics. Let’s implement a few in C:
First-In-First-Out (FIFO)
#define MAX_FRAMES 64
// FIFO queue
typedef struct {
uint32_t frames[MAX_FRAMES];
int front;
int rear;
int count;
} FIFOQueue;
void fifo_init(FIFOQueue* queue) {
queue->front = 0;
queue->rear = -1;
queue->count = 0;
}
void fifo_enqueue(FIFOQueue* queue, uint32_t frame) {
if (queue->count >= MAX_FRAMES) {
printf("Queue is full\n");
return;
}
queue->rear = (queue->rear + 1) % MAX_FRAMES;
queue->frames[queue->rear] = frame;
queue->count++;
}
uint32_t fifo_dequeue(FIFOQueue* queue) {
if (queue->count <= 0) {
printf("Queue is empty\n");
return -1;
}
uint32_t frame = queue->frames[queue->front];
queue->front = (queue->front + 1) % MAX_FRAMES;
queue->count--;
return frame;
}
// Page replacement using FIFO
uint32_t replace_page_fifo(FIFOQueue* queue) {
return fifo_dequeue(queue);
}
Least Recently Used (LRU)
// Simplified LRU implementation using a counter
typedef struct {
uint32_t frame;
uint64_t last_used_time;
} FrameUsage;
typedef struct {
FrameUsage frames[MAX_FRAMES];
int count;
uint64_t clock; // Global clock for timestamp
} LRUTracker;
void lru_init(LRUTracker* tracker) {
tracker->count = 0;
tracker->clock = 0;
}
void lru_access(LRUTracker* tracker, uint32_t frame) {
// Find frame in tracker
for (int i = 0; i < tracker->count; i++) {
if (tracker->frames[i].frame == frame) {
// Update last used time
tracker->frames[i].last_used_time = tracker->clock++;
return;
}
}
// Frame not found, add it
if (tracker->count < MAX_FRAMES) {
tracker->frames[tracker->count].frame = frame;
tracker->frames[tracker->count].last_used_time = tracker->clock++;
tracker->count++;
} else {
printf("Error: LRU tracker full\n");
}
}
uint32_t replace_page_lru(LRUTracker* tracker) {
if (tracker->count == 0) {
printf("Error: No frames to replace\n");
return -1;
}
// Find least recently used frame
int lru_index = 0;
uint64_t min_time = tracker->frames[0].last_used_time;
for (int i = 1; i < tracker->count; i++) {
if (tracker->frames[i].last_used_time < min_time) {
min_time = tracker->frames[i].last_used_time;
lru_index = i;
}
}
uint32_t frame_to_replace = tracker->frames[lru_index].frame;
// Remove the frame from tracker (or just update it when reused)
for (int i = lru_index; i < tracker->count - 1; i++) {
tracker->frames[i] = tracker->frames[i + 1];
}
tracker->count--;
return frame_to_replace;
}
The Second Chance Algorithm (Clock)
// Clock algorithm implementation
typedef struct {
uint32_t frames[MAX_FRAMES];
uint8_t referenced[MAX_FRAMES];
int count;
int hand; // Clock hand
} ClockAlgorithm;
void clock_init(ClockAlgorithm* clock) {
clock->count = 0;
clock->hand = 0;
for (int i = 0; i < MAX_FRAMES; i++) {
clock->referenced[i] = 0;
}
}
void clock_access(ClockAlgorithm* clock, uint32_t frame) {
// Find frame in clock
for (int i = 0; i < clock->count; i++) {
if (clock->frames[i] == frame) {
// Set referenced bit
clock->referenced[i] = 1;
return;
}
}
// Frame not found, add it
if (clock->count < MAX_FRAMES) {
clock->frames[clock->count] = frame;
clock->referenced[clock->count] = 1;
clock->count++;
} else {
printf("Error: Clock algorithm tracker full\n");
}
}
uint32_t replace_page_clock(ClockAlgorithm* clock) {
if (clock->count == 0) {
printf("Error: No frames to replace\n");
return -1;
}
// Find a frame to replace
while (1) {
if (clock->referenced[clock->hand] == 0) {
// Found a non-referenced frame to replace
uint32_t frame_to_replace = clock->frames[clock->hand];
// Move hand to next position
clock->hand = (clock->hand + 1) % clock->count;
return frame_to_replace;
}
// Give second chance by clearing referenced bit
clock->referenced[clock->hand] = 0;
// Move hand to next position
clock->hand = (clock->hand + 1) % clock->count;
}
}
Real-World Considerations and Challenges
Large Page Tables
As virtual address spaces grow larger, page tables can become enormous. For a 32-bit address space with 4KB pages, the page table would have 2^20 (over 1 million) entries. For 64-bit systems, this problem is exponentially worse.
Modern systems address this with multi-level page tables or inverted page tables:
Multi-level Page Tables: The page table is itself paged, creating a tree-like structure. This approach only requires the parts of the page table that are actually being used to be in memory.
Inverted Page Tables: Instead of having one entry for each virtual page, an inverted page table has one entry for each physical frame, mapping back to which process and virtual page owns it. This reduces table size but makes lookups more complex.
Here’s a simplified implementation of a two-level page table:
#define PAGE_TABLE_ENTRIES 1024 // 2^10 entries in each table
#define PAGE_DIRECTORY_ENTRIES 1024 // 2^10 entries in directory
typedef struct {
uint32_t frame_number : 20;
uint8_t present : 1;
uint8_t writable : 1;
uint8_t user_accessible : 1;
uint8_t accessed : 1;
uint8_t dirty : 1;
uint8_t reserved : 7;
} PageTableEntry;
typedef struct {
PageTableEntry* table_address;
uint8_t present : 1;
uint8_t writable : 1;
uint8_t user_accessible : 1;
uint8_t reserved : 9;
} PageDirectoryEntry;
typedef struct {
PageDirectoryEntry* page_directory;
} ProcessMemoryMap;
// Allocate a two-level page table
ProcessMemoryMap* create_two_level_page_table() {
ProcessMemoryMap* memory_map = (ProcessMemoryMap*)malloc(sizeof(ProcessMemoryMap));
// Allocate page directory
memory_map->page_directory = (PageDirectoryEntry*)calloc(PAGE_DIRECTORY_ENTRIES, sizeof(PageDirectoryEntry));
return memory_map;
}
// Translate address using two-level page table
uint32_t translate_two_level_address(ProcessMemoryMap* memory_map, uint32_t virtual_address) {
// Extract directory index, page table index, and offset
uint32_t directory_index = (virtual_address >> 22) & 0x3FF; // Top 10 bits
uint32_t page_table_index = (virtual_address >> 12) & 0x3FF; // Middle 10 bits
uint32_t offset = virtual_address & 0xFFF; // Bottom 12 bits
// Check if page directory entry is present
PageDirectoryEntry* dir_entry = &memory_map->page_directory[directory_index];
if (!dir_entry->present) {
printf("Page directory entry not present\n");
return -1;
}
// Get page table
PageTableEntry* page_table = dir_entry->table_address;
// Check if page table entry is present
PageTableEntry* page_entry = &page_table[page_table_index];
if (!page_entry->present) {
printf("Page table entry not present\n");
return -1;
}
// Calculate physical address
uint32_t physical_address = (page_entry->frame_number << 12) | offset;
return physical_address;
}
Huge Pages and Page Size Considerations
While 4KB is the standard page size for many systems, larger pages (2MB or 1GB) are becoming more common, especially in systems with large amounts of RAM. These “huge pages” reduce the number of TLB entries needed to cover a given amount of memory, improving performance for large, contiguous memory accesses.
The tradeoff is that larger pages can lead to more internal fragmentation. It’s a classic space-time tradeoff in computing.
Conclusion
Paging is a foundational technique in modern computing that enables efficient memory management, process isolation, and the illusion of unlimited memory through virtual memory. By dividing memory into fixed-size chunks, operating systems can allocate and manage memory with remarkable efficiency, even under the complex demands of multitasking environments.
The examples we’ve explored in C provide a glimpse into how memory pagination might be implemented, though real-world operating systems are considerably more complex. Modern systems typically employ multi-level page tables, TLBs, huge pages, and sophisticated page replacement algorithms to optimize both performance and memory utilization.
Understanding paging is essential for anyone working in systems programming, operating system development, or performance optimization. The concepts we’ve covered—from basic address translation to page replacement algorithms—form the foundation of virtually all modern computing environments.
For a deeper dive into practical implementations, I encourage exploring the memory management code in open-source operating systems like Linux or FreeBSD. These real-world systems showcase the elegant solutions that have evolved to address the challenges of memory management in complex computing environments.