Why my keyboard handler caused #GP (and how I fixed it)

— A true story of debugging a 32‑bit kernel

A few weeks ago I was happily writing my own 32‑bit x86 operating system, AxonOS. 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.