Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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
BitNameMeaning
7P (Present)1 = segment is valid
6-5DPL (Privilege Level)0 = ring 0 (kernel), 3 = ring 3 (user)
4S (Descriptor Type)1 = code/data segment, 0 = system segment
3E (Executable)1 = code segment, 0 = data segment
2DC (Direction/Conforming)Grows down for data, conforming for code
1RW (Read/Write)Read-enable for code, write-enable for data
0A (Accessed)CPU sets this when segment is accessed

Our Access Bytes

SegmentAccessBinaryMeaning
Kernel Code0x9A10011010Present, Ring 0, Code, Executable, Readable
Kernel Data0x9210010010Present, Ring 0, Data, Read/Write
User Code0xFA11111010Present, Ring 3, Code, Executable, Readable
User Data0xF211110010Present, Ring 3, Data, Read/Write

The Granularity Byte

Bit: 7   6   5   4 | 3 2 1 0
     G | D | L | A | Limit 19:16
BitMeaning
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-0Upper 4 bits of limit

We pass 0xC0 (11000000) as the granularity flag:

  • G = 1: Limit is in 4KB pages → 0xFFFFF * 4KB = 4GB address 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
OffsetSegment
0x00Null
0x08Kernel Code
0x10Kernel Data
0x18User Code
0x20User Data
0x28TSS

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);
        }
    }
}
}