Loading content...
No discussion of combined segmentation-paging is complete without examining the Intel x86 architecture—the platform that brought this hybrid approach to billions of computers worldwide. From the introduction of protected mode in the 80286 through the sophisticated memory management of the Pentium series, Intel's implementation of combined segmentation-paging shaped how an entire generation of programmers understood memory.
The x86 architecture didn't merely implement combined segmentation-paging; it defined the canonical example. Understanding x86's approach illuminates not just one architecture but the fundamental principles that drove memory management design for decades. Even as modern x86-64 systems have largely moved to flat address spaces, the legacy of x86's segmented-paging model remains embedded in the architecture's DNA.
By the end of this page, you will understand the complete x86 protected mode memory model, including segment registers, descriptor tables, the segment-to-linear-to-physical translation pipeline, and how the architecture evolved from 16-bit real mode to 64-bit long mode.
The Intel x86 architecture, starting with the 80386, implements a three-stage address translation process that combines segmentation with optional paging. Understanding this pipeline is fundamental to grasping how x86 manages memory.
The Three Address Spaces:
The x86 architecture defines three distinct address types:
1. Logical Address (Far Pointer)
Segment Selector : OffsetCS:EIP for code, DS:ESI for data2. Linear Address (Virtual Address)
3. Physical Address
Operating Modes:
The x86 processor operates in different modes that determine how address translation occurs:
Real Mode (Legacy 8086 Mode):
Protected Mode (32-bit):
Long Mode (64-bit):
| Characteristic | Real Mode | Protected Mode 32-bit | Long Mode 64-bit |
|---|---|---|---|
| Address Size | 20-bit (1 MB) | 32-bit (4 GB) | 48-bit (256 TB) |
| Segmentation | Simple shift | Full descriptor tables | Flat (bases = 0) |
| Paging | Not available | Optional | Mandatory |
| Protection Rings | None | 0-3 (4 levels) | 0-3 (4 levels) |
| Segment Registers | 16-bit paragraph address | 16-bit selector | 16-bit selector (limited use) |
| Default Operand Size | 16-bit | 32-bit | 64-bit |
| Typical OS Usage | BIOS, DOS | Windows 9x/XP/Vista 32-bit | All modern OS |
This progression—real mode to protected mode to long mode—reflects Intel's commitment to backward compatibility while advancing the architecture. Each mode preserved compatibility with its predecessors while adding new capabilities. Protected mode's combined segmentation-paging was the crucial middle step.
The x86 architecture provides six segment registers that hold segment selectors. Each register has a specific conventional purpose, though in protected mode the hardware enforces different rules for code versus data segments.
The Six Segment Registers:
| Register | Name | Conventional Purpose | Implicit Usage |
|---|---|---|---|
| CS | Code Segment | Executable code | Instruction fetch (CS:EIP) |
| DS | Data Segment | Program data | Default for most memory operands |
| SS | Stack Segment | Stack operations | PUSH, POP, ESP/EBP references |
| ES | Extra Segment | String destinations | String operations (ES:EDI) |
| FS | Additional Segment | OS-defined | Thread-local storage (Windows) |
| GS | Additional Segment | OS-defined | Kernel data structures (Linux) |
Segment Selector Format:
Each segment register holds a 16-bit segment selector with three fields:
┌─────────────────────────────────────────────────────────────┐│ 16-bit Segment Selector │├─────────────────────────────────────────────┬────┬───────────┤│ Index (13 bits) │ TI │ RPL ││ Bits 15-3 │ b2 │ Bits 1-0 │├─────────────────────────────────────────────┼────┼───────────┤│ Selects entry 0-8191 in descriptor table │0=GDT│ Requested ││ Max 8192 descriptors per table │1=LDT│ Privilege ││ │ │ Level │└─────────────────────────────────────────────┴────┴───────────┘ Bit Layout:15 3 2 1 0┌──────────────────────────────────────────────┬────┬────┬────┐│ Index │ TI │ RPL│ RPL│└──────────────────────────────────────────────┴────┴────┴────┘ Examples: Selector 0x0008: Index=1, TI=0 (GDT), RPL=0 → GDT entry 1, ring 0 Selector 0x0023: Index=4, TI=0 (GDT), RPL=3 → GDT entry 4, ring 3 Selector 0x000F: Index=1, TI=1 (LDT), RPL=3 → LDT entry 1, ring 3 Selector 0x0000: Null selector (unusable)Selector Components Explained:
Index (bits 15-3):
Table Indicator (TI, bit 2):
Requested Privilege Level (RPL, bits 1-0):
Each segment register has a hidden (shadow) portion that caches the segment descriptor. When a selector is loaded, the CPU fetches the corresponding descriptor from memory and stores it in the hidden register. Subsequent memory accesses use the cached descriptor, avoiding repeated table lookups. This hidden portion includes the segment base, limit, and attributes—invisible to software but critical to performance.
Segment Register Loading:
Loading a segment register triggers significant processor activity:
mov ds, ax ; Load DS with selector in AX
Hardware Steps:
Privilege Check Formula:
Access allowed if: max(CPL, RPL) ≤ DPL
Where:
CPL = Current Privilege Level (from CS selector)
RPL = Requested Privilege Level (from selector being loaded)
DPL = Descriptor Privilege Level (from descriptor)
This check ensures that ring 3 code cannot access ring 0 segments, even if it somehow obtains a ring 0 selector (the RPL would be 3, failing the check).
The x86 architecture organizes segment descriptors into two types of tables: the Global Descriptor Table (GDT) and Local Descriptor Tables (LDT). Understanding these tables is essential for comprehending x86 memory protection.
Global Descriptor Table (GDT):
The GDT is a system-wide table containing segment descriptors shared by all processes:
GDT Entry Selector Description DPL─────────────────────────────────────────────────────────────────Entry 0 0x0000 Null Descriptor (required) N/AEntry 1 0x0008 Kernel Code Segment 0Entry 2 0x0010 Kernel Data Segment 0Entry 3 0x0018 User Code Segment 3Entry 4 0x0020 User Data Segment 3Entry 5 0x0028 TSS Descriptor 0Entry 6 0x0030 LDT Descriptor (if used) 0Entry 7+ 0x0038+ Per-CPU data, TLS, etc. Varies Kernel code: Base=0x00000000, Limit=0xFFFFFFFF, Type=Code, DPL=0Kernel data: Base=0x00000000, Limit=0xFFFFFFFF, Type=Data, DPL=0User code: Base=0x00000000, Limit=0xFFFFFFFF, Type=Code, DPL=3User data: Base=0x00000000, Limit=0xFFFFFFFF, Type=Data, DPL=3 Note: Modern flat model uses base=0 and limit=max for all segments. Protection is achieved through paging, not segment limits.Local Descriptor Table (LDT):
The LDT is a per-process table for process-specific segments:
LDT Usage Example:
In older systems, each process might have:
Context switching would load LDTR with the new process's LDT selector, instantly changing the meaning of all LDT-based selectors.
Why LDTs Became Obsolete:
| Aspect | GDT | LDT |
|---|---|---|
| Scope | System-wide, all processes | Per-process |
| Count | Exactly one | One per process (optional) |
| Pointer Register | GDTR | LDTR (holds selector into GDT) |
| Null Entry | Entry 0 must be null | Entry 0 can be used |
| Context Switch | No change needed | LDTR reloaded |
| Typical Contents | Kernel/user segments, TSS | Process-private segments |
| Modern Usage | Active, required | Largely obsolete |
The GDTR is a 48-bit register loaded with the LGDT instruction (privileged). It contains the 32-bit linear base address and 16-bit limit of the GDT. The LDTR is a 16-bit register loaded with LLDT, containing a selector that points to an LDT descriptor in the GDT. On context switch, only LDTR needs updating—the GDT remains the same.
Now let's trace through the complete address translation process in x86 protected mode with paging enabled. This is the heart of combined segmentation-paging.
The Two-Stage Translation:
Stage 1: Segmentation (Logical → Linear)
Selector:OffsetStage 2: Paging (Linear → Physical)
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115
// Complete x86 Protected Mode Address Translation// Input: 48-bit logical address (16-bit selector + 32-bit offset)// Output: 32-bit physical address (or exception) function x86_translate(uint16_t selector, uint32_t offset) -> uint32_t: // ═══════════════════════════════════════════════════════ // STAGE 1: SEGMENTATION (Logical → Linear) // ═══════════════════════════════════════════════════════ // 1. Parse selector uint16_t index = (selector >> 3) & 0x1FFF // Bits 15-3 bool use_ldt = (selector >> 2) & 0x1 // Bit 2 (TI) uint8_t rpl = selector & 0x3 // Bits 1-0 // 2. Validate selector if index == 0: raise GENERAL_PROTECTION_FAULT // Null selector // 3. Locate descriptor table uint32_t table_base uint16_t table_limit if use_ldt: // Get LDT from LDTR (already validated) table_base = LDTR.hidden.base table_limit = LDTR.hidden.limit else: table_base = GDTR.base table_limit = GDTR.limit // 4. Check index within table bounds uint32_t descriptor_offset = index * 8 if descriptor_offset + 7 > table_limit: raise GENERAL_PROTECTION_FAULT // Index out of bounds // 5. Load segment descriptor (8 bytes) segment_descriptor = read_memory_64(table_base + descriptor_offset) // 6. Extract descriptor fields uint32_t base = extract_base(segment_descriptor) uint32_t limit = extract_limit(segment_descriptor) uint8_t type = extract_type(segment_descriptor) uint8_t dpl = extract_dpl(segment_descriptor) bool present = extract_present(segment_descriptor) bool granularity = extract_granularity(segment_descriptor) // 7. Check descriptor validity if not present: raise SEGMENT_NOT_PRESENT_FAULT // 8. Check privilege (data segment access) uint8_t cpl = CS.hidden.dpl // Current privilege level if max(cpl, rpl) > dpl: raise GENERAL_PROTECTION_FAULT // Privilege violation // 9. Calculate effective limit uint32_t effective_limit if granularity: // 4KB granularity effective_limit = (limit << 12) | 0xFFF else: // Byte granularity effective_limit = limit // 10. Check offset against limit if offset > effective_limit: raise GENERAL_PROTECTION_FAULT // Beyond segment limit // 11. Compute linear address uint32_t linear_address = base + offset // ═══════════════════════════════════════════════════════ // STAGE 2: PAGING (Linear → Physical) [if CR0.PG = 1] // ═══════════════════════════════════════════════════════ if not CR0.PG: return linear_address // No paging, linear = physical // 12. Parse linear address (4KB pages) uint16_t pd_index = (linear_address >> 22) & 0x3FF // Bits 31-22 uint16_t pt_index = (linear_address >> 12) & 0x3FF // Bits 21-12 uint16_t page_offset = linear_address & 0xFFF // Bits 11-0 // 13. Access page directory uint32_t pd_base = CR3 & 0xFFFFF000 // CR3 upper 20 bits uint32_t pde_addr = pd_base + (pd_index * 4) uint32_t pde = read_memory_32(pde_addr) // 14. Check PDE present if not (pde & 0x1): // Present bit raise PAGE_FAULT // Page directory entry not present // 15. Access page table uint32_t pt_base = pde & 0xFFFFF000 // PDE upper 20 bits uint32_t pte_addr = pt_base + (pt_index * 4) uint32_t pte = read_memory_32(pte_addr) // 16. Check PTE present if not (pte & 0x1): raise PAGE_FAULT // Page not present (demand paging) // 17. Check page-level permissions if operation == WRITE and not (pte & 0x2): // R/W bit raise PAGE_FAULT // Write protection violation if cpl == 3 and not (pte & 0x4): // U/S bit raise PAGE_FAULT // User access to supervisor page // 18. Compute physical address uint32_t frame_base = pte & 0xFFFFF000 uint32_t physical_address = frame_base + page_offset // 19. Update access/dirty bits pte |= 0x20 // Set accessed bit if operation == WRITE: pte |= 0x40 // Set dirty bit return physical_addressConcrete Translation Example:
Let's trace a specific memory access in detail:
Given:
mov eax, [ds:0x12345678]Stage 1: Segmentation
Stage 2: Paging
In practice, most translations hit the Translation Lookaside Buffer (TLB), which caches recent {selector, offset} → physical mappings. The multi-step translation described here only occurs on TLB misses. x86 processors have separate TLBs for instruction and data accesses, and modern CPUs may have multiple TLB levels (L1, L2) with thousands of entries.
The x86 architecture uses several control registers to configure and control memory management. Understanding these registers is essential for operating system developers.
CR0 (Control Register 0):
CR0 contains fundamental control flags affecting memory and processor operation:
| Bit | Name | Description |
|---|---|---|
| 0 (PE) | Protection Enable | 0=Real Mode, 1=Protected Mode |
| 1 (MP) | Monitor Coprocessor | Controls WAIT/FWAIT behavior |
| 16 (WP) | Write Protect | 1=Ring 0 respects page R/W bit |
| 18 (AM) | Alignment Mask | Enable alignment checking (with EFLAGS.AC) |
| 29 (NW) | Not Write-through | Disable write-through caching |
| 30 (CD) | Cache Disable | Globally disable caches |
| 31 (PG) | Paging | 1=Enable paging (PE must be 1) |
CR2 (Page Fault Linear Address):
When a page fault occurs, CR2 holds the linear address that caused the fault. This is essential for the page fault handler:
// Page fault handler reads CR2
void page_fault_handler(struct interrupt_frame* frame) {
uint32_t fault_address;
asm volatile("mov %%cr2, %0" : "=r"(fault_address));
// fault_address now contains the address that triggered the fault
// Handler can load the page, kill the process, or perform COW
}
CR3 (Page Directory Base Register):
CR3 points to the page directory for the current address space:
CR3 Format (32-bit, Standard Paging):┌──────────────────────────────────┬───┬───┬──────────┐│ Page Directory Base (20 bits) │PCD│PWT│ Reserved ││ Bits 31-12 │b4 │b3 │ Bits 2-0│└──────────────────────────────────┴───┴───┴──────────┘ Bits 31-12: Physical address of page directory (4KB aligned)Bit 4 (PCD): Page-level Cache Disable for page directoryBit 3 (PWT): Page-level Write-Through for page directoryBits 2-0: Reserved (must be 0) Loading CR3 flushes the entire TLB (except global pages).This occurs during context switches when changing address spaces. Example: mov eax, 0x00100000 ; Page directory at physical 0x00100000 mov cr3, eax ; Load and flush TLBCR4 (Control Register 4):
CR4 contains additional control flags introduced in later processors:
| Bit | Name | Description |
|---|---|---|
| 4 (PSE) | Page Size Extensions | Enable 4MB pages (PDE.PS bit) |
| 5 (PAE) | Physical Address Extension | Enable 36-bit physical addressing |
| 7 (PGE) | Page Global Enable | Enable global pages (not flushed on CR3 load) |
| 13 (VMXE) | VMX Enable | Enable hardware virtualization support |
| 17 (PCIDE) | PCID Enable | Enable process-context identifiers |
| 20 (SMEP) | Supervisor Mode Execution Prevention | Prevent ring 0 from executing user pages |
| 21 (SMAP) | Supervisor Mode Access Prevention | Prevent ring 0 from accessing user pages |
Modern x86 processors use CR4 bits like SMEP and SMAP to prevent common kernel exploitation techniques. SMEP stops the kernel from executing code in user pages (defeating many return-to-userspace attacks). SMAP prevents unintentional kernel access to user data (defeating user-pointer dereference attacks). These hardware mitigations complement software protections.
Understanding how a system transitions from real mode to protected mode with paging illuminates the relationship between segmentation and paging. Here's the sequence a bootloader and early kernel must perform:
Step-by-Step Transition:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109
; =============================================================; BOOTLOADER: Transition from Real Mode to Protected Mode; ============================================================= ; We start in 16-bit Real Mode after BIOS POST[BITS 16][ORG 0x7C00] start: ; Step 1: Disable interrupts cli ; Step 2: Enable A20 line (access memory above 1MB) in al, 0x92 or al, 2 out 0x92, al ; Step 3: Load the GDT register lgdt [gdt_descriptor] ; Step 4: Set Protected Mode bit in CR0 mov eax, cr0 or eax, 1 ; Set PE (Protection Enable) bit mov cr0, eax ; Step 5: Far jump to flush prefetch queue and load CS jmp 0x08:protected_mode_entry ; 0x08 = kernel code selector ; =============================================================; 32-bit Protected Mode Code; =============================================================[BITS 32] protected_mode_entry: ; Step 6: Load data segment registers mov ax, 0x10 ; 0x10 = kernel data selector mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax ; Step 7: Set up stack mov esp, 0x90000 ; Step 8: Now enable paging (optional but typical) ; First, set up page directory and page tables in memory ; Point CR3 to page directory mov eax, page_directory ; Physical address of PD mov cr3, eax ; Enable paging by setting PG bit in CR0 mov eax, cr0 or eax, 0x80000000 ; Set PG (Paging) bit mov cr0, eax ; Step 9: Jump to kernel jmp kernel_main ; =============================================================; GDT Definition; ============================================================= gdt_start: ; Null descriptor (required) dq 0x0000000000000000 ; Kernel code segment (selector 0x08) ; Base=0, Limit=4GB, Code, Read, Ring 0 dw 0xFFFF ; Limit 15:0 dw 0x0000 ; Base 15:0 db 0x00 ; Base 23:16 db 10011010b ; Access: P=1, DPL=00, S=1, Type=1010 (Code, Read) db 11001111b ; Granularity: G=1, D=1, Limit 19:16=0xF db 0x00 ; Base 31:24 ; Kernel data segment (selector 0x10) ; Base=0, Limit=4GB, Data, Write, Ring 0 dw 0xFFFF dw 0x0000 db 0x00 db 10010010b ; Access: P=1, DPL=00, S=1, Type=0010 (Data, Write) db 11001111b db 0x00 ; User code segment (selector 0x18, with RPL=3 → 0x1B) ; Base=0, Limit=4GB, Code, Read, Ring 3 dw 0xFFFF dw 0x0000 db 0x00 db 11111010b ; Access: P=1, DPL=11, S=1, Type=1010 db 11001111b db 0x00 ; User data segment (selector 0x20, with RPL=3 → 0x23) ; Base=0, Limit=4GB, Data, Write, Ring 3 dw 0xFFFF dw 0x0000 db 0x00 db 11110010b ; Access: P=1, DPL=11, S=1, Type=0010 db 11001111b db 0x00 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; GDT size - 1 dd gdt_start ; GDT physical addressCritical Ordering Requirements:
The transition sequence must follow specific rules:
Why the Far Jump?
The far jump (jmp 0x08:protected_mode_entry) is critical for two reasons:
CS Register Update: In protected mode, CS must contain a valid selector, not the real-mode segment value. The far jump loads CS with 0x08.
Prefetch Queue Flush: The CPU prefetch queue may contain real-mode decoded instructions. The far jump forces the queue to be flushed and refilled with protected-mode instructions.
If any step is incorrect—invalid GDT, wrong selector values, missing page tables—the CPU encounters a General Protection Fault. If the GP handler is also misconfigured, a Double Fault occurs. If the Double Fault handler fails, a Triple Fault results, causing an immediate CPU reset. This is why bootloader code requires meticulous attention to detail.
We've taken a deep dive into the Intel x86 implementation of combined segmentation-paging—the architecture that defined memory management for a generation of computers. Let's consolidate the key concepts:
What's Next:
The next page examines segment descriptors in complete detail—the 8-byte structures that define every segment's base, limit, type, and protection attributes. Understanding descriptor formats is essential for both operating system development and security analysis.
You now understand the x86 protected mode memory model, including segment registers, descriptor tables, the complete address translation pipeline, control registers, and system initialization. This knowledge forms the foundation for understanding segment descriptors, which we'll explore next.