Loading learning content...
We've explored what segment table entries contain—base addresses, limits, protection bits. But there's a fundamental question we haven't fully addressed: Where are these segment tables stored, and how does the CPU find them?
This is a classic bootstrapping problem. The CPU needs segment descriptors to access memory, but those descriptors are themselves stored in memory. How does the hardware break this circular dependency?
The answer lies in special CPU registers that hold the physical addresses and sizes of descriptor tables. These registers—GDTR (Global Descriptor Table Register) and LDTR (Local Descriptor Table Register)—are the root of trust for segmented memory access. Everything flows from them.
Understanding descriptor table location is essential for operating system development. The OS must:
This page demystifies the architecture of descriptor table management.
By the end of this page, you will understand the Global and Local Descriptor Tables (GDT/LDT), the GDTR and LDTR registers, descriptor table structure and layout, selector-to-descriptor lookup, per-process LDT management, context switch handling, and descriptor table protection.
x86 protected mode uses a two-tier descriptor table architecture:
1. Global Descriptor Table (GDT):
2. Local Descriptor Table (LDT):
Why Two Tables?
The two-table design separates concerns:
This design allows the OS to:
Many modern operating systems (Linux, Windows in 64-bit mode) minimize LDT usage. With flat memory models and paging providing isolation, per-process LDTs add complexity without significant benefit. The GDT is still essential, but often only a handful of descriptors are actually used.
The Global Descriptor Table Register (GDTR) is a special CPU register that holds the location and size of the GDT. It cannot be accessed like general-purpose registers—only special instructions can read or write it.
GDTR Structure:
┌────────────────────────────────────────────────────────┐
│ GDTR: 48 bits (10 bytes on 32-bit, 80 bits on 64-bit) │
├────────────────────────────────────────────────────────┤
│ Limit (16 bits) │ Base Address (32/64 bits) │
│ Bytes 0-1 │ Bytes 2-5 (or 2-9 on 64-bit) │
└────────────────────┴───────────────────────────────────┘
Limit: Size of GDT in bytes minus 1 (max 65535 = 8192 entries × 8 bytes)
Base: Linear (virtual) address where GDT starts
Critical Points:
Base is Linear Address: With paging enabled, the GDT base is a virtual address that must be mapped in page tables.
Limit is Size-1: A limit of 0x17 means 24 bytes (3 entries × 8 bytes), not 0x17 entries.
Entry 0 is Null: GDT entry 0 is reserved as the "null descriptor" and cannot be used.
GDTR Instructions:
1234567891011121314151617181920212223242526272829303132333435363738
; LGDT - Load GDT Register; Operand is a 6-byte (or 10-byte) memory locationlgdt [gdt_descriptor] ; SGDT - Store GDT Register ; Saves current GDTR to memory (can be executed in user mode!)sgdt [gdt_save_location] ; GDT descriptor structure (for LGDT)gdt_descriptor: dw gdt_end - gdt_start - 1 ; Limit (size - 1) dd gdt_start ; Base address (32-bit) ; 64-bit versiongdt_descriptor_64: dw gdt_end - gdt_start - 1 ; Limit (size - 1) dq gdt_start ; Base address (64-bit) ; Example GDTalign 16gdt_start:gdt_null: ; Entry 0: Null descriptor (required) dq 0gdt_code: ; Entry 1: Kernel code segment dw 0xFFFF ; Limit[15:0] dw 0x0000 ; Base[15:0] db 0x00 ; Base[23:16] db 10011010b ; Access byte: P=1, DPL=0, S=1, Type=Code Execute/Read db 11001111b ; Flags + Limit[19:16]: G=1, D=1, Limit=0xF db 0x00 ; Base[31:24]gdt_data: ; Entry 2: Kernel data segment dw 0xFFFF dw 0x0000 db 0x00 db 10010010b ; Access byte: P=1, DPL=0, S=1, Type=Data Read/Write db 11001111b ; G=1, D=1 db 0x00gdt_end:Security Consideration: SGDT Vulnerability
The SGDT instruction can be executed from user mode, revealing the GDT's location. This has been exploited for:
Modern systems mitigate this:
While SGDT can run in user mode (unfortunately), LGDT is strictly privileged (Ring 0 only). Executing LGDT from Ring 3 causes #GP. This is critical: if users could load arbitrary GDTs, all protection would be meaningless.
The Local Descriptor Table Register (LDTR) points to the current process's LDT. Unlike GDTR which holds a direct memory address, LDTR holds a selector that references a descriptor in the GDT.
LDTR Structure:
┌──────────────────────────────────────────────────────────────────┐
│ LDTR: 16-bit visible selector + hidden descriptor cache │
├──────────────────────────────────────────────────────────────────┤
│ Visible: 16-bit selector (index into GDT for LDT descriptor) │
│ Hidden: Cached LDT descriptor (base, limit, attributes) │
└──────────────────────────────────────────────────────────────────┘
The Indirection:
Why This Extra Level?
Having LDTR reference the GDT rather than directly containing a base/limit:
1234567891011121314151617181920212223242526272829303132333435363738
// Setting up an LDT for a process // Step 1: Allocate memory for the LDT#define LDT_ENTRIES 32segment_descriptor_t* ldt = kmalloc(LDT_ENTRIES * sizeof(segment_descriptor_t));memset(ldt, 0, LDT_ENTRIES * sizeof(segment_descriptor_t)); // Step 2: Populate LDT with process-specific segmentscreate_gdt_entry(&ldt[1], process->code_base, process->code_limit, ACCESS_CODE_EXEC_READ | DPL_USER, FLAGS_32BIT | FLAGS_PAGE_GRAN); create_gdt_entry(&ldt[2], process->data_base, process->data_limit, ACCESS_DATA_READ_WRITE | DPL_USER, FLAGS_32BIT | FLAGS_PAGE_GRAN); // Step 3: Create LDT descriptor in GDTuint16_t ldt_gdt_index = allocate_gdt_slot();create_system_descriptor(&gdt[ldt_gdt_index], (uint32_t)ldt, // Base: LDT's address LDT_ENTRIES * 8 - 1, // Limit: LDT size - 1 TYPE_LDT, // Type: LDT descriptor DPL_KERNEL); // Only kernel can load LDTR // Step 4: Construct LDTR selectorprocess->ldt_selector = (ldt_gdt_index << 3) | TI_GDT | RPL_KERNEL; // Step 5: Load LDTR (during context switch to this process)void switch_to_process(process_t* proc) { // Load the new LDT __asm__ volatile ("lldt %0" :: "r"(proc->ldt_selector)); // Now selectors with TI=1 reference this process's LDT}LDTR Instructions:
Null LDT:
If LDTR is loaded with a null selector (0), no LDT is active. Any segment selector with TI=1 (LDT indicator) will cause #GP because there's no LDT to look up. Many modern OSes run with LDTR=0.
On SMP systems, each CPU typically has its own GDT copy. This allows per-CPU data (via FS/GS bases) and separate TSS entries per CPU. The GDT content is mostly identical, but having separate copies avoids cache contention and allows CPU-specific entries.
Descriptor tables are arrays of 8-byte (or 16-byte in 64-bit mode for system descriptors) entries. Let's examine the structure and typical layouts.
GDT Structure:
Offset Entry Purpose
0x00 [0] Null Descriptor (required, never used)
0x08 [1] Kernel Code Segment (CS when in kernel)
0x10 [2] Kernel Data Segment (DS/ES/SS when in kernel)
0x18 [3] User Code Segment (CS when in user mode)
0x20 [4] User Data Segment (DS/ES/SS when in user mode)
0x28 [5] TSS Descriptor (for current CPU)
0x30 [6] Per-CPU Data Segment (for FS/GS base)
... Additional entries as needed
Typical Linux 32-bit GDT:
| Index | Selector | Name | Purpose |
|---|---|---|---|
| 0 | 0x00 | GDT_ENTRY_NULL | Null descriptor |
| 1 | 0x08 | GDT_ENTRY_KERNEL_CS | Kernel code (Ring 0) |
| 2 | 0x10 | GDT_ENTRY_KERNEL_DS | Kernel data (Ring 0) |
| 3 | 0x18 | GDT_ENTRY_USER_CS | User code (Ring 3) |
| 4 | 0x20 | GDT_ENTRY_USER_DS | User data (Ring 3) |
| 5 | 0x28 | GDT_ENTRY_TSS | Task State Segment |
| 6 | 0x30 | GDT_ENTRY_LDT | LDT (if used) |
| 7+ | 0x38+ | Various | Per-CPU, TLS, etc. |
Selector to Entry Mapping:
The relationship between selectors and GDT entries:
Selector = (Index << 3) | TI | RPL
Index: Entry number in the table (0, 1, 2, ...)
TI: Table Indicator (0 = GDT, 1 = LDT)
RPL: Requested Privilege Level (0-3)
Examples:
Selector 0x08 = (1 << 3) | 0 | 0 = Entry 1 in GDT, RPL=0
Selector 0x1B = (3 << 3) | 0 | 3 = Entry 3 in GDT, RPL=3
Selector 0x0F = (1 << 3) | 1 | 3 = Entry 1 in LDT, RPL=3
Finding a Descriptor:
segment_descriptor_t* find_descriptor(uint16_t selector) {
uint16_t index = selector >> 3;
bool use_ldt = (selector >> 2) & 1;
if (use_ldt) {
// TI = 1: Look in LDT
if (ldtr_is_null())
raise_gp(selector); // No LDT loaded
if (index >= ldt_limit / 8)
raise_gp(selector); // Index out of bounds
return &ldt[index];
} else {
// TI = 0: Look in GDT
if (index >= gdt_limit / 8)
raise_gp(selector); // Index out of bounds
return &gdt[index];
}
}
GDT entry 0 (the null descriptor) is reserved and cannot be used for actual segments. Loading selector 0 into a data segment register (DS, ES, FS, GS) sets it to null—subsequent data access through that register causes #GP. Loading 0 into CS or SS always causes #GP immediately.
When a segment register is loaded with a selector, the CPU must find and validate the corresponding descriptor. This is a critical path that happens on every segment load.
Lookup Algorithm:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
function load_segment_register(register, selector): // Step 1: Parse selector index = selector >> 3 // Bits 15-3: Index TI = (selector >> 2) & 1 // Bit 2: Table indicator RPL = selector & 3 // Bits 1-0: Requested privilege // Step 2: Handle null selector if selector == 0: if register in [CS, SS]: raise #GP(0) // Cannot load null into CS/SS else: register.selector = 0 register.cached.valid = false return // Null DS/ES/FS/GS is allowed // Step 3: Determine which table if TI == 0: table_base = GDTR.base table_limit = GDTR.limit else: // TI == 1 if LDTR.selector == 0: raise #GP(selector) // No LDT loaded table_base = LDTR.cached.base table_limit = LDTR.cached.limit // Step 4: Bounds check descriptor_offset = index * 8 if descriptor_offset + 7 > table_limit: raise #GP(selector) // Index out of bounds // Step 5: Fetch descriptor from memory descriptor = read_memory(table_base + descriptor_offset, 8 bytes) // Step 6: Validate descriptor type for target register if register == CS: if not descriptor.is_code_segment(): raise #GP(selector) if register == SS: if not descriptor.is_writable_data(): raise #GP(selector) if register in [DS, ES, FS, GS]: if descriptor.is_system(): raise #GP(selector) // Step 7: Presence check if not descriptor.present: raise #NP(selector) // Segment not present // Step 8: Privilege check (varies by segment type) perform_privilege_check(register, selector, descriptor) // Step 9: Cache descriptor register.selector = selector register.cached = descriptor mark_descriptor_accessed(table_base + descriptor_offset)Physical Address Calculation:
GDT Entry Address = GDTR.Base + (Selector.Index × 8)
Example:
GDTR.Base = 0xC0001000
Selector = 0x18 (Index = 3)
Entry Address = 0xC0001000 + (3 × 8) = 0xC0001018
Memory Access Pattern:
When loading a segment register, the CPU:
The accessed bit write can cause TLB activity and cache coherence traffic on SMP systems.
Because loading segment registers involves memory access and validation, performance-sensitive code minimizes segment register loads. With flat memory models (all segments base=0, limit=4GB), segment registers can be set once and never changed, eliminating this overhead during normal execution.
When a computer boots, it starts in real mode (8086-compatible mode) with no GDT. Transitioning to protected mode requires setting up a GDT first. This is one of the first tasks of any OS bootloader or kernel.
Boot Sequence:
Minimal Boot GDT:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061
; Minimal GDT for entering protected mode; Called before switching from real mode to protected mode setup_protected_mode: cli ; Disable interrupts ; Load GDT lgdt [gdt_descriptor] ; Enable protected mode (set PE bit in CR0) mov eax, cr0 or eax, 1 ; Set PE bit mov cr0, eax ; Far jump to load CS with protected mode selector jmp 0x08:protected_mode_entry [bits 32]protected_mode_entry: ; Now in 32-bit protected mode ; Load data segment registers mov ax, 0x10 ; Kernel data selector mov ds, ax mov es, ax mov ss, ax mov fs, ax mov gs, ax ; Set up stack mov esp, 0x90000 ; Continue to kernel... jmp kernel_main ; GDT dataalign 8gdt_start:gdt_null: ; Entry 0: Required null dq 0 gdt_code: ; Entry 1: 32-bit code, base=0, limit=4GB dw 0xFFFF ; Limit [0:15] dw 0x0000 ; Base [0:15] db 0x00 ; Base [16:23] db 0b10011010 ; Access: P=1, DPL=0, S=1, Type=Execute/Read db 0b11001111 ; Flags: G=1, D=1, Limit [16:19] db 0x00 ; Base [24:31] gdt_data: ; Entry 2: 32-bit data, base=0, limit=4GB dw 0xFFFF dw 0x0000 db 0x00 db 0b10010010 ; Access: P=1, DPL=0, S=1, Type=Read/Write db 0b11001111 db 0x00 gdt_end: gdt_descriptor: dw gdt_end - gdt_start - 1 ; Size - 1 dd gdt_start ; AddressWhy Far Jump After Setting CR0.PE?
When CR0.PE is set, the CPU is in protected mode, but CS still contains a real-mode value. The CPU continues in a weird hybrid state until CS is reloaded. The far jump:
Kernel GDT Setup:
After basic boot, the kernel typically:
During the transition to protected mode (and later to paging), the code being executed must be identity-mapped: virtual address = physical address. Otherwise, the instruction pointer becomes invalid the moment translation changes. This is why boot code is carefully placed in low memory with identity mappings.
When using per-process LDTs, the operating system must manage LDT creation, population, and switching. Let's examine the complete lifecycle.
LDT Lifecycle:
LDT Per-Process Architecture:
┌─────────────────────┐ ┌────────────────────────────────────┐
│ Global GDT │ │ Process A's LDT │
├─────────────────────┤ ├────────────────────────────────────┤
│ [0] Null │ │ [0] Null (reserved) │
│ [1] Kernel Code │ │ [1] User Code: Base=A's code area │
│ [2] Kernel Data │ │ [2] User Data: Base=A's data area │
│ [3] User Code │ │ [3] User Stack: Base=A's stack │
│ [4] User Data │ │ [4] User TLS: Thread-local storage │
│ [5] TSS │ └────────────────────────────────────┘
│ [6] LDT for A ─────────────────────┘
│ [7] LDT for B ──────────────────────┐
│ ... │ ┌─────────────────────────────────────┐
└─────────────────────┘ │ Process B's LDT │
├─────────────────────────────────────┤
│ [0] Null │
│ [1] User Code: Base=B's code area │
│ [2] User Data: Base=B's data area │
│ [3] User Stack: Base=B's stack │
└─────────────────────────────────────┘
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
// Complete LDT management for per-process isolation typedef struct { segment_descriptor_t entries[LDT_SIZE]; uint16_t gdt_selector; // Selector for this LDT's GDT entry spinlock_t lock; // Protect concurrent modifications} process_ldt_t; // Create a new LDT for a processprocess_ldt_t* create_process_ldt(process_t* proc) { // Allocate LDT structure (must be accessible in kernel space) process_ldt_t* ldt = kmalloc_aligned(sizeof(process_ldt_t), 8); if (!ldt) return NULL; memset(ldt->entries, 0, sizeof(ldt->entries)); spinlock_init(&ldt->lock); // Entry 0 is null (required) // Entry 1: User code segment set_segment_descriptor(&ldt->entries[1], proc->mm->code_start, // Base proc->mm->code_size - 1, // Limit DESC_CODE_EXEC_READ | DESC_DPL3, DESC_GRAN_4K | DESC_32BIT); // Entry 2: User data segment set_segment_descriptor(&ldt->entries[2], proc->mm->data_start, proc->mm->data_size - 1, DESC_DATA_READ_WRITE | DESC_DPL3, DESC_GRAN_4K | DESC_32BIT); // Entry 3: User stack (expand-down) set_segment_descriptor(&ldt->entries[3], proc->mm->stack_top - STACK_SIZE, STACK_SIZE - 1, DESC_DATA_READ_WRITE | DESC_DPL3, DESC_GRAN_4K | DESC_32BIT); // Allocate GDT slot and create LDT descriptor int gdt_slot = allocate_gdt_entry(); if (gdt_slot < 0) { kfree(ldt); return NULL; } set_ldt_descriptor(&gdt[gdt_slot], (uintptr_t)ldt->entries, sizeof(ldt->entries) - 1); ldt->gdt_selector = (gdt_slot << 3) | RPL_KERNEL; return ldt;} // Switch LDT during context switchvoid switch_ldt(process_ldt_t* new_ldt) { uint16_t selector = new_ldt ? new_ldt->gdt_selector : 0; __asm__ volatile ("lldt %0" :: "r"(selector));} // Destroy LDT on process exitvoid destroy_process_ldt(process_ldt_t* ldt) { if (!ldt) return; // Free GDT slot int gdt_index = ldt->gdt_selector >> 3; free_gdt_entry(gdt_index); // Free LDT memory kfree(ldt);}Modern systems typically use flat segment models (base=0, limit=4GB for all user segments) and rely on paging for per-process isolation. This eliminates the need for per-process LDTs. The GDT can have a single set of user segment descriptors used by all processes, simplifying management.
During a context switch, the operating system may need to switch LDTs and potentially update other segment-related state. This is a critical path that must be fast and correct.
Context Switch Segment Operations:
Context Switch Sequence:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758
// Context switch with segment handling void context_switch(task_t* prev, task_t* next) { // Save prev's segment registers __asm__ volatile ( "mov %%ds, %0\n\t" "mov %%es, %1\n\t" "mov %%fs, %2\n\t" "mov %%gs, %3" : "=m"(prev->context.ds), "=m"(prev->context.es), "=m"(prev->context.fs), "=m"(prev->context.gs) ); // Switch LDT if processes have different LDTs if (prev->process != next->process) { process_t* next_proc = next->process; process_t* prev_proc = prev->process; // Different process = different LDT if (next_proc->ldt != prev_proc->ldt) { uint16_t ldt_sel = next_proc->ldt ? next_proc->ldt->gdt_selector : 0; __asm__ volatile ("lldt %0" :: "r"(ldt_sel)); } // Also switch page tables (CR3) load_cr3(next_proc->page_directory); } // Load next's segment registers __asm__ volatile ( "mov %0, %%ds\n\t" "mov %1, %%es\n\t" "mov %2, %%fs\n\t" "mov %3, %%gs" :: "r"(next->context.ds), "r"(next->context.es), "r"(next->context.fs), "r"(next->context.gs) ); // Update FS/GS bases for thread-local storage (64-bit)#ifdef __x86_64__ wrmsrl(MSR_FS_BASE, next->fs_base); wrmsrl(MSR_GS_BASE, next->gs_base);#else // 32-bit: Update descriptors in GDT for per-thread FS/GS update_gdt_entry(GDT_ENTRY_TLS, next->tls_base, TLS_SIZE);#endif // Switch kernel stack (in TSS) tss.esp0 = (uint32_t)next->kernel_stack_top; // Perform actual register/stack switch switch_context(&prev->context, &next->context);}Performance Considerations:
Optimization: Lazy LDT Switching:
If LDTs aren't used (null LDT), skip LLDT:
if (next_proc->ldt != NULL || prev_proc->ldt != NULL) {
// Only switch if either process uses LDT
lldt(next_proc->ldt ? next_proc->ldt->gdt_selector : 0);
}
When switching between threads of the same process, LDT and page table switches can be skipped (they're shared). Only thread-specific state (registers, FS/GS bases for TLS, kernel stack) needs updating. This makes thread switches faster than process switches.
Descriptor tables are security-critical. If an attacker could modify them, they could bypass all segment-based protection. The OS must ensure tables are protected.
Protection Mechanisms:
1. Ring 0 Modification Only:
2. Page-Level Protection:
3. SMEP/SMAP (Modern x86):
4. Descriptor Validation:
| Protection | How It Helps | Bypass Difficulty |
|---|---|---|
| Privileged LGDT/LLDT | Cannot change table location from Ring 3 | Requires kernel code execution |
| Kernel-only memory | Table pages inaccessible to user mode | Requires kernel memory access |
| Read-only GDT pages | Even kernel cannot accidentally modify | Requires privilege to change page tables |
| SMEP/SMAP | Kernel cannot be tricked into accessing user pages | Requires disabling these features |
| Hardware validation | Invalid descriptors cause faults | Cannot use malformed descriptors |
Attack Scenario: GDT Corruption
Hypothetical attack without protections:
Mitigation:
Read-Only GDT in Linux:
Linux marks GDT pages read-only and only remaps them writable briefly during modifications:
void update_gdt_entry(int index, ...) {
set_page_rw(gdt_page); // Temporarily make writable
gdt[index] = new_entry; // Modify
set_page_ro(gdt_page); // Restore read-only
}
SGDT leaking GDT location is a known issue. Attackers can use this for ASLR bypass (locating kernel). Mitigations: UMIP (User-Mode Instruction Prevention) blocks SGDT from Ring 3, or hypervisors virtualize SGDT to return fake values.
We've comprehensively explored where segment tables live and how they're managed. Let's consolidate the key concepts:
Module Complete:
With this page, we've completed our deep dive into segment tables. You now understand:
This comprehensive knowledge enables you to understand how operating systems implement memory protection at the segment level, which historically was (and partially still is) the foundation of protected-mode computing.
Congratulations! You've mastered segment tables—from individual entry fields to system-wide table management. This knowledge is foundational for operating system development, security research, and understanding modern memory protection evolution from segmentation to paging.