Global Descriptor Table (GDT)
What is it
The GDT serves as a fundamental data structure in x86 architecture, playing a crucial role in memory management and protection. When our computer starts, it begins in real mode
, a simple operating mode that provides direct access to memory and I/O devices. However we need to switch to protected mode
, which introduces memory protection, virtual memory, and privilege levels.
Think of protected mode
as establishing different security clearance levels in a building. The GDT acts like the security system that defines who can access what. While my earlier comparison to sudo
captured the basic idea of privilege levels, the reality is more sophisticated. Instead of just "admin" and "user", the x86 architecture provides four rings (0-3), where ring 0 is the most privileged (kernel space) and ring 3 is the least privileged (user space). Each ring has specific permissions and restrictions, all defined in our GDT.
The GDT is essential not just for security, but also for the basic operation of protected mode
. Without a properly configured GDT, the CPU cannot execute protected mode
code at all.
For more information go to OSDev
My Technical Approach
My approach was as follows: I would start at the boot.asm
& setup the multiboot. This will then call gdt_init
, which is a Rust function. gdt_init
will setup the GDT_Entries
& ensure that it creates the correct struct pointer that will be passed to gdt.asm
. gdt.asm
will place the entries in the correct registers.
The multiboot setup is crucial because it ensures our kernel is loaded correctly by the bootloader and meets the Multiboot Specification, which is a standardized way for bootloaders to load operating systems.
Here are some snippets to give you a better idea:
; Both Rust functions
extern gdt_init
extern kernel_main
_start:
; The bootloader has loaded us into 32-bit protected mode
; but we need to set up our own GDT for proper segmentation
call gdt_init
call kernel_main
#![allow(unused)] fn main() { #[no_mangle] // Ensure rustc doesn't mangle the symbol name for external linking pub fn gdt_init() { // Create the GDT descriptor structure // size is (total_size - 1) because the limit field is maximum addressable unit let gdt_descriptor = GDTDescriptor { size: (size_of::<GdtGates>() - 1) as u16, // Size must be one less than actual size offset: 0x00000800, // Place GDT at specified address in memory }; // Call assembly function to load GDT register (GDTR) gdt_flush(&gdt_descriptor as *const _); } }
Here's how our GDT entries are structured:
#![allow(unused)] fn main() { // Each GDT entry is 64 bits (8 bytes) pub struct Gate(pub u64); #[no_mangle] #[link_section = ".gdt"] // Place in special GDT section for linking pub static GDT_ENTRIES: GdtGates = [ // Null descriptor - Required by CPU specification for error checking Gate(0), // Kernel Code Segment: Ring 0, executable, non-conforming // Parameters: base=0, limit=max, access=0b10011010 (present, ring 0, code), flags=0b1100 (32-bit, 4KB granularity) Gate::new(0, !0, 0b10011010, 0b1100), // Kernel Data Segment: Ring 0, writable, grow-up // Parameters: base=0, limit=max, access=0b10010010 (present, ring 0, data), flags=0b1100 (32-bit, 4KB granularity) Gate::new(0, !0, 0b10010010, 0b1100), // User Code Segment: Ring 3, executable, non-conforming // Parameters: base=0, limit=max, access=0b11111010 (present, ring 3, code), flags=0b1100 (32-bit, 4KB granularity) Gate::new(0, !0, 0b11111010, 0b1100), // User Data Segment: Ring 3, writable, grow-up // Parameters: base=0, limit=max, access=0b11110010 (present, ring 3, data), flags=0b1100 (32-bit, 4KB granularity) Gate::new(0, !0, 0b11110010, 0b1100), ]; }
The assembly code in gdt.asm
that actually loads the GDT:
gdt_flush:
; Load GDT descriptor structure address from stack
mov eax, [esp + 4]
lgdt [eax]
; Enable protected mode by setting the first bit of CR0
mov eax, cr0
or eax, 1
mov cr0, eax
; Set up segment registers with appropriate selectors
; 0x10 points to the kernel data segment (third GDT entry)
mov eax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
; Far jump to flush pipeline and load CS with kernel code selector (0x08)
; This is necessary to fully enter protected mode
jmp 0x08:.flush
.flush:
ret
I am not going in-depth on why specific things happen, but each step is crucial for properly initializing protected mode.
Considerations
I considered using inline assembly, but the reason I did not in the end was because it had a few known bugs in Rust. I felt much more comfortable using actual asm
files, instead of doing it inline. Inline assembly in Rust can be particularly problematic when dealing with low-level CPU features, as the compiler's assumptions about register usage and calling conventions might conflict with what we need for GDT setup.
Use C or Assembly instead of Rust
Since C is a much older & better documented program than Rust, I considered using C for the gdt_init
, instead of Rust. I did not in the end, because I wanted to stay true to my "pure" Rust kernel & I felt like it would complicate things much more. Using C would have required additional complexity in the build system to handle multiple languages and their interaction.
Linker
Force the Linker to put GDT before the rest of the code. You can do it without this, but you will have issues if you would like to place it in a specific address. If that is not necessary for you, you can ignore this. The specific placement of the GDT in memory can be important for some system designs, particularly when dealing with memory management and virtual memory setup. This was my approach:
/* Start at 2MB */
. = 2M;
.gdt 0x800 : ALIGN(0x800)
{
gdt_start = .;
*(.gdt)
gdt_end = .;
}
Memory Layout Considerations
When placing the GDT at a specific address, it's important to ensure that:
- The address is accessible during the transition to protected mode
- The address doesn't conflict with other important system structures
- The address is properly aligned for optimal performance