bochs 中文文档 下载 App

内核早期启动调试——从实模式到保护模式的模式切换追踪

内核启动的关键节点

一个最小 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 位模式下,内核还需要:

  1. PAE 页表(CR4.PAE=1)+ 4 级页表或 5 级页表(CR4.LA57=1)
  2. 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 看崩溃瞬间的状态。