So, the issue was that in kernel_main, it returns right after setting up the IDT, and then we end up back in main64.asm in long_mode_start, where it hits a hlt instruction. The problem is that hlt halts the CPU until the next interrupt occurs, like a timer. But when the first interrupt happens, there’s no code to run after the hlt. I should have done something like .hltloop: hlt jmp .hltloop so that the hlt would run in an infinite loop. Otherwise, the CPU could try to execute whatever comes after hlt, and that would lead to unpredictable behavior.
Here is the fixed part.
section .text
bits 64
long_mode_start:
; load null into all data segment registers
mov ax, 0
mov ss, ax
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
call kernel_main
.hltloop:
hlt
jmp .hltloop