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
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.
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-system-i386 -cdrom .\TamgaOS.iso -boot d -serial stdio
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.
| Vector | Mnemonic | Name | Error Code |
|---|---|---|---|
| 0 | #DE | Divide Error | No |
| 6 | #UD | Invalid Opcode | No |
| 8 | #DF | Double Fault | Yes (always 0) |
| 13 | #GP | General Protection Fault | Yes |
| 14 | #PF | Page Fault | Yes |
| 32–255 | IRQ | Hardware 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.
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));
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)
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:
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_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:
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
// 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.
| C | Zig | |
|---|---|---|
| Import from asm | just declare it | extern fn foo() void |
| Export to asm | just define it | export fn foo() void |
| Name mangling | none (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-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:
; 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:
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 }
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.
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.
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.
GDTR base=0x00103000 limit=0x00000017 CS=0x0008 DS=0x0010 SS=0x0010 IDT OK low=0x00001020 high=0x00000010 full=0x00101020 type=0x8E
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
| What | Serial output | ELF confirms |
|---|---|---|
| GDTR base | 0x00103000 | .data starts at 0x103000 ✅ |
| GDTR limit | 0x17 = 24 bytes | 3 descriptors × 8 bytes = 24 ✅ |
| CS = 0x0008 | GDT index 1, Ring 0 | Code segment = GDT[1] ✅ |
| DS = 0x0010 | GDT index 2, Ring 0 | Data segment = GDT[2] ✅ |
| IDT handler 0x101020 | full=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.
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)
.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:
IDT OK
EXCEPTION:00
#DE Divide Error
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
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.