CLOSE

For More info on the GDT Visit:

1 gdt.c:

#include <system.h>

/*
 * Global Descriptor Table (GDT) Entry
 * Each entry in the GDT is 8 bytes long.
 */
struct gdt_entry {
    unsigned short limit_low;  // The lower 16 bits of the limit.
    unsigned short base_low;   // The lower 16 bits of the base address.
    unsigned char base_middle; // The next 8 bits of the base address.
    unsigned char access;      // Access flags, determine what ring this segment can be used in.
    unsigned char granularity; // Granularity, and upper 4 bits of the limit.
    unsigned char base_high;   // The last 8 bits of the base address.
} __attribute__((packed));

/*
 * GDT pointer structure
 * Points to the start of our array of GDT entries
 * and the size of the array (limit).
 */
struct gdt_ptr {
    unsigned short limit; // The upper 16 bits of all selector limits.
    unsigned int base;    // The address of the first gdt_entry struct.
} __attribute__((packed));

struct gdt_entry gdt[3]; // Define 3 entries in our GDT.
struct gdt_ptr gp;       // Define the pointer to the GDT.

/*
 * (ASM) gdt_flush
 * This will be defined in assembly, it reloads the segment registers.
 */
extern void gdt_flush();

/*
 * gdt_set_gate
 * Set a GDT descriptor.
 * @param num: The index of the GDT entry to set.
 * @param base: The base address of the segment.
 * @param limit: The limit of the segment.
 * @param access: The access flags for the segment.
 * @param gran: The granularity flags for the segment.
 */
void gdt_set_gate(int num, unsigned long base, unsigned long limit, unsigned char access, unsigned char gran) {
    /* Base Address */
    gdt[num].base_low = (base & 0xFFFF);           // Set the lower 16 bits of the base.
    gdt[num].base_middle = (base >> 16) & 0xFF;    // Set the next 8 bits of the base.
    gdt[num].base_high = (base >> 24) & 0xFF;      // Set the high 8 bits of the base.
    /* Limits */
    gdt[num].limit_low = (limit & 0xFFFF);         // Set the lower 16 bits of the limit.
    gdt[num].granularity = (limit >> 16) & 0x0F;   // Set the high 4 bits of the limit in granularity.
    /* Granularity */
    gdt[num].granularity |= (gran & 0xF0);         // Combine the granularity flags.
    /* Access flags */
    gdt[num].access = access;                      // Set the access flags.
}

/*
 * gdt_install
 * Install the kernel's GDT.
 */
void gdt_install() {
    /* GDT pointer and limits */
    gp.limit = (sizeof(struct gdt_entry) * 3) - 1; // Calculate the total size of the GDT.
    // Set the base address of the GDT
    // Without cast: some compilers may issue a warning or error.
    // gp.base = &gdt;
    // With explicit cast: ensures the address is treated as an integer.
    gp.base = (unsigned int)&gdt;                                // Set the base address of the GDT.

    /* NULL segment: required by x86 architecture */
    gdt_set_gate(0, 0, 0, 0, 0);                   // Set the first GDT entry (null descriptor).

    /* Code segment: base=0, limit=4GB, accessed from ring 0 */
    gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);    // Set the code segment descriptor.

    /* Data segment: base=0, limit=4GB, accessed from ring 0 */
    gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF);    // Set the data segment descriptor.

    /* Load the new GDT using the gdt_flush function */
    gdt_flush();
}

Code Explanation:

Structures for GDT Entries and GDT Pointer

GDT Entry Structure (gdt_entry):

/*
 * Global Descriptor Table (GDT) Entry
 * Each entry in the GDT is 8 bytes long.
 */
struct gdt_entry {
    unsigned short limit_low;  // The lower 16 bits of the limit.
    unsigned short base_low;   // The lower 16 bits of the base address.
    unsigned char base_middle; // The next 8 bits of the base address.
    unsigned char access;      // Access flags, determine what ring this segment can be used in.
    unsigned char granularity; // Granularity, and upper 4 bits of the limit.
    unsigned char base_high;   // The last 8 bits of the base address.
} __attribute__((packed));

This structure represents a single entry in the GDT. It includes:

  • limit_low: The lower 16 bits of the segment limit.
  • base_low: The lower 16 bits of the base address.
  • base_middle: The next 8 bits of the base address.
  • access: The access flags (e.g., segment type, privilege level).
  • granularity: The granularity and size flags, along with the upper 4 bits of the segment limit.
  • base_high: The upper 8 bits of the base address.

The __attribute__((packed)) ensures that the compiler does not add any padding between the members of the structure.

GDT Pointer Structure (gdt_ptr):

/*
 * GDT pointer structure
 * Points to the start of our array of GDT entries
 * and the size of the array (limit).
 */
struct gdt_ptr {
    unsigned short limit; // The upper 16 bits of all selector limits.
    unsigned int base;    // The address of the first gdt_entry struct.
} __attribute__((packed));

This structure holds the address and size of the GDT. The CPU needs this information to load the GDT.

  • limit: The size of the GDT minus one.
  • base: The base address of the first GDT entry.

Declaration of GDT and GDT Pointer

Global Declarations:

struct gdt_entry gdt[3]; // Define 3 entries in our GDT.
struct gdt_ptr gp;       // Define the pointer to the GDT.
  • gdt: An array of three GDT entries.
  • gp: The GDT pointer.

External Function Declaration

GDT Flush Function:

/*
 * (ASM) gdt_flush
 * This will be defined in assembly, it reloads the segment registers.
 */
extern void gdt_flush();

This external function (written in assembly) reloads the segment registers to use the new GDT. The actual implementation of gdt_flush is not provided here, but it typically involves loading the new GDT pointer into the GDTR register.

Setting a GDT Entry

gdt_set_gate Function:

/*
 * gdt_set_gate
 * Set a GDT descriptor.
 * @param num: The index of the GDT entry to set.
 * @param base: The base address of the segment.
 * @param limit: The limit of the segment.
 * @param access: The access flags for the segment.
 * @param gran: The granularity flags for the segment.
 */
void gdt_set_gate(int num, unsigned long base, unsigned long limit, unsigned char access, unsigned char gran) {
    /* Base Address */
    gdt[num].base_low = (base & 0xFFFF);           // Set the lower 16 bits of the base.
    gdt[num].base_middle = (base >> 16) & 0xFF;    // Set the next 8 bits of the base.
    gdt[num].base_high = (base >> 24) & 0xFF;      // Set the high 8 bits of the base.
    /* Limits */
    gdt[num].limit_low = (limit & 0xFFFF);         // Set the lower 16 bits of the limit.
    gdt[num].granularity = (limit >> 16) & 0x0F;   // Set the high 4 bits of the limit in granularity.
    /* Granularity */
    gdt[num].granularity |= (gran & 0xF0);         // Combine the granularity flags.
    /* Access flags */
    gdt[num].access = access;                      // Set the access flags.
}

This function initializes a single GDT entry:

  • base: The base address of the segment.
  • limit: The size of the segment.
  • access: The access flags (e.g., segment type, privilege level).
  • gran: The granularity and size flags.

Installing the GDT

gdt_install Function:

/*
 * gdt_install
 * Install the kernel's GDT.
 */
void gdt_install() {
    /* GDT pointer and limits */
    gp.limit = (sizeof(struct gdt_entry) * 3) - 1; // Calculate the total size of the GDT.
    
    // Set the base address of the GDT
    // Without cast: some compilers may issue a warning or error.
    // gp.base = &gdt;
    // With explicit cast: ensures the address is treated as an integer.
    gp.base = (unsigned int)&gdt;                                // Set the base address of the GDT.

    /* NULL segment: required by x86 architecture */
    gdt_set_gate(0, 0, 0, 0, 0);                   // Set the first GDT entry (null descriptor).

    /* Code segment: base=0, limit=4GB, accessed from ring 0 */
    gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF);    // Set the code segment descriptor.

    /* Data segment: base=0, limit=4GB, accessed from ring 0 */
    gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF);    // Set the data segment descriptor.

    /* Load the new GDT using the gdt_flush function */
    gdt_flush();
}

This function sets up the GDT:

  • Initialize GDT Pointer: Set the limit and base address of the GDT.
  • Set GDT Entries:
    • Null Segment (Index 0): This is a mandatory entry that acts as a placeholder and is not used.
    • Code Segment (Index 1): Defines a code segment with read and execute permissions, spanning the entire 4 GB address space.
    • Data Segment (Index 2): Defines a data segment with read and write permissions, also spanning the entire 4 GB address space.
  • Flush the GDT: Call gdt_flush which is an external assembly function to load the new GDT and update the segment registers.

2 gdt_asm.asm

This file would contain the definition of gdt_flush, we can define it in the already existing file start.asm however doing so make our code unmaintainable later on. So we go with a new file for the definition of it.

; Declare that the gdt_flush function is global, so it can be used outside this file.
global gdt_flush

; Declare that the gp (GDT pointer) is defined in another file.
extern gp

; Define the gdt_flush function.
gdt_flush:
    ; Load the GDT pointer (gp) into the GDTR register using the lgdt instruction.
    lgdt [gp]

    ; Set up segment registers with the data segment selector.
    ; 0x10 is the selector for the data segment, which is the second entry in the GDT.
    mov ax, 0x10
    mov ds, ax   ; Load the data segment (ds) register.
    mov es, ax   ; Load the extra segment (es) register.
    mov fs, ax   ; Load the fs segment register.
    mov gs, ax   ; Load the gs segment register.
    mov ss, ax   ; Load the stack segment (ss) register.

    ; Perform a far jump to reload the code segment register (cs) with the code segment selector.
    ; 0x08 is the selector for the code segment, which is the first entry in the GDT.
    jmp 0x08:flush2

; Label for the code that is executed after the far jump.
flush2:
    ; Return from the function.
    ret

Assembly Code Explanation

1 Global and External Declarations

global gdt_flush
extern gp
  • global gdt_flush: This makes the gdt_flush label accessible from other files, allowing the C code to call this function.
  • extern gp: This declares that gp (the GDT pointer) is defined in another file (the C code in this case). The assembly code will reference gp to load the GDT.

2 gdt_flush Function:

gdt_flush:
  • This label marks the start of the gdt_flush function.
lgdt [gp]
  • lgdt [gp]: This instruction loads the GDT descriptor from the memory location pointed to by gp. The GDT descriptor contains the base address and limit of the GDT.

Reload Segment Registers

mov ax, 0x10
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
  • mov ax, 0x10: Load the value 0x10 into the ax register. The value 0x10 corresponds to the selector for the data segment in the GDT (second entry, with an index of 2, shifted left by 3 bits).
  • mov ds, ax: Load the ds (Data Segment) register with the value in ax.
  • mov es, ax: Load the es (Extra Segment) register with the value in ax.
  • mov fs, ax: Load the fs (Extra Segment) register with the value in ax.
  • mov gs, ax: Load the gs (Extra Segment) register with the value in ax.
  • mov ss, ax: Load the ss (Stack Segment) register with the value in ax.

These instructions reload all the segment registers (ds, es, fs, gs, ss) with the data segment selector (0x10). This step is necessary to ensure that the segment registers use the newly defined GDT entries.

Far Jump to Flush Instruction Pipeline

jmp 0x08:flush2
  • jmp 0x08:flush2: This is a far jump that reloads the cs (Code Segment) register with the value 0x08 (selector for the code segment in the GDT) and jumps to the flush2 label. This instruction ensures that the cs register is updated with the new GDT entry for the code segment.

Return from Function

flush2:
ret
  • flush2: This label marks the target for the far jump.
  • ret: This instruction returns control to the calling function in the C code.

3 Call gdt_install() from Kernel Main

Since we did all the setup of the gdt table in the gdt_install() function. Now it's time to call this from our kernel main() function.

Modify the main.c file to add the function call to gdt_install():

int
main() {
..........................

	gdt_install();
	
..........................

	init_video();
	puts("Hello world!\n");
	for (;;);

and need to add the extern declaration to the system.h file as well. So that 

/* GDT */
extern void gdt_install();
extern void gdt_set_gate(int num, unsigned long base, unsigned long limit, unsigned char access, unsigned char gran);

3 Modify Makefile

Since we introduced a c and asm file, now need to modify the makefile to add rule for their compilation.

.PHONY: all clean run install

# Compiler and assembler flags
CC = gcc
CFLAGS = -Wall -m32 -fno-pie -O0 -fstrength-reduce -fomit-frame-pointer -finline-functions -nostdinc -fno-builtin -I./include
AS = nasm
ASFLAGS = -f elf

# Output files and directories
ISO_DIR = iso
BOOT_DIR = $(ISO_DIR)/boot
GRUB_DIR = $(BOOT_DIR)/grub
ISO = my_os.iso

# Source files and objects
C_SOURCES = main.c vga.c gdt.c
ASM_SOURCES = start.asm gdt_asm.asm
OBJ = $(C_SOURCES:.c=.o) $(ASM_SOURCES:.asm=.o)

# Linker script
LINKER_SCRIPT = link.ld

# Kernel binary
KERNEL = kernel.bin

all: $(KERNEL)

# Build the ISO and run it with QEMU
run: install
	qemu-system-x86_64 -cdrom $(ISO)

# Install the kernel and GRUB configuration into the ISO directory and create the ISO image
install: $(KERNEL)
	mkdir -p $(GRUB_DIR)
	cp $(KERNEL) $(BOOT_DIR)/kernel.bin
	cp grub.cfg $(GRUB_DIR)/grub.cfg
	grub-mkrescue -o $(ISO) $(ISO_DIR)

# Link the kernel binary
$(KERNEL): $(OBJ) $(LINKER_SCRIPT)
	ld -m elf_i386 -T $(LINKER_SCRIPT) -o $(KERNEL) $(OBJ)

# Compile C source files
%.o: %.c
	$(CC) $(CFLAGS) -c -o $@ $<

# Assemble assembly source files
%.o: %.asm
	$(AS) $(ASFLAGS) -o $@ $<

# Clean up build artifacts
clean:
	rm -f $(OBJ) $(KERNEL) $(ISO)
	rm -rf $(ISO_DIR)

# Dependencies
main.o: main.c
vga.o: vga.c
gdt.o: gdt.c
start.o: start.asm
gdt_asm.o: gdt_asm.asm

1 Phony Targets:

.PHONY: all clean run install
  • all, clean, run, and install are declared as phony targets, meaning they don't correspond to actual files but are just names for commands to run.

2 Variables:

# Compiler and assembler flags
CC = gcc
CFLAGS = -Wall -m32 -fno-pie -O0 -fstrength-reduce -fomit-frame-pointer -finline-functions -nostdinc -fno-builtin -I./include
AS = nasm
ASFLAGS = -f elf
  • CC and AS are the compiler and assembler, respectively.
  • CFLAGS are the compiler flags for GCC, specifying options like 32-bit mode (-m32), no position-independent code (-fno-pie), and optimization level (-O0).
  • ASFLAGS are the assembler flags for NASM, specifying ELF format (-f elf).

3 Directories and Files:

# Output files and directories
ISO_DIR = iso
BOOT_DIR = $(ISO_DIR)/boot
GRUB_DIR = $(BOOT_DIR)/grub
ISO = my_os.iso
  • ISO_DIR, BOOT_DIR, and GRUB_DIR are directories for organizing the ISO structure.
  • ISO is the name of the ISO image to be created.
# Source files and objects
C_SOURCES = main.c vga.c gdt.c
ASM_SOURCES = start.asm gdt_asm.asm
OBJ = $(C_SOURCES:.c=.o) $(ASM_SOURCES:.asm=.o)
  • C_SOURCES and ASM_SOURCES list the source files.
  • OBJ lists the object files generated from these source files.
# Linker script
LINKER_SCRIPT = link.ld

# Kernel binary
KERNEL = kernel.bin
  • LINKER_SCRIPT is the linker script used by the linker to generate the final binary.
  • KERNEL is the output kernel binary.

4 Targets and Rules:

all: $(KERNEL)
  • The all target depends on the $(KERNEL) file.
run: install
    qemu-system-x86_64 -cdrom $(ISO)
  • The run target depends on the install target and runs the ISO using QEMU.
install: $(KERNEL)
    mkdir -p $(GRUB_DIR)
    cp $(KERNEL) $(BOOT_DIR)/kernel.bin
    cp grub.cfg $(GRUB_DIR)/grub.cfg
    grub-mkrescue -o $(ISO) $(ISO_DIR)
  • The install target depends on the $(KERNEL) file, creates necessary directories, copies the kernel and GRUB configuration, and creates the ISO image using grub-mkrescue.
$(KERNEL): $(OBJ) $(LINKER_SCRIPT)
    ld -m elf_i386 -T $(LINKER_SCRIPT) -o $(KERNEL) $(OBJ)
  • The $(KERNEL) target depends on the object files and the linker script, and links them into the final kernel binary.
%.o: %.c
    $(CC) $(CFLAGS) -c -o $@ $<
  • This pattern rule compiles C source files into object files.
%.o: %.asm
    $(AS) $(ASFLAGS) -o $@ $<
  • This pattern rule assembles assembly source files into object files.
clean:
    rm -f $(OBJ) $(KERNEL) $(ISO)
    rm -rf $(ISO_DIR)
  • The clean target removes all generated files and directories to clean the build environment.
# Dependencies
main.o: main.c
vga.o: vga.c
gdt.o: gdt.c
start.o: start.asm
gdt_asm.o: gdt_asm.asm
  • These explicit rules specify dependencies for the object files, ensuring that changes to source files trigger recompilation.