— A true story of debugging a 32‑bit kernel
A few weeks ago I was happily writing my own 32‑bit x86 operating system, Paull-kernel. Everything worked: the bootloader, protected mode, a simple “Hello” message. Then I decided to add keyboard input.
I wrote an IRQ1 handler, compiled, ran… and QEMU crashed hard with a General Protection Fault (#GP) on every key press. Triple fault. Reboot loop. Frustration.
After two hours of staring at the code, I finally understood the problem – and it wasn’t inside the handler logic. It was two stupid mov instructions.
🧠 What I wanted to do
- Catch keyboard interrupts (IRQ1) and read scancodes from port
0x60. - Convert scancodes to ASCII characters and print them to the screen.
- Simple, classic OS‑dev step.
My handler looked (mostly) correct: read scancode, skip key‑release, translate via a table, write to video memory.
💥 What went wrong
Every key press caused #GP. The processor reset. No error message, no debugging output – just a black screen.
Here’s the piece of code that broke everything:
keyboard_handler:
pushad
mov ax, 0x1000
mov ds, ax
mov ax, 0xB800
mov es, ax
in al, 0x60
...
Can you spot the error?
🔍 The investigation
In real mode (16‑bit), mov ds, ax with ax = 0x1000 would set the data segment to physical address 0x10000.
In protected mode (32‑bit), segment registers hold selectors – indexes into the GDT (Global Descriptor Table).
0x1000 is a garbage selector – it points to no valid descriptor.
0xB800 is also a garbage selector.
Loading them doesn’t trigger an immediate fault, but as soon as the CPU tries to access memory through ds or es (e.g., stosw with es), it throws #GP.
The handler looked like it should work, but the environment was completely different from real mode.
✅ How I fixed it
My GDT uses the flat memory model:
Code selector: 0x08
Data selector: 0x10 (base = 0, limit = 4 GiB)
So I changed two lines:
keyboard_handler:
pushad
mov ax, 0x10 ; correct data selector
mov ds, ax
mov es, ax
in al, 0x60
...
And for video memory, I stopped using segment overrides. Instead, I write directly to the linear address 0xB8000:
mov edi, [cursor_pos]
shl edi, 1
add edi, 0xB8000
mov ah, 0x07
stosw
No more es magic. Now the handler works perfectly.
📦 Key takeaways
In protected mode, segment registers store GDT selectors, not physical addresses.
Values like 0x1000 or 0xB800 are almost always invalid and will cause #GP when accessed.
Always check your GDT and use the correct selectors (e.g. 0x08 for code, 0x10 for data).
When writing to video memory, use the linear address 0xB8000 and a flat‑mapped ds/es.
One small mistake can cost you hours of debugging. But once you understand the model, it becomes obvious.
🔗 Links & resources
Source code of AxonOS (full kernel + bootloader):
👉 github.com/Paullknya/AXonOS-kernel
My GitHub profile:
👉 github.com/Paullknya
Discuss this article on the community forum:
👉 paullknya.github.io
🚀 What’s next?
Next article I’ll show how I added ring 0/3 switching and why sudo reboot asks for a password.
If you’re interested, follow me or star the repo – it helps a lot.
Happy coding, and may your selectors always be valid.
Top comments (0)