Skip to content

04 - Global Descriptor Table (GDT) Setup

In the previous chapters, we established our development environment and implemented basic VGA text output, giving us a window into our operating system. Now, it's time to take a crucial step towards modern OS development: configuring the Global Descriptor Table (GDT). The GDT is fundamental for transitioning the CPU from Real Mode (the simplistic mode it starts in) to Protected Mode, which unlocks advanced features like memory segmentation, protection, and multitasking.

Understanding Protected Mode and Segments

When an x86 processor powers on, it starts in Real Mode. In this mode, memory addressing is limited to 1MB, and there's no hardware-enforced memory protection. This is a very basic environment.

To access the full capabilities of the processor (like more than 1MB of RAM, virtual memory, and protection mechanisms), we must switch to Protected Mode. Protected Mode relies heavily on segmentation. Instead of directly using physical addresses, the CPU uses logical addresses, which are composed of a segment selector and an offset.

The Global Descriptor Table (GDT) acts as a lookup table for these segment selectors. Each entry in the GDT, called a Segment Descriptor, defines the characteristics of a memory segment, including:

  • Base Address: The starting physical memory address of the segment.
  • Limit: The maximum size of the segment.
  • Access Byte: Defines the segment's type (code, data), privilege level (ring 0 for kernel, ring 3 for user), read/write/execute permissions, and presence in memory.
  • Granularity: Determines if the limit is interpreted in bytes (granularity bit 0) or in 4KB pages (granularity bit 1).
    • If G = 0, the limit field represents the exact number of bytes in the segment (up to 1MB).
    • If G = 1, the limit is multiplied by 4KB, allowing much larger segments (up to 4GB).

By configuring the GDT, we tell the CPU how to interpret memory accesses, setting up distinct segments for kernel code, kernel data, user code, and user data, each with its own permissions.


Project Structure

Ensure your project directory is structured correctly to accommodate the new GDT files:

hashx86-os/
├── Makefile
├── linker.ld
├── kernel.h
├── kernel.cpp
├── console.h
├── console.cpp
├── asm/
│   ├── loader.asm
│   └── load_gdt.asm   <-- New assembly file
├── core/
│   ├── port.h
│   ├── port.cpp
│   └── gdt.cpp        <-- New GDT implementation
└── include/
    ├── stdint.h
    ├── types.h
    └── gdt.h          <-- New GDT header

Defining the GDT Structure

core/gdt.h

This header file defines the data structures for our GDT entries and the GDT pointer, along with function prototypes for GDT management.

#ifndef GDT_H // Note: Conventionally, this should be GDT_H
#define GDT_H

#include <types.h> // Assumed to provide uint16_t, uint32_t, uint8_t

#define NO_GDT_DESCRIPTORS     8 // Number of GDT entries we plan to use

// GDT Selectors (index * 8)
// These are the values loaded into segment registers (CS, DS, SS, etc.)
// The lowest 3 bits are for privilege level and table indicator (T.I.)
// For now, we use 0 for T.I. (GDT) and 0 for privilege (Ring 0 - Kernel)
#define NULL_SELECTOR          0x00  // 0 * 8 - Required null descriptor
#define KERNEL_CODE_SELECTOR   0x08  // 1 * 8 - Selector for kernel code segment
#define KERNEL_DATA_SELECTOR   0x10  // 2 * 8 - Selector for kernel data segment
#define USER_CODE_SELECTOR     0x18  // 3 * 8 - Selector for user mode code segment
#define USER_DATA_SELECTOR     0x20  // 4 * 8 - Selector for user mode data segment
#define TSS_SELECTOR           0x28  // 5 * 8 - Selector for Task State Segment (future use)

// Structure representing a single GDT entry (segment descriptor)
typedef struct {
    uint16_t segment_limit;    // Segment limit (bits 0-15)
    uint16_t base_low;         // Base address (bits 0-15)
    uint8_t base_middle;       // Base address (bits 16-23)
    uint8_t access;            // Access byte (defines type, permissions, privilege)
    uint8_t granularity;       // High 4 bits (flags: G, D/B, L, AVL) + low 4 bits of segment limit (bits 16-19)
    uint8_t base_high;         // Base address (bits 24-31)
} __attribute__((packed)) GDT; // '__attribute__((packed))' ensures no padding bytes are added by the compiler

// Structure representing the GDT pointer (used by LGDT instruction)
typedef struct {
    uint16_t limit;            // Size of the GDT in bytes - 1
    uint32_t base_address;     // Linear base address of the GDT
} __attribute__((packed)) GDT_PTR; // Ensures no padding

// Assembly function to load the GDT into the CPU's GDTR register
extern "C" void load_gdt(uint32_t gdt_ptr);

// Function to set up a single GDT entry
void gdt_set_entry(int index, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran);

// Function to initialize the entire GDT
void gdt_init();

#endif

core/gdt.h Breakdown:

  • NO_GDT_DESCRIPTORS: Defines the total number of segment descriptors we'll set up in our GDT. We've allocated space for 8, though we'll initially use 5.
  • GDT Selectors: These #define statements provide symbolic names for the segment selectors. A selector is simply an index into the GDT multiplied by 8 (since each entry is 8 bytes). The lowest 3 bits of a selector are used for privilege level (RPL) and a table indicator (TI). For now, we'll keep the privilege level at 0 (kernel mode).
  • GDT Structure: This struct defines the exact 8-byte layout of a segment descriptor as required by the x86 architecture. The __attribute__((packed)) directive is crucial; it tells the compiler not to add any padding bytes to the structure, ensuring it matches the hardware's expected layout.
  • GDT_PTR Structure: This struct defines the format of the pointer that the CPU's LGDT instruction expects. It contains the size of the GDT (limit) and its base memory address. Again, __attribute__((packed)) is used for precise alignment.
  • extern "C" void load_gdt(uint32_t gdt_ptr);: This declares an assembly function that will actually load our GDT into the CPU's GDTR (Global Descriptor Table Register). extern "C" prevents C++ name mangling, ensuring our C++ code can call the assembly function directly.
  • Function Prototypes: gdt_set_entry is for populating individual descriptors, and gdt_init is the main initialization routine.

GDT Initialization Logic

core/gdt.cpp

This file contains the C code responsible for populating the GDT entries and initiating the GDT load.

#include <core/gdt.h> // Includes our GDT definitions and prototypes

// Declare the GDT array and the GDT pointer structure
GDT g_gdt[NO_GDT_DESCRIPTORS];
GDT_PTR g_gdt_ptr;

/**
 * @brief Fills a specific entry in the GDT with the provided segment details.
 *
 * @param index The index of the GDT entry to set (0-based).
 * @param base The 32-bit base address of the segment.
 * @param limit The 20-bit limit of the segment (size - 1).
 * @param access The 8-bit access byte (permissions, type, privilege).
 * @param gran The 8-bit granularity byte (flags and high 4 bits of limit).
 */
void gdt_set_entry(int index, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran) {
    GDT *entry = &g_gdt[index]; // Get a pointer to the specific GDT entry

    // Set the segment limit (20 bits total, split into two parts)
    entry->segment_limit = limit & 0xFFFF;           // Lower 16 bits of limit
    entry->granularity = (limit >> 16) & 0x0F;       // Upper 4 bits of limit (bits 16-19)

    // Set the base address (32 bits total, split into three parts)
    entry->base_low = base & 0xFFFF;                 // Lower 16 bits of base
    entry->base_middle = (base >> 16) & 0xFF;        // Middle 8 bits of base
    entry->base_high = (base >> 24 & 0xFF);          // Upper 8 bits of base

    // Set the access byte and combine granularity flags
    entry->access = access;
    entry->granularity = entry->granularity | (gran & 0xF0); // Combine limit's high 4 bits with granularity flags
}

/**
 * @brief Initializes the Global Descriptor Table (GDT) with standard segments.
 *
 * Sets up the null, kernel code/data, and user code/data segments.
 * Finally, it loads the GDT into the CPU's GDTR register.
 */
void gdt_init() {
    // Set the GDT pointer's limit (size of GDT array - 1)
    g_gdt_ptr.limit = sizeof(g_gdt) - 1;
    // Set the GDT pointer's base address (address of the first GDT entry)
    g_gdt_ptr.base_address = (uint32_t)g_gdt;

    // --- Define GDT Entries ---
    // 0: NULL Segment (Required by Intel specification, always all zeros)
    gdt_set_entry(0, 0, 0, 0, 0);

    // 1: Kernel Code Segment
    // Base: 0 (starts at 0x00000000)
    // Limit: 0xFFFFFFFF (covers entire 4GB address space)
    // Access: 0x9A (Present, Ring 0, Code, Read/Execute, Conforming)
    // Granularity: 0xCF (4KB granularity, 32-bit operand size, Long Mode compatible - not used yet)
    gdt_set_entry(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);

    // 2: Kernel Data Segment
    // Base: 0
    // Limit: 0xFFFFFFFF
    // Access: 0x92 (Present, Ring 0, Data, Read/Write, Expand-up)
    // Granularity: 0xCF
    gdt_set_entry(2, 0, 0xFFFFFFFF, 0x92, 0xCF);

    // 3: User Code Segment (for future user-mode applications)
    // Base: 0
    // Limit: 0xFFFFFFFF
    // Access: 0xFA (Present, Ring 3, Code, Read/Execute, Conforming)
    // Granularity: 0xCF
    gdt_set_entry(3, 0, 0xFFFFFFFF, 0xFA, 0xCF);

    // 4: User Data Segment (for future user-mode applications)
    // Base: 0
    // Limit: 0xFFFFFFFF
    // Access: 0xF2 (Present, Ring 3, Data, Read/Write, Expand-up)
    // Granularity: 0xCF
    gdt_set_entry(4, 0, 0xFFFFFFFF, 0xF2, 0xCF);

    // Load the GDT into the GDTR register using the assembly function
    load_gdt((uint32_t)&g_gdt_ptr);
}

core/gdt.cpp Breakdown:

  • g_gdt and g_gdt_ptr: These are global variables that will hold our GDT array and the GDT pointer structure, respectively.
  • gdt_set_entry(int index, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran): This function is responsible for populating a single 8-byte GDT entry. The base address and limit are 32-bit values, but they are split and placed into different 16-bit and 8-bit fields within the GDT struct to match the x86 descriptor format. The access byte defines permissions and privilege levels, and gran (granularity) contains flags like the G bit (for 4KB page granularity) and the D/B bit (for 32-bit operand size).
  • gdt_init(): This is the main GDT initialization function.
  • It first sets the limit and base_address fields of g_gdt_ptr to point to our g_gdt array.
  • It then calls gdt_set_entry five times to define our initial segments:
    • Null Segment (Index 0): Always required and must be all zeros.
    • Kernel Code Segment (Index 1): Defines the segment for our kernel's executable code. It covers the entire 4GB address space (base 0, limit 0xFFFFFFFF) and is set for Ring 0 (highest privilege), executable, and readable.
    • Kernel Data Segment (Index 2): Defines the segment for our kernel's data. Similar to the code segment, it covers the full 4GB and is set for Ring 0, readable, and writable.
    • User Code Segment (Index 3) & User Data Segment (Index 4): These are set up for future user-mode applications. They also cover the full 4GB but are configured for Ring 3 (lowest privilege), which is crucial for memory protection between user applications and the kernel.
  • Finally, it calls load_gdt((uint32_t)&g_gdt_ptr); to load the newly configured GDT into the CPU's GDTR.

Loading the GDT and Entering Protected Mode

asm/load_gdt.asm

This assembly file contains the critical load_gdt function, which uses specific x86 instructions to load the GDT and complete the transition to Protected Mode.

section .text

global load_gdt
load_gdt:
    mov eax, [esp + 4]  ; Get the address of the GDT_PTR structure from the stack (first argument)
    lgdt [eax]          ; Load the GDT into the GDTR (Global Descriptor Table Register)

    ; Reload segment registers with new selectors (pointing to our new GDT entries)
    ; Note: CS (Code Segment) is reloaded via a far jump, not directly.
    mov ax, 0x10        ; Load KERNEL_DATA_SELECTOR (0x10) into AX
    mov ds, ax          ; Set Data Segment register
    mov es, ax          ; Set Extra Segment register
    mov fs, ax          ; Set F Segment register
    mov gs, ax          ; Set G Segment register
    mov ss, ax          ; Set Stack Segment register

    cli                 ; Clear interrupts (disable interrupts) - Good practice before mode switch

    ; Enable Protected Mode by setting the PE bit (bit 0) in CR0 register
    mov eax, cr0        ; Move contents of Control Register 0 into EAX
    or eax, 1           ; Set the least significant bit (bit 0, the Protected Mode Enable bit)
    mov cr0, eax        ; Move modified EAX back to CR0

    ; Perform a far jump to reload the Code Segment (CS) register.
    ; This is essential to flush the CPU's instruction pipeline and ensure
    ; the CPU starts executing in Protected Mode with the new CS descriptor.
    ; 0x08 is KERNEL_CODE_SELECTOR. 'far_jump' is the label within this segment.
    jmp 0x08:far_jump   
far_jump:
    ret                 ; Return from the function (now in Protected Mode)

asm/load_gdt.asm Breakdown:

  • load_gdt Function: This is the entry point called from gdt_init().
  • mov eax, [esp + 4]: Retrieves the gdt_ptr argument (which is the address of our GDT_PTR structure) from the stack and puts it into EAX.
  • lgdt [eax]: This is the crucial instruction. It loads the limit and base_address from the GDT_PTR structure (pointed to by EAX) into the CPU's GDTR (Global Descriptor Table Register). Once GDTR is loaded, the CPU knows where our GDT is located in memory.
  • Reloading Segment Registers:
  • mov ax, 0x10: We load the KERNEL_DATA_SELECTOR (which is 0x10) into the AX register.
  • mov ds, ax, mov es, ax, mov fs, ax, mov gs, ax, mov ss, ax: We then load this selector into all the data-related segment registers (DS, ES, FS, GS, SS). This tells the CPU to use the Kernel Data Segment defined in our GDT for all data accesses.
  • Note on CS (Code Segment): The CS register cannot be directly loaded like other segment registers. It's implicitly loaded by a "far jump" or "far call."
  • Entering Protected Mode:
  • cli: Clears the Interrupt Flag, effectively disabling hardware interrupts. This is a good practice during critical mode switches to prevent unexpected interruptions.
  • mov eax, cr0: Moves the contents of Control Register 0 (CR0) into the EAX register. CR0 contains various control bits for the processor.
  • or eax, 1: Sets the least significant bit (bit 0) of EAX. This bit is the Protected Mode Enable (PE) bit. Setting it enables Protected Mode.
  • mov cr0, eax: Moves the modified EAX back into CR0, activating Protected Mode.
  • Far Jump (jmp 0x08:far_jump):
  • This is the final step to fully enter Protected Mode. A far jump consists of a new code segment selector (0x08, which is KERNEL_CODE_SELECTOR) and a new offset (far_jump).
  • When the CPU executes this far jump, it does two things:
    1. It loads the KERNEL_CODE_SELECTOR into CS. This causes the CPU to use the Kernel Code Segment descriptor from our newly loaded GDT.
    2. It flushes the CPU's instruction prefetch queue, ensuring that all subsequent instructions are fetched and interpreted according to the rules of Protected Mode and the new segment descriptor.
  • far_jump:: This label marks the target of our far jump.
  • ret: Returns from the load_gdt function. At this point, the CPU is fully operating in Protected Mode.

Integration into kernel.cpp

To initialize the GDT and switch to Protected Mode, we simply need to call gdt_init() from our kernelMain function. We'll also add some printf statements to confirm the process.

Add this include to the top of kernel.h:

#include <core/gdt.h>  // For GDT initialization
Updated kernel.cpp file:

#include <kernel.h>

extern "C" void kernelMain(void* multiboot_structure, uint32_t magicnumber) {
    clearScreen(); // Clear the screen upon kernel entry
    printf(combineColors(YELLOW, BLACK), "Welcome to My OS!\n");
    printf(WHITE, "Initializing GDT...\n");

    // Call the GDT initialization function
    gdt_init();

    printf(LIGHT_GREEN, "GDT initialized and loaded successfully.\n");
    printf(LIGHT_CYAN, "Transitioned to Protected Mode.\n");

    // Keep the kernel running in an infinite loop
    while (1) {
        asm volatile("hlt"); // Halt the CPU
    }
}

Makefile Update

We need to tell our build system to compile the new core/gdt.cpp file and link it, along with the asm/load_gdt.asm file.

objects = asm/loader.o \
          asm/load_gdt.o \
          kernel.o \
          console.o \
          core/port.o \
          core/gdt.o

Building and Running Your OS

To build and run your OS with the new GDT setup, open your WSL Ubuntu terminal, navigate to your hashx86-os directory, and execute the run target in the Makefile:

make run

Expected Output:

You should observe a new QEMU window. The screen will clear, and you will see messages indicating that the GDT has been initialized and that the system has transitioned to Protected Mode.

  • "Welcome to My OS!" (Yellow text)
  • "Initializing GDT..." (White text)
  • "GDT initialized and loaded successfully." (Light Green text)
  • "Transitioned to Protected Mode." (Light Cyan text)

This output confirms that your GDT setup is correct and your OS is now running in Protected Mode, a critical prerequisite for advanced features like paging and multitasking.

img_1


Conclusion

You have successfully configured and loaded the Global Descriptor Table, enabling your operating system to operate in Protected Mode. This is a foundational achievement, as Protected Mode unlocks the full capabilities of the x86 architecture, including memory protection and access to more than 1MB of RAM. While this step doesn't immediately change the visual output significantly, it's a silent but vital architectural shift.

In the next tutorial, we will leverage Protected Mode to implement Interrupt Descriptor Table (IDT) and enable Interrupts, a crucial step for handling hardware events and system calls.


See Also


Download Project Files

04 - Global Descriptor Table.zip