TamgaOS (yula)
RSS github
05 June 2026 TamgaOS x86 IDT Serial ISR Exceptions

TamgaOS — Part 2

Serial port completed, IDT implemented, first exception caught. The kernel can now tell me what went wrong instead of silently rebooting.

Project: github.com/hrasityilmaz/TamgaOs

Short summary: Serial port write() finished → IDT struct + gate descriptors → ISR stubs in assembly → Zig linker problem with export fn → IDT= 00000000 debug → missing lidt found → EXCEPTION: #DE caught.

Serial Port — Finally Done

In Part 1, serial port init was written but the write function was left incomplete. The init configures the UART hardware — baud rate, data bits, FIFO — but without a write function it's useless for debugging.

The write function

serial.c — write
static int is_transmit_empty(void) {
    return inb(COM1 + 5) & 0x20;  // LSR bit 5: transmit holding register empty
}

void serial_write(const char *msg) {
    for (int i = 0; msg[i] != '\0'; i++) {
        while (!is_transmit_empty()) {}  // wait until ready
        outb(COM1, msg[i]);
    }
}

The key part is checking LSR bit 5 before sending each byte. The UART has a small hardware FIFO buffer — if you write faster than it can transmit, bytes get dropped. The busy-wait loop ensures we never overflow it.

serial.zig — write
fn isTransmitEmpty() bool {
    return (inb(COM1 + 5) & 0x20) != 0;
}

pub fn write(msg: []const u8) void {
    for (msg) |c| {
        while (!isTransmitEmpty()) {}
        outb(COM1, c);
    }
}

Testing Serial with QEMU

With -serial stdio QEMU pipes COM1 directly to the terminal. This means serial output appears before VGA is even set up — extremely useful for early boot debugging.

QEMU — serial to stdout
qemu-system-i386 -cdrom .\TamgaOS.iso -boot d -serial stdio
terminal output
TamgaOS booting...
GDT OK
Serial OK

If something breaks after this point, we'll see exactly where it stopped. No more blind reboots.

Why serial before VGA? VGA requires the kernel to be in protected mode with a working GDT. Serial port is just IO port reads/writes — it works from the very first instruction after the bootloader hands control over. You can even write to serial from assembly before entering C.

What is the IDT?

The GDT tells the CPU what each memory segment is. The IDT tells the CPU what to do when something goes wrong — or when hardware needs attention.

Every interrupt and exception has a number from 0 to 255. The IDT is a table of 256 entries, each pointing to a handler function. When the CPU receives interrupt number N, it looks up IDT[N] and jumps to that handler.

VectorMnemonicNameError Code
0#DEDivide ErrorNo
6#UDInvalid OpcodeNo
8#DFDouble FaultYes (always 0)
13#GPGeneral Protection FaultYes
14#PFPage FaultYes
32–255IRQHardware Interrupts (after PIC remap)No

Vectors 0–31 are reserved by Intel for CPU exceptions. Hardware interrupts (keyboard, timer) go above 32 after PIC remapping — that's the next step.

Gate Descriptor — 8 Bytes

Each IDT entry is called a gate descriptor. Like the GDT, it's 8 bytes and must be aligned. The layout is deliberately split — Intel kept this format for backward compatibility.

IDT entry — C struct
struct idt_entry {
    unsigned short offset_low;   // handler address bits 0-15
    unsigned short selector;     // kernel code segment = 0x08
    unsigned char  zero;         // always 0
    unsigned char  type_attr;    // 0x8E = Present + Ring0 + 32-bit Interrupt Gate
    unsigned short offset_high;  // handler address bits 16-31
} __attribute__((packed));
type_attr = 0x8E explained
0x8E = 10001110b

1--- ----  P       = 1  Present — entry is valid
-00- ----  DPL     = 00 Ring 0 — kernel only
---0 ----  always  = 0
---- 1110  type    = 0xE  32-bit Interrupt Gate

Interrupt Gate vs Trap Gate:
Interrupt Gate → clears IF flag (no nested interrupts during handler)
Trap Gate      → leaves IF flag (nested interrupts allowed)
IDT entry — Zig extern struct
const IDTEntry = extern struct {
    offset_low:  u16,  // extern struct = C ABI layout guaranteed
    selector:    u16,  // packed struct would break alignment
    zero:        u8,
    type_attr:   u8,
    offset_high: u16,
};

extern struct vs packed struct in Zig: extern struct follows C ABI layout rules — field order and alignment are guaranteed to match what the CPU expects. packed struct lets Zig make its own alignment decisions, which would corrupt the gate descriptor.

ISR Stubs — Why Assembly

When an exception fires, the CPU jumps to the IDT entry's handler address. But C functions don't know they're being called from an interrupt context — they expect a normal stack frame. We need a small assembly stub for each exception that:

1Optionally push a dummy error code (for exceptions that don't have one)
2Push the interrupt number
3Jump to common stub
isr.asm — individual stubs
global isr0, isr6, isr8, isr13, isr14
extern isr_handler  ; defined in C or Zig

isr0:
    push 0    ; dummy error code — CPU did not push one for #DE
    push 0    ; interrupt number = 0
    jmp isr_common_stub

isr6:
    push 0    ; dummy error code
    push 6    ; interrupt number = 6 (#UD)
    jmp isr_common_stub

isr8:
    push 8    ; CPU already pushed error code for #DF
    jmp isr_common_stub

isr13:
    push 13   ; CPU already pushed error code for #GP
    jmp isr_common_stub

isr14:
    push 14   ; CPU already pushed error code for #PF
    jmp isr_common_stub
isr.asm — common stub
isr_common_stub:
    pusha               ; save EAX ECX EDX EBX ESP EBP ESI EDI
    push ds             ; save segment registers (pusha doesn't include these)
    push es
    push fs
    push gs

    mov ax, 0x10        ; switch to kernel data segment
    mov ds, ax          ; user space may have changed these — guarantee kernel segments
    mov es, ax
    mov fs, ax
    mov gs, ax

    push esp            ; pass stack pointer as registers_t* to C/Zig handler
    call isr_handler
    add esp, 4         ; clean up the pushed argument

    pop gs              ; restore segment registers (reverse order)
    pop fs
    pop es
    pop ds
    popa                ; restore general purpose registers
    add esp, 8         ; clean up int_no + err_code pushed by stub
    iret                ; restore EIP CS EFLAGS — return to interrupted code

The reason add esp, 8 works for both cases — error code present and not — is that every stub pushes exactly 8 bytes before jumping to the common stub. For exceptions without an error code, we push a dummy zero so the frame is always the same size.

The Registers Struct

The common stub ends with push esp before calling isr_handler. This passes the current stack pointer as the first argument — a pointer into the stack frame we just built. The struct field order must exactly match the push order in the assembly.

C — registers_t

typedef struct {
  unsigned int gs, fs, es, ds;
  unsigned int edi, esi, ebp, esp;
  unsigned int ebx, edx, ecx, eax;
  unsigned int int_no, err_code;
  unsigned int eip, cs, eflags;
} register_t;

Zig — Registers

const Registers = extern struct {
  gs: u32, fs: u32, es: u32, ds: u32,
  edi: u32, esi: u32, ebp: u32, esp: u32,
  ebx: u32, edx: u32, ecx: u32, eax: u32,
  int_no: u32, err_code: u32,
  eip: u32, cs: u32, eflags: u32,
};

When isr_handler receives this pointer, it can read r->int_no to know which exception fired, r->eip to know where it happened, and r->err_code for page fault details. This is exactly the debug information we needed in Part 1 when the triple fault happened silently.

The Zig Linker Problem

The C kernel linked cleanly. The Zig kernel failed with:

zig build error
error: ld.lld: undefined symbol: isr_handler
    note: referenced by src\isr.asm
    note:               isr.o:(.text+0x2d)

The assembly file referenced isr_handler but the linker couldn't find it. The function was defined in isr.zig, and main.zig imported it. So why couldn't the linker see it?

Zig only compiles files that are reachable from the root module. Even with const isr = @import("isr.zig"), if none of isr's exports are called from the import chain, the compiler can eliminate the file entirely under ReleaseSmall optimization — dead code elimination.

The fix: Move isr_handler into idt.zig, which is already imported and actively used. Since idt.init() is called from _start, the entire file is guaranteed to be compiled and linked.

export fn — Making Zig Visible to Assembly

the difference
// extern fn — "this function exists somewhere else, linker will find it"
extern fn isr0() void;   // defined in isr.asm

// export fn — "make this function visible to the linker by its exact name"
export fn isr_handler(r: *Registers) void { ... }
// assembly can now call: extern isr_handler

Without export, Zig mangles the function name in the object file. The assembly stub does extern isr_handler and call isr_handler — it needs the exact symbol name. export fn guarantees the name is preserved and visible.

CZig
Import from asmjust declare itextern fn foo() void
Export to asmjust define itexport fn foo() void
Name manglingnone (C ABI)mangled unless export

IDT= 00000000 — The Debug Session

After fixing the linker issue, both kernels compiled. But the C kernel still rebooted in a loop after printing IDT OK. The exception handler never ran — no serial output, just a reboot cycle.

QEMU exception logging:

QEMU — exception + reset logging
qemu-system-i386 -cdrom .\TamgaOS_C.iso -boot d -serial stdio -d int,cpu_reset -no-reboot 2> qemu.log

Reading qemu.log

The log was long but two lines told the whole story:

qemu.log — critical lines
; CPU state at the moment of exception:
GDT=     00103006 00000017   ← GDT loaded correctly
IDT=     00000000 000003ff   ← IDT NOT loaded — still at BIOS default!

; Exception chain:
check_exception old: 0xffffffff new 0x0   ← #DE fired at 0x00101087
check_exception old: 0x0         new 0xd   ← no handler → #GP
check_exception old: 0x8         new 0xd   ← #DF, no handler → triple fault
Triple fault

The IDT base address was 0x00000000. Our idt_init() filled in the table and set idt_ptr correctly — but the CPU never knew about it. The IDT was built in RAM but never loaded into the IDTR register.

GDT has lgdt. IDT has lidt. Without lidt, the CPU keeps using whatever the bootloader left in IDTR — which points to the BIOS real-mode interrupt table at address 0, completely useless in protected mode.

The Missing lidt

Looking at the C idt_init() — the entries were all set, idt_ptr was filled, but the last line was simply missing:

idt.c — before fix
void idt_init(void) {
    idt_ptr.limit = (sizeof(struct idt_entry) * IDT_ENTRIES) - 1;
    idt_ptr.base  = (unsigned int)&idt;

    for (int i = 0; i < IDT_ENTRIES; i++) setEntry(i, 0);

    setEntry(0,  (unsigned int)isr0);
    setEntry(6,  (unsigned int)isr6);
    setEntry(8,  (unsigned int)isr8);
    setEntry(13, (unsigned int)isr13);
    setEntry(14, (unsigned int)isr14);
    // ← lidt was simply not here
}
idt.c — after fix
    setEntry(14, (unsigned int)isr14);
    __asm__ volatile("lidt (%0)" : : "r"(&idt_ptr));  // ← this one line
}

The pattern: GDT → lgdt. IDT → lidt. Building the table in RAM means nothing until you tell the CPU where it is. Both instructions load the base address and limit into their respective CPU registers (GDTR, IDTR).

Verifying the GDT at Runtime

Writing a GDT struct and calling lgdt is one thing. Knowing the CPU actually loaded what you wrote is another. The sgdt instruction reads the GDTR register back — base address and limit — and writes it to memory. A small debug function wraps this.

gdt_debug.zig — dumpLoadedGdtr
const GdtPtr = packed struct {
    limit: u16,
    base:  u32,
};

pub fn dumpLoadedGdtr() void {
    var gdtr: GdtPtr = .{ .limit = 0, .base = 0 };

    asm volatile ("sgdt %[ptr]"
        : [ptr] "=m" (gdtr),
        :
        : .{ .memory = true });

    serial.write("GDTR base=");
    serial.writeHex32(gdtr.base);
    serial.write(" limit=");
    serial.writeHex16(gdtr.limit);
    serial.write("\n");
}

sgdt takes a memory operand and writes 6 bytes: 2-byte limit followed by 4-byte base. The packed struct here is correct — we want exact layout with no padding, matching the CPU's GDTR format directly.

Reading Segment Registers

GDTR tells us where the table is. The segment registers tell us which entries are actually loaded. CS, DS, and SS each hold a selector — an index into the GDT plus privilege bits.

gdt_debug.zig — readCs / readDs / readSs
fn readCs() u16 {
    return asm volatile (
        \\push %cs
        \\pop %[out]
        : [out] "=r" (-> u16),
    );
}

fn readDs() u16 {
    return asm volatile (
        \\mov %ds, %[out]
        : [out] "=r" (-> u16),
    );
}

fn readSs() u16 {
    return asm volatile (
        \\mov %ss, %[out]
        : [out] "=r" (-> u16),
    );
}

pub fn dumpSegments() void {
    serial.write("CS="); serial.writeHex16(readCs());
    serial.write(" DS="); serial.writeHex16(readDs());
    serial.write(" SS="); serial.writeHex16(readSs());
    serial.write("\n");
}

CS cannot be moved to a general-purpose register directly — mov %cs, %eax is not a valid encoding on x86. The workaround is push + pop: push CS onto the stack, pop it into a register. DS and SS have no such restriction.

Cross-checking with the ELF Binary

The serial output and the ELF section headers tell the same story from two different angles. Reading both together confirms the GDT is exactly where and what it should be.

QEMU serial output
GDTR base=0x00103000  limit=0x00000017
CS=0x0008  DS=0x0010  SS=0x0010
IDT OK
low=0x00001020 high=0x00000010 full=0x00101020 type=0x8E
biber — ELF section headers (relevant)
  Nr   Name       Type      Address             Size
  -------------------------------------------------------
  2    .text      PROGBITS  0x0000000000101000  0x000445  AX
  6    .data      PROGBITS  0x0000000000103000  0x00001E  WA
  7    .bss       NOBITS    0x0000000000104000  0x000808  WA

Entry: 0x00101060
WhatSerial outputELF confirms
GDTR base0x00103000.data starts at 0x103000 ✅
GDTR limit0x17 = 24 bytes3 descriptors × 8 bytes = 24 ✅
CS = 0x0008GDT index 1, Ring 0Code segment = GDT[1] ✅
DS = 0x0010GDT index 2, Ring 0Data segment = GDT[2] ✅
IDT handler 0x101020full=0x00101020.text base + 0x20 offset ✅

The selector values decode as follows: the low 3 bits are RPL (privilege level) and TI (GDT vs LDT) flag. The remaining bits are the table index.

selector bit layout
CS = 0x0008 = 0000 0000 0000 1|0|00
                              index=1  TI=0 RPL=0  → GDT[1] Ring 0 (code)

DS = 0x0010 = 0000 0000 0001 0|0|00
                              index=2  TI=0 RPL=0  → GDT[2] Ring 0 (data)
0GDT[0] — Null descriptor (required by spec, CPU ignores it) ✅
1GDT[1] — Code segment → CS=0x08 ✅
2GDT[2] — Data segment → DS=0x10, SS=0x10 ✅

.data size is 0x1E = 30 bytes. GDT is 24 bytes (3 × 8). The remaining 6 bytes are the GdtPtr struct itself (2-byte limit + 4-byte base) sitting right after the table — exactly what lgdt reads.

IDT OK — First Exception Caught

With lidt added and a test divide-by-zero instruction uncommented:

QEMU serial output — C kernel
IDT OK
EXCEPTION:00
#DE Divide Error
QEMU serial output — Zig kernel
IDT OK
EXCEPTION:00
#DE Divide Error

No reboot. The kernel catches the exception, writes to serial, and halts cleanly with hlt. Both Zig and C kernels produce identical output.

This is a milestone. Before IDT, any CPU exception caused a silent triple fault reset. Now exceptions are visible — the kernel can tell me what went wrong, where, and what the register state was.

Summary

1Serial write() completed — COM1 transmit-empty check + byte-by-byte output ✅
2IDT struct defined — 8-byte gate descriptor, extern struct for C ABI ✅
3ISR stubs written in NASM — common stub, pusha, segment reload, push esp ✅
4Zig linker issue solved — export fn + moved handler to actively-compiled file ✅
5qemu.log revealed IDT= 00000000 — lidt was missing from idt_init() ✅
6Both C and Zig kernels catch #DE and print to serial ✅

Probably on next post ı will show differences between zig and C kernels already ı satarted actually but post will be of course on free time...

See you on next post :)

References

When in doubt, go to the spec — not a blog post.
It is only what I understand, can be wrong!

Intel SDM

253668-sdm-vol-3a.pdf -Intel 64 and IA-32 Architectures Software Developer's Manual — Volume 3A

OSDev Wiki

wiki.osdev.org/Interrupt_Descriptor_Table — gate descriptor layout, LIDT usage, ISR stub patterns.

wiki.osdev.org/Interrupt_Service_Routines — registers struct field order, common stub design.

wiki.osdev.org/Serial_Ports — LSR register, transmit empty check.

Tools

Biber — binary inspector used for ELF analysis throughout this project.