内核早期启动调试——从实模式到保护模式的模式切换追踪
内核启动的关键节点
一个最小 x86 内核从 Bootloader 接管后,通常要做这些事:
Bootloader 把控制权交给内核
│
▼
[1] 设置 GDT(段描述符表)
│
▼
[2] 打开 CR0.PE → 进入保护模式
│
▼
[3] 加载内核页表到 CR3
│
▼
[4] 打开 CR0.PG → 开启分页
│
▼
[5] 初始化中断描述符表(IDT)
│
▼
[6] 设置 IDTR、开中断(STI)
│
▼
[7] 进入 C 语言 main() 函数
Bochs 调试器让你在每一步都可以停下来检查——段寄存器是否正确、页表是否加载成功、CR0 切换后 CPU 行为是否符合预期。
节点 1:设置 GDT
内核在进入保护模式前必须先设置好 GDT——否则无法加载段选择子。
bochs:1> vb <内核设置 GDT 的入口虚拟地址> # 或者 pb + 算物理地址
bochs:2> c
# 断点触发,内核开始设置 GDT
bochs:3> info sreg # 看 GDTR 的 base 和 limit
gdtr=000000007c200000 0x00ff # GDT 基址 + limit
bochs:4> xp /16gx <GDTR base> # 看 GDT 的前 16 个条目
GDT 每个条目 8 字节,Bochs 调试器不会自动解析段描述符格式——你需要手动按 x86 段描述符格式去解读:
字节 0-1: segment limit [0:15]
字节 2-4: segment base [0:23]
字节 5: segment access rights
字节 6: flags [0:3] + segment limit [16:19]
字节 7: segment base [24:31]
节点 2:切换到保护模式
内核执行 mov eax, cr0; or eax, 1; mov cr0, eax 打开 PE 位。
bochs:5> vb <执行 mov cr0, eax 的地址>
bochs:6> c
# 断点触发
bochs:7> u # 反汇编当前指令
bochs:8> s # 单步执行 mov cr0, eax
bochs:9> info cr # 看 CR0——PE 位应该已经是 1
cr0=0x00000011 # PE=1(bit 0=1)
切换到保护模式后有一件极其重要的事——刷新指令预取队列。x86 CPU 内部有一个预取队列,切换模式后队列里的指令可能是按旧模式预取的(实模式 16 位代码)。标准做法是在 mov cr0, eax 之后立即一个远跳转:
mov cr0, eax
jmp CODE_SEG:protected_mode_start ; 远跳转刷新预取队列
在 Bochs 里验证:
bochs:10> u # 看跳转目标的指令——应该是 32 位代码了
如果不做远跳转、直接顺序执行,CPU 可能还按 16 位模式解码后续指令——Bootloader 调试常见坑。
节点 3-4:加载 CR3 并开启分页
mov eax, <页表物理基址>
mov cr3, eax # 节点 3:加载页表
mov eax, cr0
or eax, 0x80000000
mov cr0, eax # 节点 4:打开 PG 位
bochs:11> vb <mov cr3, eax 地址>
bochs:12> c
bochs:13> s # 单步执行 mov cr3, eax
bochs:14> info cr # 验证 CR3 值对不对
bochs:15> u # 反汇编下一条指令
bochs:16> s # 单步执行 mov cr0, eax 打开 PG
bochs:17> info cr # 验证 CR0.PG = 1
打开分页后,虚拟地址翻译开始生效。此时用 x 命令看虚拟地址、用 xp 看物理地址,两者应该能对应上(x 用当前 CR3 翻译,xp 直接看物理)。
验证页表:
bochs:18> x /8lx <内核入口虚拟地址> # 内核入口虚拟地址应该有映射
bochs:19> xp /8lx <对应物理地址> # xp 看物理内存应该能看到同样的内核代码
节点 5-6:设置 IDT 和开中断
lidt [idtr] # 加载 IDTR
sti # 开中断
bochs:20> vb <lidt 地址>
bochs:21> c
bochs:22> info sreg # 看 IDTR 值
idtr=000000007c210000 0x07ff
bochs:23> xp /16gx <IDTR base> # 看 IDT 前 16 个条目
每个 IDT 条目 8 字节(32 位模式)或 16 字节(64 位模式)。手动解析需要知道门描述符格式。
然后开中断前验证 IF 标志位:
bochs:24> info flags # IF 应该是 0(关)
bochs:25> vb <sti 地址>
bochs:26> c
bochs:27> s # 执行 sti
bochs:28> info flags # IF 应该变成 1(开)
64 位模式的额外步骤
64 位模式下,内核还需要:
- PAE 页表(CR4.PAE=1)+ 4 级页表或 5 级页表(CR4.LA57=1)
- EFER.LMA=1(MSR 0xC0000080 的 Long Mode Enable 位)
bochs:29> vb <wrmsr 设置 EFER.LMA 的地址>
bochs:30> c
bochs:31> s # 执行 wrmsr
bochs:32> info msr # 看 EFER MSR 值,LMA 位应该是 1
然后切换到长模式后,内核需要远跳转 + 重新加载段描述符(FS/GS 可以通过 MSR 设置基址)。
调试内核早期的常见陷阱
内核在 protected mode 里用了 16 位指令
最经典的陷阱:Bootloader 是 16 位代码,里面某些指令(如 mov sp, 0x7c00)在 32 位保护模式下有不同的编码。Bochs 反汇编器会按当前代码段的 D 位默认解码,但如果你从 16 位段跳转到了一个 D=0 的 32 位段,某些指令可能被误解码。
验证:在关键跳转后 u 看看反汇编结果——如果突然变得不对(比如看到了 addr32 前缀),说明段属性没设对。
CR3 写错了,内核一开分页就 page fault
分页开启后如果内核还在执行、但虚拟地址翻译不过去,会触发 page fault(#PF 异常)。如果你的 IDT 里还没有 page fault handler,这个异常会冒泡成 double fault → triple fault。
在 Bochs 里排查:
bochs:1> info cr # 看 CR3 值
bochs:2> xp /16lx <CR3 base> # 手动 walk page table 看根表对不对
bochs:3> x /16lx <内核当前虚拟地址> # 直接看虚拟翻译结果
triple fault 后重启循环
内核早期 triple fault 会让 Bochs 不断重启(默认 reset_on_triple_fault=1)。想让它停住调试:
# bochsrc 里
cpu: ... reset_on_triple_fault=0
这样 triple fault 后 Bochs 会停在调试器里,你可以用 info cr、info registers 看崩溃瞬间的状态。