"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
Five steps, and each one is load-bearing:
-
clibecause 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. -
lgdtloads 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, 1→cr0sets 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
PEdoes not reload the code segment or flush the pipeline. A far jump does both: it loadsCSfrom 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_CODEAhere is0x08— 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
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 byte —
ACC_CODE32 = 10011010b,ACC_DATA32 = 10010010b— whose bits encode present, the descriptor privilege level (DPL), code-vs-data, and executable/readable/writable, -
flags —
FLG_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
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 dedicatedsyscallinstruction (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 onesyscalltransitions 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
- Video: From Real Mode to Protected Mode — Building Custom GDT & IDT
- GitHub: NanoBoot — the two-stage bootloader the assembly above is from: real→protected transition, hand-built GDT/IDT, PIC remap, custom interrupts.
Top comments (0)