Loading content...
The Global Descriptor Table (GDT) is one of the most critical data structures in the x86 architecture. It serves as the system-wide repository of segment descriptors—the fundamental building blocks that define how memory is organized, protected, and accessed in protected mode.
Every memory access in protected mode (and even in 64-bit long mode) ultimately references the GDT. When a segment register is loaded with a selector, the processor uses that selector as an index into the GDT to retrieve the corresponding descriptor. The GDT is not merely a software construct—it is a hardware-mandated structure that the processor itself reads and interprets.
Understanding the GDT is essential for anyone working with operating system internals, bootloader development, hypervisors, or low-level security. Without a properly configured GDT, protected mode operation is simply impossible.
By the end of this page, you will understand the GDT's purpose and relationship to segment selectors, its in-memory structure and the GDTR register, the different types of descriptors it contains (code, data, system), how operating systems typically configure the GDT, and how the GDT evolves in x86-64 long mode.
The Global Descriptor Table serves as the central registry of segments that are available to all tasks (processes) in the system. Unlike the Local Descriptor Table (LDT), which can be different for each process, the GDT is system-wide—there is exactly one active GDT at any time, shared by all code running on the processor.
What the GDT Contains:
The GDT is an array of 8-byte segment descriptors. Each descriptor defines a memory segment's:
The processor uses segment selectors (loaded into segment registers like CS, DS, SS) to index into the GDT and retrieve these descriptors.
Why "Global"?
The term "global" indicates that this table is visible to all tasks. In contrast, each task can have its own Local Descriptor Table (LDT) containing task-specific segments. However, the GDT:
The Mandatory Null Descriptor:
The first entry (index 0) in the GDT is always a null descriptor—8 bytes of zeros. This is not optional; the processor requires it. If a segment register contains selector 0x0000, this references the null descriptor, and any attempt to use that segment for memory access will generate a General Protection Fault.
This null descriptor serves as a safety mechanism: uninitialized segment registers (containing 0) will cause immediate, predictable faults rather than unpredictable memory corruption.
Loading a null selector (0x0000) into DS, ES, FS, or GS is permitted—it simply indicates that the segment is not being used. However, any attempt to actually access memory through that segment register will fault. Notably, loading a null selector into CS or SS is never allowed and will immediately generate a #GP fault.
The GDT is simply a contiguous array of 8-byte segment descriptors stored in memory. It can be located anywhere in the linear address space—there's no fixed location. The processor learns the GDT's location and size from a special register called the GDT Register (GDTR).
The GDTR Register:
The GDTR is a 48-bit (6-byte) register containing:
Since each descriptor is 8 bytes and the limit is a 16-bit value, the maximum GDT size is 65,536 bytes, allowing for up to 8,192 descriptors (2^16 / 8 = 8,192).
12345678910111213141516171819202122232425262728
GDTR Register (48 bits total):┌───────────────────────────────────────────────────────┐│ 47 16 │ 15 0 ││ Base Address │ Limit ││ (32 bits) │ (16 bits) │└───────────────────────────────────────────────────────┘ Example: Base = 0x00100000 (GDT starts at 1 MB mark) Limit = 0x002F (GDT is 48 bytes, so 6 entries: 48/8 = 6) GDT Memory Layout: ┌─────────────────────────────────┐ 0x00100000 │ Entry 0: Null Descriptor │ 8 bytes ├─────────────────────────────────┤ 0x00100008 │ Entry 1: Kernel Code Segment │ 8 bytes ├─────────────────────────────────┤ 0x00100010 │ Entry 2: Kernel Data Segment │ 8 bytes ├─────────────────────────────────┤ 0x00100018 │ Entry 3: User Code Segment │ 8 bytes ├─────────────────────────────────┤ 0x00100020 │ Entry 4: User Data Segment │ 8 bytes ├─────────────────────────────────┤ 0x00100028 │ Entry 5: TSS Descriptor │ 8 bytes └─────────────────────────────────┘ ↑ GDTR.Base points here GDTR.Limit = 0x002F (47)Loading and Storing the GDTR:
Two special privileged instructions manage the GDTR:
The ability for unprivileged code to read the GDT location via SGDT has been a source of security concerns (e.g., the "Red Pill" technique for detecting virtual machines). Modern systems sometimes take countermeasures against this.
123456789101112131415161718192021222324252627
; Loading the GDT (privileged - Ring 0 only)gdt_descriptor: dw gdt_end - gdt_start - 1 ; Limit (size - 1) dd gdt_start ; Base address load_gdt: lgdt [gdt_descriptor] ; Load GDTR from memory ret ; Storing the GDT (unprivileged - any ring)stored_gdtr: dw 0 ; Space for limit dd 0 ; Space for base read_gdt: sgdt [stored_gdtr] ; Store current GDTR to memory ret ; Example: Creating a GDT in C (structure approach); struct gdt_entry {; uint16_t limit_low; // Limit bits 0-15; uint16_t base_low; // Base bits 0-15; uint8_t base_middle; // Base bits 16-23; uint8_t access; // Access byte; uint8_t granularity; // Limit bits 16-19 + flags; uint8_t base_high; // Base bits 24-31; } __attribute__((packed));The GDTR limit field specifies the GDT size MINUS ONE. This means a limit of 0 indicates a 1-byte table (useless), and a limit of 7 indicates an 8-byte table (just the null descriptor). The formula is: Number of entries = (Limit + 1) / 8. If your GDT has 6 entries (48 bytes), the limit should be 47 (0x2F), not 48.
To access a descriptor in the GDT, the processor uses a segment selector—a 16-bit value loaded into a segment register. The selector is not a simple byte offset; it encodes three distinct pieces of information.
Selector Format:
12345678910111213141516171819202122232425
Segment Selector (16 bits):┌────────────────────────────────────────┐│ 15 3 │ 2 │ 1 0 ││ Index │ TI │ RPL ││ (13 bits) │ │(2 bits)│└────────────────────────────────────────┘ Fields: INDEX (bits 15-3): Index into the descriptor table Multiplied by 8 to get byte offset Range: 0 to 8191 (2^13 entries) TI (bit 2): Table Indicator 0 = Use GDT (Global Descriptor Table) 1 = Use LDT (Local Descriptor Table) RPL (bits 1-0): Requested Privilege Level 0 = Ring 0 (highest privilege) 3 = Ring 3 (lowest privilege) Examples: Selector 0x0008 = Index 1, GDT, RPL 0 (Binary: 0000 0000 0000 1000) Selector 0x0010 = Index 2, GDT, RPL 0 (Binary: 0000 0000 0001 0000) Selector 0x0023 = Index 4, GDT, RPL 3 (Binary: 0000 0000 0010 0011) Selector 0x000F = Index 1, LDT, RPL 3 (Binary: 0000 0000 0000 1111)| Selector (Hex) | Index | Table | RPL | Typical Use |
|---|---|---|---|---|
| 0x0000 | 0 | GDT | 0 | Null selector (references null descriptor) |
| 0x0008 | 1 | GDT | 0 | Kernel code segment (Ring 0) |
| 0x0010 | 2 | GDT | 0 | Kernel data segment (Ring 0) |
| 0x0018 | 3 | GDT | 0 | Kernel stack segment (Ring 0) |
| 0x0020 | 4 | GDT | 0 | TSS or additional kernel segment |
| 0x0023 | 4 | GDT | 3 | User code segment (Ring 3) |
| 0x002B | 5 | GDT | 3 | User data segment (Ring 3) |
The Index-to-Offset Calculation:
To convert a selector to a byte offset in the GDT:
Byte Offset = (Selector & 0xFFF8)
Alternatively:
Byte Offset = Index × 8
Since the index field occupies bits 15-3, masking with 0xFFF8 clears the TI and RPL bits, leaving just the byte offset (since index × 8 equals the same as shifting left by 3 bits, which the encoding already provides).
Table Indicator (TI) Behavior:
The TI bit determines which descriptor table the processor consults:
The active LDT is determined by the LDT Register (LDTR), which itself contains a selector pointing to an LDT descriptor in the GDT. This creates a two-level hierarchy: the GDT contains the LDT descriptor, which points to the LDT, which contains task-specific segment descriptors.
The RPL field allows callers to limit their own privilege when accessing a segment. When the processor checks access, it uses MAX(CPL, RPL) as the effective privilege level. This prevents a higher-privileged routine from accidentally using a pointer passed by a lower-privileged caller to access protected segments. If kernel code receives a far pointer from user code, the RPL of 3 in that selector prevents the kernel from using its Ring 0 privilege to access kernel-only segments through that pointer.
The GDT can contain several types of descriptors, categorized by the S (descriptor type) bit in the access byte:
Code and Data Segment Descriptors (S=1):
When S=1, the descriptor defines a code or data segment. The E (Executable) bit distinguishes:
| Type | E | W/R | A | Description |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | Data, Read-Only |
| 1 | 0 | 0 | 1 | Data, Read-Only, Accessed |
| 2 | 0 | 1 | 0 | Data, Read/Write |
| 3 | 0 | 1 | 1 | Data, Read/Write, Accessed |
| 4 | 0 | 0 | 0 | Data, Read-Only, Expand-Down |
| 5 | 0 | 0 | 1 | Data, Read-Only, Expand-Down, Accessed |
| 6 | 0 | 1 | 0 | Data, Read/Write, Expand-Down |
| 7 | 0 | 1 | 1 | Data, Read/Write, Expand-Down, Accessed |
| 8 | 1 | 0 | 0 | Code, Execute-Only |
| 9 | 1 | 0 | 1 | Code, Execute-Only, Accessed |
| A | 1 | 1 | 0 | Code, Execute/Read |
| B | 1 | 1 | 1 | Code, Execute/Read, Accessed |
| C | 1 | 0 | 0 | Code, Execute-Only, Conforming |
| D | 1 | 0 | 1 | Code, Execute-Only, Conforming, Accessed |
| E | 1 | 1 | 0 | Code, Execute/Read, Conforming |
| F | 1 | 1 | 1 | Code, Execute/Read, Conforming, Accessed |
System Segment Descriptors (S=0):
When S=0, the descriptor defines a system object. These are critical for protected mode operation:
| Type | Description | Purpose |
|---|---|---|
| 0x0 | Reserved | Not used |
| 0x1 | 16-bit TSS (Available) | Legacy 16-bit Task State Segment |
| 0x2 | LDT | Local Descriptor Table descriptor |
| 0x3 | 16-bit TSS (Busy) | 16-bit TSS currently in use |
| 0x4 | 16-bit Call Gate | Legacy call gate for ring transitions |
| 0x5 | Task Gate | Hardware task switching mechanism |
| 0x6 | 16-bit Interrupt Gate | Legacy interrupt handler gate |
| 0x7 | 16-bit Trap Gate | Legacy trap handler gate |
| 0x8 | Reserved | Not used |
| 0x9 | 32-bit TSS (Available) | 32-bit Task State Segment (available for use) |
| 0xA | Reserved | Not used |
| 0xB | 32-bit TSS (Busy) | 32-bit TSS currently executing |
| 0xC | 32-bit Call Gate | Call gate for controlled ring transitions |
| 0xD | Reserved | Not used |
| 0xE | 32-bit Interrupt Gate | Interrupt handler entry point |
| 0xF | 32-bit Trap Gate | Trap/exception handler entry point |
The most important system segment types are: TSS (Task State Segment): Required for hardware task switching and for providing the kernel stack pointer during ring transitions (from Ring 3 to Ring 0). LDT (Local Descriptor Table): Points to a task-specific descriptor table. Gates (Call, Interrupt, Trap): These are typically placed in the IDT, not the GDT, but call gates can appear in the GDT to provide controlled ring transitions.
While the GDT can theoretically hold 8,192 entries, practical implementations use far fewer. A typical operating system GDT contains:
Flat Memory Model Configuration:
Modern operating systems use a flat memory model where all segments have:
This effectively disables segmentation's memory partitioning while retaining privilege-level protection.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102
/* GDT Entry Structure */struct gdt_entry { uint16_t limit_low; /* Limit bits 0-15 */ uint16_t base_low; /* Base bits 0-15 */ uint8_t base_middle; /* Base bits 16-23 */ uint8_t access; /* Access flags: P, DPL, S, Type */ uint8_t granularity; /* G, D/B, L, AVL, Limit bits 16-19 */ uint8_t base_high; /* Base bits 24-31 */} __attribute__((packed)); /* GDTR Structure */struct gdtr { uint16_t limit; /* Size of GDT - 1 */ uint32_t base; /* Linear address of GDT */} __attribute__((packed)); /* GDT Entries */struct gdt_entry gdt[6];struct gdtr gdt_descriptor; /* Access byte flags */#define GDT_PRESENT 0x80 /* P: Segment present */#define GDT_DPL_RING0 0x00 /* DPL: Ring 0 */#define GDT_DPL_RING3 0x60 /* DPL: Ring 3 */#define GDT_SEGMENT 0x10 /* S: Code/data segment (not system) */#define GDT_EXECUTABLE 0x08 /* E: Executable (code segment) */#define GDT_READWRITE 0x02 /* RW: Readable (code) or Writable (data) */ /* Granularity byte flags */#define GDT_4K_GRAN 0x80 /* G: Limit in 4KB units */#define GDT_32BIT 0x40 /* D/B: 32-bit segment */ void gdt_set_entry(int index, uint32_t base, uint32_t limit, uint8_t access, uint8_t granularity) { gdt[index].base_low = base & 0xFFFF; gdt[index].base_middle = (base >> 16) & 0xFF; gdt[index].base_high = (base >> 24) & 0xFF; gdt[index].limit_low = limit & 0xFFFF; gdt[index].granularity = ((limit >> 16) & 0x0F) | (granularity & 0xF0); gdt[index].access = access;} void gdt_init(void) { /* Entry 0: Null descriptor (required) */ gdt_set_entry(0, 0, 0, 0, 0); /* Entry 1: Kernel Code Segment (selector 0x08) Base=0, Limit=4GB, Ring 0, Executable, Readable */ gdt_set_entry(1, 0x00000000, 0xFFFFF, GDT_PRESENT | GDT_DPL_RING0 | GDT_SEGMENT | GDT_EXECUTABLE | GDT_READWRITE, GDT_4K_GRAN | GDT_32BIT); /* Entry 2: Kernel Data Segment (selector 0x10) Base=0, Limit=4GB, Ring 0, Writable */ gdt_set_entry(2, 0x00000000, 0xFFFFF, GDT_PRESENT | GDT_DPL_RING0 | GDT_SEGMENT | GDT_READWRITE, GDT_4K_GRAN | GDT_32BIT); /* Entry 3: User Code Segment (selector 0x1B = 0x18 | RPL3) Base=0, Limit=4GB, Ring 3, Executable, Readable */ gdt_set_entry(3, 0x00000000, 0xFFFFF, GDT_PRESENT | GDT_DPL_RING3 | GDT_SEGMENT | GDT_EXECUTABLE | GDT_READWRITE, GDT_4K_GRAN | GDT_32BIT); /* Entry 4: User Data Segment (selector 0x23 = 0x20 | RPL3) Base=0, Limit=4GB, Ring 3, Writable */ gdt_set_entry(4, 0x00000000, 0xFFFFF, GDT_PRESENT | GDT_DPL_RING3 | GDT_SEGMENT | GDT_READWRITE, GDT_4K_GRAN | GDT_32BIT); /* Entry 5: TSS will be set up separately */ /* Load the GDT */ gdt_descriptor.limit = sizeof(gdt) - 1; gdt_descriptor.base = (uint32_t)&gdt; __asm__ volatile("lgdt %0" : : "m"(gdt_descriptor)); /* Reload segment registers */ __asm__ volatile( "mov $0x10, %%ax" /* Kernel data selector */ "mov %%ax, %%ds" "mov %%ax, %%es" "mov %%ax, %%fs" "mov %%ax, %%gs" "mov %%ax, %%ss" "ljmp $0x08, $1f" /* Far jump to reload CS */ "1:" : : : "eax" );}Why Kernel and User Segments Have the Same Base/Limit:
In the flat memory model, both kernel (Ring 0) and user (Ring 3) segments have identical base (0) and limit (4GB) settings. This might seem to defeat the purpose of protection, but the protection comes from:
The GDT in a flat model primarily serves to:
In multiprocessor (SMP) systems, GDT management becomes more complex. While the GDT can theoretically be shared among all CPUs, practical considerations often lead to per-CPU GDT copies.
Why Per-CPU GDTs?
TSS Requirements: Each CPU needs its own Task State Segment (TSS) for ring transition stack pointers. Since the TSS descriptor is in the GDT, each CPU needs its own GDT entry.
FS/GS Base Optimization: Some architectures use per-CPU data accessed via FS or GS segments with different bases per CPU.
Cache Performance: Each CPU has its own cache; if all CPUs share a GDT, cache coherency traffic increases.
Isolation: A corrupted GDT affects only one CPU if GDTs are per-CPU.
Linux's Approach:
Linux uses per-CPU GDTs. The cpu_gdt_descr array contains the GDTR value for each CPU, and gdt_page contains the actual GDT. During CPU initialization, each processor loads its own GDT.
Key GDT entries in Linux include:
Modifying the GDT while the system is running is dangerous. If a CPU is using a segment whose descriptor is being modified, the results are undefined. Modification must be carefully synchronized, often by: (1) ensuring no task uses the segment being modified, (2) using atomic operations where possible, (3) on x86-64, using the WRMSR instruction for FS/GS base instead of modifying GDT entries.
When x86-64 processors operate in 64-bit long mode, the GDT remains required but its role changes significantly. Most segmentation features are disabled or ignored, but the GDT still serves critical functions.
What Changes in Long Mode:
64-bit System Descriptors:
In long mode, system segment descriptors (TSS and LDT) expand from 8 bytes to 16 bytes to accommodate 64-bit base addresses. This means they occupy two GDT slots:
64-bit TSS Descriptor (16 bytes):
┌─────────────────────┐
│ Bytes 0-7 │ Standard 8-byte format (base bits 0-23, limit, access)
├─────────────────────┤
│ Bytes 8-11 │ Base bits 32-63
├─────────────────────┤
│ Bytes 12-15 │ Reserved (must be zero)
└─────────────────────┘
The selector for a 64-bit TSS still indexes the first 8 bytes; the processor automatically reads the following 8 bytes as part of the descriptor.
123456789101112131415161718192021222324252627282930313233343536373839404142434445
/* Minimal 64-bit GDT for Long Mode */.align 16gdt64: .quad 0 /* 0x00: Null descriptor */ /* 0x08: Kernel Code Segment (64-bit) */ .word 0xFFFF /* Limit (ignored) */ .word 0 /* Base 0-15 (ignored) */ .byte 0 /* Base 16-23 (ignored) */ .byte 0b10011010 /* P=1, DPL=0, S=1, E=1, RW=1 */ .byte 0b10101111 /* G=1, L=1, Limit 16-19 */ .byte 0 /* Base 24-31 (ignored) */ /* 0x10: Kernel Data Segment */ .word 0xFFFF /* Limit (ignored) */ .word 0 /* Base 0-15 (ignored) */ .byte 0 /* Base 16-23 (ignored) */ .byte 0b10010010 /* P=1, DPL=0, S=1, E=0, RW=1 */ .byte 0b11001111 /* G=1, D/B=1, Limit 16-19 */ .byte 0 /* Base 24-31 (ignored) */ /* 0x18: User Code Segment (64-bit) */ .word 0xFFFF .word 0 .byte 0 .byte 0b11111010 /* P=1, DPL=3, S=1, E=1, RW=1 */ .byte 0b10101111 /* G=1, L=1 */ .byte 0 /* 0x20: User Data Segment */ .word 0xFFFF .word 0 .byte 0 .byte 0b11110010 /* P=1, DPL=3, S=1, E=0, RW=1 */ .byte 0b11001111 .byte 0 /* 0x28-0x30: 64-bit TSS Descriptor (16 bytes - occupies 2 slots) */ /* ... TSS descriptor setup ... */ gdt64_end: gdt64_descriptor: .word gdt64_end - gdt64 - 1 .quad gdt64The L (Long mode) bit in the code segment descriptor determines whether code executes in 64-bit mode or 32-bit compatibility mode. L=1 indicates 64-bit code; L=0 (with D=1) indicates 32-bit compatibility code. When L=1, the D bit must be 0. This allows a single operating system to run both 64-bit native code and 32-bit legacy applications by switching between code segments with different L/D settings.
We've conducted an in-depth exploration of the Global Descriptor Table—the central data structure that defines how segment selectors map to memory segment attributes in protected mode.
What's Next:
With the GDT established as the global segment repository, we'll examine its complement: the Local Descriptor Table (LDT). While rarely used in modern operating systems, the LDT provides per-process segment definitions and offers insights into the original segmentation vision of the x86 architecture.
You now have a comprehensive understanding of the Global Descriptor Table—its structure, contents, configuration, and role in both 32-bit protected mode and 64-bit long mode. This knowledge is essential for understanding bootloader development, kernel initialization, and x86 memory management at the deepest level.