Programming

GDT & IDT Selectors for Protected-Mode Kernel (Bootloader)

GDT and IDT entries for protected-mode kernels: how to read the code-segment selector, reuse or replace the bootloader GDT, and key best practices. Tips.

1 answer 1 view

Should I redefine the GDT in the kernel if I already set it up in the bootloader? How do I obtain the correct code-segment selector for IDT entries?

Context

I have a bootloader that initializes a GDT, switches to protected mode and then jumps to a tiny 32-bit kernel loaded at KERNEL_LOCATION (0x1000). In the bootloader I compute selectors like:

asm
[org 0x7c00]
[bits 16]
KERNEL_LOCATION equ 0x1000

; ... load kernel ...

; switch to protected mode
CODE_SEG equ code_descriptor - GDT_Start
DATA_SEG equ data_descriptor - GDT_Start
cli
lgdt [GDT_Descriptor]
; set PE bit in CR0
mov eax, cr0
or eax, 1
mov cr0, eax
; far jump to load CS
jmp CODE_SEG:start_protected_mode

start_protected_mode:
 mov ax, DATA_SEG
 mov ds, ax
 mov ss, ax
 mov es, ax
 mov fs, ax
 mov gs, ax
 mov ebp, 0x90000
 mov esp, ebp

 jmp KERNEL_LOCATION

In the kernel I install an IDT. IDT entries currently contain a placeholder for the code-segment selector, e.g.:

asm
[bits 32]
%define BASE 0x1000
; init the IDT table
lidt [IDT_Descriptor]
IDT_Start:
; /* Protected mode exceptions */
%rep 20
 dw (BASE + handler - $$) & 0x0000FFFF
 dw <CODE_SEG> ; <-- should this come from bootloader GDT or be redefined?
 db 0
 db 0x8F
 dw ((BASE + handler - $$) >> 16) & 0x0000FFFF
%endrep

Questions

  1. For each IDT entry, should the code-segment selector be the selector value from the bootloader GDT (i.e. CODE_SEG = code_descriptor - GDT_Start), or is it related to KERNEL_LOCATION? In other words, is the IDT’s “selector” a GDT selector (index<<3|RPL) or an address/offset?

  2. Can I obtain the current code selector inside the kernel (after the far jump) by reading segment registers (for example via MOV AX, CS or PUSH CS / POP AX) and use that for IDT entries, or will that not yield the correct selector format for the IDT?

  3. Is it acceptable to keep and reuse the GDT that the bootloader loaded (left in memory and never changed), or should the kernel install its own GDT? What are the practical reasons to redefine the GDT in the kernel (e.g., different descriptors, privilege levels, TSS)?

  4. If I must pass a selector from the bootloader to the kernel, what is the recommended mechanism (store the selector at a known memory location, pass via registers at the jump, or reconstruct it in the kernel)?

  5. Any recommended best practices or pitfalls when building IDT entries and choosing the CS selector for a small protected-mode kernel loaded by a bootloader?

The IDT’s “selector” field is a GDT/LDT segment selector (index<<3 | TI | RPL) — not an address or related to KERNEL_LOCATION. In a protected mode kernel you can read the current code-segment selector (for example with push cs; pop ax or mov ax, cs) and use that value when building IDT entries. Reusing the bootloader’s GDT is fine if it provides the exact descriptors you need and stays resident, but most kernels install their own GDT to add user segments, a TSS, per‑CPU entries and to ensure a known layout.


Contents


How IDT selectors work (GDT selector, not address)

Short technical fact: an IDT entry consists of offset_low, selector, zero, type/attributes, offset_high. The 16-bit selector field is a segment selector — it indexes into the GDT or LDT — and is not an address or an offset into your kernel image. The handler address itself goes into the offset fields (low/high). See the IDT layout on the OSDev page for the exact structure: https://wiki.osdev.org/Interrupt_Descriptor_Table.

A selector is encoded as described in Intel docs and summarized on Wikipedia: bits 3–15 are the index, bit 2 selects GDT vs LDT (TI), and bits 0–1 are the Requested Privilege Level (RPL). That means a selector like 0x08 typically denotes GDT entry 1 (index 1 << 3), which many bootloaders use for the kernel code segment in a flat 32-bit descriptor layout (code=0x08, data=0x10). See the protected mode selector breakdown: https://en.wikipedia.org/wiki/Protected_mode and segmentation overview: https://wiki.osdev.org/Segmentation.

So: KERNEL_LOCATION (0x1000) matters for the handler’s offset you place into the IDT offset fields, but the IDT “selector” must be a selector value that names the code segment descriptor in the GDT/LDT, not an address derived from KERNEL_LOCATION.


Obtaining the current CS selector in the kernel

Yes — you can obtain the current code-segment selector inside the kernel and use it in IDT entries.

Practical ways to read CS:

  • push cs / pop reg (works everywhere):
    push cs
    pop ax
  • mov ax, cs (works in many assemblers/environments, but push/pop is universally reliable).

Reading CS yields the selector value currently loaded into CS (including RPL bits). That selector is exactly what you should place into the selector field of your IDT entries if your handler lives in that code segment. Bran’s tutorial explicitly shows this approach for simple kernels: http://www.osdever.net/bkerndev/Docs/gdt.htm.

Example in C (GCC inline asm) — safe and clear:

c
static inline uint16_t read_cs(void) {
 uint16_t cs;
 asm volatile ("push %%cs; pop %0" : "=r"(cs));
 return cs;
}

Use that returned value for the selector field in each IDT gate (the offset fields still hold the handler’s linear address).

One caveat: if you later replace the GDT (see next section), the selector you read now may no longer reference the same descriptor index — so either rebuild the IDT after installing the kernel’s GDT or ensure the kernel GDT uses the same selector values.


Reusing vs redefining the GDT in the kernel

Short answer: you can reuse the bootloader’s GDT, but many kernels choose to replace it.

When reusing the bootloader GDT

  • It’s perfectly acceptable when the bootloader has prepared a sensible flat setup (null descriptor, kernel code descriptor, kernel data descriptor) and you know the selector values and that the table stays in memory. This is the simplest path for a tiny kernel and avoids an extra LGDT/far-jump sequence.
  • If your bootloader is something you control, and its GDT layout matches what your kernel expects (e.g., code=0x08, data=0x10), reuse is pragmatic.

Reasons kernels usually install their own GDT

  • You want user-mode segments (ring 3) and specific DPL values for user code/data descriptors.
  • You need a TSS (Task State Segment) for automatic kernel-stack switching on interrupts from user mode (32‑bit protected mode uses TSS to switch stacks).
  • You want a deterministic, documented layout (easier debugging and fewer surprises when using third‑party bootloaders like GRUB).
  • Per-CPU GDT entries (SMP) or different descriptor bases/limits.

If you install a new GDT in the kernel:


Passing selectors from bootloader to kernel

You have several practical options; pick the simplest for your flow.

  1. Do nothing special — read CS in the kernel.
    If the bootloader already did a far jump into the kernel’s code selector, the kernel can simply read CS (push/pop) and use that selector to build the IDT. This is the easiest and avoids explicit passing.

  2. Pass via registers at jump time.
    Before jumping to kernel code you can place selector values in registers (EAX/EBX/ESI/etc.). A far jump keeps general registers intact, so the kernel can read the value from the chosen register on entry. This is handy when the kernel wants to avoid reading CS or wants additional metadata.

  3. Store at a known memory location (boot info structure).
    Write selectors (and any other boot-time info) into a small, well-known location (e.g., a simple boot-info struct below 1MB or via Multiboot information). The kernel reads that memory on entry. This is a robust approach for more complex handoffs.

  4. Reconstruct in kernel.
    If you know the bootloader’s GDT layout conventionally uses selectors 0x08/0x10, you can assume those values — but beware: third-party bootloaders may use a different layout.

Recommendation: for a small kernel, read CS (push/pop) — it’s simplest and reliable. If you expect more complex features (user processes, TSS), pass or rebuild explicitly and then rebuild IDT using the chosen selectors.


Best practices and pitfalls when building IDT entries and choosing the CS selector

  • IDT selector = selector from GDT/LDT, not an address. Offset fields hold the handler address. See https://wiki.osdev.org/Interrupt_Descriptor_Table and https://wiki.osdev.org/Segmentation.
  • Use push cs / pop ax to get the live selector inside your kernel. If your assembler supports mov ax, cs, that’s fine too, but push/pop is universal. See Bran’s tutorial: http://www.osdever.net/bkerndev/Docs/gdt.htm.
  • Typical selector values in simple flat schemes: code = 0x08 (GDT index 1), data = 0x10 (GDT index 2). Don’t guess — verify the GDT layout (or create your own).
  • Gate type/flags: choose an interrupt gate (type_attr 0x8E, present=1, DPL=0) for hardware/CPU exceptions if you want IF cleared on entry; use a trap gate (0x8F) if you want interrupts to remain enabled on entry. Pick intentionally. See IDT docs: https://wiki.osdev.org/Interrupt_Descriptor_Table.
  • Always load a GDT before enabling interrupts, and keep interrupts disabled while you build/load the IDT. Bootloader advice: http://wiki.osdev.org/Bootloader.
  • If you replace the GDT, you must rebuild or update IDT entries to refer to the new selectors (or rebuild the IDT after setting the kernel GDT). The CPU will use whatever selector the IDT entry contains when an interrupt occurs.
  • Watch limits/relocations: IDT offset fields are 32-bit linear addresses (in 32-bit protected mode). If your kernel is relocated or uses position-independent code, ensure the offsets you write into the IDT reflect final linear addresses.
  • Don’t rely blindly on third‑party bootloader GDTs (GRUB etc.); their layout may be different. If in doubt, replace the GDT in the kernel for simplicity and clarity. See community caution: https://www.reddit.com/r/osdev/comments/ampd00/do_i_need_to_implement_gdt_before_using/.

Pitfall examples:

  • Loading a new GDT but forgetting to far-jump to reload CS → CPU still uses old hidden descriptor base/limit and things break.
  • Using incorrect gate type (trap vs interrupt) and getting unexpected interrupt masking behavior.
  • Writing only a 16-bit offset into the IDT and losing the high 16 bits — the handler address must be split across the low/high fields correctly.

Implementation checklist & example snippets

Minimal safe sequence for a small kernel that trusts the bootloader GDT:

  1. Bootloader:
  • Build GDT (null, code, data), LGDT, set PE bit, far-jump to code selector, set up data registers, jump to kernel entry point.
  • Keep interrupts disabled until kernel builds IDT.
  1. Kernel entry (very small kernel):
  • cli
  • Read CS selector: push cs; pop ax (store as kernel_cs)
  • Build IDT entries using kernel_cs as the selector field and the handler linear addresses for the offset fields.
  • lidt [IDT_descriptor]
  • sti (once handlers and stacks are safe)

Example C helpers (packed IDT entry + read_cs + set entry):

c
struct idt_entry {
 uint16_t offset_low;
 uint16_t selector;
 uint8_t zero;
 uint8_t type_attr;
 uint16_t offset_high;
} __attribute__((packed));

static inline uint16_t read_cs(void) {
 uint16_t cs;
 asm volatile ("push %%cs; pop %0" : "=r"(cs));
 return cs;
}

void set_idt_entry(int n, void (*handler)()) {
 uint32_t addr = (uint32_t)handler;
 idt[n].offset_low = addr & 0xFFFF;
 idt[n].selector = read_cs(); // use current code selector
 idt[n].zero = 0;
 idt[n].type_attr = 0x8E; // interrupt gate, present, DPL=0
 idt[n].offset_high = (addr >> 16) & 0xFFFF;
}

If you decide to install your own kernel GDT, typical sequence (assembly pseudocode):

asm
; load new GDT
lgdt [gdt_descriptor]
; far jump to new code selector to reload CS
jmp CODE_SEL:new_label
new_label:
 mov ax, DATA_SEL
 mov ds, ax
 mov ss, ax
 mov es, ax
 mov fs, ax
 mov gs, ax
; rebuild IDT using the kernel's selectors, then lidt...

See GDT init examples and the need to reload CS: https://stackoverflow.com/questions/46265264/how-can-i-initialise-the-gdt and http://wiki.osdev.org/Bootloader.


Sources


Conclusion

The essential points are simple: the IDT selector is a GDT/LDT selector (not an address) and the handler address goes into the offset fields. In a protected mode kernel you can and should read the current CS selector (e.g., push cs; pop ax) and use that for IDT entries if the bootloader’s GDT is the one you plan to keep. Reusing the bootloader GDT is practical for tiny kernels, but most kernels install their own GDT to add user segments, TSS, per‑CPU tables and to guarantee a predictable layout. If you replace the GDT, reload CS (far jump), reload other segment registers, and rebuild or update your IDT so the selector fields point to the new descriptors.

Authors
Verified by moderation
Moderation
GDT & IDT Selectors for Protected-Mode Kernel (Bootloader)