保护模式下内核IDT代码段选择器的设置与GDT复用/重定义困惑
首先得把你混淆的核心概念掰明白:IDT里的代码段选择子,是GDT(或LDT)中代码段描述符的选择子——说白了就是这个描述符在GDT里的偏移量(内核用的话RPL位设为0就行),和你的内核加载地址KERNEL_LOCATION半毛钱关系都没有!你看的那个aenix项目里的KERNEL_CS只是变量名起得像加载地址而已,它本质就是GDT中代码段描述符的偏移(0x08对应GDT里第二个描述符,第一个是必须的空描述符),别被变量名坑了。
接下来针对你纠结的几个选项,逐个给你分析清楚,结合你的代码给你实用方案:
选项1:复用Bootloader的GDT(最快最省心的实验方案)
你现在只是做小实验,复用Bootloader的GDT完全可行,只要解决「怎么在内核里拿到CODE_SEG的值」这个问题就行,给你两个靠谱的方法:
方法A:用共享头文件统一常量
这是最稳妥的,避免手写错误。你可以创建一个boot_shared.inc文件,把两边都需要的定义写进去:
; boot_shared.inc KERNEL_LOCATION equ 0x1000 ; GDT相关定义(平坦模式,覆盖整个4GB) GDT_Start: null_descriptor: dd 0x0 dd 0x0 code_descriptor: dw 0xFFFF ; 限长低16位 dw 0x0 ; 基址低16位 db 0x0 ; 基址中8位 db 0x9A ; 内核态代码段,可执行、可读 db 0xCF ; 粒度4KB,限长高4位(覆盖4GB) db 0x0 ; 基址高8位 data_descriptor: dw 0xFFFF dw 0x0 db 0x0 db 0x92 ; 内核态数据段,可读写 db 0xCF db 0x0 GDT_End: GDT_Descriptor: dw GDT_End - GDT_Start - 1 dd GDT_Start CODE_SEG equ code_descriptor - GDT_Start DATA_SEG equ data_descriptor - GDT_Start
然后Bootloader代码开头加%include "boot_shared.inc",内核代码开头也加这行,这样两边编译时CODE_SEG就是完全一样的常量,你在内核的IDT里直接写dw CODE_SEG就行,根本不用传值。
方法B:Bootloader把CODE_SEG传给内核寄存器
如果你不想用共享头文件,也可以在Bootloader跳转到内核前,把CODE_SEG放到某个寄存器里,比如:
; Bootloader里跳转到内核前的代码 start_protected_mode: mov ax, DATA_SEG ; ... 其他段寄存器设置 ... mov esp, ebp mov eax, CODE_SEG ; 把CODE_SEG的值放到eax里 jmp KERNEL_LOCATION:kernel_entry ; 跳转到内核入口
然后内核代码里先把这个值存起来:
[bits 32] [org KERNEL_LOCATION] KERNEL_CODE_SEG dd 0 ; 用来存从Bootloader传过来的CODE_SEG kernel_entry: mov [KERNEL_CODE_SEG], eax ; 把eax里的CODE_SEG存到变量里 ; 之后IDT里就用这个变量的值(注意NASM语法,可能需要先把值放到寄存器再填到IDT)
这种方法适合不想共享头文件的场景,但要注意寄存器传递的正确性,不如共享头文件省心。
⚠️ 注意:复用Bootloader的GDT时,一定要确保Bootloader的代码段是平坦模式(基址0,限长0xFFFFF,粒度4KB,覆盖整个4GB),不然你的内核代码在0x1000可能不在Bootloader代码段的寻址范围内,会触发异常。
选项2:内核重新定义自己的GDT(适合长期扩展)
如果以后你想给内核加用户态、或者担心Bootloader的GDT被内存覆盖,那最好自己在内核里重新定义GDT,步骤很简单:
- 内核里写自己的GDT定义(和平坦模式的GDT一样就行,或者以后加更多描述符)
- 用
lgdt加载新的GDT - 远跳转到新的代码段更新CS(因为CS不能直接用
mov修改) - 其他段寄存器(DS、ES等)更新为内核自己的DATA_SEG
- 之后IDT里的代码段选择器就用内核自己GDT里的
CODE_SEG
举个简单的内核GDT示例:
[bits 32] [org KERNEL_LOCATION] ; 内核自己的GDT KERNEL_GDT_Start: null_desc: dd 0x0, dd 0x0 kernel_code_desc: dw 0xFFFF dw 0x0 db 0x0 db 0x9A db 0xCF db 0x0 kernel_data_desc: dw 0xFFFF dw 0x0 db 0x0 db 0x92 db 0xCF db 0x0 KERNEL_GDT_End: KERNEL_GDT_Descriptor: dw KERNEL_GDT_End - KERNEL_GDT_Start - 1 dd KERNEL_GDT_Start KERNEL_CODE_SEG equ kernel_code_desc - KERNEL_GDT_Start KERNEL_DATA_SEG equ kernel_data_desc - KERNEL_GDT_Start init_kernel_gdt: lgdt [KERNEL_GDT_Descriptor] ; 远跳更新CS jmp KERNEL_CODE_SEG:.update_segs .update_segs: mov ax, KERNEL_DATA_SEG mov ds, ax mov ss, ax mov es, ax mov fs, ax mov gs, ax ret
然后内核启动时先调用init_kernel_gdt,之后IDT里的代码段选择器就用KERNEL_CODE_SEG就行。这种方法的好处是内核完全掌控自己的分段,不受Bootloader的限制,后期扩展更灵活。
绝对别碰的坑:把KERNEL_LOCATION当IDT选择子
你看的aenix项目里的KERNEL_CS是0x08,这刚好是GDT里代码段描述符的偏移(空描述符占8字节,所以第二个描述符偏移是8),和内核加载地址没关系,只是变量名起得像而已。KERNEL_LOCATION是你的内核在内存里的加载地址,把它填到IDT的代码段选择器里,相当于让CPU去GDT的0x1000偏移处找代码段描述符——而你的GDT一般只有几个描述符,0x1000远超过GDT的大小,直接会触发无效TSS或者段不存在异常,绝对不能这么干!
给你代码的小修正
最后看你内核里的中断处理函数,有两个小问题:
- 你用
jmp $卡死了,中断处理完必须用iretd返回,否则CPU会一直停在中断里 - 处理完PIC的中断后,必须给PIC发送EOI(中断结束信号),否则PIC不会再发新的中断
修正后的handler应该是这样:
handler: pusha ; 保存所有通用寄存器(可选,但好习惯) mov al, 'H' mov [0xb8000], al ; 给主PIC发送EOI信号 mov al, 0x20 out 0x20, al ; 如果是从从PIC来的中断,还要给0xA0端口发0x20 popa iretd ; 中断返回,必须用iretd而不是ret
总结一下:小实验用共享头文件复用Bootloader的GDT最快;想长期扩展就内核自己写GDT;绝对别把内核加载地址当IDT选择子。这样你的IDT就能正常工作啦!




