DEV Community

Cover image for From Real Mode to Protected Mode — What the GDT and IDT Actually Define
Harrison Guo
Harrison Guo

Posted on Originally published at harrisonsec.com

From Real Mode to Protected Mode — What the GDT and IDT Actually Define

"User space" and "kernel space" sound like operating-system inventions. They aren't. They're CPU privilege levels — ring 0 and ring 3 — and they come into existence during boot, defined by two tables the firmware never sets up for you: the GDT and the IDT. Every x86 machine starts in 16-bit real mode with no memory protection at all, and the switch to protected mode is where those tables, and the whole notion of a protected kernel, are established.

The switch is smaller than it sounds and more consequential than it looks. This walks it through real bootloader assembly — from NanoBoot, a minimal two-stage bootloader I wrote for exactly this — so the tables are concrete rather than diagrams.

Where the CPU starts

The processor powers on in real mode: 16-bit registers, segment:offset addressing where a physical address is segment << 4 + offset, about 1 MB reachable, and no memory protection whatsoever. Any code can touch any address and any I/O port. It's the environment BIOS lives in, and it's the environment your bootloader inherits. Protected mode is what gives you 32-bit flat addressing, privilege levels, and hardware-enforced memory protection — but you have to build the furniture yourself before you can move in.

The transition, in real assembly

The core of the switch is short:

    cli                     ; no interrupts while there is no IDT to catch them
    lgdt [gdt_descriptor]   ; point the CPU at our GDT
    mov  eax, cr0
    or   eax, 1             ; set CR0.PE — protection enable
    mov  cr0, eax
    jmp  SEL_CODEA:pm_start_32   ; far jump into a protected-mode code segment
Enter fullscreen mode Exit fullscreen mode

Five steps, and each one is load-bearing:

  • cli because the moment you flip into protected mode, the real-mode interrupt setup is meaningless and there is no valid IDT yet. An interrupt in that window is a triple fault.
  • lgdt loads the GDT register with the address and size of your descriptor table. Nothing is enforced yet; you're just telling the CPU where the table is.
  • or eax, 1cr0 sets bit 0, CR0.PE. This is the actual mode switch. But the CPU is still executing the instructions it already prefetched in 16-bit form.
  • The far jump is the part tutorials wave at. Setting PE does not reload the code segment or flush the pipeline. A far jump does both: it loads CS from a GDT selector and forces the CPU to refetch and decode the next instructions in 32-bit protected mode. Without it you're in a half-switched state running stale 16-bit decoding. SEL_CODEA here is 0x08 — the first real descriptor in the GDT.

The GDT: what a segment descriptor encodes

Here is the table that jump lands against, from NanoBoot:

gdt_start:
    dd 0, 0                                          ; null descriptor (required)
    DESC 0, LIM_4GB, ACC_CODE32, FLG_GRAN4K          ; 0x08 code:  ring0, exec, 4GB flat
    DESC 0, LIM_4GB, ACC_CODE32, FLG_GRAN4K          ; 0x10 code B
    DESC 0, LIM_4GB, ACC_DATA32, FLG_GRAN4K          ; 0x18 data:  ring0, r/w, 4GB flat
gdt_end:

gdt_descriptor:
    dw gdt_end - gdt_start - 1   ; limit (size - 1)
    dd 0                          ; base address of the table
Enter fullscreen mode Exit fullscreen mode

Each 8-byte descriptor packs four things the CPU needs to interpret a segment:

  • a base address and a limit (here base 0, limit 4 GB — the "flat" model, where segments stop being about carving memory and become just permission/mode carriers),
  • an access byteACC_CODE32 = 10011010b, ACC_DATA32 = 10010010b — whose bits encode present, the descriptor privilege level (DPL), code-vs-data, and executable/readable/writable,
  • flagsFLG_GRAN4K = 11000000b — setting 4 KB granularity and the 32-bit default size.

The DPL bits are the important part for anyone who thinks about security. This is where ring 0 versus ring 3 physically comes from. These descriptors are DPL 0 — kernel. A real OS also defines DPL 3 descriptors for user code and data, and the CPU checks the privilege level on every segment access. A selector like 0x08 or 0x18 is just an index into this table (index × 8, plus a requested privilege level in the low bits); when pm_start_32 runs mov ax, 0x18 / mov ds, ax, it's loading the data segment through GDT entry 3.

The null descriptor at index 0 isn't decoration — the architecture requires it, and loading a segment register with a null selector is how you deliberately hold an invalid segment.

The IDT: what happens on every interrupt

The GDT says what memory is. The IDT says what happens when execution is interrupted — a fault, an exception, a hardware IRQ, or a software int. It's a table of gate descriptors, one per vector, and NanoBoot builds one by hand. The gate for its custom int 0x30:

    mov ebx, idt_table
    add ebx, (0x30 * 8)             ; each IDT gate is 8 bytes; this is vector 0x30
    mov [ebx],     ax               ; handler offset, bits [0:15]
    mov word [ebx + 2], PM_CODE_SEL ; selector -> a GDT code segment (0x08)
    shr eax, 16
    mov [ebx + 6], ax               ; handler offset, bits [16:31]
    mov word [ebx + 4], 0x8E00      ; attributes: present, DPL 0, 32-bit interrupt gate
    ...
    mov word [idt_descriptor], (256 * 8) - 1   ; 256 vectors
    mov [idt_descriptor + 2], eax               ; base of the table
    lidt [idt_descriptor]           ; install it
Enter fullscreen mode Exit fullscreen mode

A gate stores three things: the handler address (split across two fields for historical reasons), a selector, and an attribute byte. Two details are worth stopping on.

First, 0x8E: present (1), DPL 00, gate type 0xE — a 32-bit interrupt gate (a trap gate would be 0xF; the difference is whether the CPU clears the interrupt flag on entry). The DPL in that byte controls who is allowed to invoke this vector with a software int. Make a gate DPL 3 and ring-3 code can trigger it; leave it DPL 0 and a user-mode int to that vector faults. That single field is the mechanism behind controlled entry into the kernel.

Second, and this is the connection people miss: the gate's selector points back into the GDT (PM_CODE_SEL, 0x08). An interrupt doesn't just jump to an address — it vectors through the IDT to a handler that runs in a GDT-defined code segment, at that segment's privilege level. The two tables are not independent. The IDT decides which handler; the GDT decides what privilege it runs at.

NanoBoot fills the rest of the table the same way — exception vectors 0–19, IRQ0 after remapping the PIC — but the shape is always this gate: address, selector into the GDT, privilege, present.

Why this is worth knowing if you never boot a CPU

You will not write a bootloader at work. You operate, every day, inside the world one built:

  • Ring 0 vs ring 3 — the reason user code can't touch hardware directly — is those descriptor DPL bits and the CPU's checks against them. It's not a kernel policy; it's silicon reading a table.
  • Entering the kernel is a privilege transition governed by these structures. Legacy Linux syscalls used int 0x80, an IDT gate with DPL 3 so ring 3 could invoke it; modern x86-64 uses the dedicated syscall instruction (faster, via MSRs) rather than an IDT gate — but every exception and hardware interrupt still dispatches through the IDT, and the ring model the GDT sets up is the same one syscall transitions across. The tables didn't stop mattering; one hot path just got its own instruction.
  • Privilege-escalation bugs frequently come down to mistakes in descriptor or gate handling — a gate with the wrong DPL, a segment with the wrong permissions. Knowing what an IDT gate's DPL means is knowing what a class of kernel exploit is actually abusing.

For infrastructure work: the "kernel boundary" you pay to cross on every syscall and every GPU-driver entry point in an inference path is not an abstraction. It's a privilege transition defined by exactly these tables, set up in the machine's first moments and load-bearing for everything after.

Related

Top comments (0)