2、汇编启动阶段:head_64.S与startup_64,解析内核解压前的页表初始化与长模式切换

好,咱们直接进入正题。这一章要聊的,是内核启动过程中最底层的汇编阶段。说白了,就是CPU刚上电、还在实模式下挣扎的时候,我们怎么把它拽进64位的长模式。

我个人习惯把这段代码称为「内核的接生婆」。它干的事情很纯粹:建立页表、开启分页、切换长模式。这三步走完,CPU才算真正进入x86_64的世界,才能去解压内核、执行C代码。

2.1 从head_64.S说起

你打开arch/x86/kernel/head_64.S,第一眼看到的肯定是这段注释:

/* 
 * head_64.S - startup_64 for 64-bit mode
 * 这是内核解压前的入口点
 */

嗯,这里要注意:head_64.S并不是内核解压后的入口。它属于压缩内核(vmlinux.bin.gz)的一部分。真正的解压工作,是由它后面的代码完成的。

我刚开始看这段代码时,有个误区——以为startup_64就是C代码的入口。后来调试一次启动失败,才发现startup_64还在页表初始化阶段,连栈都没设好呢。

2.2 startup_64:页表初始化的核心

startup_64的代码不长,但每一行都关键。咱们拆开来看:

SYM_CODE_START(startup_64)
    /* 清理方向标志 */
    cld

    /* 设置页表基址 */
    movq    $early_top_pgt, %rdi
    movq    %rdi, %cr3

    /* 启用PAE和PSE */
    movl    $(X86_CR4_PAE | X86_CR4_PSE), %eax
    movq    %rax, %cr4

    /* 设置EFER寄存器,开启长模式 */
    movl    $MSR_EFER, %ecx
    rdmsr
    btsl    $_EFER_LME, %eax
    wrmsr

    /* 启用分页 */
    movl    $(X86_CR0_PG | X86_CR0_PE), %eax
    movq    %rax, %cr0

    /* 跳转到64位代码段 */
    pushq   $__KERNEL_CS
    leaq    .Lstartup_64_after_paging(%rip), %rax
    pushq   %rax
    lretq
SYM_CODE_END(startup_64)

这段代码,我当年在QEMU上单步调试了不下20遍。每次看都有新收获。

2.2.1 页表结构:early_top_pgt

early_top_pgt是一个静态定义的页表数组。它只有4个条目,每个条目映射512GB的虚拟地址空间。为什么是4个?因为内核在启动初期只需要映射前2TB的地址空间。

我曾经犯过一个低级错误:在调试自定义内核时,修改了页表条目数,结果启动时直接三重故障。后来才意识到,页表的大小必须和CR3指向的物理页对齐

关键点:early_top_pgt的每个条目对应一个PML4(Page Map Level 4)表项。每个表项覆盖512个PDPT(Page Directory Pointer Table)条目,每个PDPT条目覆盖1GB。所以4个PML4条目 = 2TB地址空间。

2.2.2 为什么先设CR4再设CR3?

你可能会问:为什么代码里先设置CR4(启用PAE/PSE),再设置CR3(页表基址)?

嗯,这里有个硬件约束。CR4.PAE必须在CR3加载之前设置好,否则CPU会认为页表格式不对。我见过一个案例,有人把这两行顺序写反了,结果CPU直接报#GP异常。

我的建议:在写页表初始化代码时,严格按照Intel手册的顺序来:CR4 → EFER → CR3 → CR0。别问我怎么知道的,问就是吃过亏。

2.3 长模式切换的细节

长模式切换,说白了就是让CPU从32位兼容模式进入64位模式。但这里有个坑:你不能直接从实模式跳到长模式。必须经过保护模式,再切到长模式。

startup_64的代码里,切换长模式的关键是EFER寄存器的LME位。设置完LME后,再设置CR0.PG(分页启用),CPU就会自动进入长模式。

但注意:设置CR0.PG时,CPU必须已经处于保护模式。所以代码里同时设置了CR0.PE(保护模式启用)和CR0.PG。

2.3.1 代码段选择子:__KERNEL_CS

最后那个lretq指令,其实是远返回。它把__KERNEL_CS压栈作为代码段选择子,然后把.Lstartup_64_after_paging的地址压栈作为返回地址。这样CPU就会用64位代码段去执行后续代码。

__KERNEL_CS的值是0x10,对应GDT中的第2个描述符。这个描述符必须设置成64位代码段(L=1)。

注意:如果GDT中的代码段描述符没有设置L位,那么lretq之后CPU会进入32位兼容模式,而不是64位长模式。我调试过一个板子,就是GDT初始化时漏了L位,结果内核在解压前就崩溃了。

2.4 页表映射的实战经验

在实际项目中,页表初始化最容易出问题的点有两个:

  1. 物理地址对齐:页表必须放在物理地址的低32位范围内,且按4KB对齐。
  2. 映射覆盖范围:必须确保内核代码所在的物理地址被正确映射。

我记得有一次,客户的内核在启动时卡在「Decompressing Linux...」之后。我查了三天,最后发现是页表映射漏掉了解压缓冲区所在的物理地址。解压代码一运行,直接访问了未映射的内存,触发页错误。

解决方案很简单:在early_top_pgt里加一个条目,把解压缓冲区的物理地址映射到虚拟地址空间。

2.5 核心流程图

下面这张图,是我自己画的内核解压前页表初始化与长模式切换的流程。你看一眼,基本就明白整个脉络了。

内核解压前:页表初始化与长模式切换流程 1. 清理状态 cld / 初始化寄存器 2. 设置CR4 启用PAE + PSE 3. 设置EFER.LME 开启长模式使能 4. 加载CR3 设置页表基址 5. 启用分页 设置CR0.PG + CR0.PE 6. 远跳转 lretq → 64位代码段 ✅ 进入64位长模式 准备执行解压代码 注意:步骤2-4的顺序不可颠倒,否则CPU会触发异常

2.6 常见问题与避坑

问题 原因 解决方案
设置CR0.PG后三重故障 页表未正确初始化或物理地址不对齐 检查early_top_pgt的物理地址和内容
lretq后进入32位模式 GDT中代码段描述符未设置L位 确保__KERNEL_CS对应的描述符L=1
解压时页错误 页表未映射解压缓冲区 在页表中添加对应映射条目
CR4设置后系统挂起 CPU不支持PAE或PSE 检查CPU特性,或使用兼容模式

调试技巧:我习惯在QEMU上用-gdb单步调试这段汇编代码。设置断点在startup_64入口,然后逐条执行,观察CR0、CR3、CR4的变化。这样能直观看到页表切换的过程。

嗯,这一章的内容就到这里。页表初始化和长模式切换,说白了就是给内核搭好一个64位的运行环境。代码不多,但每一行都踩在硬件规则的边界上。你写的时候多想想「CPU此刻看到的是什么」,就不会出大问题。


公众号:蓝海资料掘金营,微信deep3321