I am not sure if that's the main problem but I see that you're missing segment register setup after loading new GDT in start.
First:
Code:
xor ax, ax
mov ds, ax
It makes DS register set to 0 which is invalid in protected mode (loading null segment selector).
Second:
You need to fix up CS register after loading new GDT. You do that by performing a far jump. Something like that:
Code:
...
lgdt [gdt_desc]
jmp 0x0008:fix_cs ; 0x0008 is just an example but should work with your GDT
fix_cs:
...
Third: Just after fix_cs you should put new values in other segment registers
To summarize. Your start function should look more like this:
Code:
start:
cli ;block interrupts
;we must load gdt
lgdt [gdt_desc]
jmp 0x0008:fix_cs
fix_cs:
mov ax, 0x0010
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
mov esp, stack_space ;set stack pointer
call kmain
jmp $ ;halt the CPU
Using interrupts involves pushing and popping CS register on the stack. QEMU internal loader uses 0x08 for code and 0x10 for data segment selector values. While GRUB uses 0x10 for code and 0x18 for data (at least in versions I've dealt with). With that in mind, think what would happen after returning from ISR. At entry CPU pushed current CS value on the stack (which is 0x10 for GRUB). Then it did it's thing and tried to pop CS register (again 0x10 for GRUB). But in your new GDT 0x10 is DATA!! segment selector. And these can't be loaded into CS.