You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

保护模式下内核IDT代码段选择器的设置与GDT复用/重定义困惑

保护模式下内核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,步骤很简单:

  1. 内核里写自己的GDT定义(和平坦模式的GDT一样就行,或者以后加更多描述符)
  2. lgdt加载新的GDT
  3. 远跳转到新的代码段更新CS(因为CS不能直接用mov修改)
  4. 其他段寄存器(DS、ES等)更新为内核自己的DATA_SEG
  5. 之后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或者段不存在异常,绝对不能这么干!

给你代码的小修正

最后看你内核里的中断处理函数,有两个小问题:

  1. 你用jmp $卡死了,中断处理完必须用iretd返回,否则CPU会一直停在中断里
  2. 处理完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就能正常工作啦!

火山引擎 最新活动