Global Descriptor Table (GDT)
The Global Descriptor Table is a binary data structure specific to IA-32 and x86-64 architectures. It tells the CPU about memory segments — their base addresses, sizes, and access privileges.
GRUB already sets up a basic GDT when it loads the kernel. Without it, the kernel wouldn’t boot at all. But we replace it with our own to understand memory segmentation and to set up proper kernel and user mode segments.
What Is a Segment?
A segment is a logically contiguous chunk of memory with consistent properties from the CPU’s perspective. In protected mode, every memory access goes through a segment. The CPU checks: is this access allowed for this privilege level? Does it fit within the segment limit?
In modern kernels we use the flat memory model — all segments cover the full 4GB address space, effectively disabling segmentation. We rely on paging for memory protection instead. But the GDT still needs to exist because the CPU requires it in protected mode.
GDT Entry Structure (8 bytes)
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Copy, Clone)]
pub struct GdtEntry {
limit_low: u16, // Lower 16 bits of limit
base_low: u16, // Lower 16 bits of base
base_middle: u8, // Next 8 bits of base
access: u8, // Access byte (privilege, type)
granularity: u8, // Flags and limit high bits
base_high: u8, // Upper 8 bits of base
}
}
#[repr(C, packed)] is mandatory — the CPU reads this structure directly from memory. Any padding inserted by the Rust compiler would corrupt the entries.
Memory Layout of an Entry
Bit positions in the 8-byte entry:
┌────────────────────────┬─────┬──────────┬──────────┐
│ Base 31:24 │Flags│ Limit │ Access │
│ (8 bits) │(4b) │ 19:16 │ (8 bits) │
│ │ │ (4 bits) │ │
├────────────────────────┴─────┴──────────┴──────────┤
│ Base 23:16 │ Base 15:0 │ Limit 15:0 │
│ (8 bits) │ (16 bits) │ (16 bits) │
└─────────────────┴────────────────────┴─────────────┘
Constructing an Entry
#![allow(unused)]
fn main() {
const fn new(base: u32, limit: u32, access: u8, gran: u8) -> GdtEntry {
GdtEntry {
limit_low: (limit & 0xFFFF) as u16,
base_low: (base & 0xFFFF) as u16,
base_middle: ((base >> 16) & 0xFF) as u8,
access,
granularity: ((limit >> 16) & 0x0F) as u8 | (gran & 0xF0),
base_high: ((base >> 24) & 0xFF) as u8,
}
}
}
The limit and base are split across non-contiguous bits in the entry — a legacy of the 286-era design. Our constructor packs them correctly.
The Access Byte
The access byte controls who can use a segment and how:
Bit: 7 6 5 4 3 2 1 0
P | DPL | S | E | DC | RW | A
| Bit | Name | Meaning |
|---|---|---|
| 7 | P (Present) | 1 = segment is valid |
| 6-5 | DPL (Privilege Level) | 0 = ring 0 (kernel), 3 = ring 3 (user) |
| 4 | S (Descriptor Type) | 1 = code/data segment, 0 = system segment |
| 3 | E (Executable) | 1 = code segment, 0 = data segment |
| 2 | DC (Direction/Conforming) | Grows down for data, conforming for code |
| 1 | RW (Read/Write) | Read-enable for code, write-enable for data |
| 0 | A (Accessed) | CPU sets this when segment is accessed |
Our Access Bytes
| Segment | Access | Binary | Meaning |
|---|---|---|---|
| Kernel Code | 0x9A | 10011010 | Present, Ring 0, Code, Executable, Readable |
| Kernel Data | 0x92 | 10010010 | Present, Ring 0, Data, Read/Write |
| User Code | 0xFA | 11111010 | Present, Ring 3, Code, Executable, Readable |
| User Data | 0xF2 | 11110010 | Present, Ring 3, Data, Read/Write |
The Granularity Byte
Bit: 7 6 5 4 | 3 2 1 0
G | D | L | A | Limit 19:16
| Bit | Meaning |
|---|---|
| 7 (G) | Granularity: 0 = limit in bytes, 1 = limit in 4KB pages |
| 6 (D) | Default size: 0 = 16-bit, 1 = 32-bit protected mode |
| 5 (L) | Long mode: 1 = 64-bit code (we don’t use this) |
| 4 (A) | Available for OS use |
| 3-0 | Upper 4 bits of limit |
We pass 0xC0 (11000000) as the granularity flag:
- G = 1: Limit is in 4KB pages →
0xFFFFF * 4KB = 4GBaddress space - D = 1: 32-bit protected mode
Combined with limit = 0xFFFFF, this gives every segment a 4GB reach — the flat memory model.
Our GDT: Six Entries
#![allow(unused)]
fn main() {
static mut GDT: [GdtEntry; 6] = [
GdtEntry::null(), // [0] Null (required)
GdtEntry::new(0, 0xFFFFF, 0x9A, 0xC0), // [1] Kernel Code (0x08)
GdtEntry::new(0, 0xFFFFF, 0x92, 0xC0), // [2] Kernel Data (0x10)
GdtEntry::new(0, 0xFFFFF, 0xFA, 0xC0), // [3] User Code (0x18)
GdtEntry::new(0, 0xFFFFF, 0xF2, 0xC0), // [4] User Data (0x20)
GdtEntry::null(), // [5] TSS (placeholder)
];
}
Why the Null Descriptor Is Required
The CPU uses selector 0x00 to detect errors. If code tries to access memory through the null descriptor, the CPU generates a General Protection Fault. Removing it → triple fault at boot.
Segment Selectors
Each entry is 8 bytes. The segment selector is the byte offset into the GDT, which is why our assembly uses:
mov ax, 0x10 ; Kernel data — entry 2 × 8 bytes = offset 0x10
jmp 0x08:.flush ; Kernel code — entry 1 × 8 bytes = offset 0x08
| Offset | Segment |
|---|---|
0x00 | Null |
0x08 | Kernel Code |
0x10 | Kernel Data |
0x18 | User Code |
0x20 | User Data |
0x28 | TSS |
The TSS Placeholder
The Task State Segment (TSS) is needed for hardware task switching and for switching stacks when transitioning from ring 3 to ring 0. We reserve the slot now so all selectors stay stable, but leave it as null until we actually need it.
Loading the GDT
The GDT Pointer
lgdt expects a 6-byte descriptor structure:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct GdtPointer {
limit: u16, // Size of GDT in bytes - 1
base: u32, // Linear address of GDT
}
}
#![allow(unused)]
fn main() {
let gdt_ptr = GdtPointer {
limit: (core::mem::size_of::<[GdtEntry; 6]>() - 1) as u16,
base: &raw const GDT as *const _ as u32,
};
}
&raw const GDT — takes the address of the static without creating a reference (avoiding aliasing issues). This is the addr_of! macro equivalent using the &raw syntax.
The Assembly Flush
Simply loading a new GDT with lgdt isn’t enough. The CPU caches segment register contents in hidden “shadow” registers. We must reload all segment registers to pick up the new GDT:
gdt_flush:
mov eax, [esp+4] ; Get GDT pointer from stack argument
lgdt [eax] ; Load the new GDT
mov ax, 0x10 ; Kernel data segment selector
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; Far jump to reload CS with kernel code selector
.flush:
ret
Why a far jump?
The Code Segment register (CS) can only be changed with a far jump or iretd. The far jump jmp 0x08:.flush jumps to .flush with CS = 0x08 (kernel code segment). This flushes the instruction pipeline and reloads CS from the new GDT.
From Rust
#![allow(unused)]
fn main() {
extern "C" {
fn gdt_flush(gdt_ptr: *const GdtPointer);
}
pub fn init() {
unsafe {
let gdt_ptr = GdtPointer {
limit: (core::mem::size_of::<[GdtEntry; 6]>() - 1) as u16,
base: &raw const GDT as *const _ as u32,
};
gdt_flush(&gdt_ptr);
}
}
}
Debugging the GDT
The shell’s gdt command dumps all entries:
#![allow(unused)]
fn main() {
pub fn print_gdt() {
unsafe {
println!("GDT Address: 0x{:08x}", &raw const GDT as *const _ as u32);
for (i, name) in entries.iter().enumerate() {
let entry = &GDT[i];
let base = (entry.base_low as u32)
| ((entry.base_middle as u32) << 16)
| ((entry.base_high as u32) << 24);
let limit = (entry.limit_low as u32)
| (((entry.granularity & 0x0F) as u32) << 16);
println!("[{}] {} — Base: 0x{:08x}, Limit: 0x{:05x}, Access: 0x{:02x}",
i, name, base, limit, entry.access);
}
}
}
}